feat: add database backed membership tiers
All checks were successful
deploy-socialize / image (push) Successful in 1m9s
deploy-socialize / deploy (push) Successful in 19s

This commit is contained in:
2026-05-07 20:29:53 -04:00
parent db16e79d9f
commit 6d92119c9c
23 changed files with 3512 additions and 30 deletions

View File

@@ -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>();

View File

@@ -261,6 +261,7 @@ internal static class TestDataSeedExtensions
}
organization.Name = "Northstar Agency";
organization.MembershipTierId = OrganizationMembershipTierSeed.AgencyId;
organization.OwnerUserId = managerUserId;
await UpsertOrganizationMembershipAsync(

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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)

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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,
},
];
}

View File

@@ -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 =>

View File

@@ -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);
}

View File

@@ -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)
{

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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);
}
}