feat: add database backed membership tiers
This commit is contained in:
@@ -21,6 +21,7 @@ internal class AppDbContext(
|
|||||||
: IdentityDbContext<User, Role, Guid>(options)
|
: IdentityDbContext<User, Role, Guid>(options)
|
||||||
{
|
{
|
||||||
public DbSet<Organization> Organizations => Set<Organization>();
|
public DbSet<Organization> Organizations => Set<Organization>();
|
||||||
|
public DbSet<OrganizationMembershipTier> OrganizationMembershipTiers => Set<OrganizationMembershipTier>();
|
||||||
public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>();
|
public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>();
|
||||||
public DbSet<Workspace> Workspaces => Set<Workspace>();
|
public DbSet<Workspace> Workspaces => Set<Workspace>();
|
||||||
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
|
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ internal static class TestDataSeedExtensions
|
|||||||
}
|
}
|
||||||
|
|
||||||
organization.Name = "Northstar Agency";
|
organization.Name = "Northstar Agency";
|
||||||
|
organization.MembershipTierId = OrganizationMembershipTierSeed.AgencyId;
|
||||||
organization.OwnerUserId = managerUserId;
|
organization.OwnerUserId = managerUserId;
|
||||||
|
|
||||||
await UpsertOrganizationMembershipAsync(
|
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)
|
.HasMaxLength(2048)
|
||||||
.HasColumnType("character varying(2048)");
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<Guid>("MembershipTierId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasDefaultValue(new Guid("20000000-0000-0000-0000-000000000001"));
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
@@ -1669,6 +1674,8 @@ namespace Socialize.Api.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MembershipTierId");
|
||||||
|
|
||||||
b.HasIndex("OwnerUserId");
|
b.HasIndex("OwnerUserId");
|
||||||
|
|
||||||
b.ToTable("Organizations", (string)null);
|
b.ToTable("Organizations", (string)null);
|
||||||
@@ -1708,6 +1715,110 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("OrganizationMemberships", (string)null);
|
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 =>
|
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -2127,6 +2238,15 @@ namespace Socialize.Api.Migrations
|
|||||||
.IsRequired();
|
.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 =>
|
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
|
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ internal class Organization
|
|||||||
public Guid Id { get; init; }
|
public Guid Id { get; init; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public string? LogoUrl { get; set; }
|
public string? LogoUrl { get; set; }
|
||||||
|
public Guid MembershipTierId { get; set; } = OrganizationMembershipTierSeed.FreeId;
|
||||||
public Guid OwnerUserId { get; set; }
|
public Guid OwnerUserId { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; init; }
|
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.HasKey(x => x.Id);
|
||||||
organization.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
organization.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
||||||
organization.Property(x => x.LogoUrl).HasMaxLength(2048);
|
organization.Property(x => x.LogoUrl).HasMaxLength(2048);
|
||||||
|
organization.Property(x => x.MembershipTierId)
|
||||||
|
.HasDefaultValue(OrganizationMembershipTierSeed.FreeId);
|
||||||
organization.Property(x => x.CreatedAt)
|
organization.Property(x => x.CreatedAt)
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
organization.HasIndex(x => x.MembershipTierId);
|
||||||
organization.HasIndex(x => x.OwnerUserId);
|
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 =>
|
modelBuilder.Entity<OrganizationMembership>(membership =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
using Socialize.Api.Modules.Organizations.Data;
|
using Socialize.Api.Modules.Organizations.Data;
|
||||||
@@ -7,7 +8,8 @@ using Socialize.Api.Modules.Organizations.Services;
|
|||||||
namespace Socialize.Api.Modules.Organizations.Handlers;
|
namespace Socialize.Api.Modules.Organizations.Handlers;
|
||||||
|
|
||||||
internal record CreateOrganizationRequest(
|
internal record CreateOrganizationRequest(
|
||||||
string Name);
|
string Name,
|
||||||
|
Guid? MembershipTierId = null);
|
||||||
|
|
||||||
internal class CreateOrganizationRequestValidator
|
internal class CreateOrganizationRequestValidator
|
||||||
: Validator<CreateOrganizationRequest>
|
: Validator<CreateOrganizationRequest>
|
||||||
@@ -32,11 +34,22 @@ internal class CreateOrganizationHandler(
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
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();
|
Guid userId = User.GetUserId();
|
||||||
Organization organization = new()
|
Organization organization = new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Name = request.Name.Trim(),
|
Name = request.Name.Trim(),
|
||||||
|
MembershipTierId = membershipTier.Id,
|
||||||
OwnerUserId = userId,
|
OwnerUserId = userId,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
@@ -57,7 +70,8 @@ internal class CreateOrganizationHandler(
|
|||||||
await SendAsync(
|
await SendAsync(
|
||||||
OrganizationDto.FromOrganization(
|
OrganizationDto.FromOrganization(
|
||||||
organization,
|
organization,
|
||||||
OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner)),
|
OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner),
|
||||||
|
OrganizationMembershipTierDto.FromTier(membershipTier)),
|
||||||
StatusCodes.Status201Created,
|
StatusCodes.Status201Created,
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Socialize.Api.Data;
|
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.Identity.Data;
|
||||||
using Socialize.Api.Modules.Organizations.Data;
|
using Socialize.Api.Modules.Organizations.Data;
|
||||||
using Socialize.Api.Modules.Organizations.Services;
|
using Socialize.Api.Modules.Organizations.Services;
|
||||||
@@ -44,15 +46,19 @@ internal class GetOrganizationHandler(
|
|||||||
|
|
||||||
IReadOnlyCollection<OrganizationMemberDto> members = await GetMembersAsync(organizationId, ct);
|
IReadOnlyCollection<OrganizationMemberDto> members = await GetMembersAsync(organizationId, ct);
|
||||||
IReadOnlyCollection<WorkspaceDto> workspaces = await GetWorkspacesAsync(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(
|
await SendOkAsync(
|
||||||
OrganizationDto.FromOrganization(
|
OrganizationDto.FromOrganization(
|
||||||
organization,
|
organization,
|
||||||
currentUserPermissions,
|
currentUserPermissions,
|
||||||
|
OrganizationMembershipTierDto.FromTier(membershipTier),
|
||||||
members,
|
members,
|
||||||
workspaces,
|
workspaces,
|
||||||
usage),
|
usage,
|
||||||
|
availableMembershipTiers),
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +106,7 @@ internal class GetOrganizationHandler(
|
|||||||
|
|
||||||
private async Task<OrganizationUsageDto> GetUsageAsync(
|
private async Task<OrganizationUsageDto> GetUsageAsync(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
|
OrganizationMembershipTier membershipTier,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
Guid[] workspaceIds = await dbContext.Workspaces
|
Guid[] workspaceIds = await dbContext.Workspaces
|
||||||
@@ -125,29 +132,80 @@ internal class GetOrganizationHandler(
|
|||||||
contentItem.Status != "Scheduled")
|
contentItem.Status != "Scheduled")
|
||||||
.CountAsync(ct);
|
.CountAsync(ct);
|
||||||
|
|
||||||
OrganizationUsageLimits limits = GetUsageLimits(organization.Name);
|
int externalReviewerCount = workspaceIds.Length == 0
|
||||||
|
? 0
|
||||||
|
: await GetExternalReviewerCountAsync(workspaceIds, memberUserIds, organization.OwnerUserId, ct);
|
||||||
|
|
||||||
return new OrganizationUsageDto(
|
return new OrganizationUsageDto(
|
||||||
limits.PlanName,
|
membershipTier.Key,
|
||||||
|
membershipTier.Name,
|
||||||
[
|
[
|
||||||
new OrganizationUsageItemDto("users", userCount, limits.UserLimit),
|
new OrganizationUsageItemDto("users", userCount, membershipTier.MemberLimit),
|
||||||
new OrganizationUsageItemDto("workspaces", workspaceIds.Length, limits.WorkspaceLimit),
|
new OrganizationUsageItemDto("workspaces", workspaceIds.Length, membershipTier.WorkspaceLimit),
|
||||||
new OrganizationUsageItemDto("activeContent", activeContentItemCount, limits.ActiveContentLimit),
|
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)
|
return await dbContext.OrganizationMembershipTiers
|
||||||
? new OrganizationUsageLimits("Agency", 25, 15, 250)
|
.SingleOrDefaultAsync(tier => tier.Id == membershipTierId, ct)
|
||||||
: new OrganizationUsageLimits("Free", 2, 1, 3);
|
?? await dbContext.OrganizationMembershipTiers
|
||||||
|
.SingleAsync(tier => tier.Id == OrganizationMembershipTierSeed.FreeId, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record OrganizationUsageLimits(
|
private async Task<IReadOnlyCollection<OrganizationMembershipTierDto>> GetAvailableMembershipTiersAsync(CancellationToken ct)
|
||||||
string PlanName,
|
{
|
||||||
int UserLimit,
|
List<OrganizationMembershipTier> tiers = await dbContext.OrganizationMembershipTiers
|
||||||
int WorkspaceLimit,
|
.OrderBy(tier => tier.SortOrder)
|
||||||
int ActiveContentLimit);
|
.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)
|
private static string BuildDisplayName(User user)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,6 +26,18 @@ internal class GetOrganizationsHandler(
|
|||||||
.OrderBy(organization => organization.Name)
|
.OrderBy(organization => organization.Name)
|
||||||
.ToListAsync(ct);
|
.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 = [];
|
List<OrganizationDto> response = [];
|
||||||
foreach (Organization organization in organizations)
|
foreach (Organization organization in organizations)
|
||||||
{
|
{
|
||||||
@@ -33,7 +45,10 @@ internal class GetOrganizationsHandler(
|
|||||||
User,
|
User,
|
||||||
organization.Id,
|
organization.Id,
|
||||||
ct);
|
ct);
|
||||||
response.Add(OrganizationDto.FromOrganization(organization, permissions));
|
response.Add(OrganizationDto.FromOrganization(
|
||||||
|
organization,
|
||||||
|
permissions,
|
||||||
|
membershipTiersById.GetValueOrDefault(organization.MembershipTierId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
await SendOkAsync(response, ct);
|
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,
|
Guid Id,
|
||||||
string Name,
|
string Name,
|
||||||
string? LogoUrl,
|
string? LogoUrl,
|
||||||
|
OrganizationMembershipTierDto? MembershipTier,
|
||||||
Guid OwnerUserId,
|
Guid OwnerUserId,
|
||||||
IReadOnlyCollection<string> CurrentUserPermissions,
|
IReadOnlyCollection<string> CurrentUserPermissions,
|
||||||
IReadOnlyCollection<OrganizationMemberDto> Members,
|
IReadOnlyCollection<OrganizationMemberDto> Members,
|
||||||
IReadOnlyCollection<WorkspaceDto> Workspaces,
|
IReadOnlyCollection<WorkspaceDto> Workspaces,
|
||||||
OrganizationUsageDto? Usage,
|
OrganizationUsageDto? Usage,
|
||||||
|
IReadOnlyCollection<OrganizationMembershipTierDto> AvailableMembershipTiers,
|
||||||
DateTimeOffset CreatedAt)
|
DateTimeOffset CreatedAt)
|
||||||
{
|
{
|
||||||
public static OrganizationDto FromOrganization(
|
public static OrganizationDto FromOrganization(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
IReadOnlyCollection<string> currentUserPermissions,
|
IReadOnlyCollection<string> currentUserPermissions,
|
||||||
|
OrganizationMembershipTierDto? membershipTier = null,
|
||||||
IReadOnlyCollection<OrganizationMemberDto>? members = null,
|
IReadOnlyCollection<OrganizationMemberDto>? members = null,
|
||||||
IReadOnlyCollection<WorkspaceDto>? workspaces = null,
|
IReadOnlyCollection<WorkspaceDto>? workspaces = null,
|
||||||
OrganizationUsageDto? usage = null)
|
OrganizationUsageDto? usage = null,
|
||||||
|
IReadOnlyCollection<OrganizationMembershipTierDto>? availableMembershipTiers = null)
|
||||||
{
|
{
|
||||||
return new OrganizationDto(
|
return new OrganizationDto(
|
||||||
organization.Id,
|
organization.Id,
|
||||||
organization.Name,
|
organization.Name,
|
||||||
organization.LogoUrl,
|
organization.LogoUrl,
|
||||||
|
membershipTier,
|
||||||
organization.OwnerUserId,
|
organization.OwnerUserId,
|
||||||
currentUserPermissions,
|
currentUserPermissions,
|
||||||
members ?? [],
|
members ?? [],
|
||||||
workspaces ?? [],
|
workspaces ?? [],
|
||||||
usage,
|
usage,
|
||||||
|
availableMembershipTiers ?? [],
|
||||||
organization.CreatedAt);
|
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(
|
internal record OrganizationUsageDto(
|
||||||
|
string PlanKey,
|
||||||
string PlanName,
|
string PlanName,
|
||||||
IReadOnlyCollection<OrganizationUsageItemDto> Items);
|
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;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/notifications": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1223,16 +1255,39 @@ export interface components {
|
|||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
logoUrl?: string | null;
|
logoUrl?: string | null;
|
||||||
|
membershipTier?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto"] | null;
|
||||||
/** Format: guid */
|
/** Format: guid */
|
||||||
ownerUserId?: string;
|
ownerUserId?: string;
|
||||||
currentUserPermissions?: string[];
|
currentUserPermissions?: string[];
|
||||||
members?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMemberDto"][];
|
members?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMemberDto"][];
|
||||||
workspaces?: components["schemas"]["SocializeApiModulesWorkspacesHandlersWorkspaceDto"][];
|
workspaces?: components["schemas"]["SocializeApiModulesWorkspacesHandlersWorkspaceDto"][];
|
||||||
usage?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationUsageDto"] | null;
|
usage?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationUsageDto"] | null;
|
||||||
|
availableMembershipTiers?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto"][];
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt?: string;
|
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: {
|
SocializeApiModulesOrganizationsHandlersOrganizationUsageDto: {
|
||||||
|
planKey?: string;
|
||||||
planName?: string;
|
planName?: string;
|
||||||
items?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationUsageItemDto"][];
|
items?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationUsageItemDto"][];
|
||||||
};
|
};
|
||||||
@@ -1245,10 +1300,16 @@ export interface components {
|
|||||||
};
|
};
|
||||||
SocializeApiModulesOrganizationsHandlersCreateOrganizationRequest: {
|
SocializeApiModulesOrganizationsHandlersCreateOrganizationRequest: {
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Format: guid */
|
||||||
|
membershipTierId?: string | null;
|
||||||
};
|
};
|
||||||
SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest: {
|
SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest: {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesOrganizationsHandlersUpdateOrganizationMembershipTierRequest: {
|
||||||
|
/** Format: guid */
|
||||||
|
membershipTierId: string;
|
||||||
|
};
|
||||||
SocializeApiModulesNotificationsHandlersNotificationEventDto: {
|
SocializeApiModulesNotificationsHandlersNotificationEventDto: {
|
||||||
/** Format: guid */
|
/** Format: guid */
|
||||||
id?: string;
|
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: {
|
SocializeApiModulesNotificationsHandlersGetNotificationsHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ export const useOrganizationStore = defineStore('organization', () => {
|
|||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
|
||||||
const organizations = ref([]);
|
const organizations = ref([]);
|
||||||
|
const membershipTiers = ref([]);
|
||||||
const selectedOrganizationId = ref(null);
|
const selectedOrganizationId = ref(null);
|
||||||
const detailsById = ref({});
|
const detailsById = ref({});
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const isLoadingDetails = ref(false);
|
const isLoadingDetails = ref(false);
|
||||||
const isCreating = ref(false);
|
const isCreating = ref(false);
|
||||||
|
const isLoadingMembershipTiers = ref(false);
|
||||||
const isSaving = ref(false);
|
const isSaving = ref(false);
|
||||||
|
const isUpdatingMembershipTier = ref(false);
|
||||||
const isAddingMember = ref(false);
|
const isAddingMember = ref(false);
|
||||||
const isUploadingLogo = ref(false);
|
const isUploadingLogo = ref(false);
|
||||||
const error = ref(null);
|
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) {
|
async function fetchOrganization(organizationId) {
|
||||||
if (!authStore.isAuthenticated || !organizationId) {
|
if (!authStore.isAuthenticated || !organizationId) {
|
||||||
return null;
|
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) {
|
async function addMember(organizationId, payload) {
|
||||||
if (!authStore.isAuthenticated || !organizationId) {
|
if (!authStore.isAuthenticated || !organizationId) {
|
||||||
throw new Error('You must be authenticated to add an organization member.');
|
throw new Error('You must be authenticated to add an organization member.');
|
||||||
@@ -291,13 +361,16 @@ export const useOrganizationStore = defineStore('organization', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
organizations,
|
organizations,
|
||||||
|
membershipTiers,
|
||||||
selectedOrganizationId,
|
selectedOrganizationId,
|
||||||
activeOrganization,
|
activeOrganization,
|
||||||
detailsById,
|
detailsById,
|
||||||
isLoading,
|
isLoading,
|
||||||
isLoadingDetails,
|
isLoadingDetails,
|
||||||
isCreating,
|
isCreating,
|
||||||
|
isLoadingMembershipTiers,
|
||||||
isSaving,
|
isSaving,
|
||||||
|
isUpdatingMembershipTier,
|
||||||
isAddingMember,
|
isAddingMember,
|
||||||
isUploadingLogo,
|
isUploadingLogo,
|
||||||
error,
|
error,
|
||||||
@@ -305,9 +378,11 @@ export const useOrganizationStore = defineStore('organization', () => {
|
|||||||
setSelectedOrganization,
|
setSelectedOrganization,
|
||||||
setSelectedOrganizationFromWorkspace,
|
setSelectedOrganizationFromWorkspace,
|
||||||
fetchOrganizations,
|
fetchOrganizations,
|
||||||
|
fetchMembershipTiers,
|
||||||
fetchOrganization,
|
fetchOrganization,
|
||||||
createOrganization,
|
createOrganization,
|
||||||
updateOrganization,
|
updateOrganization,
|
||||||
|
updateMembershipTier,
|
||||||
addMember,
|
addMember,
|
||||||
uploadLogo,
|
uploadLogo,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { mdiAccountArrowRightOutline, mdiDomainPlus, mdiEmailOutline } from '@mdi/js';
|
import { mdiAccountArrowRightOutline, mdiDomainPlus, mdiEmailOutline } from '@mdi/js';
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
|
membershipTierId: null,
|
||||||
});
|
});
|
||||||
const accessForm = reactive({
|
const accessForm = reactive({
|
||||||
targetType: 'organization',
|
targetType: 'organization',
|
||||||
@@ -27,6 +28,29 @@
|
|||||||
{ title: t('organizationOnboarding.request.types.organization'), value: 'organization' },
|
{ title: t('organizationOnboarding.request.types.organization'), value: 'organization' },
|
||||||
{ title: t('organizationOnboarding.request.types.workspace'), value: 'workspace' },
|
{ 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() {
|
async function createOrganization() {
|
||||||
if (organizationStore.isCreating) {
|
if (organizationStore.isCreating) {
|
||||||
@@ -42,7 +66,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await organizationStore.createOrganization({ name });
|
await organizationStore.createOrganization({
|
||||||
|
name,
|
||||||
|
membershipTierId: createForm.membershipTierId,
|
||||||
|
});
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
organizationStore.fetchOrganizations(),
|
organizationStore.fetchOrganizations(),
|
||||||
workspaceStore.fetchWorkspaces(),
|
workspaceStore.fetchWorkspaces(),
|
||||||
@@ -73,6 +100,20 @@
|
|||||||
window.location.href = `mailto:${encodeURIComponent(accessForm.adminEmail.trim())}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
window.location.href = `mailto:${encodeURIComponent(accessForm.adminEmail.trim())}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||||
requestStatus.value = t('organizationOnboarding.request.sent');
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -114,6 +155,15 @@
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
hide-details
|
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
|
<v-btn
|
||||||
:loading="organizationStore.isCreating"
|
:loading="organizationStore.isCreating"
|
||||||
|
|||||||
@@ -35,6 +35,9 @@
|
|||||||
email: '',
|
email: '',
|
||||||
role: 'Member',
|
role: 'Member',
|
||||||
});
|
});
|
||||||
|
const membershipTierForm = reactive({
|
||||||
|
membershipTierId: null,
|
||||||
|
});
|
||||||
const memberRoleOptions = ['Member', 'Admin', 'BillingManager', 'ConnectorManager'];
|
const memberRoleOptions = ['Member', 'Admin', 'BillingManager', 'ConnectorManager'];
|
||||||
|
|
||||||
const organizationId = computed(() => route.params.organizationId);
|
const organizationId = computed(() => route.params.organizationId);
|
||||||
@@ -62,6 +65,18 @@
|
|||||||
permissions.value.includes(organizationPermissions.createWorkspaces)
|
permissions.value.includes(organizationPermissions.createWorkspaces)
|
||||||
);
|
);
|
||||||
const usageItems = computed(() => organization.value?.usage?.items ?? []);
|
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(() => [
|
const visibleSections = computed(() => [
|
||||||
{ key: 'members', icon: mdiAccountGroupOutline, visible: canViewMembers.value },
|
{ key: 'members', icon: mdiAccountGroupOutline, visible: canViewMembers.value },
|
||||||
{ key: 'usage', icon: mdiChartBar, visible: canViewUsage.value },
|
{ key: 'usage', icon: mdiChartBar, visible: canViewUsage.value },
|
||||||
@@ -79,7 +94,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await organizationStore.fetchOrganization(organizationId.value);
|
await Promise.all([
|
||||||
|
organizationStore.fetchMembershipTiers(),
|
||||||
|
organizationStore.fetchOrganization(organizationId.value),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitProfile() {
|
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) {
|
function usagePercent(item) {
|
||||||
if (!item.limit) {
|
if (!item.limit) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -171,6 +231,7 @@
|
|||||||
organization,
|
organization,
|
||||||
currentOrganization => {
|
currentOrganization => {
|
||||||
profileForm.name = currentOrganization?.name ?? '';
|
profileForm.name = currentOrganization?.name ?? '';
|
||||||
|
membershipTierForm.membershipTierId = currentOrganization?.membershipTier?.id ?? null;
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
@@ -408,8 +469,30 @@
|
|||||||
>
|
>
|
||||||
<div class="usage-plan">
|
<div class="usage-plan">
|
||||||
<strong>{{ t('organizationSettings.sections.usage.planLabel') }}</strong>
|
<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>
|
</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
|
<div
|
||||||
v-for="item in usageItems"
|
v-for="item in usageItems"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
@@ -717,6 +800,11 @@
|
|||||||
@apply flex flex-col gap-3;
|
@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-plan,
|
||||||
.usage-row {
|
.usage-row {
|
||||||
@apply rounded-[0.75rem] p-4;
|
@apply rounded-[0.75rem] p-4;
|
||||||
|
|||||||
@@ -399,7 +399,13 @@
|
|||||||
"action": "Create organization",
|
"action": "Create organization",
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Organization name",
|
"name": "Organization name",
|
||||||
"namePlaceholder": "Northstar Agency"
|
"namePlaceholder": "Northstar Agency",
|
||||||
|
"membershipTier": "Membership tier"
|
||||||
|
},
|
||||||
|
"tiers": {
|
||||||
|
"freePrice": "Free",
|
||||||
|
"monthlyPrice": "{price}/month",
|
||||||
|
"customPrice": "Custom"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"required": "Organization name is required.",
|
"required": "Organization name is required.",
|
||||||
@@ -443,6 +449,8 @@
|
|||||||
"addMember": "Add member",
|
"addMember": "Add member",
|
||||||
"addingMember": "Adding...",
|
"addingMember": "Adding...",
|
||||||
"memberAdded": "Organization member added.",
|
"memberAdded": "Organization member added.",
|
||||||
|
"saveTier": "Save tier",
|
||||||
|
"tierSaved": "Membership tier updated.",
|
||||||
"logo": {
|
"logo": {
|
||||||
"title": "Organization logo",
|
"title": "Organization logo",
|
||||||
"description": "Shown in organization settings and switchers.",
|
"description": "Shown in organization settings and switchers.",
|
||||||
@@ -457,6 +465,7 @@
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"memberEmail": "Member email",
|
"memberEmail": "Member email",
|
||||||
"memberRole": "Role",
|
"memberRole": "Role",
|
||||||
|
"membershipTier": "Membership tier",
|
||||||
"createdAt": "Created"
|
"createdAt": "Created"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -464,7 +473,9 @@
|
|||||||
"profileSaveFailed": "The organization profile could not be saved.",
|
"profileSaveFailed": "The organization profile could not be saved.",
|
||||||
"memberRequired": "Email and role are required to add a member.",
|
"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.",
|
"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": {
|
"sections": {
|
||||||
"profile": {
|
"profile": {
|
||||||
@@ -506,9 +517,16 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"workspaces": "Workspaces",
|
"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": {
|
"roles": {
|
||||||
"Owner": "Owner",
|
"Owner": "Owner",
|
||||||
"Admin": "Admin",
|
"Admin": "Admin",
|
||||||
|
|||||||
@@ -399,7 +399,13 @@
|
|||||||
"action": "Creer l'organisation",
|
"action": "Creer l'organisation",
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Nom de l'organisation",
|
"name": "Nom de l'organisation",
|
||||||
"namePlaceholder": "Agence Northstar"
|
"namePlaceholder": "Agence Northstar",
|
||||||
|
"membershipTier": "Forfait"
|
||||||
|
},
|
||||||
|
"tiers": {
|
||||||
|
"freePrice": "Gratuit",
|
||||||
|
"monthlyPrice": "{price}/mois",
|
||||||
|
"customPrice": "Sur mesure"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"required": "Le nom de l'organisation est requis.",
|
"required": "Le nom de l'organisation est requis.",
|
||||||
@@ -443,6 +449,8 @@
|
|||||||
"addMember": "Ajouter un membre",
|
"addMember": "Ajouter un membre",
|
||||||
"addingMember": "Ajout...",
|
"addingMember": "Ajout...",
|
||||||
"memberAdded": "Membre de l'organisation ajoute.",
|
"memberAdded": "Membre de l'organisation ajoute.",
|
||||||
|
"saveTier": "Enregistrer le forfait",
|
||||||
|
"tierSaved": "Forfait mis a jour.",
|
||||||
"logo": {
|
"logo": {
|
||||||
"title": "Logo de l'organisation",
|
"title": "Logo de l'organisation",
|
||||||
"description": "Affiche dans les parametres et les selecteurs d'organisation.",
|
"description": "Affiche dans les parametres et les selecteurs d'organisation.",
|
||||||
@@ -457,6 +465,7 @@
|
|||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
"memberEmail": "Email du membre",
|
"memberEmail": "Email du membre",
|
||||||
"memberRole": "Role",
|
"memberRole": "Role",
|
||||||
|
"membershipTier": "Forfait",
|
||||||
"createdAt": "Cree"
|
"createdAt": "Cree"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -464,7 +473,9 @@
|
|||||||
"profileSaveFailed": "Le profil de l'organisation n'a pas pu etre enregistre.",
|
"profileSaveFailed": "Le profil de l'organisation n'a pas pu etre enregistre.",
|
||||||
"memberRequired": "L'email et le role sont requis pour ajouter un membre.",
|
"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.",
|
"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": {
|
"sections": {
|
||||||
"profile": {
|
"profile": {
|
||||||
@@ -506,9 +517,16 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"users": "Utilisateurs",
|
"users": "Utilisateurs",
|
||||||
"workspaces": "Espaces",
|
"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": {
|
"roles": {
|
||||||
"Owner": "Proprietaire",
|
"Owner": "Proprietaire",
|
||||||
"Admin": "Administrateur",
|
"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": {
|
"/api/notifications": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -4201,6 +4296,14 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"membershipTier": {
|
||||||
|
"nullable": true,
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"ownerUserId": {
|
"ownerUserId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "guid"
|
"format": "guid"
|
||||||
@@ -4231,16 +4334,76 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"availableMembershipTiers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"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": {
|
"SocializeApiModulesOrganizationsHandlersOrganizationUsageDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"planKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"planName": {
|
"planName": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4282,6 +4445,11 @@
|
|||||||
"maxLength": 256,
|
"maxLength": 256,
|
||||||
"minLength": 0,
|
"minLength": 0,
|
||||||
"nullable": false
|
"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": {
|
"SocializeApiModulesNotificationsHandlersNotificationEventDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user