From df0409d7f6b3b6bd194e2f612ed25aecab3f3741 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Fri, 1 May 2026 14:23:37 -0400 Subject: [PATCH] wip --- .../src/Socialize.Api/Data/AppDbContext.cs | 2 + .../Development/DevelopmentSeedExtensions.cs | 2 +- ...WorkspaceApprovalConfiguration.Designer.cs | 1314 +++++++++++++++ ...70646_AddWorkspaceApprovalConfiguration.cs | 63 + ...spaceApprovalStepConfiguration.Designer.cs | 1361 ++++++++++++++++ ...0_AddWorkspaceApprovalStepConfiguration.cs | 51 + ...648_AddApprovalWorkflowRuntime.Designer.cs | 1423 +++++++++++++++++ ...260501175648_AddApprovalWorkflowRuntime.cs | 117 ++ .../Migrations/AppDbContextModelSnapshot.cs | 131 ++ .../Data/ApprovalModelConfiguration.cs | 34 + .../Modules/Approvals/Data/ApprovalRequest.cs | 5 + .../Data/ApprovalWorkflowInstance.cs | 12 + .../WorkspaceApprovalStepConfiguration.cs | 13 + .../Modules/Approvals/DependencyInjection.cs | 4 +- .../Handlers/CreateApprovalRequest.cs | 32 +- .../Approvals/Handlers/GetApprovals.cs | 11 + .../Handlers/SubmitApprovalDecision.cs | 87 +- .../ApprovalStepConfigurationRules.cs | 56 + .../Services/ApprovalWorkflowRules.cs | 102 ++ .../ApprovalWorkflowRuntimeService.cs | 401 +++++ .../Handlers/CreateContentItemRevision.cs | 9 - .../Handlers/UpdateContentItemStatus.cs | 75 +- .../Modules/Workspaces/Data/Workspace.cs | 4 + .../Data/WorkspaceModelConfiguration.cs | 4 + .../Workspaces/Handlers/CreateWorkspace.cs | 5 + .../Workspaces/Handlers/GetWorkspaces.cs | 56 +- .../Workspaces/Handlers/UpdateWorkspace.cs | 176 +- .../Approvals/ApprovalWorkflowRulesTests.cs | 350 ++++ .../002-align-content-lifecycle-statuses.md | 43 + .../003-workspace-approval-configuration.md | 47 + .../004-enforce-basic-approval-modes.md | 39 + ...vel-approval-step-configuration-backend.md | 71 + .../006-multi-level-workflow-editor-ui.md | 63 + ...7-execute-multi-level-approval-workflow.md | 64 + frontend/src/api/schema.d.ts | 283 ++++ .../features/channels/views/ChannelsView.vue | 6 +- .../content/stores/contentItemsStore.js | 2 +- .../content/views/ContentItemDetailView.vue | 63 +- .../reviews/stores/reviewQueueStore.js | 15 +- .../components/ApprovalWorkflowEditor.vue | 424 +++++ .../workspaces/views/DashboardView.vue | 17 +- .../workspaces/views/OverviewView.vue | 6 +- .../views/WorkspaceSettingsView.vue | 329 +++- frontend/src/layouts/main/AppSidebar.vue | 31 + frontend/src/locales/en.json | 84 +- frontend/src/locales/fr.json | 84 +- shared/openapi/openapi.json | 423 ++++- 47 files changed, 7800 insertions(+), 194 deletions(-) create mode 100644 backend/src/Socialize.Api/Migrations/20260501170646_AddWorkspaceApprovalConfiguration.Designer.cs create mode 100644 backend/src/Socialize.Api/Migrations/20260501170646_AddWorkspaceApprovalConfiguration.cs create mode 100644 backend/src/Socialize.Api/Migrations/20260501173710_AddWorkspaceApprovalStepConfiguration.Designer.cs create mode 100644 backend/src/Socialize.Api/Migrations/20260501173710_AddWorkspaceApprovalStepConfiguration.cs create mode 100644 backend/src/Socialize.Api/Migrations/20260501175648_AddApprovalWorkflowRuntime.Designer.cs create mode 100644 backend/src/Socialize.Api/Migrations/20260501175648_AddApprovalWorkflowRuntime.cs create mode 100644 backend/src/Socialize.Api/Modules/Approvals/Data/ApprovalWorkflowInstance.cs create mode 100644 backend/src/Socialize.Api/Modules/Approvals/Data/WorkspaceApprovalStepConfiguration.cs create mode 100644 backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalStepConfigurationRules.cs create mode 100644 backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRules.cs create mode 100644 backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRuntimeService.cs create mode 100644 backend/tests/Socialize.Tests/Approvals/ApprovalWorkflowRulesTests.cs create mode 100644 docs/TASKS/approval-workflow/002-align-content-lifecycle-statuses.md create mode 100644 docs/TASKS/approval-workflow/003-workspace-approval-configuration.md create mode 100644 docs/TASKS/approval-workflow/004-enforce-basic-approval-modes.md create mode 100644 docs/TASKS/approval-workflow/005-multi-level-approval-step-configuration-backend.md create mode 100644 docs/TASKS/approval-workflow/006-multi-level-workflow-editor-ui.md create mode 100644 docs/TASKS/approval-workflow/007-execute-multi-level-approval-workflow.md create mode 100644 frontend/src/features/workspaces/components/ApprovalWorkflowEditor.vue diff --git a/backend/src/Socialize.Api/Data/AppDbContext.cs b/backend/src/Socialize.Api/Data/AppDbContext.cs index d66ee84..7ff2235 100644 --- a/backend/src/Socialize.Api/Data/AppDbContext.cs +++ b/backend/src/Socialize.Api/Data/AppDbContext.cs @@ -26,8 +26,10 @@ public class AppDbContext( public DbSet Assets => Set(); public DbSet AssetRevisions => Set(); public DbSet Comments => Set(); + public DbSet ApprovalWorkflowInstances => Set(); public DbSet ApprovalRequests => Set(); public DbSet ApprovalDecisions => Set(); + public DbSet WorkspaceApprovalStepConfigurations => Set(); public DbSet NotificationEvents => Set(); public DbSet FeedbackReports => Set(); public DbSet FeedbackTags => Set(); diff --git a/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs b/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs index ef9e504..ff37c0e 100644 --- a/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs +++ b/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs @@ -307,7 +307,7 @@ public static class DevelopmentSeedExtensions "Spring launch hero video", "Fresh seasonal menu launch across Instagram and TikTok.", "Instagram Reel, TikTok", - "In client review", + "In approval", DateTimeOffset.UtcNow.AddDays(3), "v3", 3, diff --git a/backend/src/Socialize.Api/Migrations/20260501170646_AddWorkspaceApprovalConfiguration.Designer.cs b/backend/src/Socialize.Api/Migrations/20260501170646_AddWorkspaceApprovalConfiguration.Designer.cs new file mode 100644 index 0000000..05eb069 --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260501170646_AddWorkspaceApprovalConfiguration.Designer.cs @@ -0,0 +1,1314 @@ +// +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("20260501170646_AddWorkspaceApprovalConfiguration")] + partial class AddWorkspaceApprovalConfiguration + { + /// + 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("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ReviewerEmail"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ApprovalRequests", (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.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("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("ProjectId") + .HasColumnType("uuid"); + + 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("ClientId"); + + b.HasIndex("ProjectId"); + + 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("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("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProjectName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + 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.Projects.Data.Project", 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("Projects", (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("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("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.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/20260501170646_AddWorkspaceApprovalConfiguration.cs b/backend/src/Socialize.Api/Migrations/20260501170646_AddWorkspaceApprovalConfiguration.cs new file mode 100644 index 0000000..89a5f4c --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260501170646_AddWorkspaceApprovalConfiguration.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + /// + public partial class AddWorkspaceApprovalConfiguration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ApprovalMode", + table: "Workspaces", + type: "character varying(32)", + maxLength: 32, + nullable: false, + defaultValue: "Required"); + + migrationBuilder.AddColumn( + name: "LockContentAfterApproval", + table: "Workspaces", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SchedulePostsAutomaticallyOnApproval", + table: "Workspaces", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SendAutomaticApprovalReminders", + table: "Workspaces", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ApprovalMode", + table: "Workspaces"); + + migrationBuilder.DropColumn( + name: "LockContentAfterApproval", + table: "Workspaces"); + + migrationBuilder.DropColumn( + name: "SchedulePostsAutomaticallyOnApproval", + table: "Workspaces"); + + migrationBuilder.DropColumn( + name: "SendAutomaticApprovalReminders", + table: "Workspaces"); + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/20260501173710_AddWorkspaceApprovalStepConfiguration.Designer.cs b/backend/src/Socialize.Api/Migrations/20260501173710_AddWorkspaceApprovalStepConfiguration.Designer.cs new file mode 100644 index 0000000..4b77e9d --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260501173710_AddWorkspaceApprovalStepConfiguration.Designer.cs @@ -0,0 +1,1361 @@ +// +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("20260501173710_AddWorkspaceApprovalStepConfiguration")] + partial class AddWorkspaceApprovalStepConfiguration + { + /// + 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("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ReviewerEmail"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ApprovalRequests", (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.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("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("ProjectId") + .HasColumnType("uuid"); + + 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("ClientId"); + + b.HasIndex("ProjectId"); + + 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("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("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProjectName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + 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.Projects.Data.Project", 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("Projects", (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("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("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.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/20260501173710_AddWorkspaceApprovalStepConfiguration.cs b/backend/src/Socialize.Api/Migrations/20260501173710_AddWorkspaceApprovalStepConfiguration.cs new file mode 100644 index 0000000..07c9db6 --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260501173710_AddWorkspaceApprovalStepConfiguration.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + /// + public partial class AddWorkspaceApprovalStepConfiguration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "WorkspaceApprovalStepConfigurations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkspaceId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + TargetType = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + TargetValue = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + RequiredApproverCount = table.Column(type: "integer", nullable: false, defaultValue: 1), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_WorkspaceApprovalStepConfigurations", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_WorkspaceApprovalStepConfigurations_WorkspaceId", + table: "WorkspaceApprovalStepConfigurations", + column: "WorkspaceId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkspaceApprovalStepConfigurations_WorkspaceId_SortOrder", + table: "WorkspaceApprovalStepConfigurations", + columns: new[] { "WorkspaceId", "SortOrder" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WorkspaceApprovalStepConfigurations"); + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/20260501175648_AddApprovalWorkflowRuntime.Designer.cs b/backend/src/Socialize.Api/Migrations/20260501175648_AddApprovalWorkflowRuntime.Designer.cs new file mode 100644 index 0000000..8997cd7 --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260501175648_AddApprovalWorkflowRuntime.Designer.cs @@ -0,0 +1,1423 @@ +// +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("20260501175648_AddApprovalWorkflowRuntime")] + partial class AddApprovalWorkflowRuntime + { + /// + 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.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("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("ProjectId") + .HasColumnType("uuid"); + + 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("ClientId"); + + b.HasIndex("ProjectId"); + + 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("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("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProjectName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + 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.Projects.Data.Project", 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("Projects", (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("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("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.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/20260501175648_AddApprovalWorkflowRuntime.cs b/backend/src/Socialize.Api/Migrations/20260501175648_AddApprovalWorkflowRuntime.cs new file mode 100644 index 0000000..467b193 --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260501175648_AddApprovalWorkflowRuntime.cs @@ -0,0 +1,117 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + /// + public partial class AddApprovalWorkflowRuntime : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "WorkflowInstanceId", + table: "ApprovalRequests", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "WorkflowStepRequiredApproverCount", + table: "ApprovalRequests", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "WorkflowStepSortOrder", + table: "ApprovalRequests", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "WorkflowStepTargetType", + table: "ApprovalRequests", + type: "character varying(32)", + maxLength: 32, + nullable: true); + + migrationBuilder.AddColumn( + name: "WorkflowStepTargetValue", + table: "ApprovalRequests", + type: "character varying(128)", + maxLength: 128, + nullable: true); + + migrationBuilder.CreateTable( + name: "ApprovalWorkflowInstances", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkspaceId = table.Column(type: "uuid", nullable: false), + ContentItemId = table.Column(type: "uuid", nullable: false), + State = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + ApprovalMode = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + StartedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ApprovalWorkflowInstances", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ApprovalRequests_WorkflowInstanceId", + table: "ApprovalRequests", + column: "WorkflowInstanceId"); + + migrationBuilder.CreateIndex( + name: "IX_ApprovalWorkflowInstances_ContentItemId", + table: "ApprovalWorkflowInstances", + column: "ContentItemId"); + + migrationBuilder.CreateIndex( + name: "IX_ApprovalWorkflowInstances_ContentItemId_State", + table: "ApprovalWorkflowInstances", + columns: new[] { "ContentItemId", "State" }, + unique: true, + filter: "\"State\" = 'Pending'"); + + migrationBuilder.CreateIndex( + name: "IX_ApprovalWorkflowInstances_WorkspaceId", + table: "ApprovalWorkflowInstances", + column: "WorkspaceId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApprovalWorkflowInstances"); + + migrationBuilder.DropIndex( + name: "IX_ApprovalRequests_WorkflowInstanceId", + table: "ApprovalRequests"); + + migrationBuilder.DropColumn( + name: "WorkflowInstanceId", + table: "ApprovalRequests"); + + migrationBuilder.DropColumn( + name: "WorkflowStepRequiredApproverCount", + table: "ApprovalRequests"); + + migrationBuilder.DropColumn( + name: "WorkflowStepSortOrder", + table: "ApprovalRequests"); + + migrationBuilder.DropColumn( + name: "WorkflowStepTargetType", + table: "ApprovalRequests"); + + migrationBuilder.DropColumn( + name: "WorkflowStepTargetValue", + table: "ApprovalRequests"); + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs index f9b770b..72bbe9d 100644 --- a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs @@ -216,6 +216,23 @@ namespace Socialize.Api.Migrations .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"); @@ -225,11 +242,103 @@ namespace Socialize.Api.Migrations 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") @@ -1100,11 +1209,23 @@ namespace Socialize.Api.Migrations .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)"); @@ -1117,6 +1238,16 @@ namespace Socialize.Api.Migrations 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) diff --git a/backend/src/Socialize.Api/Modules/Approvals/Data/ApprovalModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Approvals/Data/ApprovalModelConfiguration.cs index 1668471..c99e2a7 100644 --- a/backend/src/Socialize.Api/Modules/Approvals/Data/ApprovalModelConfiguration.cs +++ b/backend/src/Socialize.Api/Modules/Approvals/Data/ApprovalModelConfiguration.cs @@ -6,10 +6,28 @@ public static class ApprovalModelConfiguration { public static ModelBuilder ConfigureApprovalsModule(this ModelBuilder modelBuilder) { + modelBuilder.Entity(workflowInstance => + { + workflowInstance.ToTable("ApprovalWorkflowInstances"); + workflowInstance.HasKey(x => x.Id); + workflowInstance.Property(x => x.State).HasMaxLength(64).IsRequired(); + workflowInstance.Property(x => x.ApprovalMode).HasMaxLength(64).IsRequired(); + workflowInstance.Property(x => x.StartedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + workflowInstance.HasIndex(x => x.WorkspaceId); + workflowInstance.HasIndex(x => x.ContentItemId); + workflowInstance.HasIndex(x => new { x.ContentItemId, x.State }) + .IsUnique() + .HasFilter("\"State\" = 'Pending'"); + }); + modelBuilder.Entity(approvalRequest => { approvalRequest.ToTable("ApprovalRequests"); approvalRequest.HasKey(x => x.Id); + approvalRequest.Property(x => x.WorkflowStepTargetType).HasMaxLength(32); + approvalRequest.Property(x => x.WorkflowStepTargetValue).HasMaxLength(128); approvalRequest.Property(x => x.Stage).HasMaxLength(64).IsRequired(); approvalRequest.Property(x => x.ReviewerName).HasMaxLength(256).IsRequired(); approvalRequest.Property(x => x.ReviewerEmail).HasMaxLength(256).IsRequired(); @@ -20,6 +38,7 @@ public static class ApprovalModelConfiguration .HasDefaultValueSql("CURRENT_TIMESTAMP"); approvalRequest.HasIndex(x => x.WorkspaceId); approvalRequest.HasIndex(x => x.ContentItemId); + approvalRequest.HasIndex(x => x.WorkflowInstanceId); approvalRequest.HasIndex(x => x.ReviewerEmail); }); @@ -37,6 +56,21 @@ public static class ApprovalModelConfiguration approvalDecision.HasIndex(x => x.ApprovalRequestId); }); + modelBuilder.Entity(approvalStep => + { + approvalStep.ToTable("WorkspaceApprovalStepConfigurations"); + approvalStep.HasKey(x => x.Id); + approvalStep.Property(x => x.Name).HasMaxLength(128).IsRequired(); + approvalStep.Property(x => x.TargetType).HasMaxLength(32).IsRequired(); + approvalStep.Property(x => x.TargetValue).HasMaxLength(128).IsRequired(); + approvalStep.Property(x => x.RequiredApproverCount).HasDefaultValue(1); + approvalStep.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + approvalStep.HasIndex(x => x.WorkspaceId); + approvalStep.HasIndex(x => new { x.WorkspaceId, x.SortOrder }).IsUnique(); + }); + return modelBuilder; } } diff --git a/backend/src/Socialize.Api/Modules/Approvals/Data/ApprovalRequest.cs b/backend/src/Socialize.Api/Modules/Approvals/Data/ApprovalRequest.cs index de2a76a..d6782e4 100644 --- a/backend/src/Socialize.Api/Modules/Approvals/Data/ApprovalRequest.cs +++ b/backend/src/Socialize.Api/Modules/Approvals/Data/ApprovalRequest.cs @@ -5,6 +5,11 @@ public class ApprovalRequest public Guid Id { get; init; } public Guid WorkspaceId { get; set; } public Guid ContentItemId { get; set; } + public Guid? WorkflowInstanceId { get; set; } + public int? WorkflowStepSortOrder { get; set; } + public string? WorkflowStepTargetType { get; set; } + public string? WorkflowStepTargetValue { get; set; } + public int? WorkflowStepRequiredApproverCount { get; set; } public required string Stage { get; set; } public required string ReviewerName { get; set; } public required string ReviewerEmail { get; set; } diff --git a/backend/src/Socialize.Api/Modules/Approvals/Data/ApprovalWorkflowInstance.cs b/backend/src/Socialize.Api/Modules/Approvals/Data/ApprovalWorkflowInstance.cs new file mode 100644 index 0000000..38a2dc9 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Approvals/Data/ApprovalWorkflowInstance.cs @@ -0,0 +1,12 @@ +namespace Socialize.Api.Modules.Approvals.Data; + +public class ApprovalWorkflowInstance +{ + public Guid Id { get; init; } + public Guid WorkspaceId { get; set; } + public Guid ContentItemId { get; set; } + public required string State { get; set; } + public required string ApprovalMode { get; set; } + public DateTimeOffset StartedAt { get; init; } + public DateTimeOffset? CompletedAt { get; set; } +} diff --git a/backend/src/Socialize.Api/Modules/Approvals/Data/WorkspaceApprovalStepConfiguration.cs b/backend/src/Socialize.Api/Modules/Approvals/Data/WorkspaceApprovalStepConfiguration.cs new file mode 100644 index 0000000..173a14d --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Approvals/Data/WorkspaceApprovalStepConfiguration.cs @@ -0,0 +1,13 @@ +namespace Socialize.Api.Modules.Approvals.Data; + +public class WorkspaceApprovalStepConfiguration +{ + public Guid Id { get; init; } + public Guid WorkspaceId { get; set; } + public required string Name { get; set; } + public int SortOrder { get; set; } + public required string TargetType { get; set; } + public required string TargetValue { get; set; } + public int RequiredApproverCount { get; set; } = 1; + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/backend/src/Socialize.Api/Modules/Approvals/DependencyInjection.cs b/backend/src/Socialize.Api/Modules/Approvals/DependencyInjection.cs index 31768fc..a8457c3 100644 --- a/backend/src/Socialize.Api/Modules/Approvals/DependencyInjection.cs +++ b/backend/src/Socialize.Api/Modules/Approvals/DependencyInjection.cs @@ -1,4 +1,4 @@ -using Socialize.Api.Modules.Approvals.Data; +using Socialize.Api.Modules.Approvals.Services; namespace Socialize.Api.Modules.Approvals; @@ -7,6 +7,8 @@ public static class DependencyInjection public static WebApplicationBuilder AddApprovalsModule( this WebApplicationBuilder builder) { + builder.Services.AddScoped(); + return builder; } } diff --git a/backend/src/Socialize.Api/Modules/Approvals/Handlers/CreateApprovalRequest.cs b/backend/src/Socialize.Api/Modules/Approvals/Handlers/CreateApprovalRequest.cs index a2c9199..9dce30b 100644 --- a/backend/src/Socialize.Api/Modules/Approvals/Handlers/CreateApprovalRequest.cs +++ b/backend/src/Socialize.Api/Modules/Approvals/Handlers/CreateApprovalRequest.cs @@ -4,7 +4,9 @@ using System.Security.Cryptography; using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; using Socialize.Api.Modules.Approvals.Data; +using Socialize.Api.Modules.Approvals.Services; using Socialize.Api.Modules.Notifications.Contracts; +using Socialize.Api.Modules.Workspaces.Data; namespace Socialize.Api.Modules.Approvals.Handlers; @@ -62,6 +64,22 @@ public class CreateApprovalRequestHandler( return; } + Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct); + if (workspace is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (!ApprovalWorkflowRules.CanCreateSingleStepApprovalRequest(workspace.ApprovalMode)) + { + AddError(request => request.WorkspaceId, workspace.ApprovalMode == ApprovalModes.None + ? "Approval workflow is disabled for this workspace." + : "Move content to In approval to start the configured multi-level approval workflow."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + var approval = new ApprovalRequest() { Id = Guid.NewGuid(), @@ -79,14 +97,7 @@ public class CreateApprovalRequestHandler( dbContext.ApprovalRequests.Add(approval); - if (approval.Stage == "Internal") - { - contentItem.Status = "In internal review"; - } - else if (approval.Stage == "Client") - { - contentItem.Status = "In client review"; - } + contentItem.Status = "In approval"; await dbContext.SaveChangesAsync(ct); @@ -107,6 +118,11 @@ public class CreateApprovalRequestHandler( approval.Id, approval.WorkspaceId, approval.ContentItemId, + approval.WorkflowInstanceId, + approval.WorkflowStepSortOrder, + approval.WorkflowStepTargetType, + approval.WorkflowStepTargetValue, + approval.WorkflowStepRequiredApproverCount, approval.Stage, approval.ReviewerName, approval.ReviewerEmail, diff --git a/backend/src/Socialize.Api/Modules/Approvals/Handlers/GetApprovals.cs b/backend/src/Socialize.Api/Modules/Approvals/Handlers/GetApprovals.cs index 753a5a5..6f43907 100644 --- a/backend/src/Socialize.Api/Modules/Approvals/Handlers/GetApprovals.cs +++ b/backend/src/Socialize.Api/Modules/Approvals/Handlers/GetApprovals.cs @@ -24,6 +24,11 @@ public record ApprovalRequestDto( Guid Id, Guid WorkspaceId, Guid ContentItemId, + Guid? WorkflowInstanceId, + int? WorkflowStepSortOrder, + string? WorkflowStepTargetType, + string? WorkflowStepTargetValue, + int? WorkflowStepRequiredApproverCount, string Stage, string ReviewerName, string ReviewerEmail, @@ -65,6 +70,7 @@ public class GetApprovalsHandler( List approvals = await dbContext.ApprovalRequests .Where(approval => approval.ContentItemId == request.ContentItemId) .OrderByDescending(approval => approval.SentAt) + .ThenBy(approval => approval.WorkflowStepSortOrder) .ToListAsync(ct); List approvalIds = approvals @@ -91,6 +97,11 @@ public class GetApprovalsHandler( approval.Id, approval.WorkspaceId, approval.ContentItemId, + approval.WorkflowInstanceId, + approval.WorkflowStepSortOrder, + approval.WorkflowStepTargetType, + approval.WorkflowStepTargetValue, + approval.WorkflowStepRequiredApproverCount, approval.Stage, approval.ReviewerName, approval.ReviewerEmail, diff --git a/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs b/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs index 608e813..3818bcf 100644 --- a/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs +++ b/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs @@ -4,7 +4,9 @@ using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.Approvals.Data; +using Socialize.Api.Modules.Approvals.Services; using Socialize.Api.Modules.Notifications.Contracts; +using Socialize.Api.Modules.Workspaces.Data; namespace Socialize.Api.Modules.Approvals.Handlers; @@ -19,7 +21,10 @@ public class SubmitApprovalDecisionRequestValidator { public SubmitApprovalDecisionRequestValidator() { - RuleFor(x => x.Decision).NotEmpty().MaximumLength(64); + RuleFor(x => x.Decision) + .NotEmpty() + .Equal("Approved") + .WithMessage("Only approved decisions are supported."); RuleFor(x => x.Comment).MaximumLength(2048); RuleFor(x => x.ReviewerName).MaximumLength(256); RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail)); @@ -29,6 +34,7 @@ public class SubmitApprovalDecisionRequestValidator public class SubmitApprovalDecisionHandler( AppDbContext dbContext, AccessScopeService accessScopeService, + ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService, INotificationEventWriter notificationEventWriter) : Endpoint { @@ -64,6 +70,13 @@ public class SubmitApprovalDecisionHandler( return; } + Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct); + if (workspace is null) + { + await SendNotFoundAsync(ct); + return; + } + string normalizedDecision = request.Decision.Trim(); string decidedByName = User?.Identity?.IsAuthenticated == true ? User.GetAlias() ?? User.GetName() @@ -84,45 +97,44 @@ public class SubmitApprovalDecisionHandler( CreatedAt = DateTimeOffset.UtcNow, }; - approval.State = normalizedDecision; - approval.CompletedAt = DateTimeOffset.UtcNow; + ApprovalWorkflowDecisionResult workflowDecisionResult = await approvalWorkflowRuntimeService + .ApplyWorkflowStepDecisionAsync(approval, contentItem, workspace, User!, decision, ct); - if (approval.Stage == "Internal") + if (!workflowDecisionResult.Succeeded) { - contentItem.Status = normalizedDecision switch - { - "Approved" => "Ready for client review", - "Changes requested" => "Changes requested internally", - "Rejected" => "Rejected", - _ => contentItem.Status, - }; - } - else if (approval.Stage == "Client") - { - contentItem.Status = normalizedDecision switch - { - "Approved" => "Approved", - "Changes requested" => "Changes requested by client", - "Rejected" => "Rejected", - _ => contentItem.Status, - }; + AddError(request => request.Decision, workflowDecisionResult.ErrorMessage ?? "The approval decision could not be recorded."); + await SendErrorsAsync(workflowDecisionResult.StatusCode, ct); + return; } - dbContext.ApprovalDecisions.Add(decision); - await dbContext.SaveChangesAsync(ct); + if (!workflowDecisionResult.IsWorkflowStep) + { + approval.State = normalizedDecision; + approval.CompletedAt = DateTimeOffset.UtcNow; - await notificationEventWriter.WriteAsync( - new NotificationEventWriteModel( - approval.WorkspaceId, - approval.ContentItemId, - "approval.decision.recorded", - "ApprovalDecision", - decision.Id, - $"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.", - null, - decidedByEmail, - $$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""), - ct); + if (normalizedDecision == "Approved") + { + contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus( + workspace.SchedulePostsAutomaticallyOnApproval, + contentItem.DueDate); + } + + dbContext.ApprovalDecisions.Add(decision); + await dbContext.SaveChangesAsync(ct); + + await notificationEventWriter.WriteAsync( + new NotificationEventWriteModel( + approval.WorkspaceId, + approval.ContentItemId, + "approval.decision.recorded", + "ApprovalDecision", + decision.Id, + $"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.", + null, + decidedByEmail, + $$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""), + ct); + } List decisions = await dbContext.ApprovalDecisions .Where(candidate => candidate.ApprovalRequestId == approval.Id) @@ -158,6 +170,11 @@ public class SubmitApprovalDecisionHandler( approval.Id, approval.WorkspaceId, approval.ContentItemId, + approval.WorkflowInstanceId, + approval.WorkflowStepSortOrder, + approval.WorkflowStepTargetType, + approval.WorkflowStepTargetValue, + approval.WorkflowStepRequiredApproverCount, approval.Stage, approval.ReviewerName, approval.ReviewerEmail, diff --git a/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalStepConfigurationRules.cs b/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalStepConfigurationRules.cs new file mode 100644 index 0000000..99696f8 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalStepConfigurationRules.cs @@ -0,0 +1,56 @@ +using Socialize.Api.Modules.Identity.Contracts; + +namespace Socialize.Api.Modules.Approvals.Services; + +public static class ApprovalStepTargetTypes +{ + public const string Role = "Role"; + public const string Membership = "Membership"; + public const string Member = "Member"; +} + +public static class ApprovalMembershipTargets +{ + public const string Team = "Team"; + public const string Client = "Client"; +} + +public static class ApprovalStepConfigurationRules +{ + public static readonly IReadOnlySet AllowedTargetTypes = new HashSet(StringComparer.Ordinal) + { + ApprovalStepTargetTypes.Role, + ApprovalStepTargetTypes.Membership, + ApprovalStepTargetTypes.Member, + }; + + public static readonly IReadOnlySet AllowedRoleTargets = new HashSet(StringComparer.Ordinal) + { + KnownRoles.Administrator, + KnownRoles.Manager, + KnownRoles.WorkspaceMember, + KnownRoles.Client, + KnownRoles.Provider, + }; + + public static readonly IReadOnlySet AllowedMembershipTargets = new HashSet(StringComparer.Ordinal) + { + ApprovalMembershipTargets.Team, + ApprovalMembershipTargets.Client, + }; + + public static bool IsValidTargetType(string? targetType) + { + return !string.IsNullOrWhiteSpace(targetType) && AllowedTargetTypes.Contains(targetType.Trim()); + } + + public static bool IsValidRoleTarget(string? targetValue) + { + return !string.IsNullOrWhiteSpace(targetValue) && AllowedRoleTargets.Contains(targetValue.Trim()); + } + + public static bool IsValidMembershipTarget(string? targetValue) + { + return !string.IsNullOrWhiteSpace(targetValue) && AllowedMembershipTargets.Contains(targetValue.Trim()); + } +} diff --git a/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRules.cs b/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRules.cs new file mode 100644 index 0000000..0a831cd --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRules.cs @@ -0,0 +1,102 @@ +using Socialize.Api.Modules.Identity.Contracts; + +namespace Socialize.Api.Modules.Approvals.Services; + +public static class ApprovalModes +{ + public const string None = "None"; + public const string Optional = "Optional"; + public const string Required = "Required"; + public const string MultiLevel = "Multi-level"; +} + +public static class ApprovalWorkflowRules +{ + public static bool CanCreateSingleStepApprovalRequest(string approvalMode) + { + return approvalMode is ApprovalModes.Optional or ApprovalModes.Required; + } + + public static bool BlocksManualApprovedOrScheduledStatus(string approvalMode) + { + return approvalMode is ApprovalModes.Required or ApprovalModes.MultiLevel; + } + + public static bool IsApprovalCompletionStatus(string status) + { + return status is "Approved" or "Scheduled"; + } + + public static string GetFinalApprovalStatus(bool schedulePostsAutomaticallyOnApproval, DateTimeOffset? plannedPublishDate) + { + return schedulePostsAutomaticallyOnApproval && plannedPublishDate.HasValue + ? "Scheduled" + : "Approved"; + } + + public static bool HasRequiredStepApprovals(int approvedDecisionCount, int requiredApproverCount) + { + return approvedDecisionCount >= Math.Max(1, requiredApproverCount); + } + + public static bool CanApproveWorkflowStep( + bool isAdministrator, + bool hasWorkspaceAccess, + IReadOnlyCollection userRoles, + Guid userId, + string? targetType, + string? targetValue) + { + if (isAdministrator) + { + return true; + } + + if (!hasWorkspaceAccess || + string.IsNullOrWhiteSpace(targetType) || + string.IsNullOrWhiteSpace(targetValue)) + { + return false; + } + + return targetType switch + { + ApprovalStepTargetTypes.Role => userRoles.Contains(targetValue), + ApprovalStepTargetTypes.Membership => MatchesMembershipTarget(userRoles, targetValue), + ApprovalStepTargetTypes.Member => ParseMemberTargetIds(targetValue).Contains(userId), + _ => false, + }; + } + + public static IReadOnlyCollection ParseMemberTargetIds(string? targetValue) + { + if (string.IsNullOrWhiteSpace(targetValue)) + { + return []; + } + + return targetValue + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(value => Guid.TryParse(value, out Guid memberUserId) ? memberUserId : Guid.Empty) + .Where(memberUserId => memberUserId != Guid.Empty) + .Distinct() + .ToArray(); + } + + public static string FormatMemberTargetValue(IEnumerable memberUserIds) + { + return string.Join(",", memberUserIds.Distinct().OrderBy(memberUserId => memberUserId)); + } + + private static bool MatchesMembershipTarget( + IReadOnlyCollection userRoles, + string targetValue) + { + return targetValue switch + { + ApprovalMembershipTargets.Client => userRoles.Contains(KnownRoles.Client), + ApprovalMembershipTargets.Team => !userRoles.Contains(KnownRoles.Client), + _ => false, + }; + } +} diff --git a/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRuntimeService.cs b/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRuntimeService.cs new file mode 100644 index 0000000..2a3f5a7 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRuntimeService.cs @@ -0,0 +1,401 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.Approvals.Data; +using Socialize.Api.Modules.ContentItems.Data; +using Socialize.Api.Modules.Identity.Contracts; +using Socialize.Api.Modules.Notifications.Contracts; +using Socialize.Api.Modules.Workspaces.Data; + +namespace Socialize.Api.Modules.Approvals.Services; + +public record ApprovalWorkflowStartResult(bool Succeeded, string? ErrorMessage); + +public record ApprovalWorkflowDecisionResult( + bool Succeeded, + string? ErrorMessage, + int StatusCode, + bool IsWorkflowStep); + +public class ApprovalWorkflowRuntimeService( + AppDbContext dbContext, + INotificationEventWriter notificationEventWriter) +{ + private const string PendingState = "Pending"; + private const string ApprovedState = "Approved"; + + public async Task StartMultiLevelWorkflowAsync( + ContentItem contentItem, + Workspace workspace, + Guid requestedByUserId, + CancellationToken ct) + { + if (workspace.ApprovalMode != ApprovalModes.MultiLevel) + { + return new ApprovalWorkflowStartResult(false, "The workspace is not configured for multi-level approval."); + } + + ApprovalWorkflowInstance? activeWorkflow = await dbContext.ApprovalWorkflowInstances + .SingleOrDefaultAsync( + workflow => workflow.ContentItemId == contentItem.Id && workflow.State == PendingState, + ct); + if (activeWorkflow is not null) + { + contentItem.Status = "In approval"; + return new ApprovalWorkflowStartResult(true, null); + } + + List configuredSteps = await dbContext.WorkspaceApprovalStepConfigurations + .Where(step => step.WorkspaceId == workspace.Id) + .OrderBy(step => step.SortOrder) + .ThenBy(step => step.Name) + .ToListAsync(ct); + + if (configuredSteps.Count == 0) + { + return new ApprovalWorkflowStartResult(false, "Multi-level approval requires at least one configured approval step."); + } + + DateTimeOffset now = DateTimeOffset.UtcNow; + var workflowInstance = new ApprovalWorkflowInstance + { + Id = Guid.NewGuid(), + WorkspaceId = workspace.Id, + ContentItemId = contentItem.Id, + State = PendingState, + ApprovalMode = workspace.ApprovalMode, + StartedAt = now, + }; + + List workflowSteps = configuredSteps + .Select((step, index) => new ApprovalRequest + { + Id = Guid.NewGuid(), + WorkspaceId = workspace.Id, + ContentItemId = contentItem.Id, + WorkflowInstanceId = workflowInstance.Id, + WorkflowStepSortOrder = index, + WorkflowStepTargetType = step.TargetType, + WorkflowStepTargetValue = step.TargetValue, + WorkflowStepRequiredApproverCount = step.RequiredApproverCount, + Stage = step.Name, + ReviewerName = FormatStepTarget(step), + ReviewerEmail = string.Empty, + RequestedByUserId = requestedByUserId, + DueAt = contentItem.DueDate, + State = PendingState, + AccessToken = CreateAccessToken(), + SentAt = now, + }) + .ToList(); + + dbContext.ApprovalWorkflowInstances.Add(workflowInstance); + dbContext.ApprovalRequests.AddRange(workflowSteps); + contentItem.Status = "In approval"; + + await dbContext.SaveChangesAsync(ct); + await NotifyCurrentStepApproversAsync(workflowSteps[0], contentItem, ct); + + return new ApprovalWorkflowStartResult(true, null); + } + + public async Task ApplyWorkflowStepDecisionAsync( + ApprovalRequest approval, + ContentItem contentItem, + Workspace workspace, + ClaimsPrincipal user, + ApprovalDecision decision, + CancellationToken ct) + { + if (!approval.WorkflowInstanceId.HasValue) + { + return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, false); + } + + if (user.Identity?.IsAuthenticated != true) + { + return new ApprovalWorkflowDecisionResult(false, "Multi-level approval steps require an authenticated approver.", StatusCodes.Status401Unauthorized, true); + } + + if (!await CanApproveStepAsync(user, approval, workspace.Id, ct)) + { + return new ApprovalWorkflowDecisionResult(false, "You cannot approve the current workflow step.", StatusCodes.Status403Forbidden, true); + } + + ApprovalRequest? currentStep = await GetCurrentPendingStepAsync(approval.WorkflowInstanceId.Value, ct); + if (currentStep?.Id != approval.Id) + { + return new ApprovalWorkflowDecisionResult(false, "Only the current pending approval step can be approved.", StatusCodes.Status409Conflict, true); + } + + Guid currentUserId = user.GetUserId(); + bool alreadyApproved = await dbContext.ApprovalDecisions.AnyAsync( + candidate => candidate.ApprovalRequestId == approval.Id && + candidate.DecidedByUserId == currentUserId && + candidate.Decision == ApprovedState, + ct); + + if (alreadyApproved) + { + return new ApprovalWorkflowDecisionResult(false, "You have already approved this workflow step.", StatusCodes.Status409Conflict, true); + } + + dbContext.ApprovalDecisions.Add(decision); + await dbContext.SaveChangesAsync(ct); + + int approvedCount = await dbContext.ApprovalDecisions + .Where(candidate => candidate.ApprovalRequestId == approval.Id && candidate.Decision == ApprovedState) + .Select(candidate => candidate.DecidedByUserId.HasValue + ? candidate.DecidedByUserId.Value.ToString() + : candidate.DecidedByEmail.ToLower()) + .Distinct() + .CountAsync(ct); + + int requiredApproverCount = approval.WorkflowStepRequiredApproverCount ?? 1; + if (!ApprovalWorkflowRules.HasRequiredStepApprovals(approvedCount, requiredApproverCount)) + { + return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, true); + } + + approval.State = ApprovedState; + approval.CompletedAt = DateTimeOffset.UtcNow; + + ApprovalRequest? nextStep = await dbContext.ApprovalRequests + .Where(candidate => candidate.WorkflowInstanceId == approval.WorkflowInstanceId && + candidate.State == PendingState && + candidate.Id != approval.Id) + .OrderBy(candidate => candidate.WorkflowStepSortOrder) + .ThenBy(candidate => candidate.SentAt) + .FirstOrDefaultAsync(ct); + + if (nextStep is null) + { + ApprovalWorkflowInstance? workflowInstance = await dbContext.ApprovalWorkflowInstances + .SingleOrDefaultAsync(candidate => candidate.Id == approval.WorkflowInstanceId.Value, ct); + if (workflowInstance is null) + { + return new ApprovalWorkflowDecisionResult(false, "The approval workflow instance could not be found.", StatusCodes.Status404NotFound, true); + } + + workflowInstance.State = ApprovedState; + workflowInstance.CompletedAt = DateTimeOffset.UtcNow; + contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus( + workspace.SchedulePostsAutomaticallyOnApproval, + contentItem.DueDate); + } + + await dbContext.SaveChangesAsync(ct); + + if (nextStep is null) + { + await NotifyPublishUsersAsync(approval, contentItem, ct); + } + else + { + await NotifyCurrentStepApproversAsync(nextStep, contentItem, ct); + } + + return new ApprovalWorkflowDecisionResult(true, null, StatusCodes.Status200OK, true); + } + + public async Task HasCompletedMultiLevelWorkflowAsync(Guid contentItemId, CancellationToken ct) + { + return await dbContext.ApprovalWorkflowInstances.AnyAsync( + workflow => workflow.ContentItemId == contentItemId && workflow.State == ApprovedState, + ct); + } + + private async Task GetCurrentPendingStepAsync(Guid workflowInstanceId, CancellationToken ct) + { + return await dbContext.ApprovalRequests + .Where(candidate => candidate.WorkflowInstanceId == workflowInstanceId && candidate.State == PendingState) + .OrderBy(candidate => candidate.WorkflowStepSortOrder) + .ThenBy(candidate => candidate.SentAt) + .FirstOrDefaultAsync(ct); + } + + private async Task CanApproveStepAsync( + ClaimsPrincipal user, + ApprovalRequest approval, + Guid workspaceId, + CancellationToken ct) + { + Guid userId = user.GetUserId(); + bool hasWorkspaceAccess = await UserHasWorkspaceAccessAsync(userId, workspaceId, ct); + string[] userRoles = ApprovalStepConfigurationRules.AllowedRoleTargets + .Where(user.IsInRole) + .ToArray(); + + return ApprovalWorkflowRules.CanApproveWorkflowStep( + user.IsInRole(KnownRoles.Administrator), + hasWorkspaceAccess, + userRoles, + userId, + approval.WorkflowStepTargetType, + approval.WorkflowStepTargetValue); + } + + private async Task UserHasWorkspaceAccessAsync(Guid userId, Guid workspaceId, CancellationToken ct) + { + string workspaceClaimValue = workspaceId.ToString(); + return await dbContext.UserClaims.AnyAsync( + claim => claim.UserId == userId && + claim.ClaimType == KnownClaims.WorkspaceScope && + claim.ClaimValue == workspaceClaimValue, + ct); + } + + private async Task NotifyCurrentStepApproversAsync( + ApprovalRequest approval, + ContentItem contentItem, + CancellationToken ct) + { + List recipients = await GetStepApproverRecipientsAsync(approval, ct); + + foreach (ApprovalNotificationRecipient recipient in recipients) + { + await notificationEventWriter.WriteAsync( + new NotificationEventWriteModel( + approval.WorkspaceId, + approval.ContentItemId, + "approval.step.current", + "ApprovalRequest", + approval.Id, + $"{approval.Stage} approval is ready for {contentItem.Title}.", + recipient.UserId, + recipient.Email, + $$"""{"stage":"{{approval.Stage}}","requiredApproverCount":{{approval.WorkflowStepRequiredApproverCount ?? 1}}}"""), + ct); + } + } + + private async Task NotifyPublishUsersAsync( + ApprovalRequest approval, + ContentItem contentItem, + CancellationToken ct) + { + List recipients = await GetPublishRecipientUsersAsync(approval.WorkspaceId, ct); + + foreach (ApprovalNotificationRecipient recipient in recipients) + { + await notificationEventWriter.WriteAsync( + new NotificationEventWriteModel( + approval.WorkspaceId, + approval.ContentItemId, + "approval.workflow.completed", + "ApprovalWorkflowInstance", + approval.WorkflowInstanceId!.Value, + $"Final approval completed for {contentItem.Title}.", + recipient.UserId, + recipient.Email, + $$"""{"status":"{{contentItem.Status}}"}"""), + ct); + } + } + + private async Task> GetStepApproverRecipientsAsync( + ApprovalRequest approval, + CancellationToken ct) + { + string? targetType = approval.WorkflowStepTargetType; + string? targetValue = approval.WorkflowStepTargetValue; + if (string.IsNullOrWhiteSpace(targetType) || string.IsNullOrWhiteSpace(targetValue)) + { + return []; + } + + return targetType switch + { + ApprovalStepTargetTypes.Member => await GetMemberRecipientsAsync(targetValue, ct), + ApprovalStepTargetTypes.Role => await GetRoleRecipientsAsync(approval.WorkspaceId, [targetValue], ct), + ApprovalStepTargetTypes.Membership => await GetMembershipRecipientsAsync(approval.WorkspaceId, targetValue, ct), + _ => [], + }; + } + + private async Task> GetMemberRecipientsAsync(string targetValue, CancellationToken ct) + { + IReadOnlyCollection userIds = ApprovalWorkflowRules.ParseMemberTargetIds(targetValue); + if (userIds.Count == 0) + { + return []; + } + + return await dbContext.Users + .Where(user => userIds.Contains(user.Id)) + .Select(user => new ApprovalNotificationRecipient(user.Id, user.Email)) + .ToListAsync(ct); + } + + private async Task> GetMembershipRecipientsAsync( + Guid workspaceId, + string targetValue, + CancellationToken ct) + { + string[] roles = targetValue switch + { + ApprovalMembershipTargets.Client => [KnownRoles.Client], + ApprovalMembershipTargets.Team => [KnownRoles.Administrator, KnownRoles.Manager, KnownRoles.WorkspaceMember, KnownRoles.Provider], + _ => [], + }; + + return roles.Length == 0 + ? [] + : await GetRoleRecipientsAsync(workspaceId, roles, ct); + } + + private async Task> GetPublishRecipientUsersAsync(Guid workspaceId, CancellationToken ct) + { + return await GetRoleRecipientsAsync(workspaceId, [KnownRoles.Administrator, KnownRoles.Manager], ct); + } + + private async Task> GetRoleRecipientsAsync( + Guid workspaceId, + IReadOnlyCollection roles, + CancellationToken ct) + { + string workspaceClaimValue = workspaceId.ToString(); + + return await dbContext.UserRoles + .Join( + dbContext.Roles, + userRole => userRole.RoleId, + role => role.Id, + (userRole, role) => new { userRole.UserId, RoleName = role.Name }) + .Where(candidate => candidate.RoleName != null && roles.Contains(candidate.RoleName)) + .Join( + dbContext.UserClaims.Where(claim => + claim.ClaimType == KnownClaims.WorkspaceScope && + claim.ClaimValue == workspaceClaimValue), + candidate => candidate.UserId, + claim => claim.UserId, + (candidate, _) => candidate.UserId) + .Distinct() + .Join( + dbContext.Users, + userId => userId, + user => user.Id, + (_, user) => new ApprovalNotificationRecipient(user.Id, user.Email)) + .ToListAsync(ct); + } + + private static string FormatStepTarget(WorkspaceApprovalStepConfiguration step) + { + return step.TargetType switch + { + ApprovalStepTargetTypes.Role => $"Role: {step.TargetValue}", + ApprovalStepTargetTypes.Membership => $"Membership: {step.TargetValue}", + ApprovalStepTargetTypes.Member => "Assigned members", + _ => step.TargetValue, + }; + } + + private static string CreateAccessToken() + { + return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); + } + + private sealed record ApprovalNotificationRecipient(Guid UserId, string? Email); +} diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs index cfd190e..4df6f19 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs @@ -66,15 +66,6 @@ public class CreateContentItemRevisionHandler( item.CurrentRevisionNumber = revisionNumber; item.CurrentRevisionLabel = revisionLabel; - if (item.Status == "Changes requested internally") - { - item.Status = "Internal changes in progress"; - } - else if (item.Status == "Changes requested by client") - { - item.Status = "Client changes in progress"; - } - ContentItemRevision revision = new() { Id = Guid.NewGuid(), diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs index 07c2cef..9d2c3ac 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs @@ -2,8 +2,10 @@ using FastEndpoints; using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.Approvals.Services; using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.Notifications.Contracts; +using Socialize.Api.Modules.Workspaces.Data; namespace Socialize.Api.Modules.ContentItems.Handlers; @@ -21,24 +23,18 @@ public class UpdateContentItemStatusRequestValidator public class UpdateContentItemStatusHandler( AppDbContext dbContext, AccessScopeService accessScopeService, + ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService, INotificationEventWriter notificationEventWriter) : Endpoint { private static readonly HashSet AllowedStatuses = [ "Draft", - "In internal review", - "Changes requested internally", - "Internal changes in progress", - "Ready for client review", - "In client review", - "Changes requested by client", - "Client changes in progress", + "In production", + "In approval", "Approved", - "Rejected", - "Ready to publish", + "Scheduled", "Published", - "Archived", ]; public override void Configure() @@ -72,7 +68,64 @@ public class UpdateContentItemStatusHandler( return; } - item.Status = normalizedStatus; + Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == item.WorkspaceId, ct); + if (workspace is null) + { + await SendNotFoundAsync(ct); + return; + } + + if (normalizedStatus == "In approval" && workspace.ApprovalMode == ApprovalModes.MultiLevel) + { + ApprovalWorkflowStartResult startResult = await approvalWorkflowRuntimeService.StartMultiLevelWorkflowAsync( + item, + workspace, + User.GetUserId(), + ct); + + if (!startResult.Succeeded) + { + AddError(request => request.Status, startResult.ErrorMessage ?? "The approval workflow could not be started."); + await SendErrorsAsync(StatusCodes.Status409Conflict, ct); + return; + } + } + else if (ApprovalWorkflowRules.IsApprovalCompletionStatus(normalizedStatus) && + ApprovalWorkflowRules.BlocksManualApprovedOrScheduledStatus(workspace.ApprovalMode)) + { + if (workspace.ApprovalMode == ApprovalModes.MultiLevel) + { + bool hasCompletedWorkflow = await approvalWorkflowRuntimeService.HasCompletedMultiLevelWorkflowAsync(item.Id, ct); + + if (!hasCompletedWorkflow) + { + AddError(request => request.Status, "This workspace requires the multi-level approval workflow to complete before content can be approved or scheduled."); + await SendErrorsAsync(StatusCodes.Status409Conflict, ct); + return; + } + } + else + { + bool hasApprovedDecision = await dbContext.ApprovalRequests.AnyAsync( + approval => approval.ContentItemId == item.Id && + approval.WorkspaceId == item.WorkspaceId && + approval.State == "Approved" && + approval.CompletedAt.HasValue, + ct); + + if (!hasApprovedDecision) + { + AddError(request => request.Status, "This workspace requires approval before content can be approved or scheduled."); + await SendErrorsAsync(StatusCodes.Status409Conflict, ct); + return; + } + } + } + + if (item.Status != "In approval" || normalizedStatus != "In approval") + { + item.Status = normalizedStatus; + } await dbContext.SaveChangesAsync(ct); await notificationEventWriter.WriteAsync( diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Data/Workspace.cs b/backend/src/Socialize.Api/Modules/Workspaces/Data/Workspace.cs index 113dbd5..f9f7ddb 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Data/Workspace.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Data/Workspace.cs @@ -8,5 +8,9 @@ public class Workspace public string? LogoUrl { get; set; } public Guid OwnerUserId { get; set; } public required string TimeZone { get; set; } + public string ApprovalMode { get; set; } = "Required"; + public bool SchedulePostsAutomaticallyOnApproval { get; set; } + public bool LockContentAfterApproval { get; set; } + public bool SendAutomaticApprovalReminders { get; set; } public DateTimeOffset CreatedAt { get; init; } } diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceModelConfiguration.cs index 1d08706..7c460f2 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceModelConfiguration.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceModelConfiguration.cs @@ -14,6 +14,10 @@ public static class WorkspaceModelConfiguration workspace.Property(x => x.Slug).HasMaxLength(128).IsRequired(); workspace.Property(x => x.LogoUrl).HasMaxLength(2048); workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired(); + workspace.Property(x => x.ApprovalMode).HasMaxLength(32).IsRequired().HasDefaultValue("Required"); + workspace.Property(x => x.SchedulePostsAutomaticallyOnApproval).HasDefaultValue(false); + workspace.Property(x => x.LockContentAfterApproval).HasDefaultValue(false); + workspace.Property(x => x.SendAutomaticApprovalReminders).HasDefaultValue(false); workspace.Property(x => x.CreatedAt) .ValueGeneratedOnAdd() .HasDefaultValueSql("CURRENT_TIMESTAMP"); diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspace.cs b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspace.cs index 2bb3c26..6a488ad 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspace.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspace.cs @@ -77,6 +77,11 @@ public class CreateWorkspaceHandler( workspace.Slug, workspace.LogoUrl, workspace.TimeZone, + workspace.ApprovalMode, + workspace.SchedulePostsAutomaticallyOnApproval, + workspace.LockContentAfterApproval, + workspace.SendAutomaticApprovalReminders, + [], workspace.CreatedAt); await SendAsync(dto, StatusCodes.Status201Created, ct); diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaces.cs b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaces.cs index fb0b922..0707606 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaces.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaces.cs @@ -2,16 +2,32 @@ using FastEndpoints; using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.Approvals.Data; using Socialize.Api.Modules.Workspaces.Data; namespace Socialize.Api.Modules.Workspaces.Handlers; +public record ApprovalStepConfigurationDto( + Guid Id, + Guid WorkspaceId, + string Name, + int SortOrder, + string TargetType, + string TargetValue, + int RequiredApproverCount, + DateTimeOffset CreatedAt); + public record WorkspaceDto( Guid Id, string Name, string Slug, string? LogoUrl, string TimeZone, + string ApprovalMode, + bool SchedulePostsAutomaticallyOnApproval, + bool LockContentAfterApproval, + bool SendAutomaticApprovalReminders, + IReadOnlyCollection ApprovalSteps, DateTimeOffset CreatedAt); internal class GetWorkspacesHandler( @@ -35,17 +51,53 @@ internal class GetWorkspacesHandler( query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id)); } - var workspaces = await query + var workspaceRows = await query .OrderBy(workspace => workspace.Name) + .ToListAsync(ct); + + var workspaceIds = workspaceRows.Select(workspace => workspace.Id).ToList(); + List approvalStepRows = await dbContext.WorkspaceApprovalStepConfigurations + .Where(step => workspaceIds.Contains(step.WorkspaceId)) + .OrderBy(step => step.SortOrder) + .ThenBy(step => step.Name) + .ToListAsync(ct); + + var approvalStepsByWorkspaceId = approvalStepRows + .GroupBy(step => step.WorkspaceId) + .ToDictionary( + group => group.Key, + group => (IReadOnlyCollection)group + .Select(ToApprovalStepConfigurationDto) + .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)) - .ToListAsync(ct); + .ToList(); await SendOkAsync(workspaces, ct); } + + public static ApprovalStepConfigurationDto ToApprovalStepConfigurationDto(WorkspaceApprovalStepConfiguration step) + { + return new ApprovalStepConfigurationDto( + step.Id, + step.WorkspaceId, + step.Name, + step.SortOrder, + step.TargetType, + step.TargetValue, + step.RequiredApproverCount, + step.CreatedAt); + } } diff --git a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/UpdateWorkspace.cs b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/UpdateWorkspace.cs index c3df33e..5b5a56e 100644 --- a/backend/src/Socialize.Api/Modules/Workspaces/Handlers/UpdateWorkspace.cs +++ b/backend/src/Socialize.Api/Modules/Workspaces/Handlers/UpdateWorkspace.cs @@ -2,21 +2,52 @@ using FastEndpoints; using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.Approvals.Data; +using Socialize.Api.Modules.Approvals.Services; using Socialize.Api.Modules.Workspaces.Data; namespace Socialize.Api.Modules.Workspaces.Handlers; +public record UpdateApprovalStepConfigurationRequest( + string Name, + int SortOrder, + string TargetType, + string TargetValue, + int RequiredApproverCount); + public record UpdateWorkspaceRequest( string Name, - string TimeZone); + string TimeZone, + string? ApprovalMode, + bool? SchedulePostsAutomaticallyOnApproval, + bool? LockContentAfterApproval, + bool? SendAutomaticApprovalReminders, + IReadOnlyCollection? ApprovalSteps); public class UpdateWorkspaceRequestValidator : Validator { + private static readonly string[] AllowedApprovalModes = ["None", "Optional", "Required", "Multi-level"]; + public UpdateWorkspaceRequestValidator() { RuleFor(x => x.Name).NotEmpty().MaximumLength(256); RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128); + RuleFor(x => x.ApprovalMode) + .Must(mode => string.IsNullOrWhiteSpace(mode) || AllowedApprovalModes.Contains(mode.Trim())) + .WithMessage("A valid approval mode should be specified."); + RuleFor(x => x.ApprovalSteps) + .Must(steps => steps is null || steps.Select(step => step.SortOrder).Distinct().Count() == steps.Count) + .WithMessage("Approval step sort orders must be unique."); + RuleForEach(x => x.ApprovalSteps).ChildRules(step => + { + step.RuleFor(x => x.Name).NotEmpty().MaximumLength(128); + step.RuleFor(x => x.TargetType) + .Must(ApprovalStepConfigurationRules.IsValidTargetType) + .WithMessage("A valid approval step target type should be specified."); + step.RuleFor(x => x.TargetValue).NotEmpty().MaximumLength(128); + step.RuleFor(x => x.RequiredApproverCount).GreaterThanOrEqualTo(1); + }); } } @@ -48,19 +79,162 @@ public class UpdateWorkspaceHandler( return; } + string nextApprovalMode = string.IsNullOrWhiteSpace(request.ApprovalMode) + ? workspace.ApprovalMode + : request.ApprovalMode.Trim(); + List? requestedApprovalSteps = request.ApprovalSteps?.ToList(); + + if (nextApprovalMode == ApprovalModes.MultiLevel) + { + bool hasConfiguredSteps = requestedApprovalSteps is null + ? await dbContext.WorkspaceApprovalStepConfigurations.AnyAsync(step => step.WorkspaceId == workspace.Id, ct) + : requestedApprovalSteps.Count > 0; + + if (!hasConfiguredSteps) + { + AddError(request => request.ApprovalSteps, "Multi-level approval requires at least one approval step."); + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + } + + if (requestedApprovalSteps is not null && + !await ValidateApprovalStepsAsync(workspace.Id, requestedApprovalSteps, ct)) + { + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); + return; + } + workspace.Name = request.Name.Trim(); workspace.TimeZone = request.TimeZone.Trim(); + workspace.ApprovalMode = nextApprovalMode; + workspace.SchedulePostsAutomaticallyOnApproval = request.SchedulePostsAutomaticallyOnApproval ?? workspace.SchedulePostsAutomaticallyOnApproval; + workspace.LockContentAfterApproval = request.LockContentAfterApproval ?? workspace.LockContentAfterApproval; + workspace.SendAutomaticApprovalReminders = request.SendAutomaticApprovalReminders ?? workspace.SendAutomaticApprovalReminders; + + if (requestedApprovalSteps is not null) + { + List existingSteps = await dbContext.WorkspaceApprovalStepConfigurations + .Where(step => step.WorkspaceId == workspace.Id) + .ToListAsync(ct); + dbContext.WorkspaceApprovalStepConfigurations.RemoveRange(existingSteps); + + List replacementSteps = requestedApprovalSteps + .OrderBy(step => step.SortOrder) + .Select(step => new WorkspaceApprovalStepConfiguration + { + Id = Guid.NewGuid(), + WorkspaceId = workspace.Id, + Name = step.Name.Trim(), + SortOrder = step.SortOrder, + TargetType = step.TargetType.Trim(), + TargetValue = NormalizeTargetValue(step), + RequiredApproverCount = step.RequiredApproverCount, + CreatedAt = DateTimeOffset.UtcNow, + }) + .ToList(); + + dbContext.WorkspaceApprovalStepConfigurations.AddRange(replacementSteps); + } await dbContext.SaveChangesAsync(ct); + List approvalSteps = await dbContext.WorkspaceApprovalStepConfigurations + .Where(step => step.WorkspaceId == workspace.Id) + .OrderBy(step => step.SortOrder) + .ThenBy(step => step.Name) + .Select(step => new ApprovalStepConfigurationDto( + step.Id, + step.WorkspaceId, + step.Name, + step.SortOrder, + step.TargetType, + step.TargetValue, + step.RequiredApproverCount, + 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); await SendOkAsync(dto, ct); } + + private async Task ValidateApprovalStepsAsync( + Guid workspaceId, + IReadOnlyCollection steps, + CancellationToken ct) + { + foreach (UpdateApprovalStepConfigurationRequest step in steps) + { + string targetType = step.TargetType.Trim(); + string targetValue = step.TargetValue.Trim(); + + if (targetType == ApprovalStepTargetTypes.Role && + !ApprovalStepConfigurationRules.IsValidRoleTarget(targetValue)) + { + AddError(request => request.ApprovalSteps, $"'{targetValue}' is not a supported approval role target."); + return false; + } + + if (targetType == ApprovalStepTargetTypes.Membership && + !ApprovalStepConfigurationRules.IsValidMembershipTarget(targetValue)) + { + AddError(request => request.ApprovalSteps, $"'{targetValue}' is not a supported approval membership target."); + return false; + } + + if (targetType == ApprovalStepTargetTypes.Member) + { + IReadOnlyCollection memberUserIds = ApprovalWorkflowRules.ParseMemberTargetIds(targetValue); + + if (memberUserIds.Count == 0) + { + AddError(request => request.ApprovalSteps, "Member approval step targets must reference at least one valid user id."); + return false; + } + + if (memberUserIds.Count < step.RequiredApproverCount) + { + AddError(request => request.ApprovalSteps, "Member approval step targets must include at least as many members as required approvers."); + return false; + } + + string workspaceClaimValue = workspaceId.ToString(); + int workspaceMemberCount = await dbContext.UserClaims + .Where(claim => memberUserIds.Contains(claim.UserId) && + claim.ClaimType == KnownClaims.WorkspaceScope && + claim.ClaimValue == workspaceClaimValue) + .Select(claim => claim.UserId) + .Distinct() + .CountAsync(ct); + + if (workspaceMemberCount != memberUserIds.Count) + { + AddError(request => request.ApprovalSteps, "Member approval step targets must reference users with access to the workspace."); + return false; + } + } + } + + return true; + } + + private static string NormalizeTargetValue(UpdateApprovalStepConfigurationRequest step) + { + string targetValue = step.TargetValue.Trim(); + return step.TargetType.Trim() == ApprovalStepTargetTypes.Member + ? ApprovalWorkflowRules.FormatMemberTargetValue(ApprovalWorkflowRules.ParseMemberTargetIds(targetValue)) + : targetValue; + } } diff --git a/backend/tests/Socialize.Tests/Approvals/ApprovalWorkflowRulesTests.cs b/backend/tests/Socialize.Tests/Approvals/ApprovalWorkflowRulesTests.cs new file mode 100644 index 0000000..d185217 --- /dev/null +++ b/backend/tests/Socialize.Tests/Approvals/ApprovalWorkflowRulesTests.cs @@ -0,0 +1,350 @@ +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Modules.Approvals.Data; +using Socialize.Api.Modules.Approvals.Services; + +namespace Socialize.Tests.Approvals; + +public class ApprovalWorkflowRulesTests +{ + [Theory] + [InlineData(ApprovalModes.Optional, true)] + [InlineData(ApprovalModes.Required, true)] + [InlineData(ApprovalModes.None, false)] + [InlineData(ApprovalModes.MultiLevel, false)] + public void CanCreateSingleStepApprovalRequest_matches_basic_modes(string approvalMode, bool expected) + { + bool actual = ApprovalWorkflowRules.CanCreateSingleStepApprovalRequest(approvalMode); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(ApprovalModes.Required, true)] + [InlineData(ApprovalModes.MultiLevel, true)] + [InlineData(ApprovalModes.Optional, false)] + [InlineData(ApprovalModes.None, false)] + public void BlocksManualApprovedOrScheduledStatus_matches_blocking_modes(string approvalMode, bool expected) + { + bool actual = ApprovalWorkflowRules.BlocksManualApprovedOrScheduledStatus(approvalMode); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("Approved", true)] + [InlineData("Scheduled", true)] + [InlineData("In approval", false)] + [InlineData("Published", false)] + public void IsApprovalCompletionStatus_only_matches_approval_gate_destinations(string status, bool expected) + { + bool actual = ApprovalWorkflowRules.IsApprovalCompletionStatus(status); + + Assert.Equal(expected, actual); + } + + [Fact] + public void GetFinalApprovalStatus_schedules_when_option_enabled_and_publish_date_exists() + { + string status = ApprovalWorkflowRules.GetFinalApprovalStatus( + schedulePostsAutomaticallyOnApproval: true, + plannedPublishDate: DateTimeOffset.UtcNow.AddDays(1)); + + Assert.Equal("Scheduled", status); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GetFinalApprovalStatus_approves_when_auto_schedule_disabled_or_date_missing(bool scheduleAutomatically) + { + string status = ApprovalWorkflowRules.GetFinalApprovalStatus( + scheduleAutomatically, + plannedPublishDate: null); + + Assert.Equal("Approved", status); + } + + [Theory] + [InlineData(1, 1, true)] + [InlineData(1, 2, false)] + [InlineData(2, 2, true)] + [InlineData(1, 0, true)] + public void HasRequiredStepApprovals_enforces_configured_approver_count( + int approvedDecisionCount, + int requiredApproverCount, + bool expected) + { + bool actual = ApprovalWorkflowRules.HasRequiredStepApprovals( + approvedDecisionCount, + requiredApproverCount); + + Assert.Equal(expected, actual); + } + + [Fact] + public void CanApproveWorkflowStep_allows_admin_for_any_step_target() + { + bool actual = ApprovalWorkflowRules.CanApproveWorkflowStep( + isAdministrator: true, + hasWorkspaceAccess: false, + userRoles: [], + userId: Guid.NewGuid(), + targetType: ApprovalStepTargetTypes.Member, + targetValue: Guid.NewGuid().ToString()); + + Assert.True(actual); + } + + [Fact] + public void CanApproveWorkflowStep_requires_role_target_match() + { + bool actual = ApprovalWorkflowRules.CanApproveWorkflowStep( + isAdministrator: false, + hasWorkspaceAccess: true, + userRoles: ["manager"], + userId: Guid.NewGuid(), + targetType: ApprovalStepTargetTypes.Role, + targetValue: "manager"); + + Assert.True(actual); + } + + [Fact] + public void CanApproveWorkflowStep_rejects_later_step_actor_without_target_match() + { + bool actual = ApprovalWorkflowRules.CanApproveWorkflowStep( + isAdministrator: false, + hasWorkspaceAccess: true, + userRoles: ["provider"], + userId: Guid.NewGuid(), + targetType: ApprovalStepTargetTypes.Role, + targetValue: "client"); + + Assert.False(actual); + } + + [Fact] + public void CanApproveWorkflowStep_requires_member_target_identity_match() + { + Guid assignedMemberId = Guid.NewGuid(); + Guid secondAssignedMemberId = Guid.NewGuid(); + + bool matchingMember = ApprovalWorkflowRules.CanApproveWorkflowStep( + isAdministrator: false, + hasWorkspaceAccess: true, + userRoles: ["workspace-member"], + userId: assignedMemberId, + targetType: ApprovalStepTargetTypes.Member, + targetValue: $"{assignedMemberId},{secondAssignedMemberId}"); + + bool otherMember = ApprovalWorkflowRules.CanApproveWorkflowStep( + isAdministrator: false, + hasWorkspaceAccess: true, + userRoles: ["workspace-member"], + userId: Guid.NewGuid(), + targetType: ApprovalStepTargetTypes.Member, + targetValue: $"{assignedMemberId},{secondAssignedMemberId}"); + + Assert.True(matchingMember); + Assert.False(otherMember); + } + + [Fact] + public void ParseMemberTargetIds_reads_distinct_comma_separated_member_ids() + { + Guid firstMemberId = Guid.NewGuid(); + Guid secondMemberId = Guid.NewGuid(); + + IReadOnlyCollection memberIds = ApprovalWorkflowRules.ParseMemberTargetIds( + $" {firstMemberId},not-a-guid,{secondMemberId},{firstMemberId} "); + + Assert.Equal([firstMemberId, secondMemberId], memberIds); + } + + [Fact] + public void FormatMemberTargetValue_stores_member_ids_stably() + { + Guid firstMemberId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + Guid secondMemberId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + string targetValue = ApprovalWorkflowRules.FormatMemberTargetValue( + [ + secondMemberId, + firstMemberId, + secondMemberId, + ]); + + Assert.Equal($"{firstMemberId},{secondMemberId}", targetValue); + } + + [Fact] + public void CanApproveWorkflowStep_matches_membership_targets() + { + bool clientMatchesClient = ApprovalWorkflowRules.CanApproveWorkflowStep( + isAdministrator: false, + hasWorkspaceAccess: true, + userRoles: ["client"], + userId: Guid.NewGuid(), + targetType: ApprovalStepTargetTypes.Membership, + targetValue: ApprovalMembershipTargets.Client); + + bool providerMatchesTeam = ApprovalWorkflowRules.CanApproveWorkflowStep( + isAdministrator: false, + hasWorkspaceAccess: true, + userRoles: ["provider"], + userId: Guid.NewGuid(), + targetType: ApprovalStepTargetTypes.Membership, + targetValue: ApprovalMembershipTargets.Team); + + bool clientDoesNotMatchTeam = ApprovalWorkflowRules.CanApproveWorkflowStep( + isAdministrator: false, + hasWorkspaceAccess: true, + userRoles: ["client"], + userId: Guid.NewGuid(), + targetType: ApprovalStepTargetTypes.Membership, + targetValue: ApprovalMembershipTargets.Team); + + Assert.True(clientMatchesClient); + Assert.True(providerMatchesTeam); + Assert.False(clientDoesNotMatchTeam); + } + + [Theory] + [InlineData(ApprovalStepTargetTypes.Role)] + [InlineData(ApprovalStepTargetTypes.Membership)] + [InlineData(ApprovalStepTargetTypes.Member)] + public void IsValidTargetType_accepts_supported_target_types(string targetType) + { + bool valid = ApprovalStepConfigurationRules.IsValidTargetType(targetType); + + Assert.True(valid); + } + + [Theory] + [InlineData("")] + [InlineData("Group")] + [InlineData("role")] + public void IsValidTargetType_rejects_unsupported_target_types(string targetType) + { + bool valid = ApprovalStepConfigurationRules.IsValidTargetType(targetType); + + Assert.False(valid); + } + + [Theory] + [InlineData("administrator")] + [InlineData("manager")] + [InlineData("workspace-member")] + [InlineData("client")] + [InlineData("provider")] + public void IsValidRoleTarget_accepts_known_workspace_roles(string role) + { + bool valid = ApprovalStepConfigurationRules.IsValidRoleTarget(role); + + Assert.True(valid); + } + + [Theory] + [InlineData("")] + [InlineData("developer")] + [InlineData("owner")] + public void IsValidRoleTarget_rejects_non_workspace_approval_roles(string role) + { + bool valid = ApprovalStepConfigurationRules.IsValidRoleTarget(role); + + Assert.False(valid); + } + + [Theory] + [InlineData(ApprovalMembershipTargets.Team)] + [InlineData(ApprovalMembershipTargets.Client)] + public void IsValidMembershipTarget_accepts_supported_memberships(string membership) + { + bool valid = ApprovalStepConfigurationRules.IsValidMembershipTarget(membership); + + Assert.True(valid); + } + + [Theory] + [InlineData("")] + [InlineData("Provider")] + [InlineData("External")] + public void IsValidMembershipTarget_rejects_unsupported_memberships(string membership) + { + bool valid = ApprovalStepConfigurationRules.IsValidMembershipTarget(membership); + + Assert.False(valid); + } + + [Fact] + public void WorkspaceApprovalStepConfiguration_model_persists_workspace_ordering() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Database=socialize_model_test") + .Options; + using var dbContext = new AppDbContext(options); + + var entity = dbContext.Model.FindEntityType(typeof(WorkspaceApprovalStepConfiguration)); + + Assert.NotNull(entity); + Assert.Equal("WorkspaceApprovalStepConfigurations", entity.GetTableName()); + Assert.Equal(128, entity.FindProperty(nameof(WorkspaceApprovalStepConfiguration.Name))?.GetMaxLength()); + Assert.Equal(32, entity.FindProperty(nameof(WorkspaceApprovalStepConfiguration.TargetType))?.GetMaxLength()); + Assert.Equal(128, entity.FindProperty(nameof(WorkspaceApprovalStepConfiguration.TargetValue))?.GetMaxLength()); + Assert.Contains( + entity.GetIndexes(), + index => index.IsUnique && + index.Properties.Select(property => property.Name).SequenceEqual( + [ + nameof(WorkspaceApprovalStepConfiguration.WorkspaceId), + nameof(WorkspaceApprovalStepConfiguration.SortOrder), + ])); + } + + [Fact] + public void ApprovalWorkflowInstance_model_allows_only_one_pending_workflow_per_content_item() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Database=socialize_model_test") + .Options; + using var dbContext = new AppDbContext(options); + + var entity = dbContext.Model.FindEntityType(typeof(ApprovalWorkflowInstance)); + + Assert.NotNull(entity); + Assert.Equal("ApprovalWorkflowInstances", entity.GetTableName()); + Assert.Equal(64, entity.FindProperty(nameof(ApprovalWorkflowInstance.State))?.GetMaxLength()); + Assert.Equal(64, entity.FindProperty(nameof(ApprovalWorkflowInstance.ApprovalMode))?.GetMaxLength()); + Assert.Contains( + entity.GetIndexes(), + index => index.IsUnique && + index.GetFilter() == "\"State\" = 'Pending'" && + index.Properties.Select(property => property.Name).SequenceEqual( + [ + nameof(ApprovalWorkflowInstance.ContentItemId), + nameof(ApprovalWorkflowInstance.State), + ])); + } + + [Fact] + public void ApprovalRequest_model_persists_runtime_step_metadata() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Database=socialize_model_test") + .Options; + using var dbContext = new AppDbContext(options); + + var entity = dbContext.Model.FindEntityType(typeof(ApprovalRequest)); + + Assert.NotNull(entity); + Assert.Equal(32, entity.FindProperty(nameof(ApprovalRequest.WorkflowStepTargetType))?.GetMaxLength()); + Assert.Equal(128, entity.FindProperty(nameof(ApprovalRequest.WorkflowStepTargetValue))?.GetMaxLength()); + Assert.Contains( + entity.GetIndexes(), + index => index.Properties.Select(property => property.Name).SequenceEqual( + [ + nameof(ApprovalRequest.WorkflowInstanceId), + ])); + } +} diff --git a/docs/TASKS/approval-workflow/002-align-content-lifecycle-statuses.md b/docs/TASKS/approval-workflow/002-align-content-lifecycle-statuses.md new file mode 100644 index 0000000..74e5be9 --- /dev/null +++ b/docs/TASKS/approval-workflow/002-align-content-lifecycle-statuses.md @@ -0,0 +1,43 @@ +# Task: Align content lifecycle statuses + +## Feature + +`docs/FEATURES/approval-workflow.md` + +## Goal + +Align `ContentItem.Status` with the fixed lifecycle states defined by the approval workflow spec. + +## Scope + +- Replace older review/rework/publishing statuses with the fixed lifecycle set: + - `Draft` + - `In production` + - `In approval` + - `Approved` + - `Scheduled` + - `Published` +- Update backend status validation and approval side effects. +- Update development seed content statuses. +- Update frontend status filters, labels, and manual status actions that referenced retired statuses. + +## Constraints + +- Do not redesign the approval data model in this task. +- Do not implement workspace approval configuration, multi-level approval, comments, reminders, or magic links in this task. +- Keep the current approval request endpoints working as a compatibility layer until the workflow data model task replaces them. + +## Done When + +- [x] Backend accepts only the fixed lifecycle statuses for manual content status updates. +- [x] Creating an approval request moves content to `In approval`. +- [x] Recording an approved decision moves content to `Approved`. +- [x] Frontend no longer offers or filters against retired content statuses. +- [x] Development seed data uses fixed lifecycle statuses. + +## Validation Commands + +```bash +dotnet build backend/Socialize.slnx +cd frontend && npm run build +``` diff --git a/docs/TASKS/approval-workflow/003-workspace-approval-configuration.md b/docs/TASKS/approval-workflow/003-workspace-approval-configuration.md new file mode 100644 index 0000000..c932514 --- /dev/null +++ b/docs/TASKS/approval-workflow/003-workspace-approval-configuration.md @@ -0,0 +1,47 @@ +# Task: Workspace approval configuration + +## Feature + +`docs/FEATURES/approval-workflow.md` + +## Goal + +Persist workspace-level approval workflow configuration and expose it to workspace settings. + +## Scope + +- Add workspace approval mode: + - `None` + - `Optional` + - `Required` + - `Multi-level` +- Add approval options: + - schedule posts automatically on approval + - lock content after approval + - send automatic reminders for pending approvals +- Return approval configuration from workspace APIs. +- Allow workspace managers/admins to update approval configuration. +- Replace static workflow settings UI with saved configuration controls. + +## Constraints + +- Do not implement workflow recalculation in this task. +- Do not implement multi-level step configuration in this task. +- Do not implement automatic scheduling, locking behavior, or reminders in this task; only persist the options. +- If backend contracts change, update OpenAPI when the backend is running. + +## Done When + +- [x] Workspace approval config is persisted with defaults. +- [x] Workspace API responses include approval config. +- [x] Workspace update accepts approval config and validates allowed modes. +- [x] Workspace settings UI can edit and save approval config. +- [x] Backend and frontend builds pass. + +## Validation Commands + +```bash +dotnet build backend/Socialize.slnx +dotnet test backend/Socialize.slnx +cd frontend && npm run build +``` diff --git a/docs/TASKS/approval-workflow/004-enforce-basic-approval-modes.md b/docs/TASKS/approval-workflow/004-enforce-basic-approval-modes.md new file mode 100644 index 0000000..a852ed4 --- /dev/null +++ b/docs/TASKS/approval-workflow/004-enforce-basic-approval-modes.md @@ -0,0 +1,39 @@ +# Task: Enforce basic approval modes + +## Feature + +`docs/FEATURES/approval-workflow.md` + +## Goal + +Apply workspace approval mode configuration to the existing single-step approval request flow. + +## Scope + +- Prevent approval requests when workspace approval mode is `None`. +- Keep the current approval request endpoints as the one-step compatibility flow for `Optional` and `Required`. +- Block manual moves to `Approved` or `Scheduled` for `Required` workspaces until an approval request has an approved decision. +- Leave `Optional` approval non-blocking. +- Apply the saved "schedule posts automatically on approval" option when a final approval decision is recorded. + +## Constraints + +- Do not implement workflow recalculation in this task. +- Do not implement multi-level step configuration in this task. +- Do not implement locking behavior, reminder jobs, comments, mentions, reopening, or magic links in this task. +- Do not replace the existing approval request data model in this task. + +## Done When + +- [x] Approval mode `None` does not create approval requests. +- [x] Approval mode `Optional` allows manual approval/scheduling without approval decisions. +- [x] Approval mode `Required` blocks manual approval/scheduling until a completed approval decision exists. +- [x] Approved decisions move content to `Scheduled` when auto-scheduling is enabled and the content item has a planned publish date. +- [x] Backend tests pass. + +## Validation Commands + +```bash +dotnet build backend/Socialize.slnx +dotnet test backend/Socialize.slnx +``` diff --git a/docs/TASKS/approval-workflow/005-multi-level-approval-step-configuration-backend.md b/docs/TASKS/approval-workflow/005-multi-level-approval-step-configuration-backend.md new file mode 100644 index 0000000..0ef5ff1 --- /dev/null +++ b/docs/TASKS/approval-workflow/005-multi-level-approval-step-configuration-backend.md @@ -0,0 +1,71 @@ +# Task: Multi-level approval step configuration backend + +## Feature + +`docs/FEATURES/approval-workflow.md` + +## Goal + +Persist workspace-level multi-level approval step configuration and expose it through workspace settings APIs. + +## Context + +The workspace can currently select `Multi-level`, but there is no backend model or API surface for defining the ordered steps that make multi-level approval usable. + +## Scope + +- Add a workspace-owned approval step configuration data model. +- Each configured step must include: + - display name + - sort order + - target type: `Role`, `Membership`, or `Member` + - target value + - required approver count +- Add EF Core configuration and migration. +- Return configured approval steps from workspace APIs. +- Allow workspace managers/admins to replace the configured step list for a workspace. +- Validate: + - `Multi-level` workspaces must have at least one step. + - step names are required and bounded. + - target type is one of `Role`, `Membership`, or `Member`. + - role targets use known workspace roles. + - membership targets use known membership categories. + - member targets reference a user with workspace access. + - required approver count is at least 1. + - sort order is stable and unique per workspace. + +## Constraints + +- Do not implement active approval workflow instance recalculation in this task. +- Do not implement approval step execution or per-step approval decisions in this task. +- Do not implement reminders, comments, reopening, or magic links in this task. +- Keep backend feature code under `backend/src/Socialize.Api/Modules/Approvals` or `Modules/Workspaces` according to existing ownership patterns. +- If backend contracts change, update OpenAPI when the backend is running. + +## Likely Files + +- `backend/src/Socialize.Api/Modules/Approvals/Data/*` +- `backend/src/Socialize.Api/Modules/Approvals/Handlers/*` +- `backend/src/Socialize.Api/Modules/Workspaces/Handlers/*` +- `backend/src/Socialize.Api/Data/AppDbContext.cs` +- `backend/src/Socialize.Api/Migrations/*` +- `backend/tests/Socialize.Tests/Approvals/*` +- `shared/openapi/openapi.json` +- `frontend/src/api/schema.d.ts` + +## Done When + +- [x] Multi-level step configuration is persisted per workspace. +- [x] Workspace responses include configured approval steps. +- [x] Managers/admins can save an ordered list of approval steps. +- [x] Invalid target types, target values, counts, and empty multi-level configurations are rejected. +- [x] Backend tests cover validation and persistence rules. +- [x] OpenAPI and generated frontend schema are updated. + +## Validation Commands + +```bash +dotnet build backend/Socialize.slnx +dotnet test backend/Socialize.slnx +./scripts/update-openapi.sh +``` diff --git a/docs/TASKS/approval-workflow/006-multi-level-workflow-editor-ui.md b/docs/TASKS/approval-workflow/006-multi-level-workflow-editor-ui.md new file mode 100644 index 0000000..1c29cd5 --- /dev/null +++ b/docs/TASKS/approval-workflow/006-multi-level-workflow-editor-ui.md @@ -0,0 +1,63 @@ +# Task: Multi-level workflow editor UI + +## Feature + +`docs/FEATURES/approval-workflow.md` + +## Goal + +Add a workspace settings workflow editor that lets managers/admins configure multi-level approval steps. + +## Context + +The workspace settings screen currently saves the approval mode and simple options, but `Multi-level` has no step editor. Users can select the mode without any way to define who approves each step. + +## Scope + +- Add a feature-owned workflow editor component under `frontend/src/features/workspaces/components/`. +- Show the editor only when approval mode is `Multi-level`. +- Allow users to add, remove, reorder, and edit approval steps. +- For each step, support: + - display name + - target type: role, membership, or member + - target value selector appropriate to the selected type + - required approver count +- Load available workspace members from the existing workspace members API. +- Use existing workspace store/API patterns to save the full workflow configuration. +- Show inline validation before save for missing names, missing targets, and invalid required approver count. +- Keep the existing simple approval options in the same workflow settings tab. +- Update English and French locale strings. + +## Constraints + +- Do not implement the backend in this task; depend on the API contract from task 005. +- Do not implement approval execution, recalculation, reminders, comments, reopening, or magic links in this task. +- Keep feature-owned code under `frontend/src/features/workspaces`. +- Use `frontend/src/config.js` for runtime config if any runtime config is needed. +- Preserve the shared Axios client in `frontend/src/plugins/api.js`. +- Do not create a marketing or explanatory page; this is an app settings editor. + +## Likely Files + +- `frontend/src/features/workspaces/views/WorkspaceSettingsView.vue` +- `frontend/src/features/workspaces/components/ApprovalWorkflowEditor.vue` +- `frontend/src/features/workspaces/stores/workspaceStore.js` +- `frontend/src/locales/en.json` +- `frontend/src/locales/fr.json` +- `frontend/src/api/schema.d.ts` + +## Done When + +- [ ] Selecting `Multi-level` reveals an approval step editor. +- [ ] Users can add, remove, reorder, and edit steps. +- [ ] Role, membership, and member target selectors are available. +- [ ] The editor saves and reloads persisted workflow configuration. +- [ ] UI prevents saving invalid multi-level configurations. +- [ ] Frontend build passes. + +## Validation Commands + +```bash +cd frontend +npm run build +``` diff --git a/docs/TASKS/approval-workflow/007-execute-multi-level-approval-workflow.md b/docs/TASKS/approval-workflow/007-execute-multi-level-approval-workflow.md new file mode 100644 index 0000000..3e6604f --- /dev/null +++ b/docs/TASKS/approval-workflow/007-execute-multi-level-approval-workflow.md @@ -0,0 +1,64 @@ +# Task: Execute multi-level approval workflow + +## Feature + +`docs/FEATURES/approval-workflow.md` + +## Goal + +Use configured multi-level approval steps when content enters approval and when approvers record decisions. + +## Context + +Tasks 005 and 006 make multi-level approval configurable. This task makes that configuration affect runtime approval behavior. + +## Scope + +- Create or update the approval workflow runtime model so a `ContentItem` has at most one active approval workflow instance. +- Instantiate ordered approval steps from workspace configuration when content enters `In approval`. +- Track step status as `Pending` or `Approved`. +- Allow approval only on the current pending step. +- Require each step's configured approver count before the step becomes approved. +- Advance to the next step after the current step is approved. +- Move content to `Approved` or `Scheduled` after the final step completes, following existing workspace options. +- Preserve approval history. +- Notify current step approvers when a step becomes current. +- Notify publish-capable users when final approval completes. +- Keep the existing single-step `Optional` and `Required` flows working. + +## Constraints + +- Do not implement configuration recalculation for already-active workflows in this task unless the task is explicitly expanded. +- Do not implement reminders, comments, mentions, reopening, or magic links in this task. +- Do not delete previous approval history. +- Preserve workspace scoping and access checks. + +## Likely Files + +- `backend/src/Socialize.Api/Modules/Approvals/Data/*` +- `backend/src/Socialize.Api/Modules/Approvals/Handlers/*` +- `backend/src/Socialize.Api/Modules/Approvals/Services/*` +- `backend/src/Socialize.Api/Modules/ContentItems/Handlers/*` +- `backend/src/Socialize.Api/Modules/Notifications/*` +- `backend/src/Socialize.Api/Migrations/*` +- `backend/tests/Socialize.Tests/Approvals/*` +- `frontend/src/features/content/views/ContentItemDetailView.vue` +- `frontend/src/features/reviews/*` + +## Done When + +- [x] Content entering approval creates a runtime approval workflow with ordered steps. +- [x] Only the current pending step can be approved. +- [x] Required approver counts are enforced. +- [x] Final approval updates content status according to workspace options. +- [x] Approval history remains available after completion. +- [x] Notifications are written for current approvers and final approval. +- [x] Backend tests cover sequencing, counts, access, and final status behavior. + +## Validation Commands + +```bash +dotnet build backend/Socialize.slnx +dotnet test backend/Socialize.slnx +cd frontend && npm run build +``` diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index d6231b2..dcc2067 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -436,6 +436,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/feedback/{id}/comments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["SocializeApiModulesFeedbackHandlersAddDeveloperFeedbackCommentHandler"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/my-feedback/{id}/comments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["SocializeApiModulesFeedbackHandlersAddMyFeedbackCommentHandler"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/my-feedback/{id}/screenshot": { parameters: { query?: never; @@ -484,6 +516,22 @@ export interface paths { patch: operations["SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackHandler"]; trace?: never; }; + "/api/feedback/{id}/timeline": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesFeedbackHandlersGetDeveloperFeedbackTimelineHandler"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/feedback/{id}/screenshot": { parameters: { query?: never; @@ -516,6 +564,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/my-feedback/{id}/timeline": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesFeedbackHandlersGetMyFeedbackTimelineHandler"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/feedback": { parameters: { query?: never; @@ -818,6 +882,26 @@ export interface components { slug?: string; logoUrl?: string | null; timeZone?: string; + approvalMode?: string; + schedulePostsAutomaticallyOnApproval?: boolean; + lockContentAfterApproval?: boolean; + sendAutomaticApprovalReminders?: boolean; + approvalSteps?: components["schemas"]["SocializeApiModulesWorkspacesHandlersApprovalStepConfigurationDto"][]; + /** Format: date-time */ + createdAt?: string; + }; + SocializeApiModulesWorkspacesHandlersApprovalStepConfigurationDto: { + /** Format: guid */ + id?: string; + /** Format: guid */ + workspaceId?: string; + name?: string; + /** Format: int32 */ + sortOrder?: number; + targetType?: string; + targetValue?: string; + /** Format: int32 */ + requiredApproverCount?: number; /** Format: date-time */ createdAt?: string; }; @@ -853,6 +937,20 @@ export interface components { SocializeApiModulesWorkspacesHandlersUpdateWorkspaceRequest: { name: string; timeZone: string; + approvalMode?: string | null; + schedulePostsAutomaticallyOnApproval?: boolean | null; + lockContentAfterApproval?: boolean | null; + sendAutomaticApprovalReminders?: boolean | null; + approvalSteps?: components["schemas"]["SocializeApiModulesWorkspacesHandlersUpdateApprovalStepConfigurationRequest"][] | null; + }; + SocializeApiModulesWorkspacesHandlersUpdateApprovalStepConfigurationRequest: { + name?: string; + /** Format: int32 */ + sortOrder?: number; + targetType?: string; + targetValue?: string; + /** Format: int32 */ + requiredApproverCount?: number; }; SocializeApiModulesProjectsHandlersProjectDto: { /** Format: guid */ @@ -1018,6 +1116,26 @@ export interface components { message?: string; }; SocializeApiModulesIdentityHandlersVerifyEmailRequest: Record; + SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto: { + /** Format: guid */ + id?: string; + kind?: string; + /** Format: guid */ + actorUserId?: string; + actorDisplayName?: string; + actorEmail?: string; + actorRole?: string | null; + body?: string | null; + activityType?: string | null; + fromValue?: string | null; + toValue?: string | null; + note?: string | null; + /** Format: date-time */ + createdAt?: string; + }; + SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest: { + body: string; + }; SocializeApiModulesFeedbackContractsFeedbackReportDto: { /** Format: guid */ id?: string; @@ -1032,6 +1150,7 @@ export interface components { context?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackContextDto"]; screenshot?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackScreenshotDto"] | null; tags?: string[]; + timeline?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][]; /** Format: date-time */ createdAt?: string; /** Format: date-time */ @@ -1322,6 +1441,14 @@ export interface components { workspaceId?: string; /** Format: guid */ contentItemId?: string; + /** Format: guid */ + workflowInstanceId?: string | null; + /** Format: int32 */ + workflowStepSortOrder?: number | null; + workflowStepTargetType?: string | null; + workflowStepTargetValue?: string | null; + /** Format: int32 */ + workflowStepRequiredApproverCount?: number | null; stage?: string; reviewerName?: string; reviewerEmail?: string; @@ -2286,6 +2413,97 @@ export interface operations { }; }; }; + SocializeApiModulesFeedbackHandlersAddDeveloperFeedbackCommentHandler: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["FastEndpointsErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SocializeApiModulesFeedbackHandlersAddMyFeedbackCommentHandler: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["FastEndpointsErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler: { parameters: { query?: never; @@ -2455,6 +2673,42 @@ export interface operations { }; }; }; + SocializeApiModulesFeedbackHandlersGetDeveloperFeedbackTimelineHandler: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler: { parameters: { query?: never; @@ -2513,6 +2767,35 @@ export interface operations { }; }; }; + SocializeApiModulesFeedbackHandlersGetMyFeedbackTimelineHandler: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SocializeApiModulesFeedbackHandlersListDeveloperFeedbackHandler: { parameters: { query?: never; diff --git a/frontend/src/features/channels/views/ChannelsView.vue b/frontend/src/features/channels/views/ChannelsView.vue index e9f9742..27a0aa7 100644 --- a/frontend/src/features/channels/views/ChannelsView.vue +++ b/frontend/src/features/channels/views/ChannelsView.vue @@ -69,10 +69,8 @@ nextDueDate: matches .filter(item => item.dueDate) .sort((left, right) => new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime())[0]?.dueDate ?? null, - readyCount: matches.filter(item => ['Approved', 'Ready to publish', 'Published'].includes(item.status)).length, - blockedCount: matches.filter(item => - ['In internal review', 'Ready for client review', 'In client review', 'Changes requested by client'].includes(item.status) - ).length, + readyCount: matches.filter(item => ['Approved', 'Scheduled', 'Published'].includes(item.status)).length, + blockedCount: matches.filter(item => item.status === 'In approval').length, }; } diff --git a/frontend/src/features/content/stores/contentItemsStore.js b/frontend/src/features/content/stores/contentItemsStore.js index 52827ea..3f6e764 100644 --- a/frontend/src/features/content/stores/contentItemsStore.js +++ b/frontend/src/features/content/stores/contentItemsStore.js @@ -15,7 +15,7 @@ export const useContentItemsStore = defineStore('content-items', () => { const error = ref(null); const activeCount = computed(() => - items.value.filter(item => item.status !== 'Approved' && item.status !== 'Published' && item.status !== 'Archived') + items.value.filter(item => !['Approved', 'Scheduled', 'Published'].includes(item.status)) .length ); diff --git a/frontend/src/features/content/views/ContentItemDetailView.vue b/frontend/src/features/content/views/ContentItemDetailView.vue index 0c7dcb0..0560d6c 100644 --- a/frontend/src/features/content/views/ContentItemDetailView.vue +++ b/frontend/src/features/content/views/ContentItemDetailView.vue @@ -45,6 +45,14 @@ }); const decisionForms = reactive({}); + const manualStatuses = [ + 'Draft', + 'In production', + 'In approval', + 'Approved', + 'Scheduled', + 'Published', + ]; const saveError = reactive({ message: '', }); @@ -80,6 +88,7 @@ new Map(projectsStore.projects.map(project => [project.id, project.name])) ); const editorKey = computed(() => isCreateMode.value ? `new:${route.query.projectId ?? 'default'}` : String(route.params.id)); + const isMultiLevelApproval = computed(() => workspaceStore.activeWorkspace?.approvalMode === 'Multi-level'); function blankPlacement(channel = null) { return { @@ -116,6 +125,16 @@ return decisionForms[approvalId]; } + function formatApprovalStepMeta(approval) { + if (!approval.workflowInstanceId) { + return `${approval.stage} · ${approval.state}`; + } + + const stepNumber = Number(approval.workflowStepSortOrder ?? 0) + 1; + const requiredCount = approval.workflowStepRequiredApproverCount ?? 1; + return `Step ${stepNumber} · ${approval.state} · ${requiredCount} required`; + } + function syncPlacementChannel(placement, value) { const channel = availableChannels.value.find(candidate => candidate.id === value); placement.channelId = value; @@ -488,33 +507,21 @@ class="quick-actions" > - -