From 6d92119c9c301fa29a0474208416cff1a9d9e190 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 7 May 2026 20:29:53 -0400 Subject: [PATCH] feat: add database backed membership tiers --- .../src/Socialize.Api/Data/AppDbContext.cs | 1 + .../TestData/TestDataSeedExtensions.cs | 1 + ...AddOrganizationMembershipTiers.Designer.cs | 2293 +++++++++++++++++ ...08001846_AddOrganizationMembershipTiers.cs | 118 + .../Migrations/AppDbContextModelSnapshot.cs | 120 + .../Organizations/Data/Organization.cs | 1 + .../Data/OrganizationMembershipTier.cs | 16 + .../Data/OrganizationMembershipTierSeed.cs | 66 + .../Data/OrganizationModelConfiguration.cs | 19 + .../Handlers/CreateOrganization.cs | 18 +- .../Organizations/Handlers/GetOrganization.cs | 90 +- .../Handlers/GetOrganizations.cs | 17 +- .../ListOrganizationMembershipTiers.cs | 30 + .../Handlers/OrganizationDtos.cs | 39 +- .../UpdateOrganizationMembershipTier.cs | 80 + .../008-database-backed-membership-tiers.md | 51 + frontend/src/api/schema.d.ts | 130 + .../organizations/stores/organizationStore.js | 75 + .../views/OrganizationOnboardingView.vue | 54 +- .../views/OrganizationSettingsView.vue | 92 +- frontend/src/locales/en.json | 24 +- frontend/src/locales/fr.json | 24 +- shared/openapi/openapi.json | 183 ++ 23 files changed, 3512 insertions(+), 30 deletions(-) create mode 100644 backend/src/Socialize.Api/Migrations/20260508001846_AddOrganizationMembershipTiers.Designer.cs create mode 100644 backend/src/Socialize.Api/Migrations/20260508001846_AddOrganizationMembershipTiers.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationMembershipTier.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationMembershipTierSeed.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Handlers/ListOrganizationMembershipTiers.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Handlers/UpdateOrganizationMembershipTier.cs create mode 100644 docs/TASKS/organizations/008-database-backed-membership-tiers.md diff --git a/backend/src/Socialize.Api/Data/AppDbContext.cs b/backend/src/Socialize.Api/Data/AppDbContext.cs index 8ceb2040..e9160e95 100644 --- a/backend/src/Socialize.Api/Data/AppDbContext.cs +++ b/backend/src/Socialize.Api/Data/AppDbContext.cs @@ -21,6 +21,7 @@ internal class AppDbContext( : IdentityDbContext(options) { public DbSet Organizations => Set(); + public DbSet OrganizationMembershipTiers => Set(); public DbSet OrganizationMemberships => Set(); public DbSet Workspaces => Set(); public DbSet WorkspaceInvites => Set(); diff --git a/backend/src/Socialize.Api/Infrastructure/TestData/TestDataSeedExtensions.cs b/backend/src/Socialize.Api/Infrastructure/TestData/TestDataSeedExtensions.cs index 3125e888..304592c3 100644 --- a/backend/src/Socialize.Api/Infrastructure/TestData/TestDataSeedExtensions.cs +++ b/backend/src/Socialize.Api/Infrastructure/TestData/TestDataSeedExtensions.cs @@ -261,6 +261,7 @@ internal static class TestDataSeedExtensions } organization.Name = "Northstar Agency"; + organization.MembershipTierId = OrganizationMembershipTierSeed.AgencyId; organization.OwnerUserId = managerUserId; await UpsertOrganizationMembershipAsync( diff --git a/backend/src/Socialize.Api/Migrations/20260508001846_AddOrganizationMembershipTiers.Designer.cs b/backend/src/Socialize.Api/Migrations/20260508001846_AddOrganizationMembershipTiers.Designer.cs new file mode 100644 index 00000000..1c3cb423 --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260508001846_AddOrganizationMembershipTiers.Designer.cs @@ -0,0 +1,2293 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Socialize.Api.Data; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260508001846_AddOrganizationMembershipTiers")] + partial class AddOrganizationMembershipTiers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalDecision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DecidedByEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByUserId") + .HasColumnType("uuid"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalRequestId"); + + b.ToTable("ApprovalDecisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("DueAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedByUserId") + .HasColumnType("uuid"); + + b.Property("ReviewerEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkflowInstanceId") + .HasColumnType("uuid"); + + b.Property("WorkflowStepRequiredApproverCount") + .HasColumnType("integer"); + + b.Property("WorkflowStepSortOrder") + .HasColumnType("integer"); + + b.Property("WorkflowStepTargetType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("WorkflowStepTargetValue") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ReviewerEmail"); + + b.HasIndex("WorkflowInstanceId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ApprovalRequests", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalMode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ContentItemId", "State") + .IsUnique() + .HasFilter("\"State\" = 'Pending'"); + + b.ToTable("ApprovalWorkflowInstances", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.WorkspaceApprovalStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RequiredApproverCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetValue") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "SortOrder") + .IsUnique(); + + b.ToTable("WorkspaceApprovalStepConfigurations", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveFileId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveLink") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Assets", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.AssetRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("AssetId", "RevisionNumber") + .IsUnique(); + + b.ToTable("AssetRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarCatalogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Country") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CultureOrReligion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DefaultColor") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Region") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TrustLevel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Country"); + + b.HasIndex("ProviderName"); + + b.ToTable("CalendarCatalogEntries", (string)null); + + b.HasData( + new + { + Id = new Guid("10000000-0000-0000-0000-000000000001"), + Category = "public-holiday", + Country = "US", + CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DefaultColor = "#2F80ED", + Description = "Federal public holiday calendar for the United States.", + Language = "en", + ProviderName = "Nager.Date", + SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/US", + Title = "United States Public Holidays", + TrustLevel = "Verified" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000002"), + Category = "public-holiday", + Country = "CA", + CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DefaultColor = "#2F80ED", + Description = "Public holiday calendar for Canada.", + Language = "en", + ProviderName = "Nager.Date", + SourceUrl = "https://date.nager.at/api/v3/PublicHolidays/2026/CA", + Title = "Canada Public Holidays", + TrustLevel = "Verified" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000003"), + Category = "marketing-moment", + CreatedAt = new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DefaultColor = "#9B51E0", + Description = "Common retail, awareness, and social planning moments.", + Language = "en", + ProviderName = "Socialize", + SourceUrl = "https://example.com/socialize/marketing-moments.ics", + Title = "Common Marketing Moments", + TrustLevel = "Maintained" + }); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CalendarSourceId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("EndLocalDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("EndUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAllDay") + .HasColumnType("boolean"); + + b.Property("IsFloatingTime") + .HasColumnType("boolean"); + + b.Property("Location") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RecurrenceId") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SourceEventUid") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SourceLastModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("StartLocalDateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("StartUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TimeZoneId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("CalendarSourceId"); + + b.HasIndex("CalendarSourceId", "SourceEventUid", "StartDate") + .IsUnique(); + + b.ToTable("CalendarEvents", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CatalogSourceReference") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayTitle") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InheritanceMode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastAttemptedSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SourceUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("Scope"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("CalendarSources", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.UserCalendarExportFeed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("TokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("UserCalendarExportFeeds", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Campaigns.Data.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Notes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ClientId", "Name") + .IsUnique(); + + b.ToTable("Campaigns", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Channels.Data.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExternalUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Handle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Network") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Network", "Name") + .IsUnique(); + + b.ToTable("Channels", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PrimaryContactEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrimaryContactName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrimaryContactPortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.ToTable("Clients", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Comments.Data.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttachmentBlobContainerName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AttachmentBlobName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("AttachmentBlobUrl") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("AttachmentContentType") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AttachmentFileName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AttachmentSizeBytes") + .HasColumnType("bigint"); + + b.Property("AuthorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorUserId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ParentCommentId") + .HasColumnType("uuid"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ParentCommentId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Comments", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionLabel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Hashtags") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PublicationMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublicationTargets") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("ClientId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ContentItems", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemActivityEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ContentItemId", "CreatedAt"); + + b.ToTable("ContentItemActivityEntries", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChangeSummary") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Hashtags") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PublicationMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublicationTargets") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RevisionLabel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ContentItemId", "RevisionNumber") + .IsUnique(); + + b.ToTable("ContentItemRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("FromValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Note") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ToValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("FeedbackReportId"); + + b.ToTable("FeedbackActivityEntries", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AuthorUserId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AuthorUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("FeedbackReportId"); + + b.ToTable("FeedbackComments", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BrowserUserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("CampaignName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CancellationReason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancelledByUserId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("ContentItemTitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("LastActivityAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReporterDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReporterEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReporterUserId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubmittedPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ViewportHeight") + .HasColumnType("integer"); + + b.Property("ViewportWidth") + .HasColumnType("integer"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.Property("WorkspaceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("ClientId"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("LastActivityAt"); + + b.HasIndex("ReporterUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("FeedbackReports", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlobContainerName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FeedbackReportId") + .IsUnique(); + + b.ToTable("FeedbackScreenshots", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName"); + + b.HasIndex("FeedbackReportId", "NormalizedName") + .IsUnique(); + + b.ToTable("FeedbackTags", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Alias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FacebookId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Firstname") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Lastname") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RefreshToken") + .HasMaxLength(44) + .HasColumnType("character varying(44)"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Notifications.Data.NotificationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataJson") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RecipientEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RecipientUserId") + .HasColumnType("uuid"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RecipientUserId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("NotificationEvents", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("MembershipTierId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValue(new Guid("20000000-0000-0000-0000-000000000001")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MembershipTierId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Organizations", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique(); + + b.ToTable("OrganizationMemberships", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembershipTier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActiveContentLimit") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ExternalReviewerLimit") + .HasColumnType("integer"); + + b.Property("IsCustom") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MemberLimit") + .HasColumnType("integer"); + + b.Property("MonthlyPriceCents") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("Required"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LockContentAfterApproval") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.Property("SchedulePostsAutomaticallyOnApproval") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("SendAutomaticApprovalReminders") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Workspaces", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.WorkspaceInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InvitedByUserId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Email", "Status"); + + b.ToTable("WorkspaceInvites", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Socialize.Api.Modules.Identity.Data.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalDecision", b => + { + b.HasOne("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", null) + .WithMany() + .HasForeignKey("ApprovalRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", null) + .WithMany() + .HasForeignKey("WorkflowInstanceId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.WorkspaceApprovalStepConfiguration", b => + { + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.AssetRevision", b => + { + b.HasOne("Socialize.Api.Modules.Assets.Data.Asset", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarEvent", b => + { + b.HasOne("Socialize.Api.Modules.CalendarIntegrations.Data.CalendarSource", null) + .WithMany() + .HasForeignKey("CalendarSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Campaigns.Data.Campaign", b => + { + b.HasOne("Socialize.Api.Modules.Clients.Data.Client", null) + .WithMany() + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Channels.Data.Channel", b => + { + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b => + { + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Comments.Data.Comment", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Comments.Data.Comment", null) + .WithMany() + .HasForeignKey("ParentCommentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItem", b => + { + b.HasOne("Socialize.Api.Modules.Campaigns.Data.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Clients.Data.Client", null) + .WithMany() + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemActivityEntry", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithMany("ActivityEntries") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackComment", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithMany("Comments") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => + { + b.HasOne("Socialize.Api.Modules.Campaigns.Data.Campaign", null) + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Socialize.Api.Modules.Clients.Data.Client", null) + .WithMany() + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithOne("Screenshot") + .HasForeignKey("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", "FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b => + { + b.HasOne("Socialize.Api.Modules.Feedback.Data.FeedbackReport", "FeedbackReport") + .WithMany("Tags") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FeedbackReport"); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Notifications.Data.NotificationEvent", b => + { + b.HasOne("Socialize.Api.Modules.ContentItems.Data.ContentItem", null) + .WithMany() + .HasForeignKey("ContentItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .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) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b => + { + b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.WorkspaceInvite", b => + { + b.HasOne("Socialize.Api.Modules.Workspaces.Data.Workspace", null) + .WithMany() + .HasForeignKey("WorkspaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => + { + b.Navigation("ActivityEntries"); + + b.Navigation("Comments"); + + b.Navigation("Screenshot"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/20260508001846_AddOrganizationMembershipTiers.cs b/backend/src/Socialize.Api/Migrations/20260508001846_AddOrganizationMembershipTiers.cs new file mode 100644 index 00000000..c46df0ca --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260508001846_AddOrganizationMembershipTiers.cs @@ -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 +{ + /// + internal partial class AddOrganizationMembershipTiers : Migration + { + private static readonly string[] MembershipTierSeedColumns = + [ + "Id", + "ActiveContentLimit", + "Description", + "ExternalReviewerLimit", + "IsCustom", + "Key", + "MemberLimit", + "MonthlyPriceCents", + "Name", + "SortOrder", + "WorkspaceLimit" + ]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.AddColumn( + 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(type: "uuid", nullable: false), + Key = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Description = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + MonthlyPriceCents = table.Column(type: "integer", nullable: true), + WorkspaceLimit = table.Column(type: "integer", nullable: true), + ActiveContentLimit = table.Column(type: "integer", nullable: true), + MemberLimit = table.Column(type: "integer", nullable: true), + ExternalReviewerLimit = table.Column(type: "integer", nullable: true), + IsCustom = table.Column(type: "boolean", nullable: false), + SortOrder = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs index 0d575cf6..694e1e77 100644 --- a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs @@ -1659,6 +1659,11 @@ namespace Socialize.Api.Migrations .HasMaxLength(2048) .HasColumnType("character varying(2048)"); + b.Property("MembershipTierId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValue(new Guid("20000000-0000-0000-0000-000000000001")); + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActiveContentLimit") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ExternalReviewerLimit") + .HasColumnType("integer"); + + b.Property("IsCustom") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MemberLimit") + .HasColumnType("integer"); + + b.Property("MonthlyPriceCents") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("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("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) diff --git a/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs b/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs index dd5c6588..d7bc3948 100644 --- a/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs +++ b/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs @@ -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; } } diff --git a/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationMembershipTier.cs b/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationMembershipTier.cs new file mode 100644 index 00000000..49db436d --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationMembershipTier.cs @@ -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; } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationMembershipTierSeed.cs b/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationMembershipTierSeed.cs new file mode 100644 index 00000000..f53faa25 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationMembershipTierSeed.cs @@ -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, + }, + ]; +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationModelConfiguration.cs index fb5e8744..57801dad 100644 --- a/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationModelConfiguration.cs +++ b/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationModelConfiguration.cs @@ -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() + .WithMany() + .HasForeignKey(x => x.MembershipTierId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(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(membership => diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/CreateOrganization.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/CreateOrganization.cs index c6a6ac4a..bacc6641 100644 --- a/backend/src/Socialize.Api/Modules/Organizations/Handlers/CreateOrganization.cs +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/CreateOrganization.cs @@ -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 @@ -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); } diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganization.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganization.cs index d3017f31..0bf9466e 100644 --- a/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganization.cs +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganization.cs @@ -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 members = await GetMembersAsync(organizationId, ct); IReadOnlyCollection 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 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 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 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> GetAvailableMembershipTiersAsync(CancellationToken ct) + { + List tiers = await dbContext.OrganizationMembershipTiers + .OrderBy(tier => tier.SortOrder) + .ThenBy(tier => tier.Name) + .ToListAsync(ct); + + return tiers + .Select(OrganizationMembershipTierDto.FromTier) + .ToArray(); + } + + private async Task GetExternalReviewerCountAsync( + IReadOnlyCollection workspaceIds, + IReadOnlyCollection organizationMemberUserIds, + Guid ownerUserId, + CancellationToken ct) + { + string[] workspaceClaimValues = workspaceIds + .Select(id => id.ToString()) + .ToArray(); + + HashSet 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) { diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganizations.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganizations.cs index 5257dd3e..81573aa2 100644 --- a/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganizations.cs +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganizations.cs @@ -26,6 +26,18 @@ internal class GetOrganizationsHandler( .OrderBy(organization => organization.Name) .ToListAsync(ct); + Guid[] membershipTierIds = organizations + .Select(organization => organization.MembershipTierId) + .Distinct() + .ToArray(); + List membershipTierModels = await dbContext.OrganizationMembershipTiers + .Where(tier => membershipTierIds.Contains(tier.Id)) + .ToListAsync(ct); + Dictionary membershipTiersById = membershipTierModels + .ToDictionary( + tier => tier.Id, + OrganizationMembershipTierDto.FromTier); + List 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); diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/ListOrganizationMembershipTiers.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/ListOrganizationMembershipTiers.cs new file mode 100644 index 00000000..f97b8dd3 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/ListOrganizationMembershipTiers.cs @@ -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> +{ + public override void Configure() + { + Get("/api/organization-membership-tiers"); + Options(o => o.WithTags("Organizations")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + List 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); + } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs index 4eca75bd..06ca7131 100644 --- a/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs @@ -16,34 +16,71 @@ internal record OrganizationDto( Guid Id, string Name, string? LogoUrl, + OrganizationMembershipTierDto? MembershipTier, Guid OwnerUserId, IReadOnlyCollection CurrentUserPermissions, IReadOnlyCollection Members, IReadOnlyCollection Workspaces, OrganizationUsageDto? Usage, + IReadOnlyCollection AvailableMembershipTiers, DateTimeOffset CreatedAt) { public static OrganizationDto FromOrganization( Organization organization, IReadOnlyCollection currentUserPermissions, + OrganizationMembershipTierDto? membershipTier = null, IReadOnlyCollection? members = null, IReadOnlyCollection? workspaces = null, - OrganizationUsageDto? usage = null) + OrganizationUsageDto? usage = null, + IReadOnlyCollection? 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 Items); diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/UpdateOrganizationMembershipTier.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/UpdateOrganizationMembershipTier.cs new file mode 100644 index 00000000..d10b23c4 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/UpdateOrganizationMembershipTier.cs @@ -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 +{ + public UpdateOrganizationMembershipTierRequestValidator() + { + RuleFor(x => x.MembershipTierId).NotEmpty(); + } +} + +internal class UpdateOrganizationMembershipTierHandler( + AppDbContext dbContext, + OrganizationAccessService organizationAccessService) + : Endpoint +{ + 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("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 currentUserPermissions = await organizationAccessService.GetUserOrganizationPermissionsAsync( + User, + organizationId, + ct); + + await SendOkAsync( + OrganizationDto.FromOrganization( + organization, + currentUserPermissions, + OrganizationMembershipTierDto.FromTier(membershipTier)), + ct); + } +} diff --git a/docs/TASKS/organizations/008-database-backed-membership-tiers.md b/docs/TASKS/organizations/008-database-backed-membership-tiers.md new file mode 100644 index 00000000..5f272c1c --- /dev/null +++ b/docs/TASKS/organizations/008-database-backed-membership-tiers.md @@ -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. diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 38ce227a..56d995dd 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -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?: { diff --git a/frontend/src/features/organizations/stores/organizationStore.js b/frontend/src/features/organizations/stores/organizationStore.js index b4a769aa..b927bda2 100644 --- a/frontend/src/features/organizations/stores/organizationStore.js +++ b/frontend/src/features/organizations/stores/organizationStore.js @@ -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, }; diff --git a/frontend/src/features/organizations/views/OrganizationOnboardingView.vue b/frontend/src/features/organizations/views/OrganizationOnboardingView.vue index 514b29dd..303760b1 100644 --- a/frontend/src/features/organizations/views/OrganizationOnboardingView.vue +++ b/frontend/src/features/organizations/views/OrganizationOnboardingView.vue @@ -1,5 +1,5 @@