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

View File

@@ -0,0 +1,51 @@
# Task: Database-backed organization membership tiers
## Feature Spec
`docs/FEATURES/organizations.md`
## Goal
Move organization membership tiers and usage limits out of frontend/static logic and into the database so an organization owner can select a tier immediately, before payment-provider integration exists.
## Scope
- Add a persisted membership tier model with seeded tiers and limits.
- Add an active membership tier relationship on `Organization`.
- Add backend APIs to list available tiers and change an organization's active tier.
- Let organization creation select an initial tier, defaulting to Free.
- Show the current tier and tier selector on organization usage settings.
- Regenerate OpenAPI contracts after backend changes.
## Likely Files
- `backend/src/Socialize.Api/Modules/Organizations/Data/*`
- `backend/src/Socialize.Api/Modules/Organizations/Handlers/*`
- `backend/src/Socialize.Api/Migrations/*`
- `frontend/src/features/organizations/stores/organizationStore.js`
- `frontend/src/features/organizations/views/OrganizationOnboardingView.vue`
- `frontend/src/features/organizations/views/OrganizationSettingsView.vue`
- `frontend/src/locales/en.json`
- `frontend/src/locales/fr.json`
- `shared/openapi/openapi.json`
- `frontend/src/api/schema.d.ts`
## Validation
```bash
dotnet ef migrations add AddOrganizationMembershipTiers --project backend/src/Socialize.Api/Socialize.Api.csproj --startup-project backend/src/Socialize.Api/Socialize.Api.csproj
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
cd frontend && npm run build
./scripts/update-openapi.sh
```
## Done When
- [x] Membership tiers and limits are database-backed.
- [x] Organizations persist their selected membership tier.
- [x] Organization owners/billing managers can change tiers from the usage settings page.
- [x] New organizations can choose an initial tier.
- [x] Usage limits come from the selected tier, not organization name or frontend constants.
- [x] EF migration is generated with `dotnet ef migrations add`.
- [x] OpenAPI and frontend schema are regenerated.

View File

