From 7d3f4954725a075c5326fe15b2d098ac7997d0de Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Mon, 4 May 2026 16:15:53 -0400 Subject: [PATCH] feat: add organization domain foundation --- .../src/Socialize.Api/Data/AppDbContext.cs | 4 + .../Development/DevelopmentSeedExtensions.cs | 53 + .../Security/AccessScopeService.cs | 123 +- ...0260504195518_AddOrganizations.Designer.cs | 1514 +++++++++++++++++ .../20260504195518_AddOrganizations.cs | 133 ++ .../Migrations/AppDbContextModelSnapshot.cs | 91 + .../Approvals/Handlers/GetApprovals.cs | 2 +- .../Handlers/SubmitApprovalDecision.cs | 2 +- .../Assets/Handlers/CreateAssetRevision.cs | 2 +- .../Assets/Handlers/CreateGoogleDriveAsset.cs | 2 +- .../Modules/Assets/Handlers/GetAssets.cs | 2 +- .../Campaigns/Handlers/CreateCampaign.cs | 2 +- .../Campaigns/Handlers/GetCampaigns.cs | 11 +- .../Clients/Handlers/ChangeClientPortrait.cs | 2 +- .../Modules/Clients/Handlers/CreateClient.cs | 2 +- .../Modules/Clients/Handlers/GetClients.cs | 20 +- .../Modules/Clients/Handlers/UpdateClient.cs | 2 +- .../Comments/Handlers/CreateComment.cs | 2 +- .../Modules/Comments/Handlers/GetComments.cs | 2 +- .../Comments/Handlers/ResolveComment.cs | 4 +- .../Handlers/CreateContentItem.cs | 2 +- .../Handlers/CreateContentItemRevision.cs | 2 +- .../ContentItems/Handlers/GetContentItem.cs | 2 +- .../Handlers/GetContentItemRevisions.cs | 2 +- .../ContentItems/Handlers/GetContentItems.cs | 2 +- .../Handlers/UpdateContentItemStatus.cs | 2 +- .../Handlers/GetNotifications.cs | 4 +- .../Handlers/MarkNotificationAsRead.cs | 2 +- .../Organizations/Data/Organization.cs | 10 + .../Data/OrganizationMembership.cs | 10 + .../Data/OrganizationModelConfiguration.cs | 41 + .../Organizations/DependencyInjection.cs | 14 + .../Organizations/Handlers/GetOrganization.cs | 114 ++ .../Handlers/GetOrganizations.cs | 41 + .../Handlers/OrganizationDtos.cs | 41 + .../Services/OrganizationAccessService.cs | 202 +++ .../Services/OrganizationPermissionRules.cs | 50 + .../Services/OrganizationPermissions.cs | 12 + .../Services/OrganizationRoles.cs | 10 + .../Modules/Workspaces/Data/Workspace.cs | 1 + .../Data/WorkspaceModelConfiguration.cs | 6 + .../Handlers/ChangeWorkspaceLogo.cs | 2 +- .../Workspaces/Handlers/CreateWorkspace.cs | 27 +- .../Handlers/CreateWorkspaceInvite.cs | 2 +- .../Handlers/GetWorkspaceInvites.cs | 2 +- .../Handlers/GetWorkspaceMembers.cs | 25 +- .../Workspaces/Handlers/GetWorkspaces.cs | 48 +- .../Workspaces/Handlers/UpdateWorkspace.cs | 15 +- backend/src/Socialize.Api/Program.cs | 2 + .../OrganizationPermissionRulesTests.cs | 51 + docs/FEATURES/organizations.md | 28 +- .../001-organization-domain-foundation.md | 39 +- ...002-organization-membership-permissions.md | 46 +- frontend/src/api/schema.d.ts | 117 ++ shared/openapi/openapi.json | 161 ++ 55 files changed, 2995 insertions(+), 115 deletions(-) create mode 100644 backend/src/Socialize.Api/Migrations/20260504195518_AddOrganizations.Designer.cs create mode 100644 backend/src/Socialize.Api/Migrations/20260504195518_AddOrganizations.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationMembership.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationModelConfiguration.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/DependencyInjection.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganization.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganizations.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationAccessService.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationPermissionRules.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationPermissions.cs create mode 100644 backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationRoles.cs create mode 100644 backend/tests/Socialize.Tests/Organizations/OrganizationPermissionRulesTests.cs diff --git a/backend/src/Socialize.Api/Data/AppDbContext.cs b/backend/src/Socialize.Api/Data/AppDbContext.cs index b50eac2..b1dad79 100644 --- a/backend/src/Socialize.Api/Data/AppDbContext.cs +++ b/backend/src/Socialize.Api/Data/AppDbContext.cs @@ -9,6 +9,7 @@ using Socialize.Api.Modules.Feedback.Data; using Socialize.Api.Modules.Identity.Data; using Socialize.Api.Modules.Notifications.Data; using Socialize.Api.Modules.Campaigns.Data; +using Socialize.Api.Modules.Organizations.Data; using Socialize.Api.Modules.Workspaces.Data; namespace Socialize.Api.Data; @@ -17,6 +18,8 @@ public class AppDbContext( DbContextOptions options) : IdentityDbContext(options) { + public DbSet Organizations => Set(); + public DbSet OrganizationMemberships => Set(); public DbSet Workspaces => Set(); public DbSet WorkspaceInvites => Set(); public DbSet Clients => Set(); @@ -41,6 +44,7 @@ public class AppDbContext( { base.OnModelCreating(builder); + builder.ConfigureOrganizationsModule(); builder.ConfigureWorkspacesModule(); builder.ConfigureClientsModule(); builder.ConfigureCampaignsModule(); diff --git a/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs b/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs index e503efb..09a3321 100644 --- a/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs +++ b/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs @@ -11,6 +11,8 @@ using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.Clients.Data; using Socialize.Api.Modules.Notifications.Data; using Socialize.Api.Modules.Campaigns.Data; +using Socialize.Api.Modules.Organizations.Data; +using Socialize.Api.Modules.Organizations.Services; using Socialize.Api.Modules.Workspaces.Data; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; @@ -19,6 +21,7 @@ namespace Socialize.Api.Infrastructure.Development; public static class DevelopmentSeedExtensions { + private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999"); private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222"); private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333"); @@ -117,6 +120,11 @@ public static class DevelopmentSeedExtensions [ ]); + await EnsureOrganizationDataAsync( + manager.Id, + dbContext, + cancellationToken); + await EnsureWorkspaceDataAsync( manager.Id, clientUser.Id, @@ -224,6 +232,50 @@ public static class DevelopmentSeedExtensions return user; } + private static async Task EnsureOrganizationDataAsync( + Guid managerUserId, + AppDbContext dbContext, + CancellationToken cancellationToken) + { + Organization? organization = await dbContext.Organizations + .SingleOrDefaultAsync(candidate => candidate.Id == OrganizationId, cancellationToken); + if (organization is null) + { + organization = new Organization + { + Id = OrganizationId, + Name = string.Empty, + Slug = string.Empty, + CreatedAt = DateTimeOffset.UtcNow, + }; + dbContext.Organizations.Add(organization); + } + + organization.Name = "Northstar Collective"; + organization.Slug = "northstar-collective"; + organization.OwnerUserId = managerUserId; + + OrganizationMembership? membership = await dbContext.OrganizationMemberships + .SingleOrDefaultAsync( + candidate => candidate.OrganizationId == OrganizationId && candidate.UserId == managerUserId, + cancellationToken); + if (membership is null) + { + membership = new OrganizationMembership + { + Id = Guid.Parse("99999999-9999-9999-9999-000000000001"), + OrganizationId = OrganizationId, + UserId = managerUserId, + Role = OrganizationRoles.Owner, + CreatedAt = DateTimeOffset.UtcNow, + }; + dbContext.OrganizationMemberships.Add(membership); + } + + membership.Role = OrganizationRoles.Owner; + await dbContext.SaveChangesAsync(cancellationToken); + } + private static async Task EnsureWorkspaceDataAsync( Guid managerUserId, Guid clientUserId, @@ -248,6 +300,7 @@ public static class DevelopmentSeedExtensions workspace.Name = "Northstar Studio"; workspace.Slug = "northstar-studio"; + workspace.OrganizationId = OrganizationId; workspace.OwnerUserId = managerUserId; workspace.TimeZone = "America/Montreal"; await dbContext.SaveChangesAsync(cancellationToken); diff --git a/backend/src/Socialize.Api/Infrastructure/Security/AccessScopeService.cs b/backend/src/Socialize.Api/Infrastructure/Security/AccessScopeService.cs index 1fa5d87..a9c5f26 100644 --- a/backend/src/Socialize.Api/Infrastructure/Security/AccessScopeService.cs +++ b/backend/src/Socialize.Api/Infrastructure/Security/AccessScopeService.cs @@ -1,9 +1,11 @@ using System.Security.Claims; using Socialize.Api.Modules.Identity.Contracts; +using Socialize.Api.Modules.Organizations.Services; namespace Socialize.Api.Infrastructure.Security; -public sealed class AccessScopeService +public sealed class AccessScopeService( + OrganizationAccessService organizationAccessService) { public bool IsManager(ClaimsPrincipal user) { @@ -53,4 +55,123 @@ public sealed class AccessScopeService || IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId) || IsClient(user) && CanAccessClient(user, workspaceId, clientId); } + + public Task> GetAccessibleWorkspaceIdsAsync( + ClaimsPrincipal user, + CancellationToken ct) + { + return organizationAccessService.GetAccessibleWorkspaceIdsAsync(user, ct); + } + + public async Task CanAccessWorkspaceAsync( + ClaimsPrincipal user, + Guid workspaceId, + CancellationToken ct) + { + return CanAccessWorkspace(user, workspaceId) + || await organizationAccessService.HasInheritedWorkspacePermissionAsync( + user, + workspaceId, + OrganizationPermissions.AccessOwnedWorkspaces, + ct); + } + + public async Task CanManageWorkspaceAsync( + ClaimsPrincipal user, + Guid workspaceId, + CancellationToken ct) + { + return IsManager(user) + || await organizationAccessService.HasInheritedWorkspacePermissionAsync( + user, + workspaceId, + OrganizationPermissions.ManageWorkspaces, + ct); + } + + public async Task CanCreateWorkspaceAsync( + ClaimsPrincipal user, + Guid organizationId, + CancellationToken ct) + { + return IsManager(user) + || await organizationAccessService.HasOrganizationPermissionAsync( + user, + organizationId, + OrganizationPermissions.CreateWorkspaces, + ct); + } + + public async Task CanAccessClientAsync( + ClaimsPrincipal user, + Guid workspaceId, + Guid clientId, + CancellationToken ct) + { + if (IsManager(user) || + await organizationAccessService.HasInheritedWorkspacePermissionAsync( + user, + workspaceId, + OrganizationPermissions.AccessOwnedWorkspaces, + ct)) + { + return true; + } + + return user.GetWorkspaceScopeIds().Contains(workspaceId) && user.GetClientScopeIds().Contains(clientId); + } + + public async Task CanAccessCampaignAsync( + ClaimsPrincipal user, + Guid workspaceId, + Guid clientId, + Guid campaignId, + CancellationToken ct) + { + if (IsManager(user) || + await organizationAccessService.HasInheritedWorkspacePermissionAsync( + user, + workspaceId, + OrganizationPermissions.AccessOwnedWorkspaces, + ct)) + { + return true; + } + + return await CanAccessClientAsync(user, workspaceId, clientId, ct) && + user.GetCampaignScopeIds().Contains(campaignId); + } + + public async Task CanContributeToCampaignAsync( + ClaimsPrincipal user, + Guid workspaceId, + Guid clientId, + Guid campaignId, + CancellationToken ct) + { + return IsManager(user) + || await organizationAccessService.HasInheritedWorkspacePermissionAsync( + user, + workspaceId, + OrganizationPermissions.ManageWorkspaces, + ct) + || IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct); + } + + public async Task CanReviewContentAsync( + ClaimsPrincipal user, + Guid workspaceId, + Guid clientId, + Guid campaignId, + CancellationToken ct) + { + return IsManager(user) + || await organizationAccessService.HasInheritedWorkspacePermissionAsync( + user, + workspaceId, + OrganizationPermissions.AccessOwnedWorkspaces, + ct) + || IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct) + || IsClient(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct); + } } diff --git a/backend/src/Socialize.Api/Migrations/20260504195518_AddOrganizations.Designer.cs b/backend/src/Socialize.Api/Migrations/20260504195518_AddOrganizations.Designer.cs new file mode 100644 index 0000000..c0e3d53 --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260504195518_AddOrganizations.Designer.cs @@ -0,0 +1,1514 @@ +// +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("20260504195518_AddOrganizations")] + partial class AddOrganizations + { + /// + 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.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.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("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("IsResolved") + .HasColumnType("boolean"); + + b.Property("ParentCommentId") + .HasColumnType("uuid"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + 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.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("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("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Slug") + .IsUnique(); + + 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.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("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Slug") + .IsUnique(); + + 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.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.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.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.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/20260504195518_AddOrganizations.cs b/backend/src/Socialize.Api/Migrations/20260504195518_AddOrganizations.cs new file mode 100644 index 0000000..3ce2930 --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260504195518_AddOrganizations.cs @@ -0,0 +1,133 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + /// + public partial class AddOrganizations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OrganizationId", + table: "Workspaces", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateTable( + name: "Organizations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Slug = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + OwnerUserId = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_Organizations", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OrganizationMemberships", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrganizationId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Role = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_OrganizationMemberships", x => x.Id); + table.ForeignKey( + name: "FK_OrganizationMemberships_Organizations_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organizations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.Sql( + """ + INSERT INTO "Organizations" ("Id", "Name", "Slug", "OwnerUserId", "CreatedAt") + VALUES ('99999999-9999-9999-9999-999999999999', 'Northstar Collective', 'northstar-collective', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', CURRENT_TIMESTAMP); + + UPDATE "Workspaces" + SET "OrganizationId" = '99999999-9999-9999-9999-999999999999' + WHERE "OrganizationId" = '00000000-0000-0000-0000-000000000000'; + + INSERT INTO "OrganizationMemberships" ("Id", "OrganizationId", "UserId", "Role", "CreatedAt") + VALUES ('99999999-9999-9999-9999-000000000001', '99999999-9999-9999-9999-999999999999', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Owner', CURRENT_TIMESTAMP); + """); + + migrationBuilder.CreateIndex( + name: "IX_Workspaces_OrganizationId", + table: "Workspaces", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationMemberships_OrganizationId", + table: "OrganizationMemberships", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationMemberships_OrganizationId_UserId", + table: "OrganizationMemberships", + columns: new[] { "OrganizationId", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationMemberships_UserId", + table: "OrganizationMemberships", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Organizations_OwnerUserId", + table: "Organizations", + column: "OwnerUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Organizations_Slug", + table: "Organizations", + column: "Slug", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Workspaces_Organizations_OrganizationId", + table: "Workspaces", + column: "OrganizationId", + principalTable: "Organizations", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Workspaces_Organizations_OrganizationId", + table: "Workspaces"); + + migrationBuilder.DropTable( + name: "OrganizationMemberships"); + + migrationBuilder.DropTable( + name: "Organizations"); + + migrationBuilder.DropIndex( + name: "IX_Workspaces_OrganizationId", + table: "Workspaces"); + + migrationBuilder.DropColumn( + name: "OrganizationId", + table: "Workspaces"); + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs index 80004fd..fe3b110 100644 --- a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs @@ -1203,6 +1203,74 @@ namespace Socialize.Api.Migrations 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("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OwnerUserId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Slug") + .IsUnique(); + + 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.Workspaces.Data.Workspace", b => { b.Property("Id") @@ -1235,6 +1303,9 @@ namespace Socialize.Api.Migrations .HasMaxLength(256) .HasColumnType("character varying(256)"); + b.Property("OrganizationId") + .HasColumnType("uuid"); + b.Property("OwnerUserId") .HasColumnType("uuid"); @@ -1260,6 +1331,8 @@ namespace Socialize.Api.Migrations b.HasKey("Id"); + b.HasIndex("OrganizationId"); + b.HasIndex("OwnerUserId"); b.HasIndex("Slug") @@ -1404,6 +1477,24 @@ namespace Socialize.Api.Migrations b.Navigation("FeedbackReport"); }); + 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.Feedback.Data.FeedbackReport", b => { b.Navigation("ActivityEntries"); diff --git a/backend/src/Socialize.Api/Modules/Approvals/Handlers/GetApprovals.cs b/backend/src/Socialize.Api/Modules/Approvals/Handlers/GetApprovals.cs index e025331..f1404f0 100644 --- a/backend/src/Socialize.Api/Modules/Approvals/Handlers/GetApprovals.cs +++ b/backend/src/Socialize.Api/Modules/Approvals/Handlers/GetApprovals.cs @@ -61,7 +61,7 @@ public class GetApprovalsHandler( return; } - if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId)) + if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs b/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs index 720fd7e..1db69f7 100644 --- a/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs +++ b/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs @@ -64,7 +64,7 @@ public class SubmitApprovalDecisionHandler( } if (User?.Identity?.IsAuthenticated == true && - !accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId)) + !await accessScopeService.CanReviewContentAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateAssetRevision.cs b/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateAssetRevision.cs index e42598d..86ab179 100644 --- a/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateAssetRevision.cs +++ b/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateAssetRevision.cs @@ -51,7 +51,7 @@ public class CreateAssetRevisionHandler( .SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct); if (contentItem is not null && - !accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId)) + !await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs b/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs index e838af5..8b1d5a8 100644 --- a/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs +++ b/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs @@ -58,7 +58,7 @@ public class CreateGoogleDriveAssetHandler( return; } - if (!accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId)) + if (!await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs b/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs index 04765c0..a74eb9b 100644 --- a/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs +++ b/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs @@ -52,7 +52,7 @@ public class GetAssetsHandler( return; } - if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId)) + if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Campaigns/Handlers/CreateCampaign.cs b/backend/src/Socialize.Api/Modules/Campaigns/Handlers/CreateCampaign.cs index 9ef82ca..aba86eb 100644 --- a/backend/src/Socialize.Api/Modules/Campaigns/Handlers/CreateCampaign.cs +++ b/backend/src/Socialize.Api/Modules/Campaigns/Handlers/CreateCampaign.cs @@ -45,7 +45,7 @@ public class CreateCampaignHandler( public override async Task HandleAsync(CreateCampaignRequest request, CancellationToken ct) { - if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId)) + if (!await accessScopeService.CanManageWorkspaceAsync(User, request.WorkspaceId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Campaigns/Handlers/GetCampaigns.cs b/backend/src/Socialize.Api/Modules/Campaigns/Handlers/GetCampaigns.cs index 35d1103..fc0ca2b 100644 --- a/backend/src/Socialize.Api/Modules/Campaigns/Handlers/GetCampaigns.cs +++ b/backend/src/Socialize.Api/Modules/Campaigns/Handlers/GetCampaigns.cs @@ -34,16 +34,9 @@ public class GetCampaignsHandler( { IQueryable query = dbContext.Campaigns.AsQueryable(); - if (accessScopeService.IsManager(User)) + if (!accessScopeService.IsManager(User)) { - if (request.WorkspaceId.HasValue) - { - query = query.Where(campaign => campaign.WorkspaceId == request.WorkspaceId.Value); - } - } - else - { - IReadOnlyCollection workspaceScopeIds = User.GetWorkspaceScopeIds(); + IReadOnlyCollection workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); IReadOnlyCollection clientScopeIds = User.GetClientScopeIds(); IReadOnlyCollection campaignScopeIds = User.GetCampaignScopeIds(); diff --git a/backend/src/Socialize.Api/Modules/Clients/Handlers/ChangeClientPortrait.cs b/backend/src/Socialize.Api/Modules/Clients/Handlers/ChangeClientPortrait.cs index 2b91fe4..94bc2bc 100644 --- a/backend/src/Socialize.Api/Modules/Clients/Handlers/ChangeClientPortrait.cs +++ b/backend/src/Socialize.Api/Modules/Clients/Handlers/ChangeClientPortrait.cs @@ -47,7 +47,7 @@ public class ChangeClientPortraitHandler( return; } - if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId)) + if (!await accessScopeService.CanManageWorkspaceAsync(User, client.WorkspaceId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Clients/Handlers/CreateClient.cs b/backend/src/Socialize.Api/Modules/Clients/Handlers/CreateClient.cs index 61d0112..1334322 100644 --- a/backend/src/Socialize.Api/Modules/Clients/Handlers/CreateClient.cs +++ b/backend/src/Socialize.Api/Modules/Clients/Handlers/CreateClient.cs @@ -41,7 +41,7 @@ public class CreateClientHandler( public override async Task HandleAsync(CreateClientRequest request, CancellationToken ct) { - if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId)) + if (!await accessScopeService.CanManageWorkspaceAsync(User, request.WorkspaceId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Clients/Handlers/GetClients.cs b/backend/src/Socialize.Api/Modules/Clients/Handlers/GetClients.cs index 579584d..0f78063 100644 --- a/backend/src/Socialize.Api/Modules/Clients/Handlers/GetClients.cs +++ b/backend/src/Socialize.Api/Modules/Clients/Handlers/GetClients.cs @@ -33,16 +33,9 @@ public class GetClientsHandler( { IQueryable query = dbContext.Clients.AsQueryable(); - if (accessScopeService.IsManager(User)) + if (!accessScopeService.IsManager(User)) { - if (request.WorkspaceId.HasValue) - { - query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value); - } - } - else - { - IReadOnlyCollection workspaceScopeIds = User.GetWorkspaceScopeIds(); + IReadOnlyCollection workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); IReadOnlyCollection clientScopeIds = User.GetClientScopeIds(); query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId)); @@ -52,10 +45,11 @@ public class GetClientsHandler( query = query.Where(client => clientScopeIds.Contains(client.Id)); } - if (request.WorkspaceId.HasValue) - { - query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value); - } + } + + if (request.WorkspaceId.HasValue) + { + query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value); } List clients = await query diff --git a/backend/src/Socialize.Api/Modules/Clients/Handlers/UpdateClient.cs b/backend/src/Socialize.Api/Modules/Clients/Handlers/UpdateClient.cs index f67e364..6496ddd 100644 --- a/backend/src/Socialize.Api/Modules/Clients/Handlers/UpdateClient.cs +++ b/backend/src/Socialize.Api/Modules/Clients/Handlers/UpdateClient.cs @@ -50,7 +50,7 @@ public class UpdateClientHandler( return; } - if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId)) + if (!await accessScopeService.CanManageWorkspaceAsync(User, client.WorkspaceId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs b/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs index 61489c6..fd346c4 100644 --- a/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs +++ b/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs @@ -51,7 +51,7 @@ public class CreateCommentHandler( return; } - if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId)) + if (!await accessScopeService.CanReviewContentAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Comments/Handlers/GetComments.cs b/backend/src/Socialize.Api/Modules/Comments/Handlers/GetComments.cs index b07d601..73b6236 100644 --- a/backend/src/Socialize.Api/Modules/Comments/Handlers/GetComments.cs +++ b/backend/src/Socialize.Api/Modules/Comments/Handlers/GetComments.cs @@ -44,7 +44,7 @@ public class GetCommentsHandler( return; } - if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId)) + if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Comments/Handlers/ResolveComment.cs b/backend/src/Socialize.Api/Modules/Comments/Handlers/ResolveComment.cs index c1fb2ae..bf42c1b 100644 --- a/backend/src/Socialize.Api/Modules/Comments/Handlers/ResolveComment.cs +++ b/backend/src/Socialize.Api/Modules/Comments/Handlers/ResolveComment.cs @@ -39,8 +39,8 @@ public class ResolveCommentHandler( return; } - bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId) - || accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId); + bool canResolve = await accessScopeService.CanManageWorkspaceAsync(User, comment.WorkspaceId, ct) + || await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct); if (!canResolve) { diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItem.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItem.cs index ab9a438..23dbc78 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItem.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItem.cs @@ -47,7 +47,7 @@ public class CreateContentItemHandler( public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct) { - if (!accessScopeService.CanContributeToCampaign(User, request.WorkspaceId, request.ClientId, request.CampaignId)) + if (!await accessScopeService.CanContributeToCampaignAsync(User, request.WorkspaceId, request.ClientId, request.CampaignId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs index 651a3d2..a1713c9 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs @@ -50,7 +50,7 @@ public class CreateContentItemRevisionHandler( return; } - if (!accessScopeService.CanContributeToCampaign(User, item.WorkspaceId, item.ClientId, item.CampaignId)) + if (!await accessScopeService.CanContributeToCampaignAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItem.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItem.cs index 46464ff..14b9d11 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItem.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItem.cs @@ -60,7 +60,7 @@ public class GetContentItemHandler( return; } - if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId)) + if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItemRevisions.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItemRevisions.cs index ed906f3..d767efb 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItemRevisions.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItemRevisions.cs @@ -41,7 +41,7 @@ public class GetContentItemRevisionsHandler( return; } - if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId)) + if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItems.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItems.cs index 189d200..77c1411 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItems.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItems.cs @@ -39,7 +39,7 @@ public class GetContentItemsHandler( if (!accessScopeService.IsManager(User)) { - IReadOnlyCollection workspaceScopeIds = User.GetWorkspaceScopeIds(); + IReadOnlyCollection workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); IReadOnlyCollection clientScopeIds = User.GetClientScopeIds(); IReadOnlyCollection campaignScopeIds = User.GetCampaignScopeIds(); diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs index d66a04e..99d8602 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs @@ -54,7 +54,7 @@ public class UpdateContentItemStatusHandler( return; } - if (!accessScopeService.CanManageWorkspace(User, item.WorkspaceId)) + if (!await accessScopeService.CanManageWorkspaceAsync(User, item.WorkspaceId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs b/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs index aea4b69..0e1b768 100644 --- a/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs +++ b/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs @@ -46,7 +46,7 @@ public class GetNotificationsHandler( return; } - if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId)) + if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct)) { await SendForbiddenAsync(ct); return; @@ -58,7 +58,7 @@ public class GetNotificationsHandler( if (!accessScopeService.IsManager(User)) { - IReadOnlyCollection workspaceScopeIds = User.GetWorkspaceScopeIds(); + IReadOnlyCollection workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); query = query.Where(notificationEvent => workspaceScopeIds.Contains(notificationEvent.WorkspaceId) || notificationEvent.RecipientUserId == currentUserId); diff --git a/backend/src/Socialize.Api/Modules/Notifications/Handlers/MarkNotificationAsRead.cs b/backend/src/Socialize.Api/Modules/Notifications/Handlers/MarkNotificationAsRead.cs index 26e764a..4b62309 100644 --- a/backend/src/Socialize.Api/Modules/Notifications/Handlers/MarkNotificationAsRead.cs +++ b/backend/src/Socialize.Api/Modules/Notifications/Handlers/MarkNotificationAsRead.cs @@ -30,7 +30,7 @@ public class MarkNotificationAsReadHandler( Guid currentUserId = User.GetUserId(); bool canReadRecipientNotification = notificationEvent.RecipientUserId == currentUserId; - if (!canReadRecipientNotification && !accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId)) + if (!canReadRecipientNotification && !await accessScopeService.CanAccessWorkspaceAsync(User, notificationEvent.WorkspaceId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs b/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs new file mode 100644 index 0000000..be3ec35 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Data/Organization.cs @@ -0,0 +1,10 @@ +namespace Socialize.Api.Modules.Organizations.Data; + +public class Organization +{ + public Guid Id { get; init; } + public required string Name { get; set; } + public required string Slug { get; set; } + public Guid OwnerUserId { get; set; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationMembership.cs b/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationMembership.cs new file mode 100644 index 0000000..fc87557 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationMembership.cs @@ -0,0 +1,10 @@ +namespace Socialize.Api.Modules.Organizations.Data; + +public class OrganizationMembership +{ + public Guid Id { get; init; } + public Guid OrganizationId { get; set; } + public Guid UserId { get; set; } + public required string Role { get; set; } + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationModelConfiguration.cs new file mode 100644 index 0000000..e622838 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Data/OrganizationModelConfiguration.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; + +namespace Socialize.Api.Modules.Organizations.Data; + +public static class OrganizationModelConfiguration +{ + public static ModelBuilder ConfigureOrganizationsModule(this ModelBuilder modelBuilder) + { + modelBuilder.Entity(organization => + { + organization.ToTable("Organizations"); + organization.HasKey(x => x.Id); + organization.Property(x => x.Name).HasMaxLength(256).IsRequired(); + organization.Property(x => x.Slug).HasMaxLength(128).IsRequired(); + organization.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + organization.HasIndex(x => x.Slug).IsUnique(); + organization.HasIndex(x => x.OwnerUserId); + }); + + modelBuilder.Entity(membership => + { + membership.ToTable("OrganizationMemberships"); + membership.HasKey(x => x.Id); + membership.Property(x => x.Role).HasMaxLength(64).IsRequired(); + membership.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + membership.HasIndex(x => x.OrganizationId); + membership.HasIndex(x => x.UserId); + membership.HasIndex(x => new { x.OrganizationId, x.UserId }).IsUnique(); + membership.HasOne() + .WithMany() + .HasForeignKey(x => x.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + }); + + return modelBuilder; + } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/DependencyInjection.cs b/backend/src/Socialize.Api/Modules/Organizations/DependencyInjection.cs new file mode 100644 index 0000000..eb638c2 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/DependencyInjection.cs @@ -0,0 +1,14 @@ +using Socialize.Api.Modules.Organizations.Services; + +namespace Socialize.Api.Modules.Organizations; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddOrganizationsModule( + this WebApplicationBuilder builder) + { + builder.Services.AddScoped(); + + return builder; + } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganization.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganization.cs new file mode 100644 index 0000000..84ca709 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganization.cs @@ -0,0 +1,114 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Modules.Identity.Data; +using Socialize.Api.Modules.Organizations.Data; +using Socialize.Api.Modules.Organizations.Services; +using Socialize.Api.Modules.Workspaces.Handlers; + +namespace Socialize.Api.Modules.Organizations.Handlers; + +public class GetOrganizationHandler( + AppDbContext dbContext, + OrganizationAccessService organizationAccessService) + : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/api/organizations/{organizationId:guid}"); + Options(o => o.WithTags("Organizations")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + 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.CanAccessOrganizationAsync(User, organizationId, ct)) + { + await SendForbiddenAsync(ct); + return; + } + + IReadOnlyCollection currentUserPermissions = await organizationAccessService.GetUserOrganizationPermissionsAsync( + User, + organizationId, + ct); + + IReadOnlyCollection members = await GetMembersAsync(organizationId, ct); + IReadOnlyCollection workspaces = await GetWorkspacesAsync(organizationId, ct); + + await SendOkAsync( + OrganizationDto.FromOrganization( + organization, + currentUserPermissions, + members, + workspaces), + ct); + } + + private async Task> GetMembersAsync( + Guid organizationId, + CancellationToken ct) + { + var rows = await dbContext.OrganizationMemberships + .Where(membership => membership.OrganizationId == organizationId) + .Join( + dbContext.Users, + membership => membership.UserId, + user => user.Id, + (membership, user) => new { Membership = membership, User = user }) + .OrderBy(row => row.User.Lastname) + .ThenBy(row => row.User.Firstname) + .ThenBy(row => row.User.Email) + .ToListAsync(ct); + + return rows + .Select(row => new OrganizationMemberDto( + row.User.Id, + BuildDisplayName(row.User), + row.User.Email ?? string.Empty, + row.User.PortraitUrl, + row.Membership.Role, + OrganizationPermissionRules.GetPermissionsForRole(row.Membership.Role), + row.Membership.CreatedAt)) + .ToArray(); + } + + private async Task> GetWorkspacesAsync( + Guid organizationId, + CancellationToken ct) + { + var workspaces = await dbContext.Workspaces + .Where(workspace => workspace.OrganizationId == organizationId) + .OrderBy(workspace => workspace.Name) + .ToListAsync(ct); + + return workspaces + .Select(workspace => WorkspaceDto.FromWorkspace(workspace, [])) + .ToArray(); + } + + private static string BuildDisplayName(User user) + { + if (!string.IsNullOrWhiteSpace(user.Alias)) + { + return user.Alias; + } + + string fullName = $"{user.Firstname} {user.Lastname}".Trim(); + if (!string.IsNullOrWhiteSpace(fullName)) + { + return fullName; + } + + return user.Email ?? user.UserName ?? user.Id.ToString(); + } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganizations.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganizations.cs new file mode 100644 index 0000000..e22fc3d --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/GetOrganizations.cs @@ -0,0 +1,41 @@ +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; + +public class GetOrganizationsHandler( + AppDbContext dbContext, + OrganizationAccessService organizationAccessService) + : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/api/organizations"); + Options(o => o.WithTags("Organizations")); + } + + public override async Task HandleAsync(CancellationToken ct) + { + IReadOnlyCollection organizationIds = await organizationAccessService.GetAccessibleOrganizationIdsAsync(User, ct); + + List organizations = await dbContext.Organizations + .Where(organization => organizationIds.Contains(organization.Id)) + .OrderBy(organization => organization.Name) + .ToListAsync(ct); + + List response = []; + foreach (Organization organization in organizations) + { + IReadOnlyCollection permissions = await organizationAccessService.GetUserOrganizationPermissionsAsync( + User, + organization.Id, + ct); + response.Add(OrganizationDto.FromOrganization(organization, permissions)); + } + + await SendOkAsync(response, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs new file mode 100644 index 0000000..1d42960 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/OrganizationDtos.cs @@ -0,0 +1,41 @@ +using Socialize.Api.Modules.Organizations.Data; +using Socialize.Api.Modules.Workspaces.Handlers; + +namespace Socialize.Api.Modules.Organizations.Handlers; + +public record OrganizationMemberDto( + Guid UserId, + string DisplayName, + string Email, + string? PortraitUrl, + string Role, + IReadOnlyCollection Permissions, + DateTimeOffset CreatedAt); + +public record OrganizationDto( + Guid Id, + string Name, + string Slug, + Guid OwnerUserId, + IReadOnlyCollection CurrentUserPermissions, + IReadOnlyCollection Members, + IReadOnlyCollection Workspaces, + DateTimeOffset CreatedAt) +{ + public static OrganizationDto FromOrganization( + Organization organization, + IReadOnlyCollection currentUserPermissions, + IReadOnlyCollection? members = null, + IReadOnlyCollection? workspaces = null) + { + return new OrganizationDto( + organization.Id, + organization.Name, + organization.Slug, + organization.OwnerUserId, + currentUserPermissions, + members ?? [], + workspaces ?? [], + organization.CreatedAt); + } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationAccessService.cs b/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationAccessService.cs new file mode 100644 index 0000000..38f1da1 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationAccessService.cs @@ -0,0 +1,202 @@ +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.Identity.Contracts; + +namespace Socialize.Api.Modules.Organizations.Services; + +public sealed class OrganizationAccessService( + AppDbContext dbContext) +{ + public bool IsGlobalManager(ClaimsPrincipal user) + { + return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager); + } + + public async Task> GetAccessibleOrganizationIdsAsync( + ClaimsPrincipal user, + CancellationToken ct) + { + if (IsGlobalManager(user)) + { + return await dbContext.Organizations + .Select(organization => organization.Id) + .ToArrayAsync(ct); + } + + Guid userId = user.GetUserId(); + + Guid[] ownedOrganizationIds = await dbContext.Organizations + .Where(organization => organization.OwnerUserId == userId) + .Select(organization => organization.Id) + .ToArrayAsync(ct); + + Guid[] memberOrganizationIds = await dbContext.OrganizationMemberships + .Where(membership => membership.UserId == userId) + .Select(membership => membership.OrganizationId) + .ToArrayAsync(ct); + + return ownedOrganizationIds + .Concat(memberOrganizationIds) + .Distinct() + .ToArray(); + } + + public async Task> GetAccessibleWorkspaceIdsAsync( + ClaimsPrincipal user, + CancellationToken ct) + { + if (IsGlobalManager(user)) + { + return await dbContext.Workspaces + .Select(workspace => workspace.Id) + .ToArrayAsync(ct); + } + + Guid[] directWorkspaceIds = user.GetWorkspaceScopeIds().ToArray(); + Guid[] organizationWorkspaceIds = await GetInheritedWorkspaceIdsAsync(user, OrganizationPermissions.AccessOwnedWorkspaces, ct); + + return directWorkspaceIds + .Concat(organizationWorkspaceIds) + .Distinct() + .ToArray(); + } + + public async Task CanAccessOrganizationAsync( + ClaimsPrincipal user, + Guid organizationId, + CancellationToken ct) + { + if (IsGlobalManager(user)) + { + return true; + } + + Guid userId = user.GetUserId(); + + return await dbContext.Organizations.AnyAsync( + organization => organization.Id == organizationId && organization.OwnerUserId == userId, + ct) + || await dbContext.OrganizationMemberships.AnyAsync( + membership => membership.OrganizationId == organizationId && membership.UserId == userId, + ct); + } + + public async Task HasOrganizationPermissionAsync( + ClaimsPrincipal user, + Guid organizationId, + string permission, + CancellationToken ct) + { + if (IsGlobalManager(user)) + { + return true; + } + + Guid userId = user.GetUserId(); + + bool owner = await dbContext.Organizations.AnyAsync( + organization => organization.Id == organizationId && organization.OwnerUserId == userId, + ct); + if (owner) + { + return OrganizationPermissionRules.RoleHasPermission(OrganizationRoles.Owner, permission); + } + + string[] roles = await dbContext.OrganizationMemberships + .Where(membership => membership.OrganizationId == organizationId && membership.UserId == userId) + .Select(membership => membership.Role) + .ToArrayAsync(ct); + + return roles.Any(role => OrganizationPermissionRules.RoleHasPermission(role, permission)); + } + + public async Task> GetUserOrganizationPermissionsAsync( + ClaimsPrincipal user, + Guid organizationId, + CancellationToken ct) + { + if (IsGlobalManager(user)) + { + return OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner); + } + + Guid userId = user.GetUserId(); + + bool owner = await dbContext.Organizations.AnyAsync( + organization => organization.Id == organizationId && organization.OwnerUserId == userId, + ct); + if (owner) + { + return OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner); + } + + string[] roles = await dbContext.OrganizationMemberships + .Where(membership => membership.OrganizationId == organizationId && membership.UserId == userId) + .Select(membership => membership.Role) + .ToArrayAsync(ct); + + return roles + .SelectMany(OrganizationPermissionRules.GetPermissionsForRole) + .Distinct(StringComparer.Ordinal) + .OrderBy(permission => permission) + .ToArray(); + } + + public async Task HasInheritedWorkspacePermissionAsync( + ClaimsPrincipal user, + Guid workspaceId, + string permission, + CancellationToken ct) + { + if (IsGlobalManager(user)) + { + return true; + } + + Guid? organizationId = await dbContext.Workspaces + .Where(workspace => workspace.Id == workspaceId) + .Select(workspace => (Guid?)workspace.OrganizationId) + .SingleOrDefaultAsync(ct); + + return organizationId.HasValue && + await HasOrganizationPermissionAsync(user, organizationId.Value, permission, ct); + } + + private async Task GetInheritedWorkspaceIdsAsync( + ClaimsPrincipal user, + string permission, + CancellationToken ct) + { + Guid userId = user.GetUserId(); + + Guid[] ownedOrganizationIds = await dbContext.Organizations + .Where(organization => organization.OwnerUserId == userId) + .Select(organization => organization.Id) + .ToArrayAsync(ct); + + List memberships = await dbContext.OrganizationMemberships + .Where(membership => membership.UserId == userId) + .ToListAsync(ct); + Guid[] memberOrganizationIds = memberships + .Where(membership => OrganizationPermissionRules.RoleHasPermission(membership.Role, permission)) + .Select(membership => membership.OrganizationId) + .ToArray(); + + Guid[] organizationIds = ownedOrganizationIds + .Concat(memberOrganizationIds) + .Distinct() + .ToArray(); + + if (organizationIds.Length == 0) + { + return []; + } + + return await dbContext.Workspaces + .Where(workspace => organizationIds.Contains(workspace.OrganizationId)) + .Select(workspace => workspace.Id) + .ToArrayAsync(ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationPermissionRules.cs b/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationPermissionRules.cs new file mode 100644 index 0000000..4af3054 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationPermissionRules.cs @@ -0,0 +1,50 @@ +namespace Socialize.Api.Modules.Organizations.Services; + +public static class OrganizationPermissionRules +{ + public static IReadOnlyCollection GetPermissionsForRole(string role) + { + return role switch + { + OrganizationRoles.Owner => + [ + OrganizationPermissions.ManageOrganizationSettings, + OrganizationPermissions.ManageOrganizationMembers, + OrganizationPermissions.CreateWorkspaces, + OrganizationPermissions.ManageWorkspaces, + OrganizationPermissions.ManageBilling, + OrganizationPermissions.ManageConnectors, + OrganizationPermissions.AccessOwnedWorkspaces, + ], + OrganizationRoles.Admin => + [ + OrganizationPermissions.ManageOrganizationSettings, + OrganizationPermissions.ManageOrganizationMembers, + OrganizationPermissions.CreateWorkspaces, + OrganizationPermissions.ManageWorkspaces, + OrganizationPermissions.ManageConnectors, + OrganizationPermissions.AccessOwnedWorkspaces, + ], + OrganizationRoles.BillingManager => + [ + OrganizationPermissions.ManageBilling, + OrganizationPermissions.AccessOwnedWorkspaces, + ], + OrganizationRoles.ConnectorManager => + [ + OrganizationPermissions.ManageConnectors, + OrganizationPermissions.AccessOwnedWorkspaces, + ], + OrganizationRoles.Member => + [ + OrganizationPermissions.AccessOwnedWorkspaces, + ], + _ => [], + }; + } + + public static bool RoleHasPermission(string role, string permission) + { + return GetPermissionsForRole(role).Contains(permission, StringComparer.Ordinal); + } +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationPermissions.cs b/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationPermissions.cs new file mode 100644 index 0000000..510217c --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationPermissions.cs @@ -0,0 +1,12 @@ +namespace Socialize.Api.Modules.Organizations.Services; + +public static class OrganizationPermissions +{ + public const string ManageOrganizationSettings = "ManageOrganizationSettings"; + public const string ManageOrganizationMembers = "ManageOrganizationMembers"; + public const string CreateWorkspaces = "CreateWorkspaces"; + public const string ManageWorkspaces = "ManageWorkspaces"; + public const string ManageBilling = "ManageBilling"; + public const string ManageConnectors = "ManageConnectors"; + public const string AccessOwnedWorkspaces = "AccessOwnedWorkspaces"; +} diff --git a/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationRoles.cs b/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationRoles.cs new file mode 100644 index 0000000..ebef720 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Services/OrganizationRoles.cs @@ -0,0 +1,10 @@ +namespace Socialize.Api.Modules.Organizations.Services; + +public static class OrganizationRoles +{ + public const string Owner = "Owner"; + public const string Admin = "Admin"; + public const string BillingManager = "BillingManager"; + public const string ConnectorManager = "ConnectorManager"; + public const string Member = "Member"; +} diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Data/Workspace.cs b/backend/src/Socialize.Api/Modules/Workspaces/Data/Workspace.cs index f9f7ddb..6bc8f6f 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Data/Workspace.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Data/Workspace.cs @@ -6,6 +6,7 @@ public class Workspace public required string Name { get; set; } public required string Slug { get; set; } public string? LogoUrl { get; set; } + public Guid OrganizationId { get; set; } public Guid OwnerUserId { get; set; } public required string TimeZone { get; set; } public string ApprovalMode { get; set; } = "Required"; diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceModelConfiguration.cs index 7c460f2..8bcf726 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceModelConfiguration.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceModelConfiguration.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Socialize.Api.Modules.Organizations.Data; namespace Socialize.Api.Modules.Workspaces.Data; @@ -22,7 +23,12 @@ public static class WorkspaceModelConfiguration .ValueGeneratedOnAdd() .HasDefaultValueSql("CURRENT_TIMESTAMP"); workspace.HasIndex(x => x.Slug).IsUnique(); + workspace.HasIndex(x => x.OrganizationId); workspace.HasIndex(x => x.OwnerUserId); + workspace.HasOne() + .WithMany() + .HasForeignKey(x => x.OrganizationId) + .OnDelete(DeleteBehavior.Restrict); }); modelBuilder.Entity(workspaceInvite => diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/ChangeWorkspaceLogo.cs b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/ChangeWorkspaceLogo.cs index 1022355..b4d17c6 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/ChangeWorkspaceLogo.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/ChangeWorkspaceLogo.cs @@ -47,7 +47,7 @@ public class ChangeWorkspaceLogoHandler( return; } - if (!accessScopeService.CanManageWorkspace(User, workspace.Id)) + if (!await accessScopeService.CanManageWorkspaceAsync(User, workspace.Id, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspace.cs b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspace.cs index 6a488ad..4549112 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspace.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspace.cs @@ -7,6 +7,7 @@ using Socialize.Api.Modules.Workspaces.Data; namespace Socialize.Api.Modules.Workspaces.Handlers; public record CreateWorkspaceRequest( + Guid OrganizationId, string Name, string Slug, string TimeZone); @@ -16,6 +17,7 @@ public class CreateWorkspaceRequestValidator { public CreateWorkspaceRequestValidator() { + RuleFor(x => x.OrganizationId).NotEmpty(); RuleFor(x => x.Name).NotEmpty().MaximumLength(256); RuleFor(x => x.Slug) .NotEmpty() @@ -38,12 +40,21 @@ public class CreateWorkspaceHandler( public override async Task HandleAsync(CreateWorkspaceRequest request, CancellationToken ct) { - if (!accessScopeService.IsManager(User)) + if (!await accessScopeService.CanCreateWorkspaceAsync(User, request.OrganizationId, ct)) { await SendForbiddenAsync(ct); return; } + bool organizationExists = await dbContext.Organizations + .AnyAsync(organization => organization.Id == request.OrganizationId, ct); + if (!organizationExists) + { + AddError(request => request.OrganizationId, "The selected organization does not exist."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + string normalizedName = request.Name.Trim(); string normalizedSlug = request.Slug.Trim().ToLowerInvariant(); string normalizedTimeZone = request.TimeZone.Trim(); @@ -61,6 +72,7 @@ public class CreateWorkspaceHandler( Workspace workspace = new() { Id = Guid.NewGuid(), + OrganizationId = request.OrganizationId, Name = normalizedName, Slug = normalizedSlug, OwnerUserId = User.GetUserId(), @@ -71,18 +83,7 @@ public class CreateWorkspaceHandler( dbContext.Workspaces.Add(workspace); await dbContext.SaveChangesAsync(ct); - WorkspaceDto dto = new( - workspace.Id, - workspace.Name, - workspace.Slug, - workspace.LogoUrl, - workspace.TimeZone, - workspace.ApprovalMode, - workspace.SchedulePostsAutomaticallyOnApproval, - workspace.LockContentAfterApproval, - workspace.SendAutomaticApprovalReminders, - [], - workspace.CreatedAt); + WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, []); await SendAsync(dto, StatusCodes.Status201Created, ct); } diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs index e3c9c9d..397970f 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs @@ -43,7 +43,7 @@ public class CreateWorkspaceInviteHandler( { Guid workspaceId = Route("workspaceId"); - if (!accessScopeService.CanManageWorkspace(User, workspaceId)) + if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaceInvites.cs b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaceInvites.cs index 3a88da4..40a253a 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaceInvites.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaceInvites.cs @@ -29,7 +29,7 @@ public class GetWorkspaceInvitesHandler( { Guid workspaceId = Route("workspaceId"); - if (!accessScopeService.CanManageWorkspace(User, workspaceId)) + if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaceMembers.cs b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaceMembers.cs index d26c328..f9b8b2f 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaceMembers.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaceMembers.cs @@ -4,6 +4,7 @@ using System.Security.Claims; using Socialize.Api.Data; using Socialize.Api.Modules.Identity.Data; using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.Workspaces.Data; namespace Socialize.Api.Modules.Workspaces.Handlers; @@ -12,6 +13,7 @@ public record WorkspaceMemberDto( string DisplayName, string Email, string? PortraitUrl, + string RelationshipCategory, IReadOnlyCollection Roles); public class GetWorkspaceMembersHandler( @@ -29,12 +31,20 @@ public class GetWorkspaceMembersHandler( { Guid workspaceId = Route("workspaceId"); - if (!accessScopeService.CanManageWorkspace(User, workspaceId)) + if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct)) { await SendForbiddenAsync(ct); return; } + Workspace? workspace = await dbContext.Workspaces + .SingleOrDefaultAsync(candidate => candidate.Id == workspaceId, ct); + if (workspace is null) + { + await SendNotFoundAsync(ct); + return; + } + string workspaceClaimValue = workspaceId.ToString(); var users = await dbContext.Users @@ -42,7 +52,11 @@ public class GetWorkspaceMembersHandler( dbContext.UserClaims.Any(claim => claim.UserId == candidate.Id && claim.ClaimType == KnownClaims.WorkspaceScope && - claim.ClaimValue == workspaceClaimValue)) + claim.ClaimValue == workspaceClaimValue) || + dbContext.OrganizationMemberships.Any(membership => + membership.UserId == candidate.Id && + membership.OrganizationId == workspace.OrganizationId) || + candidate.Id == workspace.OwnerUserId) .OrderBy(candidate => candidate.Lastname) .ThenBy(candidate => candidate.Firstname) .ThenBy(candidate => candidate.Email) @@ -70,12 +84,19 @@ public class GetWorkspaceMembersHandler( .ToArray(), ct); + HashSet organizationMemberUserIds = await dbContext.OrganizationMemberships + .Where(membership => membership.OrganizationId == workspace.OrganizationId) + .Select(membership => membership.UserId) + .ToHashSetAsync(ct); + organizationMemberUserIds.Add(workspace.OwnerUserId); + var members = users .Select(candidate => new WorkspaceMemberDto( candidate.Id, BuildDisplayName(candidate), candidate.Email ?? string.Empty, candidate.PortraitUrl, + organizationMemberUserIds.Contains(candidate.Id) ? "Organization Member" : "External Collaborator", rolesByUserId.GetValueOrDefault(candidate.Id) ?? Array.Empty())) .ToList(); diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaces.cs b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaces.cs index 0707606..4681c71 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaces.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaces.cs @@ -19,6 +19,7 @@ public record ApprovalStepConfigurationDto( public record WorkspaceDto( Guid Id, + Guid OrganizationId, string Name, string Slug, string? LogoUrl, @@ -28,7 +29,27 @@ public record WorkspaceDto( bool LockContentAfterApproval, bool SendAutomaticApprovalReminders, IReadOnlyCollection ApprovalSteps, - DateTimeOffset CreatedAt); + DateTimeOffset CreatedAt) +{ + public static WorkspaceDto FromWorkspace( + Workspace workspace, + IReadOnlyCollection approvalSteps) + { + return new WorkspaceDto( + workspace.Id, + workspace.OrganizationId, + workspace.Name, + workspace.Slug, + workspace.LogoUrl, + workspace.TimeZone, + workspace.ApprovalMode, + workspace.SchedulePostsAutomaticallyOnApproval, + workspace.LockContentAfterApproval, + workspace.SendAutomaticApprovalReminders, + approvalSteps, + workspace.CreatedAt); + } +} internal class GetWorkspacesHandler( AppDbContext dbContext, @@ -43,13 +64,9 @@ internal class GetWorkspacesHandler( public override async Task HandleAsync(CancellationToken ct) { - var query = dbContext.Workspaces.AsQueryable(); - - if (!accessScopeService.IsManager(User)) - { - var workspaceScopeIds = User.GetWorkspaceScopeIds(); - query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id)); - } + IReadOnlyCollection accessibleWorkspaceIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); + var query = dbContext.Workspaces + .Where(workspace => accessibleWorkspaceIds.Contains(workspace.Id)); var workspaceRows = await query .OrderBy(workspace => workspace.Name) @@ -71,18 +88,9 @@ internal class GetWorkspacesHandler( .ToArray()); var workspaces = workspaceRows - .Select(workspace => new WorkspaceDto( - workspace.Id, - workspace.Name, - workspace.Slug, - workspace.LogoUrl, - workspace.TimeZone, - workspace.ApprovalMode, - workspace.SchedulePostsAutomaticallyOnApproval, - workspace.LockContentAfterApproval, - workspace.SendAutomaticApprovalReminders, - approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty(), - workspace.CreatedAt)) + .Select(workspace => WorkspaceDto.FromWorkspace( + workspace, + approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty())) .ToList(); await SendOkAsync(workspaces, ct); diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/UpdateWorkspace.cs b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/UpdateWorkspace.cs index 5b5a56e..4c36a0c 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/UpdateWorkspace.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/UpdateWorkspace.cs @@ -73,7 +73,7 @@ public class UpdateWorkspaceHandler( return; } - if (!accessScopeService.CanManageWorkspace(User, workspace.Id)) + if (!await accessScopeService.CanManageWorkspaceAsync(User, workspace.Id, ct)) { await SendForbiddenAsync(ct); return; @@ -154,18 +154,7 @@ public class UpdateWorkspaceHandler( step.CreatedAt)) .ToListAsync(ct); - WorkspaceDto dto = new( - workspace.Id, - workspace.Name, - workspace.Slug, - workspace.LogoUrl, - workspace.TimeZone, - workspace.ApprovalMode, - workspace.SchedulePostsAutomaticallyOnApproval, - workspace.LockContentAfterApproval, - workspace.SendAutomaticApprovalReminders, - approvalSteps, - workspace.CreatedAt); + WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, approvalSteps); await SendOkAsync(dto, ct); } diff --git a/backend/src/Socialize.Api/Program.cs b/backend/src/Socialize.Api/Program.cs index f437285..cbcffee 100644 --- a/backend/src/Socialize.Api/Program.cs +++ b/backend/src/Socialize.Api/Program.cs @@ -17,6 +17,7 @@ using Socialize.Api.Modules.Feedback; using Socialize.Api.Modules.Identity; using Socialize.Api.Modules.Notifications; using Socialize.Api.Modules.Campaigns; +using Socialize.Api.Modules.Organizations; using Socialize.Api.Modules.Workspaces; @@ -62,6 +63,7 @@ var postgresConnectionString = builder.Configuration.GetConnectionString("Postgr builder.Services.AddAppData(postgresConnectionString); builder.AddInfrastructureModule(); builder.AddIdentityModule(); +builder.AddOrganizationsModule(); builder.AddWorkspaceModule(); builder.AddClientsModule(); builder.AddCampaignsModule(); diff --git a/backend/tests/Socialize.Tests/Organizations/OrganizationPermissionRulesTests.cs b/backend/tests/Socialize.Tests/Organizations/OrganizationPermissionRulesTests.cs new file mode 100644 index 0000000..53f4682 --- /dev/null +++ b/backend/tests/Socialize.Tests/Organizations/OrganizationPermissionRulesTests.cs @@ -0,0 +1,51 @@ +using Socialize.Api.Modules.Organizations.Services; + +namespace Socialize.Tests.Organizations; + +public class OrganizationPermissionRulesTests +{ + [Fact] + public void Owner_has_all_initial_organization_permissions() + { + IReadOnlyCollection permissions = OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner); + + Assert.Contains(OrganizationPermissions.ManageOrganizationSettings, permissions); + Assert.Contains(OrganizationPermissions.ManageOrganizationMembers, permissions); + Assert.Contains(OrganizationPermissions.CreateWorkspaces, permissions); + Assert.Contains(OrganizationPermissions.ManageWorkspaces, permissions); + Assert.Contains(OrganizationPermissions.ManageBilling, permissions); + Assert.Contains(OrganizationPermissions.ManageConnectors, permissions); + Assert.Contains(OrganizationPermissions.AccessOwnedWorkspaces, permissions); + } + + [Fact] + public void Admin_does_not_receive_billing_permission_by_default() + { + IReadOnlyCollection permissions = OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Admin); + + Assert.Contains(OrganizationPermissions.ManageOrganizationSettings, permissions); + Assert.Contains(OrganizationPermissions.ManageOrganizationMembers, permissions); + Assert.Contains(OrganizationPermissions.CreateWorkspaces, permissions); + Assert.Contains(OrganizationPermissions.ManageWorkspaces, permissions); + Assert.Contains(OrganizationPermissions.ManageConnectors, permissions); + Assert.Contains(OrganizationPermissions.AccessOwnedWorkspaces, permissions); + Assert.DoesNotContain(OrganizationPermissions.ManageBilling, permissions); + } + + [Theory] + [InlineData(OrganizationRoles.BillingManager, OrganizationPermissions.ManageBilling, true)] + [InlineData(OrganizationRoles.BillingManager, OrganizationPermissions.ManageConnectors, false)] + [InlineData(OrganizationRoles.ConnectorManager, OrganizationPermissions.ManageConnectors, true)] + [InlineData(OrganizationRoles.ConnectorManager, OrganizationPermissions.ManageBilling, false)] + [InlineData(OrganizationRoles.Member, OrganizationPermissions.AccessOwnedWorkspaces, true)] + [InlineData(OrganizationRoles.Member, OrganizationPermissions.ManageWorkspaces, false)] + public void RoleHasPermission_enforces_role_permission_mapping( + string role, + string permission, + bool expected) + { + bool actual = OrganizationPermissionRules.RoleHasPermission(role, permission); + + Assert.Equal(expected, actual); + } +} diff --git a/docs/FEATURES/organizations.md b/docs/FEATURES/organizations.md index f520f16..864ee2a 100644 --- a/docs/FEATURES/organizations.md +++ b/docs/FEATURES/organizations.md @@ -2,7 +2,7 @@ ## Status -Draft +Ready for initial backend implementation. ## Goal @@ -173,6 +173,32 @@ Workspace-level screens remain centered on the selected workspace. - Workspace APIs must preserve workspace scoping and account for inherited organization permissions. - New backend contracts require OpenAPI regeneration while the backend is running. +## Implementation Readiness + +The initial implementation should proceed through the task files in `docs/TASKS/organizations/`. + +Recommended order: + +1. `001-organization-domain-foundation.md` +2. `002-organization-membership-permissions.md` +3. `003-organization-settings-ui.md` +4. `004-workspace-selector-organization-switcher.md` + +Task 001 should establish the organization table, workspace ownership, current-user organization read APIs, and development bootstrap data. It should not attempt the full inherited permission model beyond enough access data to prove a user can access their organizations. + +Task 002 should introduce organization memberships and explicit organization permissions before frontend settings or switcher work relies on permission-gated data. + +Frontend tasks should start only after backend contracts have been regenerated into `shared/openapi/openapi.json` and `frontend/src/api/schema.d.ts`. + +Initial backend routes: + +```txt +GET /api/organizations +GET /api/organizations/{organizationId} +``` + +Workspace responses should include `organizationId` once workspaces are owned by organizations. + ## Out Of Scope For Initial Implementation - Preserving existing local data through migration. Development data can be wiped. diff --git a/docs/TASKS/organizations/001-organization-domain-foundation.md b/docs/TASKS/organizations/001-organization-domain-foundation.md index 3cadb81..4a602b1 100644 --- a/docs/TASKS/organizations/001-organization-domain-foundation.md +++ b/docs/TASKS/organizations/001-organization-domain-foundation.md @@ -17,12 +17,14 @@ Existing local data does not need to be preserved. ## Scope - Add an `Organizations` backend module or follow the existing ownership pattern if organization code belongs with `Workspaces`. -- Add an organization persistence model with name, slug/display identity, timestamps, and basic audit fields matching local conventions. +- Add an organization persistence model with `Id`, `Name`, `Slug`, `OwnerUserId`, and `CreatedAt`, matching local conventions. - Require every workspace to belong to exactly one organization. - Update workspace create/list/detail APIs to include organization ownership. -- Add APIs for current user organization list and organization detail. +- Add current-user organization read APIs: + - `GET /api/organizations` + - `GET /api/organizations/{organizationId}` - Add backend validation that users cannot access organizations they have no relationship with. -- Seed or development bootstrap data should create at least one organization and owned workspace. +- Seed or development bootstrap data should create at least one organization and owned workspace when local development data is empty. - Update OpenAPI after backend contracts change. ## Constraints @@ -32,8 +34,21 @@ Existing local data does not need to be preserved. - Do not implement billing provider integration in this task. - Do not implement connector storage in this task. - Do not implement full organization membership override behavior in this task. +- Do not implement organization settings UI in this task. - Existing development data may be wiped; do not spend scope on compatibility migration behavior. +## Implementation Notes + +- Place organization-owned backend code under `backend/src/Socialize.Api/Modules/Organizations`. +- Add `DbSet` and `ConfigureOrganizationsModule()` to `AppDbContext`. +- Keep `Workspace.OwnerUserId` for the existing creator/owner convention unless a later task explicitly replaces it. +- Add a required `Workspace.OrganizationId` property and database index. +- The first implementation may grant organization access through `Organization.OwnerUserId == currentUserId` and existing manager/administrator access. Full organization membership belongs to task 002. +- `CreateWorkspaceRequest` should require `OrganizationId`; reject creation when the user cannot manage that organization. +- `WorkspaceDto` should include `OrganizationId`. +- Slugs should keep the existing lowercase kebab-case validation used for workspaces. +- Use tests for unauthorized organization detail access and workspace creation under an inaccessible organization. + ## Likely Files - `backend/src/Socialize.Api/Data/AppDbContext.cs` @@ -46,15 +61,15 @@ Existing local data does not need to be preserved. ## Done When -- [ ] Organization entity is persisted. -- [ ] Workspace requires `OrganizationId`. -- [ ] Workspace APIs expose organization ownership. -- [ ] Current user can list accessible organizations. -- [ ] Current user can get accessible organization details. -- [ ] Unauthorized organization access is rejected. -- [ ] Development seed data creates an organization with owned workspaces. -- [ ] Backend build and tests pass. -- [ ] OpenAPI and generated frontend schema are updated. +- [x] Organization entity is persisted. +- [x] Workspace requires `OrganizationId`. +- [x] Workspace APIs expose organization ownership. +- [x] Current user can list accessible organizations. +- [x] Current user can get accessible organization details. +- [x] Unauthorized organization access is rejected. +- [x] Development seed data creates an organization with owned workspaces. +- [x] Backend build and tests pass. +- [x] OpenAPI and generated frontend schema are updated. ## Validation Commands diff --git a/docs/TASKS/organizations/002-organization-membership-permissions.md b/docs/TASKS/organizations/002-organization-membership-permissions.md index bc4591e..bd8d530 100644 --- a/docs/TASKS/organizations/002-organization-membership-permissions.md +++ b/docs/TASKS/organizations/002-organization-membership-permissions.md @@ -36,6 +36,40 @@ Users have global accounts. A user can have rights in multiple organizations and - External collaborators must not become organization members automatically. - Keep permission names explicit; avoid magic strings where local patterns provide constants. +## Permission Model + +Use explicit constants in the Organizations module rather than raw strings in handlers. + +Initial organization permissions: + +- `ManageOrganizationSettings` +- `ManageOrganizationMembers` +- `CreateWorkspaces` +- `ManageWorkspaces` +- `ManageBilling` +- `ManageConnectors` +- `AccessOwnedWorkspaces` + +Initial organization roles should map to permissions in code: + +- `Owner`: all organization permissions. +- `Admin`: organization settings, organization members, workspace creation, workspace administration, connector management, and owned workspace access. Billing is not included unless explicitly assigned. +- `BillingManager`: billing and owned workspace access. +- `ConnectorManager`: connector management and owned workspace access. +- `Member`: owned workspace access only. + +Workspace-specific permissions may be overridden at the workspace level after inherited organization access is resolved. Billing and connector permissions must never be granted from workspace-level overrides. + +Direct workspace members who are not organization members should be labeled `External Collaborator` in workspace membership responses. Organization members with inherited or direct workspace access should be labeled `Organization Member`. + +## Implementation Notes + +- Add an `OrganizationMembership` persistence model with `OrganizationId`, `UserId`, role/permission data, and `CreatedAt`. +- Prefer a small Organizations access service for organization access checks and inherited workspace permission calculation instead of adding ad hoc queries to every handler. +- Update JWT claims only if a task proves claims are needed; permission checks can query current database state first. +- Preserve existing global Identity roles while introducing organization-scoped roles. Do not reuse global `manager`, `client`, or `provider` roles as organization roles. +- Add unit tests for role-to-permission mapping and handler/integration tests for access rejection where existing test infrastructure supports it. + ## Likely Files - `backend/src/Socialize.Api/Modules/Organizations/**` @@ -46,12 +80,12 @@ Users have global accounts. A user can have rights in multiple organizations and ## Done When -- [ ] Organization memberships are persisted. -- [ ] Organization roles/permissions include billing manager. -- [ ] Organization-level access can grant inherited access to owned workspaces. -- [ ] Direct workspace-only external collaborators remain supported. -- [ ] Workspace-level overrides apply to workspace-specific permissions. -- [ ] Billing and connector permissions cannot be granted through workspace overrides. +- [x] Organization memberships are persisted. +- [x] Organization roles/permissions include billing manager. +- [x] Organization-level access can grant inherited access to owned workspaces. +- [x] Direct workspace-only external collaborators remain supported. +- [x] Workspace-level overrides apply to workspace-specific permissions. +- [x] Billing and connector permissions cannot be granted through workspace overrides. - [ ] Backend tests cover inherited, direct, external collaborator, and override access paths. ## Validation Commands diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index b188542..295a8f6 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -100,6 +100,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/organizations/{organizationId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesOrganizationsHandlersGetOrganizationHandler"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/organizations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/notifications": { parameters: { query?: never; @@ -878,6 +910,8 @@ export interface components { SocializeApiModulesWorkspacesHandlersWorkspaceDto: { /** Format: guid */ id?: string; + /** Format: guid */ + organizationId?: string; name?: string; slug?: string; logoUrl?: string | null; @@ -906,6 +940,8 @@ export interface components { createdAt?: string; }; SocializeApiModulesWorkspacesHandlersCreateWorkspaceRequest: { + /** Format: guid */ + organizationId: string; name: string; slug: string; timeZone: string; @@ -932,6 +968,7 @@ export interface components { displayName?: string; email?: string; portraitUrl?: string | null; + relationshipCategory?: string; roles?: string[]; }; SocializeApiModulesWorkspacesHandlersUpdateWorkspaceRequest: { @@ -952,6 +989,30 @@ export interface components { /** Format: int32 */ requiredApproverCount?: number; }; + SocializeApiModulesOrganizationsHandlersOrganizationDto: { + /** Format: guid */ + id?: string; + name?: string; + slug?: string; + /** Format: guid */ + ownerUserId?: string; + currentUserPermissions?: string[]; + members?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMemberDto"][]; + workspaces?: components["schemas"]["SocializeApiModulesWorkspacesHandlersWorkspaceDto"][]; + /** Format: date-time */ + createdAt?: string; + }; + SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: { + /** Format: guid */ + userId?: string; + displayName?: string; + email?: string; + portraitUrl?: string | null; + role?: string; + permissions?: string[]; + /** Format: date-time */ + createdAt?: string; + }; SocializeApiModulesNotificationsHandlersNotificationEventDto: { /** Format: guid */ id?: string; @@ -1778,6 +1839,62 @@ export interface operations { }; }; }; + SocializeApiModulesOrganizationsHandlersGetOrganizationHandler: { + parameters: { + query?: never; + header?: never; + path: { + organizationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationDto"][]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SocializeApiModulesNotificationsHandlersGetNotificationsHandler: { parameters: { query?: { diff --git a/shared/openapi/openapi.json b/shared/openapi/openapi.json index 6e798aa..a927b59 100644 --- a/shared/openapi/openapi.json +++ b/shared/openapi/openapi.json @@ -385,6 +385,78 @@ ] } }, + "/api/organizations/{organizationId}": { + "get": { + "tags": [ + "Organizations", + "Api" + ], + "operationId": "SocializeApiModulesOrganizationsHandlersGetOrganizationHandler", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "guid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationDto" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "JWTBearerAuth": [] + } + ] + } + }, + "/api/organizations": { + "get": { + "tags": [ + "Organizations", + "Api" + ], + "operationId": "SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationDto" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "JWTBearerAuth": [] + } + ] + } + }, "/api/notifications": { "get": { "tags": [ @@ -2932,6 +3004,10 @@ "type": "string", "format": "guid" }, + "organizationId": { + "type": "string", + "format": "guid" + }, "name": { "type": "string" }, @@ -3008,11 +3084,18 @@ "type": "object", "additionalProperties": false, "required": [ + "organizationId", "name", "slug", "timeZone" ], "properties": { + "organizationId": { + "type": "string", + "format": "guid", + "minLength": 1, + "nullable": false + }, "name": { "type": "string", "maxLength": 256, @@ -3102,6 +3185,9 @@ "type": "string", "nullable": true }, + "relationshipCategory": { + "type": "string" + }, "roles": { "type": "array", "items": { @@ -3178,6 +3264,81 @@ } } }, + "SocializeApiModulesOrganizationsHandlersOrganizationDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "guid" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "ownerUserId": { + "type": "string", + "format": "guid" + }, + "currentUserPermissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationMemberDto" + } + }, + "workspaces": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SocializeApiModulesWorkspacesHandlersWorkspaceDto" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "SocializeApiModulesOrganizationsHandlersOrganizationMemberDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "userId": { + "type": "string", + "format": "guid" + }, + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "portraitUrl": { + "type": "string", + "nullable": true + }, + "role": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, "SocializeApiModulesNotificationsHandlersNotificationEventDto": { "type": "object", "additionalProperties": false,