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

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