diff --git a/backend/src/Socialize.Api/Data/AppDbContext.cs b/backend/src/Socialize.Api/Data/AppDbContext.cs index d66ee84..b50eac2 100644 --- a/backend/src/Socialize.Api/Data/AppDbContext.cs +++ b/backend/src/Socialize.Api/Data/AppDbContext.cs @@ -8,7 +8,7 @@ using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.Feedback.Data; using Socialize.Api.Modules.Identity.Data; using Socialize.Api.Modules.Notifications.Data; -using Socialize.Api.Modules.Projects.Data; +using Socialize.Api.Modules.Campaigns.Data; using Socialize.Api.Modules.Workspaces.Data; namespace Socialize.Api.Data; @@ -20,14 +20,16 @@ public class AppDbContext( public DbSet Workspaces => Set(); public DbSet WorkspaceInvites => Set(); public DbSet Clients => Set(); - public DbSet Projects => Set(); + public DbSet Campaigns => Set(); public DbSet ContentItems => Set(); public DbSet ContentItemRevisions => Set(); 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(); @@ -41,7 +43,7 @@ public class AppDbContext( builder.ConfigureWorkspacesModule(); builder.ConfigureClientsModule(); - builder.ConfigureProjectsModule(); + builder.ConfigureCampaignsModule(); builder.ConfigureContentItemsModule(); builder.ConfigureAssetsModule(); builder.ConfigureCommentsModule(); diff --git a/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs b/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs index ef9e504..e503efb 100644 --- a/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs +++ b/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs @@ -10,7 +10,7 @@ using Socialize.Api.Modules.Comments.Data; using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.Clients.Data; using Socialize.Api.Modules.Notifications.Data; -using Socialize.Api.Modules.Projects.Data; +using Socialize.Api.Modules.Campaigns.Data; using Socialize.Api.Modules.Workspaces.Data; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; @@ -22,8 +22,8 @@ public static class DevelopmentSeedExtensions private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222"); private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333"); - private static readonly Guid ScopedProjectId = Guid.Parse("33333333-3333-3333-3333-333333333333"); - private static readonly Guid HiddenProjectId = Guid.Parse("33333333-3333-3333-3333-444444444444"); + private static readonly Guid ScopedCampaignId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + private static readonly Guid HiddenCampaignId = Guid.Parse("33333333-3333-3333-3333-444444444444"); private static readonly Guid ScopedContentItemId = Guid.Parse("44444444-4444-4444-4444-444444444444"); private static readonly Guid HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555"); private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-555555555555"); @@ -99,7 +99,7 @@ public static class DevelopmentSeedExtensions [ new Claim(KnownClaims.WorkspaceScope, WorkspaceId.ToString()), new Claim(KnownClaims.ClientScope, ScopedClientId.ToString()), - new Claim(KnownClaims.ProjectScope, ScopedProjectId.ToString()), + new Claim(KnownClaims.CampaignScope, ScopedCampaignId.ToString()), ]); User dev = await EnsureUserAsync( @@ -200,7 +200,7 @@ public static class DevelopmentSeedExtensions IList existingClaims = await userManager.GetClaimsAsync(user); List managedClaims = existingClaims - .Where(claim => claim.Type is KnownClaims.WorkspaceScope or KnownClaims.ClientScope or KnownClaims.ProjectScope or KnownClaims.Persona) + .Where(claim => claim.Type is KnownClaims.WorkspaceScope or KnownClaims.ClientScope or KnownClaims.CampaignScope or KnownClaims.Persona) .ToList(); foreach (Claim claim in managedClaims) @@ -273,9 +273,9 @@ public static class DevelopmentSeedExtensions WorkspaceId, cancellationToken); - await UpsertProjectAsync( + await UpsertCampaignAsync( dbContext, - ScopedProjectId, + ScopedCampaignId, WorkspaceId, ScopedClientId, "Spring Launch", @@ -285,9 +285,9 @@ public static class DevelopmentSeedExtensions "Cross-channel launch campaign for the spring offer.", "Coordinate creative approvals before the final week.", cancellationToken); - await UpsertProjectAsync( + await UpsertCampaignAsync( dbContext, - HiddenProjectId, + HiddenCampaignId, WorkspaceId, HiddenClientId, "Summer Retention", @@ -303,11 +303,11 @@ public static class DevelopmentSeedExtensions ScopedContentItemId, WorkspaceId, ScopedClientId, - ScopedProjectId, + ScopedCampaignId, "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, @@ -317,7 +317,7 @@ public static class DevelopmentSeedExtensions HiddenContentItemId, WorkspaceId, HiddenClientId, - HiddenProjectId, + HiddenCampaignId, "Bakery loyalty carousel", "Reward regular customers with a four-card retention carousel.", "Instagram Carousel", @@ -491,7 +491,7 @@ public static class DevelopmentSeedExtensions await dbContext.SaveChangesAsync(cancellationToken); } - private static async Task UpsertProjectAsync( + private static async Task UpsertCampaignAsync( AppDbContext dbContext, Guid id, Guid workspaceId, @@ -504,26 +504,26 @@ public static class DevelopmentSeedExtensions string? notes, CancellationToken cancellationToken) { - Project? project = await dbContext.Projects.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken); - if (project is null) + Campaign? campaign = await dbContext.Campaigns.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken); + if (campaign is null) { - project = new Project + campaign = new Campaign { Id = id, Name = string.Empty, Status = string.Empty, CreatedAt = DateTimeOffset.UtcNow, }; - dbContext.Projects.Add(project); + dbContext.Campaigns.Add(campaign); } - project.WorkspaceId = workspaceId; - project.ClientId = clientId; - project.Name = name; - project.Description = description; - project.Notes = notes; - project.Status = status; - project.StartDate = startDate; - project.EndDate = endDate; + campaign.WorkspaceId = workspaceId; + campaign.ClientId = clientId; + campaign.Name = name; + campaign.Description = description; + campaign.Notes = notes; + campaign.Status = status; + campaign.StartDate = startDate; + campaign.EndDate = endDate; await dbContext.SaveChangesAsync(cancellationToken); } @@ -532,7 +532,7 @@ public static class DevelopmentSeedExtensions Guid id, Guid workspaceId, Guid clientId, - Guid projectId, + Guid campaignId, string title, string publicationMessage, string publicationTargets, @@ -559,7 +559,7 @@ public static class DevelopmentSeedExtensions } item.WorkspaceId = workspaceId; item.ClientId = clientId; - item.ProjectId = projectId; + item.CampaignId = campaignId; item.Title = title; item.PublicationMessage = publicationMessage; item.PublicationTargets = publicationTargets; diff --git a/backend/src/Socialize.Api/Infrastructure/Security/AccessScopeService.cs b/backend/src/Socialize.Api/Infrastructure/Security/AccessScopeService.cs index 67e4f91..1fa5d87 100644 --- a/backend/src/Socialize.Api/Infrastructure/Security/AccessScopeService.cs +++ b/backend/src/Socialize.Api/Infrastructure/Security/AccessScopeService.cs @@ -36,21 +36,21 @@ public sealed class AccessScopeService || (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId)); } - public bool CanAccessProject(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId) + public bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId) { return IsManager(user) - || (CanAccessClient(user, workspaceId, clientId) && user.GetProjectScopeIds().Contains(projectId)); + || (CanAccessClient(user, workspaceId, clientId) && user.GetCampaignScopeIds().Contains(campaignId)); } - public bool CanContributeToProject(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId) + public bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId) { - return IsManager(user) || (IsProvider(user) && CanAccessProject(user, workspaceId, clientId, projectId)); + return IsManager(user) || (IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)); } - public bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid projectId) + public bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId) { return IsManager(user) - || IsProvider(user) && CanAccessProject(user, workspaceId, clientId, projectId) + || IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId) || IsClient(user) && CanAccessClient(user, workspaceId, clientId); } } diff --git a/backend/src/Socialize.Api/Infrastructure/Security/ClaimsPrincipalExtensions.cs b/backend/src/Socialize.Api/Infrastructure/Security/ClaimsPrincipalExtensions.cs index a03ddde..ff07f53 100644 --- a/backend/src/Socialize.Api/Infrastructure/Security/ClaimsPrincipalExtensions.cs +++ b/backend/src/Socialize.Api/Infrastructure/Security/ClaimsPrincipalExtensions.cs @@ -23,9 +23,9 @@ public static class ClaimsPrincipalExtensions return claims.GetScopeIds(KnownClaims.ClientScope); } - public static IReadOnlyCollection GetProjectScopeIds(this ClaimsPrincipal claims) + public static IReadOnlyCollection GetCampaignScopeIds(this ClaimsPrincipal claims) { - return claims.GetScopeIds(KnownClaims.ProjectScope); + return claims.GetScopeIds(KnownClaims.CampaignScope); } public static string? GetPersona(this ClaimsPrincipal claims) diff --git a/backend/src/Socialize.Api/Infrastructure/Security/KnownClaims.cs b/backend/src/Socialize.Api/Infrastructure/Security/KnownClaims.cs index f84e27a..f48bef5 100644 --- a/backend/src/Socialize.Api/Infrastructure/Security/KnownClaims.cs +++ b/backend/src/Socialize.Api/Infrastructure/Security/KnownClaims.cs @@ -6,6 +6,6 @@ public static class KnownClaims public const string PortraitUrl = "portraitUrl"; public const string WorkspaceScope = "workspace"; public const string ClientScope = "client"; - public const string ProjectScope = "project"; + public const string CampaignScope = "campaign"; public const string Persona = "persona"; } 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/20260501191447_RenameProjectsToCampaigns.Designer.cs b/backend/src/Socialize.Api/Migrations/20260501191447_RenameProjectsToCampaigns.Designer.cs new file mode 100644 index 0000000..f3a37d7 --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260501191447_RenameProjectsToCampaigns.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("20260501191447_RenameProjectsToCampaigns")] + partial class RenameProjectsToCampaigns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalDecision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalRequestId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DecidedByEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DecidedByUserId") + .HasColumnType("uuid"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalRequestId"); + + b.ToTable("ApprovalDecisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("DueAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedByUserId") + .HasColumnType("uuid"); + + b.Property("ReviewerEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkflowInstanceId") + .HasColumnType("uuid"); + + b.Property("WorkflowStepRequiredApproverCount") + .HasColumnType("integer"); + + b.Property("WorkflowStepSortOrder") + .HasColumnType("integer"); + + b.Property("WorkflowStepTargetType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("WorkflowStepTargetValue") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ReviewerEmail"); + + b.HasIndex("WorkflowInstanceId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ApprovalRequests", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalMode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ContentItemId", "State") + .IsUnique() + .HasFilter("\"State\" = 'Pending'"); + + b.ToTable("ApprovalWorkflowInstances", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.WorkspaceApprovalStepConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RequiredApproverCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetValue") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "SortOrder") + .IsUnique(); + + b.ToTable("WorkspaceApprovalStepConfigurations", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveFileId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleDriveLink") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Assets", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.AssetRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("AssetId", "RevisionNumber") + .IsUnique(); + + b.ToTable("AssetRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Campaigns.Data.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Notes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ClientId", "Name") + .IsUnique(); + + b.ToTable("Campaigns", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("PrimaryContactEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrimaryContactName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PrimaryContactPortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("WorkspaceId", "Name") + .IsUnique(); + + b.ToTable("Clients", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Comments.Data.Comment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorUserId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsResolved") + .HasColumnType("boolean"); + + b.Property("ParentCommentId") + .HasColumnType("uuid"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ParentCommentId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("Comments", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CurrentRevisionLabel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CurrentRevisionNumber") + .HasColumnType("integer"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Hashtags") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PublicationMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublicationTargets") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("ClientId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("ContentItems", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.ContentItems.Data.ContentItemRevision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChangeSummary") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Hashtags") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("PublicationMessage") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublicationTargets") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RevisionLabel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RevisionNumber") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("ContentItemId", "RevisionNumber") + .IsUnique(); + + b.ToTable("ContentItemRevisions", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackActivityEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("FromValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Note") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ToValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("FeedbackReportId"); + + b.ToTable("FeedbackActivityEntries", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AuthorRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AuthorUserId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AuthorUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("FeedbackReportId"); + + b.ToTable("FeedbackComments", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BrowserUserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("CampaignName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CancellationReason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancelledByUserId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("ContentItemTitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("LastActivityAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReporterDisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReporterEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ReporterUserId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubmittedPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ViewportHeight") + .HasColumnType("integer"); + + b.Property("ViewportWidth") + .HasColumnType("integer"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.Property("WorkspaceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("LastActivityAt"); + + b.HasIndex("ReporterUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("FeedbackReports", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackScreenshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlobContainerName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FeedbackReportId") + .IsUnique(); + + b.ToTable("FeedbackScreenshots", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName"); + + b.HasIndex("FeedbackReportId", "NormalizedName") + .IsUnique(); + + b.ToTable("FeedbackTags", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Identity.Data.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Alias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FacebookId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Firstname") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Lastname") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PortraitUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RefreshToken") + .HasMaxLength(44) + .HasColumnType("character varying(44)"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.Notifications.Data.NotificationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentItemId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataJson") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RecipientEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RecipientUserId") + .HasColumnType("uuid"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ContentItemId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RecipientUserId"); + + b.HasIndex("WorkspaceId"); + + b.ToTable("NotificationEvents", (string)null); + }); + + modelBuilder.Entity("Socialize.Api.Modules.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/20260501191447_RenameProjectsToCampaigns.cs b/backend/src/Socialize.Api/Migrations/20260501191447_RenameProjectsToCampaigns.cs new file mode 100644 index 0000000..b9e0912 --- /dev/null +++ b/backend/src/Socialize.Api/Migrations/20260501191447_RenameProjectsToCampaigns.cs @@ -0,0 +1,117 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Socialize.Api.Migrations +{ + /// + public partial class RenameProjectsToCampaigns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameTable( + name: "Projects", + newName: "Campaigns"); + + migrationBuilder.DropPrimaryKey( + name: "PK_Projects", + table: "Campaigns"); + + migrationBuilder.AddPrimaryKey( + name: "PK_Campaigns", + table: "Campaigns", + column: "Id"); + + migrationBuilder.RenameIndex( + name: "IX_Projects_WorkspaceId", + table: "Campaigns", + newName: "IX_Campaigns_WorkspaceId"); + + migrationBuilder.RenameIndex( + name: "IX_Projects_ClientId_Name", + table: "Campaigns", + newName: "IX_Campaigns_ClientId_Name"); + + migrationBuilder.RenameIndex( + name: "IX_Projects_ClientId", + table: "Campaigns", + newName: "IX_Campaigns_ClientId"); + + migrationBuilder.RenameColumn( + name: "ProjectName", + table: "FeedbackReports", + newName: "CampaignName"); + + migrationBuilder.RenameColumn( + name: "ProjectId", + table: "FeedbackReports", + newName: "CampaignId"); + + migrationBuilder.RenameColumn( + name: "ProjectId", + table: "ContentItems", + newName: "CampaignId"); + + migrationBuilder.RenameIndex( + name: "IX_ContentItems_ProjectId", + table: "ContentItems", + newName: "IX_ContentItems_CampaignId"); + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_Campaigns", + table: "Campaigns"); + + migrationBuilder.AddPrimaryKey( + name: "PK_Projects", + table: "Campaigns", + column: "Id"); + + migrationBuilder.RenameIndex( + name: "IX_Campaigns_WorkspaceId", + table: "Campaigns", + newName: "IX_Projects_WorkspaceId"); + + migrationBuilder.RenameIndex( + name: "IX_Campaigns_ClientId_Name", + table: "Campaigns", + newName: "IX_Projects_ClientId_Name"); + + migrationBuilder.RenameIndex( + name: "IX_Campaigns_ClientId", + table: "Campaigns", + newName: "IX_Projects_ClientId"); + + migrationBuilder.RenameTable( + name: "Campaigns", + newName: "Projects"); + + migrationBuilder.RenameColumn( + name: "CampaignName", + table: "FeedbackReports", + newName: "ProjectName"); + + migrationBuilder.RenameColumn( + name: "CampaignId", + table: "FeedbackReports", + newName: "ProjectId"); + + migrationBuilder.RenameColumn( + name: "CampaignId", + table: "ContentItems", + newName: "ProjectId"); + + migrationBuilder.RenameIndex( + name: "IX_ContentItems_CampaignId", + table: "ContentItems", + newName: "IX_ContentItems_ProjectId"); + + } + } +} diff --git a/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Socialize.Api/Migrations/AppDbContextModelSnapshot.cs index f9b770b..80004fd 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") @@ -329,6 +438,59 @@ namespace Socialize.Api.Migrations b.ToTable("AssetRevisions", (string)null); }); + modelBuilder.Entity("Socialize.Api.Modules.Campaigns.Data.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Notes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("WorkspaceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("WorkspaceId"); + + b.HasIndex("ClientId", "Name") + .IsUnique(); + + b.ToTable("Campaigns", (string)null); + }); + modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b => { b.Property("Id") @@ -440,6 +602,9 @@ namespace Socialize.Api.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("CampaignId") + .HasColumnType("uuid"); + b.Property("ClientId") .HasColumnType("uuid"); @@ -463,9 +628,6 @@ namespace Socialize.Api.Migrations .HasMaxLength(1024) .HasColumnType("character varying(1024)"); - b.Property("ProjectId") - .HasColumnType("uuid"); - b.Property("PublicationMessage") .IsRequired() .HasMaxLength(4000) @@ -491,9 +653,9 @@ namespace Socialize.Api.Migrations b.HasKey("Id"); - b.HasIndex("ClientId"); + b.HasIndex("CampaignId"); - b.HasIndex("ProjectId"); + b.HasIndex("ClientId"); b.HasIndex("WorkspaceId"); @@ -675,6 +837,13 @@ namespace Socialize.Api.Migrations .HasMaxLength(1024) .HasColumnType("character varying(1024)"); + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("CampaignName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + b.Property("CancellationReason") .HasMaxLength(2000) .HasColumnType("character varying(2000)"); @@ -712,13 +881,6 @@ namespace Socialize.Api.Migrations 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) @@ -1041,70 +1203,29 @@ namespace Socialize.Api.Migrations 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)"); @@ -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..e025331 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, @@ -56,7 +61,7 @@ public class GetApprovalsHandler( return; } - if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) + if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId)) { await SendForbiddenAsync(ct); return; @@ -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..720fd7e 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 { @@ -58,12 +64,19 @@ public class SubmitApprovalDecisionHandler( } if (User?.Identity?.IsAuthenticated == true && - !accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId)) + !accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId)) { await SendForbiddenAsync(ct); 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/Assets/Handlers/CreateAssetRevision.cs b/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateAssetRevision.cs index 60d164a..e42598d 100644 --- a/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateAssetRevision.cs +++ b/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateAssetRevision.cs @@ -51,7 +51,7 @@ public class CreateAssetRevisionHandler( .SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct); if (contentItem is not null && - !accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId)) + !accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs b/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs index 6d1741f..e838af5 100644 --- a/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs +++ b/backend/src/Socialize.Api/Modules/Assets/Handlers/CreateGoogleDriveAsset.cs @@ -58,7 +58,7 @@ public class CreateGoogleDriveAssetHandler( return; } - if (!accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId)) + if (!accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs b/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs index 578bd86..04765c0 100644 --- a/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs +++ b/backend/src/Socialize.Api/Modules/Assets/Handlers/GetAssets.cs @@ -52,7 +52,7 @@ public class GetAssetsHandler( return; } - if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) + if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Projects/Data/Project.cs b/backend/src/Socialize.Api/Modules/Campaigns/Data/Campaign.cs similarity index 86% rename from backend/src/Socialize.Api/Modules/Projects/Data/Project.cs rename to backend/src/Socialize.Api/Modules/Campaigns/Data/Campaign.cs index f18a024..aafe569 100644 --- a/backend/src/Socialize.Api/Modules/Projects/Data/Project.cs +++ b/backend/src/Socialize.Api/Modules/Campaigns/Data/Campaign.cs @@ -1,6 +1,6 @@ -namespace Socialize.Api.Modules.Projects.Data; +namespace Socialize.Api.Modules.Campaigns.Data; -public class Project +public class Campaign { public Guid Id { get; init; } public Guid WorkspaceId { get; set; } diff --git a/backend/src/Socialize.Api/Modules/Campaigns/Data/CampaignModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Campaigns/Data/CampaignModelConfiguration.cs new file mode 100644 index 0000000..a709d26 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Campaigns/Data/CampaignModelConfiguration.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; + +namespace Socialize.Api.Modules.Campaigns.Data; + +public static class CampaignModelConfiguration +{ + public static ModelBuilder ConfigureCampaignsModule(this ModelBuilder modelBuilder) + { + modelBuilder.Entity(campaign => + { + campaign.ToTable("Campaigns"); + campaign.HasKey(x => x.Id); + campaign.Property(x => x.Name).HasMaxLength(256).IsRequired(); + campaign.Property(x => x.Description).HasMaxLength(4000); + campaign.Property(x => x.Notes).HasMaxLength(4000); + campaign.Property(x => x.Status).HasMaxLength(64).IsRequired(); + campaign.Property(x => x.CreatedAt) + .ValueGeneratedOnAdd() + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + campaign.HasIndex(x => new { x.ClientId, x.Name }).IsUnique(); + campaign.HasIndex(x => x.WorkspaceId); + campaign.HasIndex(x => x.ClientId); + }); + + return modelBuilder; + } +} diff --git a/backend/src/Socialize.Api/Modules/Campaigns/DependencyInjection.cs b/backend/src/Socialize.Api/Modules/Campaigns/DependencyInjection.cs new file mode 100644 index 0000000..fbb4521 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Campaigns/DependencyInjection.cs @@ -0,0 +1,12 @@ +using Socialize.Api.Modules.Campaigns.Data; + +namespace Socialize.Api.Modules.Campaigns; + +public static class DependencyInjection +{ + public static WebApplicationBuilder AddCampaignsModule( + this WebApplicationBuilder builder) + { + return builder; + } +} diff --git a/backend/src/Socialize.Api/Modules/Projects/Handlers/CreateProject.cs b/backend/src/Socialize.Api/Modules/Campaigns/Handlers/CreateCampaign.cs similarity index 69% rename from backend/src/Socialize.Api/Modules/Projects/Handlers/CreateProject.cs rename to backend/src/Socialize.Api/Modules/Campaigns/Handlers/CreateCampaign.cs index 0e9c7c5..9ef82ca 100644 --- a/backend/src/Socialize.Api/Modules/Projects/Handlers/CreateProject.cs +++ b/backend/src/Socialize.Api/Modules/Campaigns/Handlers/CreateCampaign.cs @@ -2,11 +2,11 @@ using FastEndpoints; using Microsoft.EntityFrameworkCore; using Socialize.Api.Data; using Socialize.Api.Infrastructure.Security; -using Socialize.Api.Modules.Projects.Data; +using Socialize.Api.Modules.Campaigns.Data; -namespace Socialize.Api.Modules.Projects.Handlers; +namespace Socialize.Api.Modules.Campaigns.Handlers; -public record CreateProjectRequest( +public record CreateCampaignRequest( Guid WorkspaceId, Guid ClientId, string Name, @@ -15,10 +15,10 @@ public record CreateProjectRequest( string? Description, string? Notes); -public class CreateProjectRequestValidator - : Validator +public class CreateCampaignRequestValidator + : Validator { - public CreateProjectRequestValidator() + public CreateCampaignRequestValidator() { RuleFor(x => x.WorkspaceId).NotEmpty(); RuleFor(x => x.ClientId).NotEmpty(); @@ -32,18 +32,18 @@ public class CreateProjectRequestValidator } } -public class CreateProjectHandler( +public class CreateCampaignHandler( AppDbContext dbContext, AccessScopeService accessScopeService) - : Endpoint + : Endpoint { public override void Configure() { - Post("/api/projects"); - Options(o => o.WithTags("Projects")); + Post("/api/campaigns"); + Options(o => o.WithTags("Campaigns")); } - public override async Task HandleAsync(CreateProjectRequest request, CancellationToken ct) + public override async Task HandleAsync(CreateCampaignRequest request, CancellationToken ct) { if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId)) { @@ -75,19 +75,19 @@ public class CreateProjectHandler( string normalizedName = request.Name.Trim(); - bool duplicateProject = await dbContext.Projects + bool duplicateCampaign = await dbContext.Campaigns .AnyAsync( - project => project.ClientId == request.ClientId && project.Name == normalizedName, + campaign => campaign.ClientId == request.ClientId && campaign.Name == normalizedName, ct); - if (duplicateProject) + if (duplicateCampaign) { - AddError(request => request.Name, "A project with this name already exists for the selected client."); + AddError(request => request.Name, "A campaign with this name already exists for the selected client."); await SendErrorsAsync(StatusCodes.Status409Conflict, ct); return; } - Project project = new() + Campaign campaign = new() { Id = Guid.NewGuid(), WorkspaceId = request.WorkspaceId, @@ -101,19 +101,19 @@ public class CreateProjectHandler( CreatedAt = DateTimeOffset.UtcNow, }; - dbContext.Projects.Add(project); + dbContext.Campaigns.Add(campaign); await dbContext.SaveChangesAsync(ct); - ProjectDto dto = new( - project.Id, - project.WorkspaceId, - project.ClientId, - project.Name, - project.Description, - project.Notes, - project.Status, - project.StartDate, - project.EndDate); + CampaignDto dto = new( + campaign.Id, + campaign.WorkspaceId, + campaign.ClientId, + campaign.Name, + campaign.Description, + campaign.Notes, + campaign.Status, + campaign.StartDate, + campaign.EndDate); await SendAsync(dto, StatusCodes.Status201Created, ct); } diff --git a/backend/src/Socialize.Api/Modules/Campaigns/Handlers/GetCampaigns.cs b/backend/src/Socialize.Api/Modules/Campaigns/Handlers/GetCampaigns.cs new file mode 100644 index 0000000..35d1103 --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Campaigns/Handlers/GetCampaigns.cs @@ -0,0 +1,89 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.Campaigns.Data; + +namespace Socialize.Api.Modules.Campaigns.Handlers; + +public record GetCampaignsRequest(Guid? WorkspaceId, Guid? ClientId); + +public record CampaignDto( + Guid Id, + Guid WorkspaceId, + Guid ClientId, + string Name, + string? Description, + string? Notes, + string Status, + DateTimeOffset StartDate, + DateTimeOffset EndDate); + +public class GetCampaignsHandler( + AppDbContext dbContext, + AccessScopeService accessScopeService) + : Endpoint> +{ + public override void Configure() + { + Get("/api/campaigns"); + Options(o => o.WithTags("Campaigns")); + } + + public override async Task HandleAsync(GetCampaignsRequest request, CancellationToken ct) + { + IQueryable query = dbContext.Campaigns.AsQueryable(); + + if (accessScopeService.IsManager(User)) + { + if (request.WorkspaceId.HasValue) + { + query = query.Where(campaign => campaign.WorkspaceId == request.WorkspaceId.Value); + } + } + else + { + IReadOnlyCollection workspaceScopeIds = User.GetWorkspaceScopeIds(); + IReadOnlyCollection clientScopeIds = User.GetClientScopeIds(); + IReadOnlyCollection campaignScopeIds = User.GetCampaignScopeIds(); + + query = query.Where(campaign => workspaceScopeIds.Contains(campaign.WorkspaceId)); + + if (clientScopeIds.Count > 0) + { + query = query.Where(campaign => clientScopeIds.Contains(campaign.ClientId)); + } + + if (campaignScopeIds.Count > 0) + { + query = query.Where(campaign => campaignScopeIds.Contains(campaign.Id)); + } + } + + if (request.ClientId.HasValue) + { + query = query.Where(campaign => campaign.ClientId == request.ClientId.Value); + } + + if (request.WorkspaceId.HasValue) + { + query = query.Where(campaign => campaign.WorkspaceId == request.WorkspaceId.Value); + } + + List campaigns = await query + .OrderBy(campaign => campaign.Name) + .Select(campaign => new CampaignDto( + campaign.Id, + campaign.WorkspaceId, + campaign.ClientId, + campaign.Name, + campaign.Description, + campaign.Notes, + campaign.Status, + campaign.StartDate, + campaign.EndDate)) + .ToListAsync(ct); + + await SendOkAsync(campaigns, ct); + } +} diff --git a/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs b/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs index ed85298..61489c6 100644 --- a/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs +++ b/backend/src/Socialize.Api/Modules/Comments/Handlers/CreateComment.cs @@ -51,7 +51,7 @@ public class CreateCommentHandler( return; } - if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId)) + if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Comments/Handlers/GetComments.cs b/backend/src/Socialize.Api/Modules/Comments/Handlers/GetComments.cs index 38e9bac..b07d601 100644 --- a/backend/src/Socialize.Api/Modules/Comments/Handlers/GetComments.cs +++ b/backend/src/Socialize.Api/Modules/Comments/Handlers/GetComments.cs @@ -44,7 +44,7 @@ public class GetCommentsHandler( return; } - if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) + if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Comments/Handlers/ResolveComment.cs b/backend/src/Socialize.Api/Modules/Comments/Handlers/ResolveComment.cs index 2cbe540..c1fb2ae 100644 --- a/backend/src/Socialize.Api/Modules/Comments/Handlers/ResolveComment.cs +++ b/backend/src/Socialize.Api/Modules/Comments/Handlers/ResolveComment.cs @@ -40,7 +40,7 @@ public class ResolveCommentHandler( } bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId) - || accessScopeService.CanContributeToProject(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId); + || accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId); if (!canResolve) { diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItem.cs b/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItem.cs index f668627..d9e42bd 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItem.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItem.cs @@ -5,7 +5,7 @@ public class ContentItem public Guid Id { get; init; } public Guid WorkspaceId { get; set; } public Guid ClientId { get; set; } - public Guid ProjectId { get; set; } + public Guid CampaignId { get; set; } public required string Title { get; set; } public required string PublicationMessage { get; set; } public required string PublicationTargets { get; set; } diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItemModelConfiguration.cs b/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItemModelConfiguration.cs index 3ed767c..04fabc9 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItemModelConfiguration.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Data/ContentItemModelConfiguration.cs @@ -21,7 +21,7 @@ public static class ContentItemModelConfiguration .HasDefaultValueSql("CURRENT_TIMESTAMP"); contentItem.HasIndex(x => x.WorkspaceId); contentItem.HasIndex(x => x.ClientId); - contentItem.HasIndex(x => x.ProjectId); + contentItem.HasIndex(x => x.CampaignId); }); modelBuilder.Entity(revision => diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItem.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItem.cs index 5bcf0df..ab9a438 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItem.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItem.cs @@ -11,7 +11,7 @@ namespace Socialize.Api.Modules.ContentItems.Handlers; public record CreateContentItemRequest( Guid WorkspaceId, Guid ClientId, - Guid ProjectId, + Guid CampaignId, string Title, string PublicationMessage, string PublicationTargets, @@ -25,7 +25,7 @@ public class CreateContentItemRequestValidator { RuleFor(x => x.WorkspaceId).NotEmpty(); RuleFor(x => x.ClientId).NotEmpty(); - RuleFor(x => x.ProjectId).NotEmpty(); + RuleFor(x => x.CampaignId).NotEmpty(); RuleFor(x => x.Title).NotEmpty().MaximumLength(256); RuleFor(x => x.PublicationMessage).NotEmpty().MaximumLength(4000); RuleFor(x => x.PublicationTargets).NotEmpty().MaximumLength(512); @@ -47,7 +47,7 @@ public class CreateContentItemHandler( public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct) { - if (!accessScopeService.CanContributeToProject(User, request.WorkspaceId, request.ClientId, request.ProjectId)) + if (!accessScopeService.CanContributeToCampaign(User, request.WorkspaceId, request.ClientId, request.CampaignId)) { await SendForbiddenAsync(ct); return; @@ -75,16 +75,16 @@ public class CreateContentItemHandler( return; } - bool projectExists = await dbContext.Projects + bool campaignExists = await dbContext.Campaigns .AnyAsync( - project => project.Id == request.ProjectId && - project.WorkspaceId == request.WorkspaceId && - project.ClientId == request.ClientId, + campaign => campaign.Id == request.CampaignId && + campaign.WorkspaceId == request.WorkspaceId && + campaign.ClientId == request.ClientId, ct); - if (!projectExists) + if (!campaignExists) { - AddError(request => request.ProjectId, "The selected project does not belong to the selected client."); + AddError(request => request.CampaignId, "The selected campaign does not belong to the selected client."); await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); return; } @@ -94,7 +94,7 @@ public class CreateContentItemHandler( Id = Guid.NewGuid(), WorkspaceId = request.WorkspaceId, ClientId = request.ClientId, - ProjectId = request.ProjectId, + CampaignId = request.CampaignId, Title = request.Title.Trim(), PublicationMessage = request.PublicationMessage.Trim(), PublicationTargets = request.PublicationTargets.Trim(), @@ -138,7 +138,7 @@ public class CreateContentItemHandler( item.Id, item.WorkspaceId, item.ClientId, - item.ProjectId, + item.CampaignId, item.Title, item.PublicationMessage, item.PublicationTargets, diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs index cfd190e..651a3d2 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/CreateContentItemRevision.cs @@ -50,7 +50,7 @@ public class CreateContentItemRevisionHandler( return; } - if (!accessScopeService.CanContributeToProject(User, item.WorkspaceId, item.ClientId, item.ProjectId)) + if (!accessScopeService.CanContributeToCampaign(User, item.WorkspaceId, item.ClientId, item.CampaignId)) { await SendForbiddenAsync(ct); return; @@ -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/GetContentItem.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItem.cs index b4e088b..46464ff 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItem.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItem.cs @@ -10,7 +10,7 @@ public record ContentItemDetailDto( Guid Id, Guid WorkspaceId, Guid ClientId, - Guid ProjectId, + Guid CampaignId, string Title, string PublicationMessage, string PublicationTargets, @@ -42,7 +42,7 @@ public class GetContentItemHandler( candidate.Id, candidate.WorkspaceId, candidate.ClientId, - candidate.ProjectId, + candidate.CampaignId, candidate.Title, candidate.PublicationMessage, candidate.PublicationTargets, @@ -60,7 +60,7 @@ public class GetContentItemHandler( return; } - if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) + if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItemRevisions.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItemRevisions.cs index e862175..ed906f3 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItemRevisions.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItemRevisions.cs @@ -41,7 +41,7 @@ public class GetContentItemRevisionsHandler( return; } - if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) + if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItems.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItems.cs index b145e14..189d200 100644 --- a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItems.cs +++ b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/GetContentItems.cs @@ -6,13 +6,13 @@ using Socialize.Api.Modules.ContentItems.Data; namespace Socialize.Api.Modules.ContentItems.Handlers; -public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? ProjectId); +public record GetContentItemsRequest(Guid? WorkspaceId, Guid? ClientId, Guid? CampaignId); public record ContentItemDto( Guid Id, Guid WorkspaceId, Guid ClientId, - Guid ProjectId, + Guid CampaignId, string Title, string PublicationMessage, string PublicationTargets, @@ -41,7 +41,7 @@ public class GetContentItemsHandler( { IReadOnlyCollection workspaceScopeIds = User.GetWorkspaceScopeIds(); IReadOnlyCollection clientScopeIds = User.GetClientScopeIds(); - IReadOnlyCollection projectScopeIds = User.GetProjectScopeIds(); + IReadOnlyCollection campaignScopeIds = User.GetCampaignScopeIds(); query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId)); @@ -50,9 +50,9 @@ public class GetContentItemsHandler( query = query.Where(item => clientScopeIds.Contains(item.ClientId)); } - if (projectScopeIds.Count > 0) + if (campaignScopeIds.Count > 0) { - query = query.Where(item => projectScopeIds.Contains(item.ProjectId)); + query = query.Where(item => campaignScopeIds.Contains(item.CampaignId)); } } @@ -61,9 +61,9 @@ public class GetContentItemsHandler( query = query.Where(item => item.WorkspaceId == request.WorkspaceId.Value); } - if (request.ProjectId.HasValue) + if (request.CampaignId.HasValue) { - query = query.Where(item => item.ProjectId == request.ProjectId.Value); + query = query.Where(item => item.CampaignId == request.CampaignId.Value); } if (request.ClientId.HasValue) @@ -78,7 +78,7 @@ public class GetContentItemsHandler( item.Id, item.WorkspaceId, item.ClientId, - item.ProjectId, + item.CampaignId, item.Title, item.PublicationMessage, item.PublicationTargets, diff --git a/backend/src/Socialize.Api/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs b/backend/src/Socialize.Api/Modules/ContentItems/Handlers/UpdateContentItemStatus.cs index 07c2cef..d66a04e 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( @@ -92,7 +145,7 @@ public class UpdateContentItemStatusHandler( item.Id, item.WorkspaceId, item.ClientId, - item.ProjectId, + item.CampaignId, item.Title, item.PublicationMessage, item.PublicationTargets, diff --git a/backend/src/Socialize.Api/Modules/Feedback/Contracts/FeedbackDtos.cs b/backend/src/Socialize.Api/Modules/Feedback/Contracts/FeedbackDtos.cs index 18814b5..807f468 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Contracts/FeedbackDtos.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Contracts/FeedbackDtos.cs @@ -7,8 +7,8 @@ public record FeedbackContextDto( string? WorkspaceName, Guid? ClientId, string? ClientName, - Guid? ProjectId, - string? ProjectName, + Guid? CampaignId, + string? CampaignName, Guid? ContentItemId, string? ContentItemTitle); @@ -82,8 +82,8 @@ public static class FeedbackDtoMapper report.WorkspaceName, report.ClientId, report.ClientName, - report.ProjectId, - report.ProjectName, + report.CampaignId, + report.CampaignName, report.ContentItemId, report.ContentItemTitle), report.Screenshot is null diff --git a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs index d56538a..4645e1e 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackModelConfiguration.cs @@ -20,7 +20,7 @@ public static class FeedbackModelConfiguration feedback.Property(x => x.AppVersion).HasMaxLength(128); feedback.Property(x => x.WorkspaceName).HasMaxLength(256); feedback.Property(x => x.ClientName).HasMaxLength(256); - feedback.Property(x => x.ProjectName).HasMaxLength(256); + feedback.Property(x => x.CampaignName).HasMaxLength(256); feedback.Property(x => x.ContentItemTitle).HasMaxLength(256); feedback.Property(x => x.CancellationReason).HasMaxLength(2000); feedback.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP"); diff --git a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackReport.cs b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackReport.cs index ee9cd7f..2e2ef51 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackReport.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Data/FeedbackReport.cs @@ -18,8 +18,8 @@ public class FeedbackReport public string? WorkspaceName { get; set; } public Guid? ClientId { get; set; } public string? ClientName { get; set; } - public Guid? ProjectId { get; set; } - public string? ProjectName { get; set; } + public Guid? CampaignId { get; set; } + public string? CampaignName { get; set; } public Guid? ContentItemId { get; set; } public string? ContentItemTitle { get; set; } public DateTimeOffset CreatedAt { get; set; } diff --git a/backend/src/Socialize.Api/Modules/Feedback/Handlers/SubmitFeedback.cs b/backend/src/Socialize.Api/Modules/Feedback/Handlers/SubmitFeedback.cs index fc3c671..17e6a3d 100644 --- a/backend/src/Socialize.Api/Modules/Feedback/Handlers/SubmitFeedback.cs +++ b/backend/src/Socialize.Api/Modules/Feedback/Handlers/SubmitFeedback.cs @@ -19,8 +19,8 @@ public record SubmitFeedbackRequest( string? WorkspaceName, Guid? ClientId, string? ClientName, - Guid? ProjectId, - string? ProjectName, + Guid? CampaignId, + string? CampaignName, Guid? ContentItemId, string? ContentItemTitle); @@ -36,7 +36,7 @@ public class SubmitFeedbackRequestValidator RuleFor(x => x.AppVersion).MaximumLength(128); RuleFor(x => x.WorkspaceName).MaximumLength(256); RuleFor(x => x.ClientName).MaximumLength(256); - RuleFor(x => x.ProjectName).MaximumLength(256); + RuleFor(x => x.CampaignName).MaximumLength(256); RuleFor(x => x.ContentItemTitle).MaximumLength(256); RuleFor(x => x.ViewportWidth).GreaterThan(0).When(x => x.ViewportWidth.HasValue); RuleFor(x => x.ViewportHeight).GreaterThan(0).When(x => x.ViewportHeight.HasValue); @@ -82,8 +82,8 @@ public class SubmitFeedbackHandler( WorkspaceName = NormalizeOptional(request.WorkspaceName), ClientId = request.ClientId, ClientName = NormalizeOptional(request.ClientName), - ProjectId = request.ProjectId, - ProjectName = NormalizeOptional(request.ProjectName), + CampaignId = request.CampaignId, + CampaignName = NormalizeOptional(request.CampaignName), ContentItemId = request.ContentItemId, ContentItemTitle = NormalizeOptional(request.ContentItemTitle), CreatedAt = now, diff --git a/backend/src/Socialize.Api/Modules/Identity/Handlers/GetCurrentUser.cs b/backend/src/Socialize.Api/Modules/Identity/Handlers/GetCurrentUser.cs index 6abbdf8..31bd0b4 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Handlers/GetCurrentUser.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Handlers/GetCurrentUser.cs @@ -50,8 +50,8 @@ public class GetCurrentUserQueryHandler( .Distinct() .ToList(); - List projectIds = claims - .Where(claim => claim.Type == KnownClaims.ProjectScope) + List campaignIds = claims + .Where(claim => claim.Type == KnownClaims.CampaignScope) .Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty) .Where(id => id != Guid.Empty) .Distinct() @@ -64,7 +64,7 @@ public class GetCurrentUserQueryHandler( Persona = persona, AuthorizedWorkspaceIds = workspaceIds, AuthorizedClientIds = clientIds, - AuthorizedProjectIds = projectIds, + AuthorizedCampaignIds = campaignIds, Alias = userModel.Alias, PortraitUrl = userModel.PortraitUrl, Firstname = userModel.Firstname, diff --git a/backend/src/Socialize.Api/Modules/Identity/Models/UserDto.cs b/backend/src/Socialize.Api/Modules/Identity/Models/UserDto.cs index 5f64a2b..c565f37 100644 --- a/backend/src/Socialize.Api/Modules/Identity/Models/UserDto.cs +++ b/backend/src/Socialize.Api/Modules/Identity/Models/UserDto.cs @@ -7,7 +7,7 @@ public class UserDto public string? Persona { get; init; } public IList AuthorizedWorkspaceIds { get; init; } = []; public IList AuthorizedClientIds { get; init; } = []; - public IList AuthorizedProjectIds { get; init; } = []; + public IList AuthorizedCampaignIds { get; init; } = []; public string Username { get; init; } = null!; public string? Alias { get; init; } public string? PortraitUrl { get; init; } diff --git a/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs b/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs index 5fabdb8..aea4b69 100644 --- a/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs +++ b/backend/src/Socialize.Api/Modules/Notifications/Handlers/GetNotifications.cs @@ -46,7 +46,7 @@ public class GetNotificationsHandler( return; } - if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.ProjectId)) + if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId)) { await SendForbiddenAsync(ct); return; diff --git a/backend/src/Socialize.Api/Modules/Projects/Data/ProjectModelConfiguration.cs b/backend/src/Socialize.Api/Modules/Projects/Data/ProjectModelConfiguration.cs deleted file mode 100644 index ab871f1..0000000 --- a/backend/src/Socialize.Api/Modules/Projects/Data/ProjectModelConfiguration.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Socialize.Api.Modules.Projects.Data; - -public static class ProjectModelConfiguration -{ - public static ModelBuilder ConfigureProjectsModule(this ModelBuilder modelBuilder) - { - modelBuilder.Entity(project => - { - project.ToTable("Projects"); - project.HasKey(x => x.Id); - project.Property(x => x.Name).HasMaxLength(256).IsRequired(); - project.Property(x => x.Description).HasMaxLength(4000); - project.Property(x => x.Notes).HasMaxLength(4000); - project.Property(x => x.Status).HasMaxLength(64).IsRequired(); - project.Property(x => x.CreatedAt) - .ValueGeneratedOnAdd() - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - project.HasIndex(x => new { x.ClientId, x.Name }).IsUnique(); - project.HasIndex(x => x.WorkspaceId); - project.HasIndex(x => x.ClientId); - }); - - return modelBuilder; - } -} diff --git a/backend/src/Socialize.Api/Modules/Projects/DependencyInjection.cs b/backend/src/Socialize.Api/Modules/Projects/DependencyInjection.cs deleted file mode 100644 index a0db954..0000000 --- a/backend/src/Socialize.Api/Modules/Projects/DependencyInjection.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Socialize.Api.Modules.Projects.Data; - -namespace Socialize.Api.Modules.Projects; - -public static class DependencyInjection -{ - public static WebApplicationBuilder AddProjectsModule( - this WebApplicationBuilder builder) - { - return builder; - } -} diff --git a/backend/src/Socialize.Api/Modules/Projects/Handlers/GetProjects.cs b/backend/src/Socialize.Api/Modules/Projects/Handlers/GetProjects.cs deleted file mode 100644 index 2bbbcf2..0000000 --- a/backend/src/Socialize.Api/Modules/Projects/Handlers/GetProjects.cs +++ /dev/null @@ -1,89 +0,0 @@ -using FastEndpoints; -using Microsoft.EntityFrameworkCore; -using Socialize.Api.Data; -using Socialize.Api.Infrastructure.Security; -using Socialize.Api.Modules.Projects.Data; - -namespace Socialize.Api.Modules.Projects.Handlers; - -public record GetProjectsRequest(Guid? WorkspaceId, Guid? ClientId); - -public record ProjectDto( - Guid Id, - Guid WorkspaceId, - Guid ClientId, - string Name, - string? Description, - string? Notes, - string Status, - DateTimeOffset StartDate, - DateTimeOffset EndDate); - -public class GetProjectsHandler( - AppDbContext dbContext, - AccessScopeService accessScopeService) - : Endpoint> -{ - public override void Configure() - { - Get("/api/projects"); - Options(o => o.WithTags("Projects")); - } - - public override async Task HandleAsync(GetProjectsRequest request, CancellationToken ct) - { - IQueryable query = dbContext.Projects.AsQueryable(); - - if (accessScopeService.IsManager(User)) - { - if (request.WorkspaceId.HasValue) - { - query = query.Where(project => project.WorkspaceId == request.WorkspaceId.Value); - } - } - else - { - IReadOnlyCollection workspaceScopeIds = User.GetWorkspaceScopeIds(); - IReadOnlyCollection clientScopeIds = User.GetClientScopeIds(); - IReadOnlyCollection projectScopeIds = User.GetProjectScopeIds(); - - query = query.Where(project => workspaceScopeIds.Contains(project.WorkspaceId)); - - if (clientScopeIds.Count > 0) - { - query = query.Where(project => clientScopeIds.Contains(project.ClientId)); - } - - if (projectScopeIds.Count > 0) - { - query = query.Where(project => projectScopeIds.Contains(project.Id)); - } - } - - if (request.ClientId.HasValue) - { - query = query.Where(project => project.ClientId == request.ClientId.Value); - } - - if (request.WorkspaceId.HasValue) - { - query = query.Where(project => project.WorkspaceId == request.WorkspaceId.Value); - } - - List projects = await query - .OrderBy(project => project.Name) - .Select(project => new ProjectDto( - project.Id, - project.WorkspaceId, - project.ClientId, - project.Name, - project.Description, - project.Notes, - project.Status, - project.StartDate, - project.EndDate)) - .ToListAsync(ct); - - await SendOkAsync(projects, ct); - } -} 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/src/Socialize.Api/Program.cs b/backend/src/Socialize.Api/Program.cs index 5f9d68c..f437285 100644 --- a/backend/src/Socialize.Api/Program.cs +++ b/backend/src/Socialize.Api/Program.cs @@ -16,7 +16,7 @@ using Socialize.Api.Modules.ContentItems; using Socialize.Api.Modules.Feedback; using Socialize.Api.Modules.Identity; using Socialize.Api.Modules.Notifications; -using Socialize.Api.Modules.Projects; +using Socialize.Api.Modules.Campaigns; using Socialize.Api.Modules.Workspaces; @@ -64,7 +64,7 @@ builder.AddInfrastructureModule(); builder.AddIdentityModule(); builder.AddWorkspaceModule(); builder.AddClientsModule(); -builder.AddProjectsModule(); +builder.AddCampaignsModule(); builder.AddContentItemsModule(); builder.AddAssetsModule(); builder.AddCommentsModule(); diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Microsoft.Build.Locator.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Microsoft.Build.Locator.dll" new file mode 100755 index 0000000..13b1021 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Microsoft.Build.Locator.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.exe" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.exe" new file mode 100755 index 0000000..00dd99f Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.exe" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.exe.config" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.exe.config" new file mode 100755 index 0000000..14c9184 --- /dev/null +++ "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.exe.config" @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Microsoft.IO.Redist.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Microsoft.IO.Redist.dll" new file mode 100755 index 0000000..88e63d8 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Microsoft.IO.Redist.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Newtonsoft.Json.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Newtonsoft.Json.dll" new file mode 100755 index 0000000..1d035d6 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/Newtonsoft.Json.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Buffers.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Buffers.dll" new file mode 100755 index 0000000..f2d83c5 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Buffers.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Collections.Immutable.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Collections.Immutable.dll" new file mode 100755 index 0000000..7594b2e Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Collections.Immutable.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.CommandLine.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.CommandLine.dll" new file mode 100755 index 0000000..d0bbad5 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.CommandLine.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Memory.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Memory.dll" new file mode 100755 index 0000000..4617199 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Memory.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Numerics.Vectors.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Numerics.Vectors.dll" new file mode 100755 index 0000000..0865972 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Numerics.Vectors.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Runtime.CompilerServices.Unsafe.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Runtime.CompilerServices.Unsafe.dll" new file mode 100755 index 0000000..c5ba4e4 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Runtime.CompilerServices.Unsafe.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Threading.Tasks.Extensions.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Threading.Tasks.Extensions.dll" new file mode 100755 index 0000000..eeec928 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/System.Threading.Tasks.Extensions.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/cs/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/cs/System.CommandLine.resources.dll" new file mode 100755 index 0000000..0be3757 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/cs/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/de/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/de/System.CommandLine.resources.dll" new file mode 100755 index 0000000..bfed293 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/de/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/es/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/es/System.CommandLine.resources.dll" new file mode 100755 index 0000000..5e1c416 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/es/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/fr/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/fr/System.CommandLine.resources.dll" new file mode 100755 index 0000000..2916bdf Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/fr/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/it/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/it/System.CommandLine.resources.dll" new file mode 100755 index 0000000..1a55c94 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/it/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/ja/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/ja/System.CommandLine.resources.dll" new file mode 100755 index 0000000..c1be153 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/ja/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/ko/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/ko/System.CommandLine.resources.dll" new file mode 100755 index 0000000..bfcbbc6 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/ko/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/pl/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/pl/System.CommandLine.resources.dll" new file mode 100755 index 0000000..b9efaec Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/pl/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/pt-BR/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/pt-BR/System.CommandLine.resources.dll" new file mode 100755 index 0000000..69612cb Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/pt-BR/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/ru/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/ru/System.CommandLine.resources.dll" new file mode 100755 index 0000000..042aaf8 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/ru/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/tr/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/tr/System.CommandLine.resources.dll" new file mode 100755 index 0000000..629b98b Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/tr/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/zh-Hans/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/zh-Hans/System.CommandLine.resources.dll" new file mode 100755 index 0000000..ff8dacb Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/zh-Hans/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/zh-Hant/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/zh-Hant/System.CommandLine.resources.dll" new file mode 100755 index 0000000..9b9870a Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-net472/zh-Hant/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.Build.Locator.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.Build.Locator.dll" new file mode 100755 index 0000000..cafcf21 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.Build.Locator.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.deps.json" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.deps.json" new file mode 100755 index 0000000..059c550 --- /dev/null +++ "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.deps.json" @@ -0,0 +1,260 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v6.0", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v6.0": { + "Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost/4.14.0-3.25262.10": { + "dependencies": { + "Microsoft.Build.Locator": "1.6.10", + "Microsoft.CodeAnalysis.NetAnalyzers": "8.0.0-preview.23468.1", + "Microsoft.CodeAnalysis.PerformanceSensitiveAnalyzers": "3.3.4-beta1.22504.1", + "Microsoft.DotNet.XliffTasks": "9.0.0-beta.25255.5", + "Microsoft.VisualStudio.Threading.Analyzers": "17.13.2", + "Newtonsoft.Json": "13.0.3", + "Roslyn.Diagnostics.Analyzers": "3.11.0-beta1.24081.1", + "System.Collections.Immutable": "9.0.0", + "System.CommandLine": "2.0.0-beta4.24528.1" + }, + "runtime": { + "Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.dll": {} + }, + "resources": { + "cs/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": { + "locale": "cs" + }, + "de/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": { + "locale": "de" + }, + "es/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": { + "locale": "es" + }, + "fr/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": { + "locale": "fr" + }, + "it/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": { + "locale": "it" + }, + "ja/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": { + "locale": "ja" + }, + "ko/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": { + "locale": "ko" + }, + "pl/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": { + "locale": "pl" + }, + "pt-BR/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": { + "locale": "pt-BR" + }, + "ru/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": { + "locale": "ru" + }, + "tr/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": { + "locale": "tr" + }, + "zh-Hans/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": { + "locale": "zh-Hans" + }, + "zh-Hant/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": { + "locale": "zh-Hant" + } + } + }, + "Microsoft.Build.Locator/1.6.10": { + "runtime": { + "lib/net6.0/Microsoft.Build.Locator.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.6.10.57384" + } + } + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers/3.11.0-beta1.24081.1": {}, + "Microsoft.CodeAnalysis.NetAnalyzers/8.0.0-preview.23468.1": {}, + "Microsoft.CodeAnalysis.PerformanceSensitiveAnalyzers/3.3.4-beta1.22504.1": {}, + "Microsoft.CodeAnalysis.PublicApiAnalyzers/3.11.0-beta1.24081.1": {}, + "Microsoft.DotNet.XliffTasks/9.0.0-beta.25255.5": {}, + "Microsoft.VisualStudio.Threading.Analyzers/17.13.2": {}, + "Newtonsoft.Json/13.0.3": { + "runtime": { + "lib/net6.0/Newtonsoft.Json.dll": { + "assemblyVersion": "13.0.0.0", + "fileVersion": "13.0.3.27908" + } + } + }, + "Roslyn.Diagnostics.Analyzers/3.11.0-beta1.24081.1": { + "dependencies": { + "Microsoft.CodeAnalysis.BannedApiAnalyzers": "3.11.0-beta1.24081.1", + "Microsoft.CodeAnalysis.PublicApiAnalyzers": "3.11.0-beta1.24081.1" + } + }, + "System.Collections.Immutable/9.0.0": { + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + }, + "runtime": { + "lib/netstandard2.0/System.Collections.Immutable.dll": { + "assemblyVersion": "9.0.0.0", + "fileVersion": "9.0.24.52809" + } + } + }, + "System.CommandLine/2.0.0-beta4.24528.1": { + "dependencies": { + "System.Memory": "4.5.5" + }, + "runtime": { + "lib/netstandard2.0/System.CommandLine.dll": { + "assemblyVersion": "2.0.0.0", + "fileVersion": "2.0.24.52801" + } + }, + "resources": { + "lib/netstandard2.0/cs/System.CommandLine.resources.dll": { + "locale": "cs" + }, + "lib/netstandard2.0/de/System.CommandLine.resources.dll": { + "locale": "de" + }, + "lib/netstandard2.0/es/System.CommandLine.resources.dll": { + "locale": "es" + }, + "lib/netstandard2.0/fr/System.CommandLine.resources.dll": { + "locale": "fr" + }, + "lib/netstandard2.0/it/System.CommandLine.resources.dll": { + "locale": "it" + }, + "lib/netstandard2.0/ja/System.CommandLine.resources.dll": { + "locale": "ja" + }, + "lib/netstandard2.0/ko/System.CommandLine.resources.dll": { + "locale": "ko" + }, + "lib/netstandard2.0/pl/System.CommandLine.resources.dll": { + "locale": "pl" + }, + "lib/netstandard2.0/pt-BR/System.CommandLine.resources.dll": { + "locale": "pt-BR" + }, + "lib/netstandard2.0/ru/System.CommandLine.resources.dll": { + "locale": "ru" + }, + "lib/netstandard2.0/tr/System.CommandLine.resources.dll": { + "locale": "tr" + }, + "lib/netstandard2.0/zh-Hans/System.CommandLine.resources.dll": { + "locale": "zh-Hans" + }, + "lib/netstandard2.0/zh-Hant/System.CommandLine.resources.dll": { + "locale": "zh-Hant" + } + } + }, + "System.Memory/4.5.5": {}, + "System.Runtime.CompilerServices.Unsafe/6.0.0": {} + } + }, + "libraries": { + "Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost/4.14.0-3.25262.10": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Microsoft.Build.Locator/1.6.10": { + "type": "package", + "serviceable": true, + "sha512": "sha512-DJhCkTGqy1LMJzEmG/2qxRTMHwdPc3WdVoGQI5o5mKHVo4dsHrCMLIyruwU/NSvPNSdvONlaf7jdFXnAMuxAuA==", + "path": "microsoft.build.locator/1.6.10", + "hashPath": "microsoft.build.locator.1.6.10.nupkg.sha512" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers/3.11.0-beta1.24081.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-DH6L3rsbjppLrHM2l2/NKbnMaYd0NFHx2pjZaFdrVcRkONrV3i9FHv6Id8Dp6/TmjhXQsJVJJFbhhjkpuP1xxg==", + "path": "microsoft.codeanalysis.bannedapianalyzers/3.11.0-beta1.24081.1", + "hashPath": "microsoft.codeanalysis.bannedapianalyzers.3.11.0-beta1.24081.1.nupkg.sha512" + }, + "Microsoft.CodeAnalysis.NetAnalyzers/8.0.0-preview.23468.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-ZhIvyxmUCqb8OiU/VQfxfuAmIB4lQsjqhMVYKeoyxzSI+d7uR5Pzx3ZKoaIhPizQ15wa4lnyD6wg3TnSJ6P4LA==", + "path": "microsoft.codeanalysis.netanalyzers/8.0.0-preview.23468.1", + "hashPath": "microsoft.codeanalysis.netanalyzers.8.0.0-preview.23468.1.nupkg.sha512" + }, + "Microsoft.CodeAnalysis.PerformanceSensitiveAnalyzers/3.3.4-beta1.22504.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-2XRlqPAzVke7Sb80+UqaC7o57OwfK+tIr+aIOxrx41RWDMeR2SBUW7kL4sd6hfLFfBNsLo3W5PT+UwfvwPaOzA==", + "path": "microsoft.codeanalysis.performancesensitiveanalyzers/3.3.4-beta1.22504.1", + "hashPath": "microsoft.codeanalysis.performancesensitiveanalyzers.3.3.4-beta1.22504.1.nupkg.sha512" + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers/3.11.0-beta1.24081.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-3bYGBihvoNO0rhCOG1U9O50/4Q8suZ+glHqQLIAcKvnodSnSW+dYWYzTNb1UbS8pUS8nAUfxSFMwuMup/G5DtQ==", + "path": "microsoft.codeanalysis.publicapianalyzers/3.11.0-beta1.24081.1", + "hashPath": "microsoft.codeanalysis.publicapianalyzers.3.11.0-beta1.24081.1.nupkg.sha512" + }, + "Microsoft.DotNet.XliffTasks/9.0.0-beta.25255.5": { + "type": "package", + "serviceable": true, + "sha512": "sha512-bb0fZB5ViPscdfYeWlmtyXJMzNkgcpkV5RWmXktfV9lwIUZgNZmFotUXrdcTyZzrN7v1tQK/Y6BGnbkP9gEsXg==", + "path": "microsoft.dotnet.xlifftasks/9.0.0-beta.25255.5", + "hashPath": "microsoft.dotnet.xlifftasks.9.0.0-beta.25255.5.nupkg.sha512" + }, + "Microsoft.VisualStudio.Threading.Analyzers/17.13.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-Qcd8IlaTXZVq3wolBnzby1P7kWihdWaExtD8riumiKuG1sHa8EgjV/o70TMjTaeUMhomBbhfdC9OPwAHoZfnjQ==", + "path": "microsoft.visualstudio.threading.analyzers/17.13.2", + "hashPath": "microsoft.visualstudio.threading.analyzers.17.13.2.nupkg.sha512" + }, + "Newtonsoft.Json/13.0.3": { + "type": "package", + "serviceable": true, + "sha512": "sha512-HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==", + "path": "newtonsoft.json/13.0.3", + "hashPath": "newtonsoft.json.13.0.3.nupkg.sha512" + }, + "Roslyn.Diagnostics.Analyzers/3.11.0-beta1.24081.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-reHqZCDKifA+DURcL8jUfYkMGL4FpgNt5LI0uWTS6IpM8kKVbu/kO8byZsqfhBu4wUzT3MBDcoMfzhZPdENIpg==", + "path": "roslyn.diagnostics.analyzers/3.11.0-beta1.24081.1", + "hashPath": "roslyn.diagnostics.analyzers.3.11.0-beta1.24081.1.nupkg.sha512" + }, + "System.Collections.Immutable/9.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w==", + "path": "system.collections.immutable/9.0.0", + "hashPath": "system.collections.immutable.9.0.0.nupkg.sha512" + }, + "System.CommandLine/2.0.0-beta4.24528.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-Xt8tsSU8yd0ZpbT9gl5DAwkMYWLo8PV1fq2R/belrUbHVVOIKqhLfbWksbdknUDpmzMHZenBtD6AGAp9uJTa2w==", + "path": "system.commandline/2.0.0-beta4.24528.1", + "hashPath": "system.commandline.2.0.0-beta4.24528.1.nupkg.sha512" + }, + "System.Memory/4.5.5": { + "type": "package", + "serviceable": true, + "sha512": "sha512-XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "path": "system.memory/4.5.5", + "hashPath": "system.memory.4.5.5.nupkg.sha512" + }, + "System.Runtime.CompilerServices.Unsafe/6.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==", + "path": "system.runtime.compilerservices.unsafe/6.0.0", + "hashPath": "system.runtime.compilerservices.unsafe.6.0.0.nupkg.sha512" + } + } +} \ No newline at end of file diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.dll" new file mode 100755 index 0000000..993b54f Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.dll.config" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.dll.config" new file mode 100755 index 0000000..f78385c --- /dev/null +++ "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.dll.config" @@ -0,0 +1,659 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.runtimeconfig.json" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.runtimeconfig.json" new file mode 100755 index 0000000..9a67d63 --- /dev/null +++ "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.runtimeconfig.json" @@ -0,0 +1,13 @@ +{ + "runtimeOptions": { + "tfm": "net6.0", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "6.0.0" + }, + "rollForward": "Major", + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false + } + } +} \ No newline at end of file diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Newtonsoft.Json.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Newtonsoft.Json.dll" new file mode 100755 index 0000000..87bf9aa Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/Newtonsoft.Json.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/System.Collections.Immutable.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/System.Collections.Immutable.dll" new file mode 100755 index 0000000..b182127 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/System.Collections.Immutable.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/System.CommandLine.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/System.CommandLine.dll" new file mode 100755 index 0000000..d0bbad5 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/System.CommandLine.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/cs/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/cs/System.CommandLine.resources.dll" new file mode 100755 index 0000000..0be3757 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/cs/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/de/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/de/System.CommandLine.resources.dll" new file mode 100755 index 0000000..bfed293 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/de/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/es/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/es/System.CommandLine.resources.dll" new file mode 100755 index 0000000..5e1c416 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/es/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/fr/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/fr/System.CommandLine.resources.dll" new file mode 100755 index 0000000..2916bdf Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/fr/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/it/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/it/System.CommandLine.resources.dll" new file mode 100755 index 0000000..1a55c94 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/it/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/ja/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/ja/System.CommandLine.resources.dll" new file mode 100755 index 0000000..c1be153 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/ja/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/ko/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/ko/System.CommandLine.resources.dll" new file mode 100755 index 0000000..bfcbbc6 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/ko/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/pl/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/pl/System.CommandLine.resources.dll" new file mode 100755 index 0000000..b9efaec Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/pl/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/pt-BR/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/pt-BR/System.CommandLine.resources.dll" new file mode 100755 index 0000000..69612cb Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/pt-BR/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/ru/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/ru/System.CommandLine.resources.dll" new file mode 100755 index 0000000..042aaf8 Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/ru/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/tr/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/tr/System.CommandLine.resources.dll" new file mode 100755 index 0000000..629b98b Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/tr/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/zh-Hans/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/zh-Hans/System.CommandLine.resources.dll" new file mode 100755 index 0000000..ff8dacb Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/zh-Hans/System.CommandLine.resources.dll" differ diff --git "a/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/zh-Hant/System.CommandLine.resources.dll" "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/zh-Hant/System.CommandLine.resources.dll" new file mode 100755 index 0000000..9b9870a Binary files /dev/null and "b/backend/src/Socialize.Api/bin\\Debug/net10.0/BuildHost-netcore/zh-Hant/System.CommandLine.resources.dll" differ 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/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ed17e10..d98f7ef 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,7 +31,7 @@ Composition registers: - web services and auth in `DependencyInjection.cs` - infrastructure in `Infrastructure/DependencyInjection.cs` -- domain modules for Identity, Workspaces, Clients, Projects, ContentItems, Assets, Comments, Approvals, Notifications, and Feedback +- domain modules for Identity, Workspaces, Clients, Campaigns, ContentItems, Assets, Comments, Approvals, Notifications, and Feedback ## Data Ownership diff --git a/docs/FEATURES/product-feedback.md b/docs/FEATURES/product-feedback.md index 93ba378..226f2be 100644 --- a/docs/FEATURES/product-feedback.md +++ b/docs/FEATURES/product-feedback.md @@ -100,7 +100,7 @@ Each report should capture useful debugging context automatically when available - current app URL/path - active workspace id/name - active client id/name -- active project id/name +- active campaign id/name - active content item id/title - browser user agent - viewport size 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/docs/TASKS/campaigns/001-rename-projects-to-campaigns.md b/docs/TASKS/campaigns/001-rename-projects-to-campaigns.md new file mode 100644 index 0000000..2226b8e --- /dev/null +++ b/docs/TASKS/campaigns/001-rename-projects-to-campaigns.md @@ -0,0 +1,25 @@ +# Rename Projects To Campaigns + +## Goal + +Align the codebase terminology with the product language by replacing the `Project` domain surface with `Campaign`. + +## Relevant Specs + +- `docs/product/glossary.md` +- `docs/ARCHITECTURE.md` + +## Scope + +- Rename backend module, entity, DTOs, handlers, EF configuration, and route/tag names from projects to campaigns. +- Update content item and access-scope references that point at the renamed campaign concept. +- Update frontend feature naming and API calls where they still refer to projects. +- Update OpenAPI snapshots if backend contracts change and the backend can run. + +## Validation + +```bash +dotnet build backend/Socialize.slnx +dotnet test backend/Socialize.slnx +cd frontend && npm run build +``` diff --git a/docs/TASKS/product-feedback/001-backend-feedback-foundation.md b/docs/TASKS/product-feedback/001-backend-feedback-foundation.md index 989e1f4..e5d56b3 100644 --- a/docs/TASKS/product-feedback/001-backend-feedback-foundation.md +++ b/docs/TASKS/product-feedback/001-backend-feedback-foundation.md @@ -17,7 +17,7 @@ Add the backend foundation for product feedback reports. - type: `Bug`, `Suggestion`, `Request` - status: `New`, `Planned`, `Resolved`, `Won't Do`, `Cancelled` - Add `DbSet` entries and module configuration to `AppDbContext`. -- Capture reporter id, reporter display fields, submitted route, browser metadata, viewport size, app version if available, and optional workspace/client/project/content context. +- Capture reporter id, reporter display fields, submitted route, browser metadata, viewport size, app version if available, and optional workspace/client/campaign/content context. - Add API endpoints for: - submit feedback - list current user's feedback diff --git a/docs/product/glossary.md b/docs/product/glossary.md index d15628d..b452183 100644 --- a/docs/product/glossary.md +++ b/docs/product/glossary.md @@ -29,6 +29,10 @@ An agency is above the workspace level in the business model. Business, brand, or organization represented by a workspace and participating in review and approval flows. +## Campaign + +Client-owned body of content work that groups related content items, notes, and timelines. + ## Content Item Primary reviewable unit in the system. Contains metadata, copy, due dates, networks, channels, and linked assets. diff --git a/frontend/.env.development b/frontend/.env.development index b4bf1c3..be051ea 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,4 +1,4 @@ -VITE_API_URL=http://192.168.1.2:5080 +VITE_API_URL=http://192.168.1.17:5080 VITE_STRIPE_API_KEY=pk_test_51OoveVDrRyqXtNdB2st1NgA8WQA9rhgGaf3q7bCpAOoQyyRS30HMCzGeHba7meVGCSPfb1BVWmOTmFOcr9MkKf5H00bLu5MqsS VITE_GOOGLE_CLIENT_ID=213344094492-9dbaet2gaschju3hj1sgv1umk0qpd833.apps.googleusercontent.com VITE_FACEBOOK_APP_ID=1076433907621883 diff --git a/frontend/docs/claims-and-roles.md b/frontend/docs/claims-and-roles.md new file mode 100644 index 0000000..478b725 --- /dev/null +++ b/frontend/docs/claims-and-roles.md @@ -0,0 +1,16 @@ +# Claims and Roles Guidelines + +To ensure consistency across the application, all claim and role values MUST be in lowercase. + +## Roles +The following roles are currently used in the system: +- `administrator` +- `manager` +- `client` +- `provider` +- `developer` + +## Implementation Notes +- **Processing**: The `authStore.js` automatically converts all roles extracted from JWT tokens to lowercase. +- **Comparisons**: All checks (e.g., `authStore.hasAnyRole(['role-name'])` or `meta: { roles: ['role-name'] }`) should use lowercase strings. +- **Routing**: Route guards in `router.js` expect lowercase role names in the `meta.roles` field. diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index d6231b2..b188542 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -100,22 +100,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/projects": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["SocializeApiModulesProjectsHandlersGetProjectsHandler"]; - put?: never; - post: operations["SocializeApiModulesProjectsHandlersCreateProjectHandler"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/notifications": { parameters: { query?: never; @@ -436,6 +420,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 +500,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 +548,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; @@ -708,6 +756,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/campaigns": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesCampaignsHandlersGetCampaignsHandler"]; + put?: never; + post: operations["SocializeApiModulesCampaignsHandlersCreateCampaignHandler"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/assets/{id}/revisions": { 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,37 +937,21 @@ 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; }; - SocializeApiModulesProjectsHandlersProjectDto: { - /** Format: guid */ - id?: string; - /** Format: guid */ - workspaceId?: string; - /** Format: guid */ - clientId?: string; + SocializeApiModulesWorkspacesHandlersUpdateApprovalStepConfigurationRequest: { name?: string; - description?: string | null; - notes?: string | null; - status?: string; - /** Format: date-time */ - startDate?: string; - /** Format: date-time */ - endDate?: string; + /** Format: int32 */ + sortOrder?: number; + targetType?: string; + targetValue?: string; + /** Format: int32 */ + requiredApproverCount?: number; }; - SocializeApiModulesProjectsHandlersCreateProjectRequest: { - /** Format: guid */ - workspaceId: string; - /** Format: guid */ - clientId: string; - name: string; - /** Format: date-time */ - startDate: string; - /** Format: date-time */ - endDate: string; - description?: string | null; - notes?: string | null; - }; - SocializeApiModulesProjectsHandlersGetProjectsRequest: Record; SocializeApiModulesNotificationsHandlersNotificationEventDto: { /** Format: guid */ id?: string; @@ -943,7 +1011,7 @@ export interface components { persona?: string | null; authorizedWorkspaceIds?: string[]; authorizedClientIds?: string[]; - authorizedProjectIds?: string[]; + authorizedCampaignIds?: string[]; username?: string; alias?: string | null; portraitUrl?: string | null; @@ -1018,6 +1086,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 +1120,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 */ @@ -1057,8 +1146,8 @@ export interface components { clientId?: string | null; clientName?: string | null; /** Format: guid */ - projectId?: string | null; - projectName?: string | null; + campaignId?: string | null; + campaignName?: string | null; /** Format: guid */ contentItemId?: string | null; contentItemTitle?: string | null; @@ -1098,8 +1187,8 @@ export interface components { clientId?: string | null; clientName?: string | null; /** Format: guid */ - projectId?: string | null; - projectName?: string | null; + campaignId?: string | null; + campaignName?: string | null; /** Format: guid */ contentItemId?: string | null; contentItemTitle?: string | null; @@ -1117,7 +1206,7 @@ export interface components { /** Format: guid */ clientId?: string; /** Format: guid */ - projectId?: string; + campaignId?: string; title?: string; publicationMessage?: string; publicationTargets?: string; @@ -1135,7 +1224,7 @@ export interface components { /** Format: guid */ clientId: string; /** Format: guid */ - projectId: string; + campaignId: string; title: string; publicationMessage: string; publicationTargets: string; @@ -1176,7 +1265,7 @@ export interface components { /** Format: guid */ clientId?: string; /** Format: guid */ - projectId?: string; + campaignId?: string; title?: string; publicationMessage?: string; publicationTargets?: string; @@ -1264,6 +1353,36 @@ export interface components { primaryContactEmail?: string | null; primaryContactPortraitUrl?: string | null; }; + SocializeApiModulesCampaignsHandlersCampaignDto: { + /** Format: guid */ + id?: string; + /** Format: guid */ + workspaceId?: string; + /** Format: guid */ + clientId?: string; + name?: string; + description?: string | null; + notes?: string | null; + status?: string; + /** Format: date-time */ + startDate?: string; + /** Format: date-time */ + endDate?: string; + }; + SocializeApiModulesCampaignsHandlersCreateCampaignRequest: { + /** Format: guid */ + workspaceId: string; + /** Format: guid */ + clientId: string; + name: string; + /** Format: date-time */ + startDate: string; + /** Format: date-time */ + endDate: string; + description?: string | null; + notes?: string | null; + }; + SocializeApiModulesCampaignsHandlersGetCampaignsRequest: Record; SocializeApiModulesAssetsHandlersAssetRevisionDto: { /** Format: guid */ id?: string; @@ -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; @@ -1651,76 +1778,6 @@ export interface operations { }; }; }; - SocializeApiModulesProjectsHandlersGetProjectsHandler: { - parameters: { - query?: { - workspaceId?: string | null; - clientId?: string | null; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SocializeApiModulesProjectsHandlersProjectDto"][]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - SocializeApiModulesProjectsHandlersCreateProjectHandler: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SocializeApiModulesProjectsHandlersCreateProjectRequest"]; - }; - }; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SocializeApiModulesProjectsHandlersProjectDto"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["FastEndpointsErrorResponse"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; SocializeApiModulesNotificationsHandlersGetNotificationsHandler: { parameters: { query?: { @@ -2286,6 +2343,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 +2603,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 +2697,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; @@ -2653,7 +2866,7 @@ export interface operations { query?: { workspaceId?: string | null; clientId?: string | null; - projectId?: string | null; + campaignId?: string | null; }; header?: never; path?: never; @@ -3112,6 +3325,76 @@ export interface operations { }; }; }; + SocializeApiModulesCampaignsHandlersGetCampaignsHandler: { + parameters: { + query?: { + workspaceId?: string | null; + clientId?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesCampaignsHandlersCampaignDto"][]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SocializeApiModulesCampaignsHandlersCreateCampaignHandler: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SocializeApiModulesCampaignsHandlersCreateCampaignRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesCampaignsHandlersCampaignDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["FastEndpointsErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SocializeApiModulesAssetsHandlersCreateAssetRevisionHandler: { parameters: { query?: never; diff --git a/frontend/src/features/auth/stores/authStore.js b/frontend/src/features/auth/stores/authStore.js index 5abada1..c68efa9 100644 --- a/frontend/src/features/auth/stores/authStore.js +++ b/frontend/src/features/auth/stores/authStore.js @@ -7,294 +7,295 @@ import { jwtDecode } from 'jwt-decode'; import { formatDuration } from '@/internal_time_ago.js'; export const useAuthStore = defineStore('auth', () => { - const clientApi = useClient(); - const router = useRouter(); + const clientApi = useClient(); + const router = useRouter(); - const isRefreshing = ref(false); - let refreshPromise = null; + const isRefreshing = ref(false); + let refreshPromise = null; - const accessToken = useSessionStorage('auth-accessToken', undefined); - const refreshToken = useSessionStorage('auth-refreshToken', undefined); - const tokenClaims = useSessionStorage('auth-tokenClaims', null, { - serializer: { - read: v => (v ? JSON.parse(v) : null), - write: v => (v ? JSON.stringify(v) : null), - }, - }); + const accessToken = useSessionStorage('auth-accessToken', undefined); + const refreshToken = useSessionStorage('auth-refreshToken', undefined); + const tokenClaims = useSessionStorage('auth-tokenClaims', null, { + serializer: { + read: v => (v ? JSON.parse(v) : null), + write: v => (v ? JSON.stringify(v) : null), + }, + }); - const isAuthenticated = computed(() => !!accessToken.value); - const userId = computed(() => tokenClaims.value?.sub); - const userRoles = computed(() => { - const claims = tokenClaims.value ?? {}; - const candidates = [ - claims.role, - claims.roles, - claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'], - ].flatMap(value => Array.isArray(value) ? value : value ? [value] : []); + const isAuthenticated = computed(() => !!accessToken.value); + const userId = computed(() => tokenClaims.value?.sub); + const userRoles = computed(() => { + const claims = tokenClaims.value ?? {}; + const candidates = [ + claims.role, + claims.roles, + claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'], + ].flatMap(value => Array.isArray(value) ? value : value ? [value] : []) + .map(v => v.toLowerCase()); - return [...new Set(candidates)]; - }); - const persona = computed(() => tokenClaims.value?.persona ?? null); - const isManager = computed(() => userRoles.value.includes('Administrator') || userRoles.value.includes('Manager')); - const isClient = computed(() => userRoles.value.includes('Client')); - const isProvider = computed(() => userRoles.value.includes('Provider')); + return [...new Set(candidates)]; + }); + const persona = computed(() => tokenClaims.value?.persona ?? null); + const isManager = computed(() => userRoles.value.includes('administrator') || userRoles.value.includes('manager')); + const isClient = computed(() => userRoles.value.includes('client')); + const isProvider = computed(() => userRoles.value.includes('provider')); - function updateTokens(data) { - if (!data?.accessToken || !data?.refreshToken) { - throw new Error('Invalid token data'); - } - accessToken.value = data.accessToken; - refreshToken.value = data.refreshToken; - const claims = getClaimsFromToken(data.accessToken); - tokenClaims.value = claims; - console.log('Tokens updated, user ID:', claims?.sub); + function updateTokens(data) { + if (!data?.accessToken || !data?.refreshToken) { + throw new Error('Invalid token data'); + } + accessToken.value = data.accessToken; + refreshToken.value = data.refreshToken; + const claims = getClaimsFromToken(data.accessToken); + tokenClaims.value = claims; + console.log('Tokens updated, user ID:', claims?.sub); + } + + function cleanTokens() { + console.log('cleanTokens called - clearing stored tokens'); + accessToken.value = undefined; + refreshToken.value = undefined; + tokenClaims.value = null; + } + + async function logout() { + cleanTokens(); + await router.push('/'); + } + + async function login(email, password) { + console.log('login called with email:', email); + if (!email || !password) { + throw new Error('Email and password are required'); } - function cleanTokens() { - console.log('cleanTokens called - clearing stored tokens'); - accessToken.value = undefined; - refreshToken.value = undefined; - tokenClaims.value = null; + try { + const response = await clientApi.post('api/users/login', { + email: email.trim(), + password: password, + }); + + if (!response.data?.accessToken || !response.data?.refreshToken) { + throw new Error('Invalid login response'); + } + + updateTokens(response.data); + console.log('login successful'); + return true; + } catch (error) { + console.error('Login failed:', error); + cleanTokens(); + throw error; + } + } + + async function loginWithGoogle(accessTokenParam) { + console.log('loginWithGoogle called'); + if (!accessTokenParam) { + throw new Error('Google access token is required'); } - async function logout() { - cleanTokens(); - await router.push('/'); + try { + const response = await clientApi.post('api/users/login-with-google', { + token: accessTokenParam, + }); + + if (!response.data?.accessToken || !response.data?.refreshToken) { + throw new Error('Invalid Google login response'); + } + + updateTokens(response.data); + console.log('Google login successful'); + return true; + } catch (error) { + console.error('Google login failed:', error); + cleanTokens(); + throw error; + } + } + + async function loginWithFacebook(authResponse) { + console.log('loginWithFacebook called'); + if (!authResponse?.accessToken) { + throw new Error('Facebook access token is required'); } - async function login(email, password) { - console.log('login called with email:', email); - if (!email || !password) { - throw new Error('Email and password are required'); - } + try { + const response = await clientApi.post('api/users/login-with-facebook', { + token: authResponse.accessToken, + }); + if (!response.data?.accessToken || !response.data?.refreshToken) { + throw new Error('Invalid Facebook login response'); + } + + updateTokens(response.data); + console.log('Facebook login successful'); + return true; + } catch (error) { + console.error('Facebook login failed:', error); + cleanTokens(); + throw error; + } + } + + async function refresh() { + console.log('refresh called'); + + if (!refreshToken.value) { + cleanTokens(); // Clear tokens first + throw new Error('No refresh token available'); + } + + if (isRefreshing.value && refreshPromise) { + console.log('Already refreshing, returning existing refreshPromise'); + return refreshPromise; + } + + try { + isRefreshing.value = true; + refreshPromise = (async () => { try { - const response = await clientApi.post('api/users/login', { - email: email.trim(), - password: password, + console.log('Sending refresh request...'); + + const response = await clientApi.post('api/users/refresh', { + refreshToken: refreshToken.value, + }); + + if (!response.data?.accessToken || !response.data?.refreshToken) { + throw new Error('Invalid refresh response'); + } + + updateTokens({ + accessToken: response.data.accessToken, + refreshToken: response.data.refreshToken, + }); + + console.log('Token refresh successful'); + return true; + } catch (error) { + console.error('Token refresh failed:', error); + cleanTokens(); + + const currentRoute = router.currentRoute.value; + const returnUrl = currentRoute.fullPath; + + // Handle navigation + router + .push({ + name: 'login', + query: { returnUrl }, + }) + .catch(navError => { + console.error('Navigation error after token refresh failure:', navError); }); - if (!response.data?.accessToken || !response.data?.refreshToken) { - throw new Error('Invalid login response'); - } - - updateTokens(response.data); - console.log('login successful'); - return true; - } catch (error) { - console.error('Login failed:', error); - cleanTokens(); - throw error; + throw error; // Re-throw to notify callers } + })(); + + return await refreshPromise; + } catch (error) { + throw error; + } finally { + // Ensure these are always reset, even if an error is thrown + isRefreshing.value = false; + refreshPromise = null; + } + } + + function getClaimsFromToken(token) { + if (!token) return null; + try { + return jwtDecode(token); + } catch (error) { + console.error('Failed to decode token:', error); + return null; + } + } + + function isTokenExpiringSoon(token) { + if (!token) { + console.log('No token provided, considered expiring soon'); + return true; } - async function loginWithGoogle(accessTokenParam) { - console.log('loginWithGoogle called'); - if (!accessTokenParam) { - throw new Error('Google access token is required'); - } - - try { - const response = await clientApi.post('api/users/login-with-google', { - token: accessTokenParam, - }); - - if (!response.data?.accessToken || !response.data?.refreshToken) { - throw new Error('Invalid Google login response'); - } - - updateTokens(response.data); - console.log('Google login successful'); - return true; - } catch (error) { - console.error('Google login failed:', error); - cleanTokens(); - throw error; - } + const claims = getClaimsFromToken(token); + if (!claims || !claims.exp) { + console.log('No valid claims found, considered expiring soon'); + return true; } - async function loginWithFacebook(authResponse) { - console.log('loginWithFacebook called'); - if (!authResponse?.accessToken) { - throw new Error('Facebook access token is required'); - } + const expirationTime = claims.exp * 1000; // Convert to milliseconds + const currentTime = Date.now(); + const fiveMinutesInMs = 2 * 60 * 1000; // 2 minutes for demonstration - try { - const response = await clientApi.post('api/users/login-with-facebook', { - token: authResponse.accessToken, - }); + // Calculate time remaining (can be negative if already expired) + const timeRemainingMs = expirationTime - currentTime; - if (!response.data?.accessToken || !response.data?.refreshToken) { - throw new Error('Invalid Facebook login response'); - } + // Token is expiring soon if less than 2 minutes remaining or already expired + const isExpiring = timeRemainingMs < fiveMinutesInMs; - updateTokens(response.data); - console.log('Facebook login successful'); - return true; - } catch (error) { - console.error('Facebook login failed:', error); - cleanTokens(); - throw error; - } + // Determine the sign for display purposes + const formattedTimeRemaining = + timeRemainingMs < 0 ? `-${formatDuration(Math.abs(timeRemainingMs))}` : formatDuration(timeRemainingMs); + + if (isExpiring) { + console.log(`Token expiration check; is token expired: ${isExpiring}`, { + expirationTime: new Date(expirationTime).toLocaleString(), + currentTime: new Date(currentTime).toLocaleString(), + timeRemaining: formattedTimeRemaining, + }); } - async function refresh() { - console.log('refresh called'); + return isExpiring; + } - if (!refreshToken.value) { - cleanTokens(); // Clear tokens first - throw new Error('No refresh token available'); - } - - if (isRefreshing.value && refreshPromise) { - console.log('Already refreshing, returning existing refreshPromise'); - return refreshPromise; - } - - try { - isRefreshing.value = true; - refreshPromise = (async () => { - try { - console.log('Sending refresh request...'); - - const response = await clientApi.post('api/users/refresh', { - refreshToken: refreshToken.value, - }); - - if (!response.data?.accessToken || !response.data?.refreshToken) { - throw new Error('Invalid refresh response'); - } - - updateTokens({ - accessToken: response.data.accessToken, - refreshToken: response.data.refreshToken, - }); - - console.log('Token refresh successful'); - return true; - } catch (error) { - console.error('Token refresh failed:', error); - cleanTokens(); - - const currentRoute = router.currentRoute.value; - const returnUrl = currentRoute.fullPath; - - // Handle navigation - router - .push({ - name: 'login', - query: { returnUrl }, - }) - .catch(navError => { - console.error('Navigation error after token refresh failure:', navError); - }); - - throw error; // Re-throw to notify callers - } - })(); - - return await refreshPromise; - } catch (error) { - throw error; - } finally { - // Ensure these are always reset, even if an error is thrown - isRefreshing.value = false; - refreshPromise = null; - } + async function changePassword(newPassword) { + console.log('changePassword called'); + if (!isAuthenticated.value) { + throw new Error('User must be authenticated to change password'); } - function getClaimsFromToken(token) { - if (!token) return null; - try { - return jwtDecode(token); - } catch (error) { - console.error('Failed to decode token:', error); - return null; - } + if (!newPassword) { + throw new Error('New password is required'); } - function isTokenExpiringSoon(token) { - if (!token) { - console.log('No token provided, considered expiring soon'); - return true; - } + try { + const response = await clientApi.post('api/users/set-password', { + newPassword, + }); - const claims = getClaimsFromToken(token); - if (!claims || !claims.exp) { - console.log('No valid claims found, considered expiring soon'); - return true; - } - - const expirationTime = claims.exp * 1000; // Convert to milliseconds - const currentTime = Date.now(); - const fiveMinutesInMs = 2 * 60 * 1000; // 2 minutes for demonstration - - // Calculate time remaining (can be negative if already expired) - const timeRemainingMs = expirationTime - currentTime; - - // Token is expiring soon if less than 2 minutes remaining or already expired - const isExpiring = timeRemainingMs < fiveMinutesInMs; - - // Determine the sign for display purposes - const formattedTimeRemaining = - timeRemainingMs < 0 ? `-${formatDuration(Math.abs(timeRemainingMs))}` : formatDuration(timeRemainingMs); - - if (isExpiring) { - console.log(`Token expiration check; is token expired: ${isExpiring}`, { - expirationTime: new Date(expirationTime).toLocaleString(), - currentTime: new Date(currentTime).toLocaleString(), - timeRemaining: formattedTimeRemaining, - }); - } - - return isExpiring; + console.log('Password changed successfully'); + return true; + } catch (error) { + console.error('Password change failed:', error); + throw error; } + } - async function changePassword(newPassword) { - console.log('changePassword called'); - if (!isAuthenticated.value) { - throw new Error('User must be authenticated to change password'); - } + function hasAnyRole(roles) { + return roles.some(role => userRoles.value.includes(role)); + } - if (!newPassword) { - throw new Error('New password is required'); - } - - try { - const response = await clientApi.post('api/users/set-password', { - newPassword, - }); - - console.log('Password changed successfully'); - return true; - } catch (error) { - console.error('Password change failed:', error); - throw error; - } - } - - function hasAnyRole(roles) { - return roles.some(role => userRoles.value.includes(role)); - } - - return { - accessToken, - refreshToken, - isAuthenticated, - userId, - userRoles, - persona, - hasAnyRole, - isManager, - isClient, - isProvider, - isRefreshing, - login, - loginWithGoogle, - loginWithFacebook, - logout, - refresh, - isTokenExpiringSoon, - changePassword, - }; + return { + accessToken, + refreshToken, + isAuthenticated, + userId, + userRoles, + persona, + hasAnyRole, + isManager, + isClient, + isProvider, + isRefreshing, + login, + loginWithGoogle, + loginWithFacebook, + logout, + refresh, + isTokenExpiringSoon, + changePassword, + }; }); diff --git a/frontend/src/features/projects/stores/projectsStore.js b/frontend/src/features/campaigns/stores/campaignsStore.js similarity index 67% rename from frontend/src/features/projects/stores/projectsStore.js rename to frontend/src/features/campaigns/stores/campaignsStore.js index cc557a8..c992bca 100644 --- a/frontend/src/features/projects/stores/projectsStore.js +++ b/frontend/src/features/campaigns/stores/campaignsStore.js @@ -4,19 +4,19 @@ import { useAuthStore } from '@/features/auth/stores/authStore.js'; import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js'; import { useClient } from '@/plugins/api.js'; -export const useProjectsStore = defineStore('projects', () => { +export const useCampaignsStore = defineStore('campaigns', () => { const authStore = useAuthStore(); const workspaceStore = useWorkspaceStore(); const client = useClient(); - const projects = ref([]); + const campaigns = ref([]); const isLoading = ref(false); const isCreating = ref(false); const error = ref(null); - async function fetchProjects() { + async function fetchCampaigns() { if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) { - projects.value = []; + campaigns.value = []; error.value = null; return; } @@ -25,49 +25,49 @@ export const useProjectsStore = defineStore('projects', () => { error.value = null; try { - const response = await client.get('/api/projects', { + const response = await client.get('/api/campaigns', { params: { workspaceId: workspaceStore.activeWorkspaceId, }, }); - projects.value = response.data ?? []; + campaigns.value = response.data ?? []; } catch (fetchError) { - console.error('Failed to fetch projects:', fetchError); - projects.value = []; - error.value = 'Failed to load projects.'; + console.error('Failed to fetch campaigns:', fetchError); + campaigns.value = []; + error.value = 'Failed to load campaigns.'; } finally { isLoading.value = false; } } - async function createProject(payload) { + async function createCampaign(payload) { if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) { - throw new Error('You must be authenticated to create a project.'); + throw new Error('You must be authenticated to create a campaign.'); } if (isCreating.value) { - throw new Error('A project creation request is already in progress.'); + throw new Error('A campaign creation request is already in progress.'); } isCreating.value = true; error.value = null; try { - const response = await client.post('/api/projects', { + const response = await client.post('/api/campaigns', { ...payload, workspaceId: workspaceStore.activeWorkspaceId, }); if (response.data) { - projects.value = [...projects.value, response.data] + campaigns.value = [...campaigns.value, response.data] .sort((left, right) => left.name.localeCompare(right.name)); } return response.data; } catch (createError) { - console.error('Failed to create project:', createError); - error.value = 'Failed to create project.'; + console.error('Failed to create campaign:', createError); + error.value = 'Failed to create campaign.'; throw createError; } finally { isCreating.value = false; @@ -78,22 +78,22 @@ export const useProjectsStore = defineStore('projects', () => { () => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId], async ([isAuthenticated, workspaceId]) => { if (!isAuthenticated || !workspaceId) { - projects.value = []; + campaigns.value = []; error.value = null; return; } - await fetchProjects(); + await fetchCampaigns(); }, { immediate: true } ); return { - projects, + campaigns, isLoading, isCreating, error, - fetchProjects, - createProject, + fetchCampaigns, + createCampaign, }; }); diff --git a/frontend/src/features/projects/views/ProjectDetailView.vue b/frontend/src/features/campaigns/views/CampaignDetailView.vue similarity index 84% rename from frontend/src/features/projects/views/ProjectDetailView.vue rename to frontend/src/features/campaigns/views/CampaignDetailView.vue index 9956050..8fbcf12 100644 --- a/frontend/src/features/projects/views/ProjectDetailView.vue +++ b/frontend/src/features/campaigns/views/CampaignDetailView.vue @@ -3,22 +3,22 @@ import { useRoute } from 'vue-router'; import { useAuthStore } from '@/features/auth/stores/authStore.js'; import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js'; - import { useProjectsStore } from '@/features/projects/stores/projectsStore.js'; + import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js'; import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js'; const authStore = useAuthStore(); const route = useRoute(); const workspaceStore = useWorkspaceStore(); - const projectsStore = useProjectsStore(); + const campaignsStore = useCampaignsStore(); const contentItemsStore = useContentItemsStore(); - const project = computed(() => - projectsStore.projects.find(candidate => candidate.id === route.params.projectId) ?? null + const campaign = computed(() => + campaignsStore.campaigns.find(candidate => candidate.id === route.params.campaignId) ?? null ); const scopedItems = computed(() => contentItemsStore.items - .filter(item => item.projectId === route.params.projectId) + .filter(item => item.campaignId === route.params.campaignId) .sort((left, right) => { const leftDue = left.dueDate ? new Date(left.dueDate).getTime() : Number.MAX_SAFE_INTEGER; const rightDue = right.dueDate ? new Date(right.dueDate).getTime() : Number.MAX_SAFE_INTEGER; @@ -26,8 +26,8 @@ }) ); - function formatProjectDateRange(projectValue) { - if (!projectValue?.startDate || !projectValue?.endDate) { + function formatCampaignDateRange(campaignValue) { + if (!campaignValue?.startDate || !campaignValue?.endDate) { return 'No date range'; } @@ -35,14 +35,14 @@ month: 'short', day: 'numeric', year: 'numeric', - }).formatRange(new Date(projectValue.startDate), new Date(projectValue.endDate)); + }).formatRange(new Date(campaignValue.startDate), new Date(campaignValue.endDate)); } @@ -267,9 +267,9 @@ .header p, .panel-header span, - .project-row span, - .project-meta span, - .project-meta em { + .campaign-row span, + .campaign-meta span, + .campaign-meta em { @apply text-sm leading-6 not-italic; color: #526178; } @@ -296,7 +296,7 @@ } .create-panel, - .project-row { + .campaign-row { @apply rounded-[1.5rem] border; background: rgba(255, 255, 255, 0.9); border-color: rgba(23, 32, 51, 0.08); @@ -311,7 +311,7 @@ } .panel-header strong, - .project-row strong { + .campaign-row strong { color: #172033; } @@ -347,19 +347,19 @@ @apply flex justify-end gap-3; } - .project-stack { + .campaign-stack { @apply flex flex-col gap-4; } - .project-row { + .campaign-row { @apply flex flex-col justify-between gap-4 p-5 no-underline lg:flex-row lg:items-center; } - .project-row strong { + .campaign-row strong { @apply block text-xl font-black; } - .project-meta { + .campaign-meta { @apply flex flex-col items-start gap-1 lg:items-end; } 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/clients/views/ClientDetailView.vue b/frontend/src/features/clients/views/ClientDetailView.vue index ceb74e0..c62ff1e 100644 --- a/frontend/src/features/clients/views/ClientDetailView.vue +++ b/frontend/src/features/clients/views/ClientDetailView.vue @@ -5,13 +5,13 @@ import ImageCropperDialog from '@/components/ImageCropperDialog.vue'; import { useAuthStore } from '@/features/auth/stores/authStore.js'; import { useClientsStore } from '@/features/clients/stores/clientsStore.js'; - import { useProjectsStore } from '@/features/projects/stores/projectsStore.js'; + import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js'; import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js'; const authStore = useAuthStore(); const route = useRoute(); const clientsStore = useClientsStore(); - const projectsStore = useProjectsStore(); + const campaignsStore = useCampaignsStore(); const contentItemsStore = useContentItemsStore(); const isEditFormVisible = ref(false); const isPortraitDialogOpen = ref(false); @@ -48,9 +48,9 @@ clientsStore.clients.find(candidate => candidate.id === route.params.clientId) ?? null ); - const scopedProjects = computed(() => - projectsStore.projects - .filter(project => project.clientId === route.params.clientId) + const scopedCampaigns = computed(() => + campaignsStore.campaigns + .filter(campaign => campaign.clientId === route.params.clientId) .sort((left, right) => { const leftDue = left.endDate ? new Date(left.endDate).getTime() : Number.MAX_SAFE_INTEGER; const rightDue = right.endDate ? new Date(right.endDate).getTime() : Number.MAX_SAFE_INTEGER; @@ -58,26 +58,26 @@ }) ); - const currentProjects = computed(() => - scopedProjects.value.filter(project => project.status !== 'Completed' && project.status !== 'Archived') + const currentCampaigns = computed(() => + scopedCampaigns.value.filter(campaign => campaign.status !== 'Completed' && campaign.status !== 'Archived') ); - const pastProjects = computed(() => - scopedProjects.value.filter(project => project.status === 'Completed' || project.status === 'Archived') + const pastCampaigns = computed(() => + scopedCampaigns.value.filter(campaign => campaign.status === 'Completed' || campaign.status === 'Archived') ); - const itemCountByProjectId = computed(() => { + const itemCountByCampaignId = computed(() => { const counts = new Map(); for (const item of contentItemsStore.items.filter(candidate => candidate.clientId === route.params.clientId)) { - counts.set(item.projectId, (counts.get(item.projectId) ?? 0) + 1); + counts.set(item.campaignId, (counts.get(item.campaignId) ?? 0) + 1); } return counts; }); - function formatProjectDateRange(project) { - if (!project?.startDate || !project?.endDate) { + function formatCampaignDateRange(campaign) { + if (!campaign?.startDate || !campaign?.endDate) { return 'No date range'; } @@ -85,7 +85,7 @@ month: 'short', day: 'numeric', year: 'numeric', - }).formatRange(new Date(project.startDate), new Date(project.endDate)); + }).formatRange(new Date(campaign.startDate), new Date(campaign.endDate)); } function syncForm() { @@ -188,18 +188,18 @@
{{ client.status }}
-

The client area scopes projects and content so review stays inside one account.

+

The client area scopes campaigns and content so review stays inside one account.

Current campaigns - {{ currentProjects.length }} + {{ currentCampaigns.length }}
Past campaigns - {{ pastProjects.length }} + {{ pastCampaigns.length }}
Total content items @@ -420,26 +420,26 @@
Current campaigns - {{ currentProjects.length }} active + {{ currentCampaigns.length }} active
- {{ project.name }} - {{ project.status }} + {{ campaign.name }} + {{ campaign.status }}
-
- {{ itemCountByProjectId.get(project.id) ?? 0 }} content items - {{ formatProjectDateRange(project) }} +
+ {{ itemCountByCampaignId.get(campaign.id) ?? 0 }} content items + {{ formatCampaignDateRange(campaign) }}
@@ -454,26 +454,26 @@
Past campaigns - {{ pastProjects.length }} archived or completed + {{ pastCampaigns.length }} archived or completed
- {{ project.name }} - {{ project.status }} + {{ campaign.name }} + {{ campaign.status }}
-
- {{ itemCountByProjectId.get(project.id) ?? 0 }} content items - {{ formatProjectDateRange(project) }} +
+ {{ itemCountByCampaignId.get(campaign.id) ?? 0 }} content items + {{ formatCampaignDateRange(campaign) }}
@@ -489,7 +489,7 @@ .hero, .stat-card, - .project-card { + .campaign-card { @apply rounded-[1.5rem] border; background: rgba(255, 255, 255, 0.9); border-color: rgba(23, 32, 51, 0.08); @@ -501,7 +501,7 @@ .hero-main h1, .stat-card strong, - .project-card strong, + .campaign-card strong, .contact-card strong { color: #172033; } @@ -513,9 +513,9 @@ .hero-main p, .breadcrumb, .stat-card span, - .project-card span, - .project-card small, - .project-card em, + .campaign-card span, + .campaign-card small, + .campaign-card em, .section-header span { @apply text-sm leading-6 not-italic; color: #526178; @@ -675,27 +675,27 @@ color: #172033; } - .project-list { + .campaign-list { @apply grid gap-4 md:grid-cols-2; } - .project-card { + .campaign-card { @apply flex flex-col gap-4 p-5 no-underline transition; } - .project-card:hover { + .campaign-card:hover { transform: translateY(-2px); } - .project-card.muted { + .campaign-card.muted { background: rgba(255, 250, 242, 0.88); } - .project-card span { + .campaign-card span { @apply uppercase tracking-[0.16em]; } - .project-meta { + .campaign-meta { @apply flex items-center justify-between gap-3; } diff --git a/frontend/src/features/content/stores/contentItemsStore.js b/frontend/src/features/content/stores/contentItemsStore.js index 52827ea..d94c6a5 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 ); @@ -34,7 +34,7 @@ export const useContentItemsStore = defineStore('content-items', () => { params: { workspaceId: workspaceStore.activeWorkspaceId, clientId: filters.clientId, - projectId: filters.projectId, + campaignId: filters.campaignId, }, }); diff --git a/frontend/src/features/content/views/ContentItemDetailView.vue b/frontend/src/features/content/views/ContentItemDetailView.vue index 0c7dcb0..284e29f 100644 --- a/frontend/src/features/content/views/ContentItemDetailView.vue +++ b/frontend/src/features/content/views/ContentItemDetailView.vue @@ -7,13 +7,13 @@ import { useClientsStore } from '@/features/clients/stores/clientsStore.js'; import { useContentItemDetailStore } from '@/features/content/stores/contentItemDetailStore.js'; import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js'; - import { useProjectsStore } from '@/features/projects/stores/projectsStore.js'; + import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js'; import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js'; const route = useRoute(); const router = useRouter(); const workspaceStore = useWorkspaceStore(); - const projectsStore = useProjectsStore(); + const campaignsStore = useCampaignsStore(); const clientsStore = useClientsStore(); const channelsStore = useChannelsStore(); const contentItemsStore = useContentItemsStore(); @@ -25,7 +25,7 @@ const form = reactive({ title: '', - projectId: '', + campaignId: '', dueDate: '', body: '', hashtags: '', @@ -45,6 +45,14 @@ }); const decisionForms = reactive({}); + const manualStatuses = [ + 'Draft', + 'In production', + 'In approval', + 'Approved', + 'Scheduled', + 'Published', + ]; const saveError = reactive({ message: '', }); @@ -52,7 +60,7 @@ const isCreateMode = computed(() => route.name === 'content-item-create'); const contentItemId = computed(() => isCreateMode.value ? null : route.params.id); const item = computed(() => detailStore.item); - const availableProjects = computed(() => projectsStore.projects); + const availableCampaigns = computed(() => campaignsStore.campaigns); const availableChannels = computed(() => channelsStore.channels); const groupedChannels = computed(() => { const groups = new Map(); @@ -76,10 +84,11 @@ .join(', ') ); const operationalClient = computed(() => clientsStore.operationalClient); - const projectNameById = computed(() => - new Map(projectsStore.projects.map(project => [project.id, project.name])) + const campaignNameById = computed(() => + new Map(campaignsStore.campaigns.map(campaign => [campaign.id, campaign.name])) ); - const editorKey = computed(() => isCreateMode.value ? `new:${route.query.projectId ?? 'default'}` : String(route.params.id)); + const editorKey = computed(() => isCreateMode.value ? `new:${route.query.campaignId ?? '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; @@ -162,7 +181,7 @@ function serializeDraft() { return JSON.parse(JSON.stringify({ title: form.title, - projectId: form.projectId, + campaignId: form.campaignId, dueDate: form.dueDate, body: form.body, hashtags: form.hashtags, @@ -173,7 +192,7 @@ function restoreDraft(draft) { form.title = draft.title ?? ''; - form.projectId = draft.projectId ?? availableProjects.value[0]?.id ?? ''; + form.campaignId = draft.campaignId ?? availableCampaigns.value[0]?.id ?? ''; form.dueDate = draft.dueDate ?? ''; form.body = draft.body ?? ''; form.hashtags = draft.hashtags ?? ''; @@ -196,7 +215,7 @@ } function buildDraftFromItem() { - const projectId = item.value?.projectId ?? ''; + const campaignId = item.value?.campaignId ?? ''; const placements = parseTargets(item.value?.publicationTargets).map(target => { const channel = availableChannels.value.find(candidate => candidate.name.toLowerCase() === target.toLowerCase()); @@ -214,7 +233,7 @@ restoreDraft({ title: item.value?.title ?? '', - projectId, + campaignId, dueDate: item.value?.dueDate ? new Date(item.value.dueDate).toISOString().slice(0, 10) : '', body: item.value?.publicationMessage ?? '', hashtags: item.value?.hashtags ?? '', @@ -224,13 +243,13 @@ } function buildDraftForNew() { - const projectIdFromRoute = typeof route.query.projectId === 'string' ? route.query.projectId : ''; + const campaignIdFromRoute = typeof route.query.campaignId === 'string' ? route.query.campaignId : ''; restoreDraft({ title: '', - projectId: availableProjects.value.some(project => project.id === projectIdFromRoute) - ? projectIdFromRoute - : availableProjects.value[0]?.id ?? '', + campaignId: availableCampaigns.value.some(campaign => campaign.id === campaignIdFromRoute) + ? campaignIdFromRoute + : availableCampaigns.value[0]?.id ?? '', dueDate: '', body: '', hashtags: '', @@ -283,7 +302,7 @@ async function saveContent() { saveError.message = ''; - if (!form.title.trim() || !form.projectId || !form.placements.length) { + if (!form.title.trim() || !form.campaignId || !form.placements.length) { saveError.message = 'Title, campaign, and at least one channel are required.'; return; } @@ -295,7 +314,7 @@ const payload = { title: form.title.trim(), - projectId: form.projectId, + campaignId: form.campaignId, publicationMessage: form.body.trim(), publicationTargets: placementSummary.value, hashtags: form.hashtags.trim(), @@ -389,8 +408,8 @@ () => [ isCreateMode.value, route.params.id, - route.query.projectId, - availableProjects.value.length, + route.query.campaignId, + availableCampaigns.value.length, availableChannels.value.length, ], async () => { @@ -402,7 +421,7 @@ watch( () => [ form.title, - form.projectId, + form.campaignId, form.dueDate, form.body, form.hashtags, @@ -448,7 +467,7 @@
{{ isCreateMode ? 'New content' : 'Content item' }}

{{ form.title || 'Untitled content' }}

- {{ projectNameById.get(form.projectId) || 'Choose a campaign' }} + {{ campaignNameById.get(form.campaignId) || 'Choose a campaign' }} @@ -488,33 +507,21 @@ class="quick-actions" > - -