feat: add database backed membership tiers
This commit is contained in:
@@ -21,6 +21,7 @@ internal class AppDbContext(
|
||||
: IdentityDbContext<User, Role, Guid>(options)
|
||||
{
|
||||
public DbSet<Organization> Organizations => Set<Organization>();
|
||||
public DbSet<OrganizationMembershipTier> OrganizationMembershipTiers => Set<OrganizationMembershipTier>();
|
||||
public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>();
|
||||
public DbSet<Workspace> Workspaces => Set<Workspace>();
|
||||
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
|
||||
|
||||
@@ -261,6 +261,7 @@ internal static class TestDataSeedExtensions
|
||||
}
|
||||
|
||||
organization.Name = "Northstar Agency";
|
||||
organization.MembershipTierId = OrganizationMembershipTierSeed.AgencyId;
|
||||
organization.OwnerUserId = managerUserId;
|
||||
|
||||
await UpsertOrganizationMembershipAsync(
|
||||
|
||||
2293
backend/src/Socialize.Api/Migrations/20260508001846_AddOrganizationMembershipTiers.Designer.cs
generated
Normal file
2293
backend/src/Socialize.Api/Migrations/20260508001846_AddOrganizationMembershipTiers.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
internal partial class AddOrganizationMembershipTiers : Migration
|
||||
{
|
||||
private static readonly string[] MembershipTierSeedColumns =
|
||||
[
|
||||
"Id",
|
||||
"ActiveContentLimit",
|
||||
"Description",
|
||||
"ExternalReviewerLimit",
|
||||
"IsCustom",
|
||||
"Key",
|
||||
"MemberLimit",
|
||||
"MonthlyPriceCents",
|
||||
"Name",
|
||||
"SortOrder",
|
||||
"WorkspaceLimit"
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "MembershipTierId",
|
||||
table: "Organizations",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("20000000-0000-0000-0000-000000000001"));
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OrganizationMembershipTiers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Key = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
MonthlyPriceCents = table.Column<int>(type: "integer", nullable: true),
|
||||
WorkspaceLimit = table.Column<int>(type: "integer", nullable: true),
|
||||
ActiveContentLimit = table.Column<int>(type: "integer", nullable: true),
|
||||
MemberLimit = table.Column<int>(type: "integer", nullable: true),
|
||||
ExternalReviewerLimit = table.Column<int>(type: "integer", nullable: true),
|
||||
IsCustom = table.Column<bool>(type: "boolean", nullable: false),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OrganizationMembershipTiers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "OrganizationMembershipTiers",
|
||||
columns: MembershipTierSeedColumns,
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("20000000-0000-0000-0000-000000000001"), 3, "For trying Socialize on one real approval workflow.", 1, false, "free", 2, 0, "Free", 10, 1 },
|
||||
{ new Guid("20000000-0000-0000-0000-000000000002"), 25, "For solo operators managing recurring client reviews.", 10, false, "freelance", 5, 1900, "Freelance", 20, 3 },
|
||||
{ new Guid("20000000-0000-0000-0000-000000000003"), 250, "For agencies that need repeatable client approval operations.", null, false, "agency", 25, 7900, "Agency", 30, 15 },
|
||||
{ new Guid("20000000-0000-0000-0000-000000000004"), null, "For larger organizations with governance and access needs.", null, true, "enterprise", null, null, "Enterprise", 40, null }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Organizations_MembershipTierId",
|
||||
table: "Organizations",
|
||||
column: "MembershipTierId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OrganizationMembershipTiers_Key",
|
||||
table: "OrganizationMembershipTiers",
|
||||
column: "Key",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OrganizationMembershipTiers_SortOrder",
|
||||
table: "OrganizationMembershipTiers",
|
||||
column: "SortOrder");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Organizations_OrganizationMembershipTiers_MembershipTierId",
|
||||
table: "Organizations",
|
||||
column: "MembershipTierId",
|
||||
principalTable: "OrganizationMembershipTiers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migrationBuilder);
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Organizations_OrganizationMembershipTiers_MembershipTierId",
|
||||
table: "Organizations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "OrganizationMembershipTiers");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Organizations_MembershipTierId",
|
||||
table: "Organizations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MembershipTierId",
|
||||
table: "Organizations");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1659,6 +1659,11 @@ namespace Socialize.Api.Migrations
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid>("MembershipTierId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasDefaultValue(new Guid("20000000-0000-0000-0000-000000000001"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
@@ -1669,6 +1674,8 @@ namespace Socialize.Api.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MembershipTierId");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Organizations", (string)null);
|
||||
@@ -1708,6 +1715,110 @@ namespace Socialize.Api.Migrations
|
||||
b.ToTable("OrganizationMemberships", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTier", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int?>("ActiveContentLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<int?>("ExternalReviewerLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsCustom")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int?>("MemberLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("MonthlyPriceCents")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("WorkspaceLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Key")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("SortOrder");
|
||||
|
||||
b.ToTable("OrganizationMembershipTiers", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = new Guid("20000000-0000-0000-0000-000000000001"),
|
||||
ActiveContentLimit = 3,
|
||||
Description = "For trying Socialize on one real approval workflow.",
|
||||
ExternalReviewerLimit = 1,
|
||||
IsCustom = false,
|
||||
Key = "free",
|
||||
MemberLimit = 2,
|
||||
MonthlyPriceCents = 0,
|
||||
Name = "Free",
|
||||
SortOrder = 10,
|
||||
WorkspaceLimit = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("20000000-0000-0000-0000-000000000002"),
|
||||
ActiveContentLimit = 25,
|
||||
Description = "For solo operators managing recurring client reviews.",
|
||||
ExternalReviewerLimit = 10,
|
||||
IsCustom = false,
|
||||
Key = "freelance",
|
||||
MemberLimit = 5,
|
||||
MonthlyPriceCents = 1900,
|
||||
Name = "Freelance",
|
||||
SortOrder = 20,
|
||||
WorkspaceLimit = 3
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("20000000-0000-0000-0000-000000000003"),
|
||||
ActiveContentLimit = 250,
|
||||
Description = "For agencies that need repeatable client approval operations.",
|
||||
IsCustom = false,
|
||||
Key = "agency",
|
||||
MemberLimit = 25,
|
||||
MonthlyPriceCents = 7900,
|
||||
Name = "Agency",
|
||||
SortOrder = 30,
|
||||
WorkspaceLimit = 15
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("20000000-0000-0000-0000-000000000004"),
|
||||
Description = "For larger organizations with governance and access needs.",
|
||||
IsCustom = true,
|
||||
Key = "enterprise",
|
||||
Name = "Enterprise",
|
||||
SortOrder = 40
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -2127,6 +2238,15 @@ namespace Socialize.Api.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.Organization", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTier", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MembershipTierId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
|
||||
{
|
||||
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
|
||||
|
||||
@@ -5,6 +5,7 @@ internal class Organization
|
||||
public Guid Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public string? LogoUrl { get; set; }
|
||||
public Guid MembershipTierId { get; set; } = OrganizationMembershipTierSeed.FreeId;
|
||||
public Guid OwnerUserId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Socialize.Api.Modules.Organizations.Data;
|
||||
|
||||
internal class OrganizationMembershipTier
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string Key { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string Description { get; set; }
|
||||
public int? MonthlyPriceCents { get; set; }
|
||||
public int? WorkspaceLimit { get; set; }
|
||||
public int? ActiveContentLimit { get; set; }
|
||||
public int? MemberLimit { get; set; }
|
||||
public int? ExternalReviewerLimit { get; set; }
|
||||
public bool IsCustom { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace Socialize.Api.Modules.Organizations.Data;
|
||||
|
||||
internal static class OrganizationMembershipTierSeed
|
||||
{
|
||||
public static readonly Guid FreeId = Guid.Parse("20000000-0000-0000-0000-000000000001");
|
||||
public static readonly Guid FreelanceId = Guid.Parse("20000000-0000-0000-0000-000000000002");
|
||||
public static readonly Guid AgencyId = Guid.Parse("20000000-0000-0000-0000-000000000003");
|
||||
public static readonly Guid EnterpriseId = Guid.Parse("20000000-0000-0000-0000-000000000004");
|
||||
|
||||
public static readonly OrganizationMembershipTier[] Tiers =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Id = FreeId,
|
||||
Key = "free",
|
||||
Name = "Free",
|
||||
Description = "For trying Socialize on one real approval workflow.",
|
||||
MonthlyPriceCents = 0,
|
||||
WorkspaceLimit = 1,
|
||||
ActiveContentLimit = 3,
|
||||
MemberLimit = 2,
|
||||
ExternalReviewerLimit = 1,
|
||||
SortOrder = 10,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = FreelanceId,
|
||||
Key = "freelance",
|
||||
Name = "Freelance",
|
||||
Description = "For solo operators managing recurring client reviews.",
|
||||
MonthlyPriceCents = 1900,
|
||||
WorkspaceLimit = 3,
|
||||
ActiveContentLimit = 25,
|
||||
MemberLimit = 5,
|
||||
ExternalReviewerLimit = 10,
|
||||
SortOrder = 20,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = AgencyId,
|
||||
Key = "agency",
|
||||
Name = "Agency",
|
||||
Description = "For agencies that need repeatable client approval operations.",
|
||||
MonthlyPriceCents = 7900,
|
||||
WorkspaceLimit = 15,
|
||||
ActiveContentLimit = 250,
|
||||
MemberLimit = 25,
|
||||
ExternalReviewerLimit = null,
|
||||
SortOrder = 30,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = EnterpriseId,
|
||||
Key = "enterprise",
|
||||
Name = "Enterprise",
|
||||
Description = "For larger organizations with governance and access needs.",
|
||||
MonthlyPriceCents = null,
|
||||
WorkspaceLimit = null,
|
||||
ActiveContentLimit = null,
|
||||
MemberLimit = null,
|
||||
ExternalReviewerLimit = null,
|
||||
IsCustom = true,
|
||||
SortOrder = 40,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -12,10 +12,29 @@ internal static class OrganizationModelConfiguration
|
||||
organization.HasKey(x => x.Id);
|
||||
organization.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
||||
organization.Property(x => x.LogoUrl).HasMaxLength(2048);
|
||||
organization.Property(x => x.MembershipTierId)
|
||||
.HasDefaultValue(OrganizationMembershipTierSeed.FreeId);
|
||||
organization.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
organization.HasIndex(x => x.MembershipTierId);
|
||||
organization.HasIndex(x => x.OwnerUserId);
|
||||
organization.HasOne<OrganizationMembershipTier>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.MembershipTierId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<OrganizationMembershipTier>(tier =>
|
||||
{
|
||||
tier.ToTable("OrganizationMembershipTiers");
|
||||
tier.HasKey(x => x.Id);
|
||||
tier.Property(x => x.Key).HasMaxLength(64).IsRequired();
|
||||
tier.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
||||
tier.Property(x => x.Description).HasMaxLength(512).IsRequired();
|
||||
tier.HasIndex(x => x.Key).IsUnique();
|
||||
tier.HasIndex(x => x.SortOrder);
|
||||
tier.HasData(OrganizationMembershipTierSeed.Tiers);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<OrganizationMembership>(membership =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
@@ -7,7 +8,8 @@ using Socialize.Api.Modules.Organizations.Services;
|
||||
namespace Socialize.Api.Modules.Organizations.Handlers;
|
||||
|
||||
internal record CreateOrganizationRequest(
|
||||
string Name);
|
||||
string Name,
|
||||
Guid? MembershipTierId = null);
|
||||
|
||||
internal class CreateOrganizationRequestValidator
|
||||
: Validator<CreateOrganizationRequest>
|
||||
@@ -32,11 +34,22 @@ internal class CreateOrganizationHandler(
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
Guid membershipTierId = request.MembershipTierId ?? OrganizationMembershipTierSeed.FreeId;
|
||||
OrganizationMembershipTier? membershipTier = await dbContext.OrganizationMembershipTiers
|
||||
.SingleOrDefaultAsync(tier => tier.Id == membershipTierId, ct);
|
||||
if (membershipTier is null)
|
||||
{
|
||||
AddError(request => request.MembershipTierId, "The selected membership tier does not exist.");
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Guid userId = User.GetUserId();
|
||||
Organization organization = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = request.Name.Trim(),
|
||||
MembershipTierId = membershipTier.Id,
|
||||
OwnerUserId = userId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
@@ -57,7 +70,8 @@ internal class CreateOrganizationHandler(
|
||||
await SendAsync(
|
||||
OrganizationDto.FromOrganization(
|
||||
organization,
|
||||
OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner)),
|
||||
OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner),
|
||||
OrganizationMembershipTierDto.FromTier(membershipTier)),
|
||||
StatusCodes.Status201Created,
|
||||
ct);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
using Socialize.Api.Modules.Identity.Data;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
@@ -44,15 +46,19 @@ internal class GetOrganizationHandler(
|
||||
|
||||
IReadOnlyCollection<OrganizationMemberDto> members = await GetMembersAsync(organizationId, ct);
|
||||
IReadOnlyCollection<WorkspaceDto> workspaces = await GetWorkspacesAsync(organizationId, ct);
|
||||
OrganizationUsageDto usage = await GetUsageAsync(organization, ct);
|
||||
OrganizationMembershipTier membershipTier = await GetMembershipTierAsync(organization.MembershipTierId, ct);
|
||||
OrganizationUsageDto usage = await GetUsageAsync(organization, membershipTier, ct);
|
||||
IReadOnlyCollection<OrganizationMembershipTierDto> availableMembershipTiers = await GetAvailableMembershipTiersAsync(ct);
|
||||
|
||||
await SendOkAsync(
|
||||
OrganizationDto.FromOrganization(
|
||||
organization,
|
||||
currentUserPermissions,
|
||||
OrganizationMembershipTierDto.FromTier(membershipTier),
|
||||
members,
|
||||
workspaces,
|
||||
usage),
|
||||
usage,
|
||||
availableMembershipTiers),
|
||||
ct);
|
||||
}
|
||||
|
||||
@@ -100,6 +106,7 @@ internal class GetOrganizationHandler(
|
||||
|
||||
private async Task<OrganizationUsageDto> GetUsageAsync(
|
||||
Organization organization,
|
||||
OrganizationMembershipTier membershipTier,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Guid[] workspaceIds = await dbContext.Workspaces
|
||||
@@ -125,29 +132,80 @@ internal class GetOrganizationHandler(
|
||||
contentItem.Status != "Scheduled")
|
||||
.CountAsync(ct);
|
||||
|
||||
OrganizationUsageLimits limits = GetUsageLimits(organization.Name);
|
||||
int externalReviewerCount = workspaceIds.Length == 0
|
||||
? 0
|
||||
: await GetExternalReviewerCountAsync(workspaceIds, memberUserIds, organization.OwnerUserId, ct);
|
||||
|
||||
return new OrganizationUsageDto(
|
||||
limits.PlanName,
|
||||
membershipTier.Key,
|
||||
membershipTier.Name,
|
||||
[
|
||||
new OrganizationUsageItemDto("users", userCount, limits.UserLimit),
|
||||
new OrganizationUsageItemDto("workspaces", workspaceIds.Length, limits.WorkspaceLimit),
|
||||
new OrganizationUsageItemDto("activeContent", activeContentItemCount, limits.ActiveContentLimit),
|
||||
new OrganizationUsageItemDto("users", userCount, membershipTier.MemberLimit),
|
||||
new OrganizationUsageItemDto("workspaces", workspaceIds.Length, membershipTier.WorkspaceLimit),
|
||||
new OrganizationUsageItemDto("activeContent", activeContentItemCount, membershipTier.ActiveContentLimit),
|
||||
new OrganizationUsageItemDto("externalReviewers", externalReviewerCount, membershipTier.ExternalReviewerLimit),
|
||||
]);
|
||||
}
|
||||
|
||||
private static OrganizationUsageLimits GetUsageLimits(string organizationName)
|
||||
private async Task<OrganizationMembershipTier> GetMembershipTierAsync(Guid membershipTierId, CancellationToken ct)
|
||||
{
|
||||
return string.Equals(organizationName, "Northstar Agency", StringComparison.OrdinalIgnoreCase)
|
||||
? new OrganizationUsageLimits("Agency", 25, 15, 250)
|
||||
: new OrganizationUsageLimits("Free", 2, 1, 3);
|
||||
return await dbContext.OrganizationMembershipTiers
|
||||
.SingleOrDefaultAsync(tier => tier.Id == membershipTierId, ct)
|
||||
?? await dbContext.OrganizationMembershipTiers
|
||||
.SingleAsync(tier => tier.Id == OrganizationMembershipTierSeed.FreeId, ct);
|
||||
}
|
||||
|
||||
private sealed record OrganizationUsageLimits(
|
||||
string PlanName,
|
||||
int UserLimit,
|
||||
int WorkspaceLimit,
|
||||
int ActiveContentLimit);
|
||||
private async Task<IReadOnlyCollection<OrganizationMembershipTierDto>> GetAvailableMembershipTiersAsync(CancellationToken ct)
|
||||
{
|
||||
List<OrganizationMembershipTier> tiers = await dbContext.OrganizationMembershipTiers
|
||||
.OrderBy(tier => tier.SortOrder)
|
||||
.ThenBy(tier => tier.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return tiers
|
||||
.Select(OrganizationMembershipTierDto.FromTier)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<int> GetExternalReviewerCountAsync(
|
||||
IReadOnlyCollection<Guid> workspaceIds,
|
||||
IReadOnlyCollection<Guid> organizationMemberUserIds,
|
||||
Guid ownerUserId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string[] workspaceClaimValues = workspaceIds
|
||||
.Select(id => id.ToString())
|
||||
.ToArray();
|
||||
|
||||
HashSet<Guid> internalUserIds = organizationMemberUserIds
|
||||
.Append(ownerUserId)
|
||||
.ToHashSet();
|
||||
|
||||
Guid[] scopedUserIds = await dbContext.UserClaims
|
||||
.Where(claim => claim.ClaimType == KnownClaims.WorkspaceScope &&
|
||||
workspaceClaimValues.Contains(claim.ClaimValue!))
|
||||
.Select(claim => claim.UserId)
|
||||
.Distinct()
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
if (scopedUserIds.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
Guid[] clientRoleIds = await dbContext.Roles
|
||||
.Where(role => role.Name == KnownRoles.Client)
|
||||
.Select(role => role.Id)
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
return await dbContext.UserRoles
|
||||
.Where(userRole => scopedUserIds.Contains(userRole.UserId) &&
|
||||
clientRoleIds.Contains(userRole.RoleId) &&
|
||||
!internalUserIds.Contains(userRole.UserId))
|
||||
.Select(userRole => userRole.UserId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
}
|
||||
|
||||
private static string BuildDisplayName(User user)
|
||||
{
|
||||
|
||||
@@ -26,6 +26,18 @@ internal class GetOrganizationsHandler(
|
||||
.OrderBy(organization => organization.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
Guid[] membershipTierIds = organizations
|
||||
.Select(organization => organization.MembershipTierId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
List<OrganizationMembershipTier> membershipTierModels = await dbContext.OrganizationMembershipTiers
|
||||
.Where(tier => membershipTierIds.Contains(tier.Id))
|
||||
.ToListAsync(ct);
|
||||
Dictionary<Guid, OrganizationMembershipTierDto> membershipTiersById = membershipTierModels
|
||||
.ToDictionary(
|
||||
tier => tier.Id,
|
||||
OrganizationMembershipTierDto.FromTier);
|
||||
|
||||
List<OrganizationDto> response = [];
|
||||
foreach (Organization organization in organizations)
|
||||
{
|
||||
@@ -33,7 +45,10 @@ internal class GetOrganizationsHandler(
|
||||
User,
|
||||
organization.Id,
|
||||
ct);
|
||||
response.Add(OrganizationDto.FromOrganization(organization, permissions));
|
||||
response.Add(OrganizationDto.FromOrganization(
|
||||
organization,
|
||||
permissions,
|
||||
membershipTiersById.GetValueOrDefault(organization.MembershipTierId)));
|
||||
}
|
||||
|
||||
await SendOkAsync(response, ct);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations.Handlers;
|
||||
|
||||
internal class ListOrganizationMembershipTiersHandler(AppDbContext dbContext)
|
||||
: EndpointWithoutRequest<IReadOnlyCollection<OrganizationMembershipTierDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/organization-membership-tiers");
|
||||
Options(o => o.WithTags("Organizations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
List<OrganizationMembershipTier> tierModels = await dbContext.OrganizationMembershipTiers
|
||||
.OrderBy(tier => tier.SortOrder)
|
||||
.ThenBy(tier => tier.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
OrganizationMembershipTierDto[] tiers = tierModels
|
||||
.Select(OrganizationMembershipTierDto.FromTier)
|
||||
.ToArray();
|
||||
|
||||
await SendOkAsync(tiers, ct);
|
||||
}
|
||||
}
|
||||
@@ -16,34 +16,71 @@ internal record OrganizationDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? LogoUrl,
|
||||
OrganizationMembershipTierDto? MembershipTier,
|
||||
Guid OwnerUserId,
|
||||
IReadOnlyCollection<string> CurrentUserPermissions,
|
||||
IReadOnlyCollection<OrganizationMemberDto> Members,
|
||||
IReadOnlyCollection<WorkspaceDto> Workspaces,
|
||||
OrganizationUsageDto? Usage,
|
||||
IReadOnlyCollection<OrganizationMembershipTierDto> AvailableMembershipTiers,
|
||||
DateTimeOffset CreatedAt)
|
||||
{
|
||||
public static OrganizationDto FromOrganization(
|
||||
Organization organization,
|
||||
IReadOnlyCollection<string> currentUserPermissions,
|
||||
OrganizationMembershipTierDto? membershipTier = null,
|
||||
IReadOnlyCollection<OrganizationMemberDto>? members = null,
|
||||
IReadOnlyCollection<WorkspaceDto>? workspaces = null,
|
||||
OrganizationUsageDto? usage = null)
|
||||
OrganizationUsageDto? usage = null,
|
||||
IReadOnlyCollection<OrganizationMembershipTierDto>? availableMembershipTiers = null)
|
||||
{
|
||||
return new OrganizationDto(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
organization.LogoUrl,
|
||||
membershipTier,
|
||||
organization.OwnerUserId,
|
||||
currentUserPermissions,
|
||||
members ?? [],
|
||||
workspaces ?? [],
|
||||
usage,
|
||||
availableMembershipTiers ?? [],
|
||||
organization.CreatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
internal record OrganizationMembershipTierDto(
|
||||
Guid Id,
|
||||
string Key,
|
||||
string Name,
|
||||
string Description,
|
||||
int? MonthlyPriceCents,
|
||||
int? WorkspaceLimit,
|
||||
int? ActiveContentLimit,
|
||||
int? MemberLimit,
|
||||
int? ExternalReviewerLimit,
|
||||
bool IsCustom,
|
||||
int SortOrder)
|
||||
{
|
||||
public static OrganizationMembershipTierDto FromTier(OrganizationMembershipTier tier)
|
||||
{
|
||||
return new OrganizationMembershipTierDto(
|
||||
tier.Id,
|
||||
tier.Key,
|
||||
tier.Name,
|
||||
tier.Description,
|
||||
tier.MonthlyPriceCents,
|
||||
tier.WorkspaceLimit,
|
||||
tier.ActiveContentLimit,
|
||||
tier.MemberLimit,
|
||||
tier.ExternalReviewerLimit,
|
||||
tier.IsCustom,
|
||||
tier.SortOrder);
|
||||
}
|
||||
}
|
||||
|
||||
internal record OrganizationUsageDto(
|
||||
string PlanKey,
|
||||
string PlanName,
|
||||
IReadOnlyCollection<OrganizationUsageItemDto> Items);
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations.Handlers;
|
||||
|
||||
internal record UpdateOrganizationMembershipTierRequest(
|
||||
Guid MembershipTierId);
|
||||
|
||||
internal class UpdateOrganizationMembershipTierRequestValidator
|
||||
: Validator<UpdateOrganizationMembershipTierRequest>
|
||||
{
|
||||
public UpdateOrganizationMembershipTierRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.MembershipTierId).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
internal class UpdateOrganizationMembershipTierHandler(
|
||||
AppDbContext dbContext,
|
||||
OrganizationAccessService organizationAccessService)
|
||||
: Endpoint<UpdateOrganizationMembershipTierRequest, OrganizationDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/organizations/{organizationId:guid}/membership-tier");
|
||||
Options(o => o.WithTags("Organizations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateOrganizationMembershipTierRequest request, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
Guid organizationId = Route<Guid>("organizationId");
|
||||
|
||||
Organization? organization = await dbContext.Organizations
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == organizationId, ct);
|
||||
if (organization is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await organizationAccessService.HasOrganizationPermissionAsync(
|
||||
User,
|
||||
organizationId,
|
||||
OrganizationPermissions.ManageBilling,
|
||||
ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
OrganizationMembershipTier? membershipTier = await dbContext.OrganizationMembershipTiers
|
||||
.SingleOrDefaultAsync(tier => tier.Id == request.MembershipTierId, ct);
|
||||
if (membershipTier is null)
|
||||
{
|
||||
AddError(x => x.MembershipTierId, "The selected membership tier does not exist.");
|
||||
await SendErrorsAsync(cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
organization.MembershipTierId = membershipTier.Id;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
IReadOnlyCollection<string> currentUserPermissions = await organizationAccessService.GetUserOrganizationPermissionsAsync(
|
||||
User,
|
||||
organizationId,
|
||||
ct);
|
||||
|
||||
await SendOkAsync(
|
||||
OrganizationDto.FromOrganization(
|
||||
organization,
|
||||
currentUserPermissions,
|
||||
OrganizationMembershipTierDto.FromTier(membershipTier)),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user