@@ -164,6 +164,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/organization-membership-tiers": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesOrganizationsHandlersListOrganizationMembershipTiersHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/organizations/{organizationId}/membership-tier": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["SocializeApiModulesOrganizationsHandlersUpdateOrganizationMembershipTierHandler"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/notifications": {
parameters: {
query?: never;
@@ -1223,16 +1255,39 @@ export interface components {
id?: string;
name?: string;
logoUrl?: string | null;
membershipTier?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto"] | null;
/** Format: guid */
ownerUserId?: string;
currentUserPermissions?: string[];
members?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMemberDto"][];
workspaces?: components["schemas"]["SocializeApiModulesWorkspacesHandlersWorkspaceDto"][];
usage?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationUsageDto"] | null;
availableMembershipTiers?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto"][];
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto: {
/** Format: guid */
id?: string;
key?: string;
name?: string;
description?: string;
/** Format: int32 */
monthlyPriceCents?: number | null;
/** Format: int32 */
workspaceLimit?: number | null;
/** Format: int32 */
activeContentLimit?: number | null;
/** Format: int32 */
memberLimit?: number | null;
/** Format: int32 */
externalReviewerLimit?: number | null;
isCustom?: boolean;
/** Format: int32 */
sortOrder?: number;
};
SocializeApiModulesOrganizationsHandlersOrganizationUsageDto: {
planKey?: string;
planName?: string;
items?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationUsageItemDto"][];
};
@@ -1245,10 +1300,16 @@ export interface components {
};
SocializeApiModulesOrganizationsHandlersCreateOrganizationRequest: {
name: string;
/** Format: guid */
membershipTierId?: string | null;
};
SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest: {
name: string;
};
SocializeApiModulesOrganizationsHandlersUpdateOrganizationMembershipTierRequest: {
/** Format: guid */
membershipTierId: string;
};
SocializeApiModulesNotificationsHandlersNotificationEventDto: {
/** Format: guid */
id?: string;
@@ -2438,6 +2499,75 @@ export interface operations {
};
};
};
SocializeApiModulesOrganizationsHandlersListOrganizationMembershipTiersHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesOrganizationsHandlersUpdateOrganizationMembershipTierHandler: {
parameters: {
query?: never;
header?: never;
path: {
organizationId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersUpdateOrganizationMembershipTierRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesNotificationsHandlersGetNotificationsHandler: {
parameters: {
query?: {

View File

@@ -18,12 +18,15 @@ export const useOrganizationStore = defineStore('organization', () => {
const client = useClient();
const organizations = ref([]);
const membershipTiers = ref([]);
const selectedOrganizationId = ref(null);
const detailsById = ref({});
const isLoading = ref(false);
const isLoadingDetails = ref(false);
const isCreating = ref(false);
const isLoadingMembershipTiers = ref(false);
const isSaving = ref(false);
const isUpdatingMembershipTier = ref(false);
const isAddingMember = ref(false);
const isUploadingLogo = ref(false);
const error = ref(null);
@@ -90,6 +93,28 @@ export const useOrganizationStore = defineStore('organization', () => {
}
}
async function fetchMembershipTiers() {
if (membershipTiers.value.length > 0) {
return membershipTiers.value;
}
isLoadingMembershipTiers.value = true;
error.value = null;
try {
const response = await client.get('/api/organization-membership-tiers');
membershipTiers.value = response.data ?? [];
return membershipTiers.value;
} catch (fetchError) {
console.error('Failed to fetch organization membership tiers:', fetchError);
membershipTiers.value = [];
error.value = 'Failed to load membership tiers.';
return [];
} finally {
isLoadingMembershipTiers.value = false;
}
}
async function fetchOrganization(organizationId) {
if (!authStore.isAuthenticated || !organizationId) {
return null;
@@ -190,6 +215,51 @@ export const useOrganizationStore = defineStore('organization', () => {
}
}
async function updateMembershipTier(organizationId, membershipTierId) {
if (!authStore.isAuthenticated || !organizationId) {
throw new Error('You must be authenticated to update an organization membership tier.');
}
isUpdatingMembershipTier.value = true;
error.value = null;
try {
const response = await client.put(`/api/organizations/${organizationId}/membership-tier`, {
membershipTierId,
});
const organization = response.data;
if (organization) {
const currentDetails = detailsById.value[organizationId];
detailsById.value = {
...detailsById.value,
[organizationId]: {
...(currentDetails ?? {}),
...organization,
members: currentDetails?.members ?? organization.members ?? [],
workspaces: currentDetails?.workspaces ?? organization.workspaces ?? [],
usage: currentDetails?.usage ?? organization.usage ?? null,
availableMembershipTiers: currentDetails?.availableMembershipTiers ?? organization.availableMembershipTiers ?? [],
},
};
organizations.value = organizations.value.map(candidate =>
candidate.id === organizationId
? { ...candidate, ...organization }
: candidate
);
}
await fetchOrganization(organizationId);
return detailsById.value[organizationId] ?? organization;
} catch (updateError) {
console.error('Failed to update organization membership tier:', updateError);
error.value = 'Failed to update organization membership tier.';
throw updateError;
} finally {
isUpdatingMembershipTier.value = false;
}
}
async function addMember(organizationId, payload) {
if (!authStore.isAuthenticated || !organizationId) {
throw new Error('You must be authenticated to add an organization member.');
@@ -291,13 +361,16 @@ export const useOrganizationStore = defineStore('organization', () => {
return {
organizations,
membershipTiers,
selectedOrganizationId,
activeOrganization,
detailsById,
isLoading,
isLoadingDetails,
isCreating,
isLoadingMembershipTiers,
isSaving,
isUpdatingMembershipTier,
isAddingMember,
isUploadingLogo,
error,
@@ -305,9 +378,11 @@ export const useOrganizationStore = defineStore('organization', () => {
setSelectedOrganization,
setSelectedOrganizationFromWorkspace,
fetchOrganizations,
fetchMembershipTiers,
fetchOrganization,
createOrganization,
updateOrganization,
updateMembershipTier,
addMember,
uploadLogo,
};

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { mdiAccountArrowRightOutline, mdiDomainPlus, mdiEmailOutline } from '@mdi/js';
@@ -13,6 +13,7 @@
const createForm = reactive({
name: '',
membershipTierId: null,
});
const accessForm = reactive({
targetType: 'organization',
@@ -27,6 +28,29 @@
{ title: t('organizationOnboarding.request.types.organization'), value: 'organization' },
{ title: t('organizationOnboarding.request.types.workspace'), value: 'workspace' },
]);
const membershipTierOptions = computed(() =>
organizationStore.membershipTiers.map(tier => ({
title: tier.name,
value: tier.id,
props: {
subtitle: formatTierPrice(tier),
},
}))
);
function formatTierPrice(tier) {
if (tier.isCustom || tier.monthlyPriceCents === null || tier.monthlyPriceCents === undefined) {
return t('organizationOnboarding.create.tiers.customPrice');
}
if (tier.monthlyPriceCents === 0) {
return t('organizationOnboarding.create.tiers.freePrice');
}
return t('organizationOnboarding.create.tiers.monthlyPrice', {
price: `$${Math.round(tier.monthlyPriceCents / 100)}`,
});
}
async function createOrganization() {
if (organizationStore.isCreating) {
@@ -42,7 +66,10 @@
}
try {
await organizationStore.createOrganization({ name });
await organizationStore.createOrganization({
name,
membershipTierId: createForm.membershipTierId,
});
await Promise.all([
organizationStore.fetchOrganizations(),
workspaceStore.fetchWorkspaces(),
@@ -73,6 +100,20 @@
window.location.href = `mailto:${encodeURIComponent(accessForm.adminEmail.trim())}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
requestStatus.value = t('organizationOnboarding.request.sent');
}
onMounted(async () => {
const tiers = await organizationStore.fetchMembershipTiers();
createForm.membershipTierId = tiers[0]?.id ?? null;
});
watch(
() => organizationStore.membershipTiers,
tiers => {
if (!createForm.membershipTierId) {
createForm.membershipTierId = tiers[0]?.id ?? null;
}
}
);
</script>
<template>
@@ -114,6 +155,15 @@
variant="outlined"
hide-details
/>
<v-select
v-model="createForm.membershipTierId"
:items="membershipTierOptions"
:label="t('organizationOnboarding.create.fields.membershipTier')"
:loading="organizationStore.isLoadingMembershipTiers"
:disabled="organizationStore.isCreating"
variant="outlined"
hide-details
/>
<v-btn
:loading="organizationStore.isCreating"

View File

@@ -35,6 +35,9 @@
email: '',
role: 'Member',
});
const membershipTierForm = reactive({
membershipTierId: null,
});
const memberRoleOptions = ['Member', 'Admin', 'BillingManager', 'ConnectorManager'];
const organizationId = computed(() => route.params.organizationId);
@@ -62,6 +65,18 @@
permissions.value.includes(organizationPermissions.createWorkspaces)
);
const usageItems = computed(() => organization.value?.usage?.items ?? []);
const membershipTierOptions = computed(() =>
(organization.value?.availableMembershipTiers?.length
? organization.value.availableMembershipTiers
: organizationStore.membershipTiers
).map(tier => ({
title: tier.name,
value: tier.id,
props: {
subtitle: formatTierSummary(tier),
},
}))
);
const visibleSections = computed(() => [
{ key: 'members', icon: mdiAccountGroupOutline, visible: canViewMembers.value },
{ key: 'usage', icon: mdiChartBar, visible: canViewUsage.value },
@@ -79,7 +94,10 @@
return;
}
await organizationStore.fetchOrganization(organizationId.value);
await Promise.all([
organizationStore.fetchMembershipTiers(),
organizationStore.fetchOrganization(organizationId.value),
]);
}
async function submitProfile() {
@@ -156,6 +174,48 @@
}
}
async function submitMembershipTier() {
settingsError.value = null;
settingsStatus.value = null;
if (!membershipTierForm.membershipTierId) {
settingsError.value = t('organizationSettings.errors.membershipTierRequired');
return;
}
try {
await organizationStore.updateMembershipTier(
organizationId.value,
membershipTierForm.membershipTierId
);
settingsStatus.value = t('organizationSettings.tierSaved');
} catch (error) {
console.error('Failed to update organization membership tier:', error);
settingsError.value = t('organizationSettings.errors.tierSaveFailed');
}
}
function formatTierSummary(tier) {
const price = tier.isCustom || tier.monthlyPriceCents === null || tier.monthlyPriceCents === undefined
? t('organizationSettings.tiers.customPrice')
: tier.monthlyPriceCents === 0
? t('organizationSettings.tiers.freePrice')
: t('organizationSettings.tiers.monthlyPrice', {
price: `$${Math.round(tier.monthlyPriceCents / 100)}`,
});
return t('organizationSettings.tiers.summary', {
price,
workspaces: formatLimit(tier.workspaceLimit),
members: formatLimit(tier.memberLimit),
activeContent: formatLimit(tier.activeContentLimit),
});
}
function formatLimit(limit) {
return limit ?? t('organizationSettings.usage.unlimited');
}
function usagePercent(item) {
if (!item.limit) {
return 0;
@@ -171,6 +231,7 @@
organization,
currentOrganization => {
profileForm.name = currentOrganization?.name ?? '';
membershipTierForm.membershipTierId = currentOrganization?.membershipTier?.id ?? null;
},
{ immediate: true }
);
@@ -408,8 +469,30 @@
>
<div class="usage-plan">
<strong>{{ t('organizationSettings.sections.usage.planLabel') }}</strong>
<span>{{ organization.usage?.planName ?? t('organizationSettings.sections.usage.planFallback') }}</span>
<span>{{ organization.membershipTier?.name ?? organization.usage?.planName ?? t('organizationSettings.sections.usage.planFallback') }}</span>
</div>
<v-form
v-if="canViewBilling"
class="tier-form"
@submit.prevent="submitMembershipTier"
>
<v-select
v-model="membershipTierForm.membershipTierId"
:items="membershipTierOptions"
:label="t('organizationSettings.fields.membershipTier')"
:loading="organizationStore.isLoadingMembershipTiers"
:disabled="organizationStore.isUpdatingMembershipTier"
variant="outlined"
hide-details
/>
<v-btn
color="primary"
type="submit"
:loading="organizationStore.isUpdatingMembershipTier"
>
{{ t('organizationSettings.saveTier') }}
</v-btn>
</v-form>
<div
v-for="item in usageItems"
:key="item.key"
@@ -717,6 +800,11 @@
@apply flex flex-col gap-3;
}
.tier-form {
@apply grid gap-3 rounded-[0.75rem] p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-end;
background: rgba(23, 32, 51, 0.04);
}
.usage-plan,
.usage-row {
@apply rounded-[0.75rem] p-4;

View File

@@ -399,7 +399,13 @@
"action": "Create organization",
"fields": {
"name": "Organization name",
"namePlaceholder": "Northstar Agency"
"namePlaceholder": "Northstar Agency",
"membershipTier": "Membership tier"
},
"tiers": {
"freePrice": "Free",
"monthlyPrice": "{price}/month",
"customPrice": "Custom"
},
"errors": {
"required": "Organization name is required.",
@@ -443,6 +449,8 @@
"addMember": "Add member",
"addingMember": "Adding...",
"memberAdded": "Organization member added.",
"saveTier": "Save tier",
"tierSaved": "Membership tier updated.",
"logo": {
"title": "Organization logo",
"description": "Shown in organization settings and switchers.",
@@ -457,6 +465,7 @@
"name": "Name",
"memberEmail": "Member email",
"memberRole": "Role",
"membershipTier": "Membership tier",
"createdAt": "Created"
},
"errors": {
@@ -464,7 +473,9 @@
"profileSaveFailed": "The organization profile could not be saved.",
"memberRequired": "Email and role are required to add a member.",
"memberAddFailed": "The organization member could not be added. Existing users can be added by email.",
"logoUploadFailed": "The organization logo could not be saved."
"logoUploadFailed": "The organization logo could not be saved.",
"membershipTierRequired": "Select a membership tier.",
"tierSaveFailed": "The membership tier could not be updated."
},
"sections": {
"profile": {
@@ -506,9 +517,16 @@
"items": {
"users": "Users",
"workspaces": "Workspaces",
"activeContent": "Active content"
"activeContent": "Active content",
"externalReviewers": "External reviewers"
}
},
"tiers": {
"freePrice": "Free",
"monthlyPrice": "{price}/month",
"customPrice": "Custom",
"summary": "{price} - {workspaces} workspaces, {members} members, {activeContent} active content"
},
"roles": {
"Owner": "Owner",
"Admin": "Admin",

View File

@@ -399,7 +399,13 @@
"action": "Creer l'organisation",
"fields": {
"name": "Nom de l'organisation",
"namePlaceholder": "Agence Northstar"
"namePlaceholder": "Agence Northstar",
"membershipTier": "Forfait"
},
"tiers": {
"freePrice": "Gratuit",
"monthlyPrice": "{price}/mois",
"customPrice": "Sur mesure"
},
"errors": {
"required": "Le nom de l'organisation est requis.",
@@ -443,6 +449,8 @@
"addMember": "Ajouter un membre",
"addingMember": "Ajout...",
"memberAdded": "Membre de l'organisation ajoute.",
"saveTier": "Enregistrer le forfait",
"tierSaved": "Forfait mis a jour.",
"logo": {
"title": "Logo de l'organisation",
"description": "Affiche dans les parametres et les selecteurs d'organisation.",
@@ -457,6 +465,7 @@
"name": "Nom",
"memberEmail": "Email du membre",
"memberRole": "Role",
"membershipTier": "Forfait",
"createdAt": "Cree"
},
"errors": {
@@ -464,7 +473,9 @@
"profileSaveFailed": "Le profil de l'organisation n'a pas pu etre enregistre.",
"memberRequired": "L'email et le role sont requis pour ajouter un membre.",
"memberAddFailed": "Le membre de l'organisation n'a pas pu etre ajoute. Les utilisateurs existants peuvent etre ajoutes par email.",
"logoUploadFailed": "Le logo de l'organisation n'a pas pu etre enregistre."
"logoUploadFailed": "Le logo de l'organisation n'a pas pu etre enregistre.",
"membershipTierRequired": "Selectionnez un forfait.",
"tierSaveFailed": "Le forfait n'a pas pu etre mis a jour."
},
"sections": {
"profile": {
@@ -506,9 +517,16 @@
"items": {
"users": "Utilisateurs",
"workspaces": "Espaces",
"activeContent": "Contenu actif"
"activeContent": "Contenu actif",
"externalReviewers": "Reviseurs externes"
}
},
"tiers": {
"freePrice": "Gratuit",
"monthlyPrice": "{price}/mois",
"customPrice": "Sur mesure",
"summary": "{price} - {workspaces} espaces, {members} membres, {activeContent} contenus actifs"
},
"roles": {
"Owner": "Proprietaire",
"Admin": "Administrateur",

View File

@@ -694,6 +694,101 @@
]
}
},
"/api/organization-membership-tiers": {
"get": {
"tags": [
"Organizations",
"Api"
],
"operationId": "SocializeApiModulesOrganizationsHandlersListOrganizationMembershipTiersHandler",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto"
}
}
}
}
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"JWTBearerAuth": []
}
]
}
},
"/api/organizations/{organizationId}/membership-tier": {
"put": {
"tags": [
"Organizations",
"Api"
],
"operationId": "SocializeApiModulesOrganizationsHandlersUpdateOrganizationMembershipTierHandler",
"parameters": [
{
"name": "organizationId",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "guid"
}
}
],
"requestBody": {
"x-name": "UpdateOrganizationMembershipTierRequest",
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersUpdateOrganizationMembershipTierRequest"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationDto"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"JWTBearerAuth": []
}
]
}
},
"/api/notifications": {
"get": {
"tags": [
@@ -4201,6 +4296,14 @@
"type": "string",
"nullable": true
},
"membershipTier": {
"nullable": true,
"oneOf": [
{
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto"
}
]
},
"ownerUserId": {
"type": "string",
"format": "guid"
@@ -4231,16 +4334,76 @@
}
]
},
"availableMembershipTiers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto"
}
},
"createdAt": {
"type": "string",
"format": "date-time"
}
}
},
"SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"format": "guid"
},
"key": {
"type": "string"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"monthlyPriceCents": {
"type": "integer",
"format": "int32",
"nullable": true
},
"workspaceLimit": {
"type": "integer",
"format": "int32",
"nullable": true
},
"activeContentLimit": {
"type": "integer",
"format": "int32",
"nullable": true
},
"memberLimit": {
"type": "integer",
"format": "int32",
"nullable": true
},
"externalReviewerLimit": {
"type": "integer",
"format": "int32",
"nullable": true
},
"isCustom": {
"type": "boolean"
},
"sortOrder": {
"type": "integer",
"format": "int32"
}
}
},
"SocializeApiModulesOrganizationsHandlersOrganizationUsageDto": {
"type": "object",
"additionalProperties": false,
"properties": {
"planKey": {
"type": "string"
},
"planName": {
"type": "string"
},
@@ -4282,6 +4445,11 @@
"maxLength": 256,
"minLength": 0,
"nullable": false
},
"membershipTierId": {
"type": "string",
"format": "guid",
"nullable": true
}
}
},
@@ -4300,6 +4468,21 @@
}
}
},
"SocializeApiModulesOrganizationsHandlersUpdateOrganizationMembershipTierRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"membershipTierId"
],
"properties": {
"membershipTierId": {
"type": "string",
"format": "guid",
"minLength": 1,
"nullable": false
}
}
},
"SocializeApiModulesNotificationsHandlersNotificationEventDto": {
"type": "object",
"additionalProperties": false,