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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
130
frontend/src/api/schema.d.ts
vendored
130
frontend/src/api/schema.d.ts
vendored
@@ -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?: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user