Compare commits
6 Commits
work-in-pr
...
approval-w
| Author | SHA1 | Date | |
|---|---|---|---|
| df0409d7f6 | |||
| 5077f557f4 | |||
| 1722d65d22 | |||
| 14023e65d5 | |||
| 237b1a4242 | |||
| ace0279bd0 |
@@ -26,8 +26,10 @@ public class AppDbContext(
|
|||||||
public DbSet<Asset> Assets => Set<Asset>();
|
public DbSet<Asset> Assets => Set<Asset>();
|
||||||
public DbSet<AssetRevision> AssetRevisions => Set<AssetRevision>();
|
public DbSet<AssetRevision> AssetRevisions => Set<AssetRevision>();
|
||||||
public DbSet<Comment> Comments => Set<Comment>();
|
public DbSet<Comment> Comments => Set<Comment>();
|
||||||
|
public DbSet<ApprovalWorkflowInstance> ApprovalWorkflowInstances => Set<ApprovalWorkflowInstance>();
|
||||||
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
|
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
|
||||||
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
|
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
|
||||||
|
public DbSet<WorkspaceApprovalStepConfiguration> WorkspaceApprovalStepConfigurations => Set<WorkspaceApprovalStepConfiguration>();
|
||||||
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
|
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
|
||||||
public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>();
|
public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>();
|
||||||
public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>();
|
public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>();
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ public static class DevelopmentSeedExtensions
|
|||||||
"Spring launch hero video",
|
"Spring launch hero video",
|
||||||
"Fresh seasonal menu launch across Instagram and TikTok.",
|
"Fresh seasonal menu launch across Instagram and TikTok.",
|
||||||
"Instagram Reel, TikTok",
|
"Instagram Reel, TikTok",
|
||||||
"In client review",
|
"In approval",
|
||||||
DateTimeOffset.UtcNow.AddDays(3),
|
DateTimeOffset.UtcNow.AddDays(3),
|
||||||
"v3",
|
"v3",
|
||||||
3,
|
3,
|
||||||
|
|||||||
1314
backend/src/Socialize.Api/Migrations/20260501170646_AddWorkspaceApprovalConfiguration.Designer.cs
generated
Normal file
1314
backend/src/Socialize.Api/Migrations/20260501170646_AddWorkspaceApprovalConfiguration.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Socialize.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddWorkspaceApprovalConfiguration : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ApprovalMode",
|
||||||
|
table: "Workspaces",
|
||||||
|
type: "character varying(32)",
|
||||||
|
maxLength: 32,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "Required");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "LockContentAfterApproval",
|
||||||
|
table: "Workspaces",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "SchedulePostsAutomaticallyOnApproval",
|
||||||
|
table: "Workspaces",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "SendAutomaticApprovalReminders",
|
||||||
|
table: "Workspaces",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1361
backend/src/Socialize.Api/Migrations/20260501173710_AddWorkspaceApprovalStepConfiguration.Designer.cs
generated
Normal file
1361
backend/src/Socialize.Api/Migrations/20260501173710_AddWorkspaceApprovalStepConfiguration.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Socialize.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddWorkspaceApprovalStepConfiguration : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "WorkspaceApprovalStepConfigurations",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
TargetType = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
TargetValue = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
RequiredApproverCount = table.Column<int>(type: "integer", nullable: false, defaultValue: 1),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "WorkspaceApprovalStepConfigurations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1423
backend/src/Socialize.Api/Migrations/20260501175648_AddApprovalWorkflowRuntime.Designer.cs
generated
Normal file
1423
backend/src/Socialize.Api/Migrations/20260501175648_AddApprovalWorkflowRuntime.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Socialize.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddApprovalWorkflowRuntime : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "WorkflowInstanceId",
|
||||||
|
table: "ApprovalRequests",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "WorkflowStepRequiredApproverCount",
|
||||||
|
table: "ApprovalRequests",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "WorkflowStepSortOrder",
|
||||||
|
table: "ApprovalRequests",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "WorkflowStepTargetType",
|
||||||
|
table: "ApprovalRequests",
|
||||||
|
type: "character varying(32)",
|
||||||
|
maxLength: 32,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "WorkflowStepTargetValue",
|
||||||
|
table: "ApprovalRequests",
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ApprovalWorkflowInstances",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ContentItemId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
State = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
ApprovalMode = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
StartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||||
|
CompletedAt = table.Column<DateTimeOffset>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -216,6 +216,23 @@ namespace Socialize.Api.Migrations
|
|||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("character varying(64)");
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("WorkflowInstanceId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int?>("WorkflowStepRequiredApproverCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("WorkflowStepSortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("WorkflowStepTargetType")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("WorkflowStepTargetValue")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
b.Property<Guid>("WorkspaceId")
|
b.Property<Guid>("WorkspaceId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
@@ -225,11 +242,103 @@ namespace Socialize.Api.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ReviewerEmail");
|
b.HasIndex("ReviewerEmail");
|
||||||
|
|
||||||
|
b.HasIndex("WorkflowInstanceId");
|
||||||
|
|
||||||
b.HasIndex("WorkspaceId");
|
b.HasIndex("WorkspaceId");
|
||||||
|
|
||||||
b.ToTable("ApprovalRequests", (string)null);
|
b.ToTable("ApprovalRequests", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ApprovalMode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("CompletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("ContentItemId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("StartedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<int>("RequiredApproverCount")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("TargetType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("TargetValue")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<Guid>("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 =>
|
modelBuilder.Entity("Socialize.Api.Modules.Assets.Data.Asset", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1100,11 +1209,23 @@ namespace Socialize.Api.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ApprovalMode")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasDefaultValue("Required");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
|
||||||
|
b.Property<bool>("LockContentAfterApproval")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
b.Property<string>("LogoUrl")
|
b.Property<string>("LogoUrl")
|
||||||
.HasMaxLength(2048)
|
.HasMaxLength(2048)
|
||||||
.HasColumnType("character varying(2048)");
|
.HasColumnType("character varying(2048)");
|
||||||
@@ -1117,6 +1238,16 @@ namespace Socialize.Api.Migrations
|
|||||||
b.Property<Guid>("OwnerUserId")
|
b.Property<Guid>("OwnerUserId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("SchedulePostsAutomaticallyOnApproval")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("SendAutomaticApprovalReminders")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
b.Property<string>("Slug")
|
b.Property<string>("Slug")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
|
|||||||
@@ -6,10 +6,28 @@ public static class ApprovalModelConfiguration
|
|||||||
{
|
{
|
||||||
public static ModelBuilder ConfigureApprovalsModule(this ModelBuilder modelBuilder)
|
public static ModelBuilder ConfigureApprovalsModule(this ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
modelBuilder.Entity<ApprovalWorkflowInstance>(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 =>
|
modelBuilder.Entity<ApprovalRequest>(approvalRequest =>
|
||||||
{
|
{
|
||||||
approvalRequest.ToTable("ApprovalRequests");
|
approvalRequest.ToTable("ApprovalRequests");
|
||||||
approvalRequest.HasKey(x => x.Id);
|
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.Stage).HasMaxLength(64).IsRequired();
|
||||||
approvalRequest.Property(x => x.ReviewerName).HasMaxLength(256).IsRequired();
|
approvalRequest.Property(x => x.ReviewerName).HasMaxLength(256).IsRequired();
|
||||||
approvalRequest.Property(x => x.ReviewerEmail).HasMaxLength(256).IsRequired();
|
approvalRequest.Property(x => x.ReviewerEmail).HasMaxLength(256).IsRequired();
|
||||||
@@ -20,6 +38,7 @@ public static class ApprovalModelConfiguration
|
|||||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
approvalRequest.HasIndex(x => x.WorkspaceId);
|
approvalRequest.HasIndex(x => x.WorkspaceId);
|
||||||
approvalRequest.HasIndex(x => x.ContentItemId);
|
approvalRequest.HasIndex(x => x.ContentItemId);
|
||||||
|
approvalRequest.HasIndex(x => x.WorkflowInstanceId);
|
||||||
approvalRequest.HasIndex(x => x.ReviewerEmail);
|
approvalRequest.HasIndex(x => x.ReviewerEmail);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,6 +56,21 @@ public static class ApprovalModelConfiguration
|
|||||||
approvalDecision.HasIndex(x => x.ApprovalRequestId);
|
approvalDecision.HasIndex(x => x.ApprovalRequestId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<WorkspaceApprovalStepConfiguration>(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;
|
return modelBuilder;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ public class ApprovalRequest
|
|||||||
public Guid Id { get; init; }
|
public Guid Id { get; init; }
|
||||||
public Guid WorkspaceId { get; set; }
|
public Guid WorkspaceId { get; set; }
|
||||||
public Guid ContentItemId { 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 Stage { get; set; }
|
||||||
public required string ReviewerName { get; set; }
|
public required string ReviewerName { get; set; }
|
||||||
public required string ReviewerEmail { get; set; }
|
public required string ReviewerEmail { get; set; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using Socialize.Api.Modules.Approvals.Data;
|
using Socialize.Api.Modules.Approvals.Services;
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.Approvals;
|
namespace Socialize.Api.Modules.Approvals;
|
||||||
|
|
||||||
@@ -7,6 +7,8 @@ public static class DependencyInjection
|
|||||||
public static WebApplicationBuilder AddApprovalsModule(
|
public static WebApplicationBuilder AddApprovalsModule(
|
||||||
this WebApplicationBuilder builder)
|
this WebApplicationBuilder builder)
|
||||||
{
|
{
|
||||||
|
builder.Services.AddScoped<ApprovalWorkflowRuntimeService>();
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ using System.Security.Cryptography;
|
|||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
using Socialize.Api.Modules.Approvals.Data;
|
using Socialize.Api.Modules.Approvals.Data;
|
||||||
|
using Socialize.Api.Modules.Approvals.Services;
|
||||||
using Socialize.Api.Modules.Notifications.Contracts;
|
using Socialize.Api.Modules.Notifications.Contracts;
|
||||||
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.Approvals.Handlers;
|
namespace Socialize.Api.Modules.Approvals.Handlers;
|
||||||
|
|
||||||
@@ -62,6 +64,22 @@ public class CreateApprovalRequestHandler(
|
|||||||
return;
|
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()
|
var approval = new ApprovalRequest()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -79,14 +97,7 @@ public class CreateApprovalRequestHandler(
|
|||||||
|
|
||||||
dbContext.ApprovalRequests.Add(approval);
|
dbContext.ApprovalRequests.Add(approval);
|
||||||
|
|
||||||
if (approval.Stage == "Internal")
|
contentItem.Status = "In approval";
|
||||||
{
|
|
||||||
contentItem.Status = "In internal review";
|
|
||||||
}
|
|
||||||
else if (approval.Stage == "Client")
|
|
||||||
{
|
|
||||||
contentItem.Status = "In client review";
|
|
||||||
}
|
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync(ct);
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
@@ -107,6 +118,11 @@ public class CreateApprovalRequestHandler(
|
|||||||
approval.Id,
|
approval.Id,
|
||||||
approval.WorkspaceId,
|
approval.WorkspaceId,
|
||||||
approval.ContentItemId,
|
approval.ContentItemId,
|
||||||
|
approval.WorkflowInstanceId,
|
||||||
|
approval.WorkflowStepSortOrder,
|
||||||
|
approval.WorkflowStepTargetType,
|
||||||
|
approval.WorkflowStepTargetValue,
|
||||||
|
approval.WorkflowStepRequiredApproverCount,
|
||||||
approval.Stage,
|
approval.Stage,
|
||||||
approval.ReviewerName,
|
approval.ReviewerName,
|
||||||
approval.ReviewerEmail,
|
approval.ReviewerEmail,
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ public record ApprovalRequestDto(
|
|||||||
Guid Id,
|
Guid Id,
|
||||||
Guid WorkspaceId,
|
Guid WorkspaceId,
|
||||||
Guid ContentItemId,
|
Guid ContentItemId,
|
||||||
|
Guid? WorkflowInstanceId,
|
||||||
|
int? WorkflowStepSortOrder,
|
||||||
|
string? WorkflowStepTargetType,
|
||||||
|
string? WorkflowStepTargetValue,
|
||||||
|
int? WorkflowStepRequiredApproverCount,
|
||||||
string Stage,
|
string Stage,
|
||||||
string ReviewerName,
|
string ReviewerName,
|
||||||
string ReviewerEmail,
|
string ReviewerEmail,
|
||||||
@@ -65,6 +70,7 @@ public class GetApprovalsHandler(
|
|||||||
List<ApprovalRequest> approvals = await dbContext.ApprovalRequests
|
List<ApprovalRequest> approvals = await dbContext.ApprovalRequests
|
||||||
.Where(approval => approval.ContentItemId == request.ContentItemId)
|
.Where(approval => approval.ContentItemId == request.ContentItemId)
|
||||||
.OrderByDescending(approval => approval.SentAt)
|
.OrderByDescending(approval => approval.SentAt)
|
||||||
|
.ThenBy(approval => approval.WorkflowStepSortOrder)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
List<Guid> approvalIds = approvals
|
List<Guid> approvalIds = approvals
|
||||||
@@ -91,6 +97,11 @@ public class GetApprovalsHandler(
|
|||||||
approval.Id,
|
approval.Id,
|
||||||
approval.WorkspaceId,
|
approval.WorkspaceId,
|
||||||
approval.ContentItemId,
|
approval.ContentItemId,
|
||||||
|
approval.WorkflowInstanceId,
|
||||||
|
approval.WorkflowStepSortOrder,
|
||||||
|
approval.WorkflowStepTargetType,
|
||||||
|
approval.WorkflowStepTargetValue,
|
||||||
|
approval.WorkflowStepRequiredApproverCount,
|
||||||
approval.Stage,
|
approval.Stage,
|
||||||
approval.ReviewerName,
|
approval.ReviewerName,
|
||||||
approval.ReviewerEmail,
|
approval.ReviewerEmail,
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ using Socialize.Api.Data;
|
|||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
using Socialize.Api.Modules.ContentItems.Data;
|
using Socialize.Api.Modules.ContentItems.Data;
|
||||||
using Socialize.Api.Modules.Approvals.Data;
|
using Socialize.Api.Modules.Approvals.Data;
|
||||||
|
using Socialize.Api.Modules.Approvals.Services;
|
||||||
using Socialize.Api.Modules.Notifications.Contracts;
|
using Socialize.Api.Modules.Notifications.Contracts;
|
||||||
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.Approvals.Handlers;
|
namespace Socialize.Api.Modules.Approvals.Handlers;
|
||||||
|
|
||||||
@@ -19,7 +21,10 @@ public class SubmitApprovalDecisionRequestValidator
|
|||||||
{
|
{
|
||||||
public 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.Comment).MaximumLength(2048);
|
||||||
RuleFor(x => x.ReviewerName).MaximumLength(256);
|
RuleFor(x => x.ReviewerName).MaximumLength(256);
|
||||||
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
|
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
|
||||||
@@ -29,6 +34,7 @@ public class SubmitApprovalDecisionRequestValidator
|
|||||||
public class SubmitApprovalDecisionHandler(
|
public class SubmitApprovalDecisionHandler(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
AccessScopeService accessScopeService,
|
AccessScopeService accessScopeService,
|
||||||
|
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
|
||||||
INotificationEventWriter notificationEventWriter)
|
INotificationEventWriter notificationEventWriter)
|
||||||
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
|
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
|
||||||
{
|
{
|
||||||
@@ -64,6 +70,13 @@ public class SubmitApprovalDecisionHandler(
|
|||||||
return;
|
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 normalizedDecision = request.Decision.Trim();
|
||||||
string decidedByName = User?.Identity?.IsAuthenticated == true
|
string decidedByName = User?.Identity?.IsAuthenticated == true
|
||||||
? User.GetAlias() ?? User.GetName()
|
? User.GetAlias() ?? User.GetName()
|
||||||
@@ -84,45 +97,44 @@ public class SubmitApprovalDecisionHandler(
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
approval.State = normalizedDecision;
|
ApprovalWorkflowDecisionResult workflowDecisionResult = await approvalWorkflowRuntimeService
|
||||||
approval.CompletedAt = DateTimeOffset.UtcNow;
|
.ApplyWorkflowStepDecisionAsync(approval, contentItem, workspace, User!, decision, ct);
|
||||||
|
|
||||||
if (approval.Stage == "Internal")
|
if (!workflowDecisionResult.Succeeded)
|
||||||
{
|
{
|
||||||
contentItem.Status = normalizedDecision switch
|
AddError(request => request.Decision, workflowDecisionResult.ErrorMessage ?? "The approval decision could not be recorded.");
|
||||||
{
|
await SendErrorsAsync(workflowDecisionResult.StatusCode, ct);
|
||||||
"Approved" => "Ready for client review",
|
return;
|
||||||
"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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dbContext.ApprovalDecisions.Add(decision);
|
if (!workflowDecisionResult.IsWorkflowStep)
|
||||||
await dbContext.SaveChangesAsync(ct);
|
{
|
||||||
|
approval.State = normalizedDecision;
|
||||||
|
approval.CompletedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
await notificationEventWriter.WriteAsync(
|
if (normalizedDecision == "Approved")
|
||||||
new NotificationEventWriteModel(
|
{
|
||||||
approval.WorkspaceId,
|
contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus(
|
||||||
approval.ContentItemId,
|
workspace.SchedulePostsAutomaticallyOnApproval,
|
||||||
"approval.decision.recorded",
|
contentItem.DueDate);
|
||||||
"ApprovalDecision",
|
}
|
||||||
decision.Id,
|
|
||||||
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.",
|
dbContext.ApprovalDecisions.Add(decision);
|
||||||
null,
|
await dbContext.SaveChangesAsync(ct);
|
||||||
decidedByEmail,
|
|
||||||
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
|
await notificationEventWriter.WriteAsync(
|
||||||
ct);
|
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<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
|
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
|
||||||
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
|
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
|
||||||
@@ -158,6 +170,11 @@ public class SubmitApprovalDecisionHandler(
|
|||||||
approval.Id,
|
approval.Id,
|
||||||
approval.WorkspaceId,
|
approval.WorkspaceId,
|
||||||
approval.ContentItemId,
|
approval.ContentItemId,
|
||||||
|
approval.WorkflowInstanceId,
|
||||||
|
approval.WorkflowStepSortOrder,
|
||||||
|
approval.WorkflowStepTargetType,
|
||||||
|
approval.WorkflowStepTargetValue,
|
||||||
|
approval.WorkflowStepRequiredApproverCount,
|
||||||
approval.Stage,
|
approval.Stage,
|
||||||
approval.ReviewerName,
|
approval.ReviewerName,
|
||||||
approval.ReviewerEmail,
|
approval.ReviewerEmail,
|
||||||
|
|||||||
@@ -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<string> AllowedTargetTypes = new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
ApprovalStepTargetTypes.Role,
|
||||||
|
ApprovalStepTargetTypes.Membership,
|
||||||
|
ApprovalStepTargetTypes.Member,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly IReadOnlySet<string> AllowedRoleTargets = new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
KnownRoles.Administrator,
|
||||||
|
KnownRoles.Manager,
|
||||||
|
KnownRoles.WorkspaceMember,
|
||||||
|
KnownRoles.Client,
|
||||||
|
KnownRoles.Provider,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly IReadOnlySet<string> AllowedMembershipTargets = new HashSet<string>(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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> 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<Guid> 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<Guid> memberUserIds)
|
||||||
|
{
|
||||||
|
return string.Join(",", memberUserIds.Distinct().OrderBy(memberUserId => memberUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesMembershipTarget(
|
||||||
|
IReadOnlyCollection<string> userRoles,
|
||||||
|
string targetValue)
|
||||||
|
{
|
||||||
|
return targetValue switch
|
||||||
|
{
|
||||||
|
ApprovalMembershipTargets.Client => userRoles.Contains(KnownRoles.Client),
|
||||||
|
ApprovalMembershipTargets.Team => !userRoles.Contains(KnownRoles.Client),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ApprovalWorkflowStartResult> 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<WorkspaceApprovalStepConfiguration> 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<ApprovalRequest> 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<ApprovalWorkflowDecisionResult> 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<bool> HasCompletedMultiLevelWorkflowAsync(Guid contentItemId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await dbContext.ApprovalWorkflowInstances.AnyAsync(
|
||||||
|
workflow => workflow.ContentItemId == contentItemId && workflow.State == ApprovedState,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ApprovalRequest?> 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<bool> 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<bool> 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<ApprovalNotificationRecipient> 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<ApprovalNotificationRecipient> 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<List<ApprovalNotificationRecipient>> 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<List<ApprovalNotificationRecipient>> GetMemberRecipientsAsync(string targetValue, CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyCollection<Guid> 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<List<ApprovalNotificationRecipient>> 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<List<ApprovalNotificationRecipient>> GetPublishRecipientUsersAsync(Guid workspaceId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await GetRoleRecipientsAsync(workspaceId, [KnownRoles.Administrator, KnownRoles.Manager], ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<ApprovalNotificationRecipient>> GetRoleRecipientsAsync(
|
||||||
|
Guid workspaceId,
|
||||||
|
IReadOnlyCollection<string> 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);
|
||||||
|
}
|
||||||
@@ -66,15 +66,6 @@ public class CreateContentItemRevisionHandler(
|
|||||||
item.CurrentRevisionNumber = revisionNumber;
|
item.CurrentRevisionNumber = revisionNumber;
|
||||||
item.CurrentRevisionLabel = revisionLabel;
|
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()
|
ContentItemRevision revision = new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ using FastEndpoints;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Approvals.Services;
|
||||||
using Socialize.Api.Modules.ContentItems.Data;
|
using Socialize.Api.Modules.ContentItems.Data;
|
||||||
using Socialize.Api.Modules.Notifications.Contracts;
|
using Socialize.Api.Modules.Notifications.Contracts;
|
||||||
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.ContentItems.Handlers;
|
namespace Socialize.Api.Modules.ContentItems.Handlers;
|
||||||
|
|
||||||
@@ -21,24 +23,18 @@ public class UpdateContentItemStatusRequestValidator
|
|||||||
public class UpdateContentItemStatusHandler(
|
public class UpdateContentItemStatusHandler(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
AccessScopeService accessScopeService,
|
AccessScopeService accessScopeService,
|
||||||
|
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
|
||||||
INotificationEventWriter notificationEventWriter)
|
INotificationEventWriter notificationEventWriter)
|
||||||
: Endpoint<UpdateContentItemStatusRequest, ContentItemDetailDto>
|
: Endpoint<UpdateContentItemStatusRequest, ContentItemDetailDto>
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> AllowedStatuses =
|
private static readonly HashSet<string> AllowedStatuses =
|
||||||
[
|
[
|
||||||
"Draft",
|
"Draft",
|
||||||
"In internal review",
|
"In production",
|
||||||
"Changes requested internally",
|
"In approval",
|
||||||
"Internal changes in progress",
|
|
||||||
"Ready for client review",
|
|
||||||
"In client review",
|
|
||||||
"Changes requested by client",
|
|
||||||
"Client changes in progress",
|
|
||||||
"Approved",
|
"Approved",
|
||||||
"Rejected",
|
"Scheduled",
|
||||||
"Ready to publish",
|
|
||||||
"Published",
|
"Published",
|
||||||
"Archived",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@@ -72,7 +68,64 @@ public class UpdateContentItemStatusHandler(
|
|||||||
return;
|
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 dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
await notificationEventWriter.WriteAsync(
|
await notificationEventWriter.WriteAsync(
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
public static class KnownRoles
|
public static class KnownRoles
|
||||||
{
|
{
|
||||||
public const string Administrator = nameof(Administrator);
|
public const string Administrator = "administrator";
|
||||||
public const string Manager = nameof(Manager);
|
public const string Manager = "manager";
|
||||||
public const string Client = nameof(Client);
|
public const string Client = "client";
|
||||||
public const string Provider = nameof(Provider);
|
public const string Provider = "provider";
|
||||||
public const string WorkspaceMember = nameof(WorkspaceMember);
|
public const string WorkspaceMember = "workspace-member";
|
||||||
public const string Developer = nameof(Developer);
|
public const string Developer = "developer";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,9 @@ public class Workspace
|
|||||||
public string? LogoUrl { get; set; }
|
public string? LogoUrl { get; set; }
|
||||||
public Guid OwnerUserId { get; set; }
|
public Guid OwnerUserId { get; set; }
|
||||||
public required string TimeZone { 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; }
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
|
public static class WorkspaceInviteStatuses
|
||||||
|
{
|
||||||
|
public const string Pending = "Pending";
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ public static class WorkspaceModelConfiguration
|
|||||||
workspace.Property(x => x.Slug).HasMaxLength(128).IsRequired();
|
workspace.Property(x => x.Slug).HasMaxLength(128).IsRequired();
|
||||||
workspace.Property(x => x.LogoUrl).HasMaxLength(2048);
|
workspace.Property(x => x.LogoUrl).HasMaxLength(2048);
|
||||||
workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired();
|
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)
|
workspace.Property(x => x.CreatedAt)
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ public class CreateWorkspaceHandler(
|
|||||||
workspace.Slug,
|
workspace.Slug,
|
||||||
workspace.LogoUrl,
|
workspace.LogoUrl,
|
||||||
workspace.TimeZone,
|
workspace.TimeZone,
|
||||||
|
workspace.ApprovalMode,
|
||||||
|
workspace.SchedulePostsAutomaticallyOnApproval,
|
||||||
|
workspace.LockContentAfterApproval,
|
||||||
|
workspace.SendAutomaticApprovalReminders,
|
||||||
|
[],
|
||||||
workspace.CreatedAt);
|
workspace.CreatedAt);
|
||||||
|
|
||||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public class CreateWorkspaceInviteRequestValidator
|
|||||||
public CreateWorkspaceInviteRequestValidator()
|
public CreateWorkspaceInviteRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Email).NotEmpty().MaximumLength(256).EmailAddress();
|
RuleFor(x => x.Email).NotEmpty().MaximumLength(256).EmailAddress();
|
||||||
RuleFor(x => x.Role).NotEmpty().Must(role => AllowedRoles.Contains(role));
|
RuleFor(x => x.Role).NotEmpty().Must(role => AllowedRoles.Contains(role)).WithMessage("A valid role should be specified");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ public class CreateWorkspaceInviteHandler(
|
|||||||
bool duplicateInvite = await dbContext.WorkspaceInvites.AnyAsync(
|
bool duplicateInvite = await dbContext.WorkspaceInvites.AnyAsync(
|
||||||
invite => invite.WorkspaceId == workspaceId &&
|
invite => invite.WorkspaceId == workspaceId &&
|
||||||
invite.Email == normalizedEmail &&
|
invite.Email == normalizedEmail &&
|
||||||
invite.Status == "Pending",
|
invite.Status == WorkspaceInviteStatuses.Pending,
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
if (duplicateInvite)
|
if (duplicateInvite)
|
||||||
@@ -81,7 +81,7 @@ public class CreateWorkspaceInviteHandler(
|
|||||||
WorkspaceId = workspaceId,
|
WorkspaceId = workspaceId,
|
||||||
Email = normalizedEmail,
|
Email = normalizedEmail,
|
||||||
Role = normalizedRole,
|
Role = normalizedRole,
|
||||||
Status = "Pending",
|
Status = WorkspaceInviteStatuses.Pending,
|
||||||
InvitedByUserId = User.GetUserId(),
|
InvitedByUserId = User.GetUserId(),
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,16 +2,32 @@ using FastEndpoints;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Approvals.Data;
|
||||||
using Socialize.Api.Modules.Workspaces.Data;
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.Workspaces.Handlers;
|
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(
|
public record WorkspaceDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Name,
|
string Name,
|
||||||
string Slug,
|
string Slug,
|
||||||
string? LogoUrl,
|
string? LogoUrl,
|
||||||
string TimeZone,
|
string TimeZone,
|
||||||
|
string ApprovalMode,
|
||||||
|
bool SchedulePostsAutomaticallyOnApproval,
|
||||||
|
bool LockContentAfterApproval,
|
||||||
|
bool SendAutomaticApprovalReminders,
|
||||||
|
IReadOnlyCollection<ApprovalStepConfigurationDto> ApprovalSteps,
|
||||||
DateTimeOffset CreatedAt);
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
internal class GetWorkspacesHandler(
|
internal class GetWorkspacesHandler(
|
||||||
@@ -35,17 +51,53 @@ internal class GetWorkspacesHandler(
|
|||||||
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
|
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
var workspaces = await query
|
var workspaceRows = await query
|
||||||
.OrderBy(workspace => workspace.Name)
|
.OrderBy(workspace => workspace.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var workspaceIds = workspaceRows.Select(workspace => workspace.Id).ToList();
|
||||||
|
List<WorkspaceApprovalStepConfiguration> 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<ApprovalStepConfigurationDto>)group
|
||||||
|
.Select(ToApprovalStepConfigurationDto)
|
||||||
|
.ToArray());
|
||||||
|
|
||||||
|
var workspaces = workspaceRows
|
||||||
.Select(workspace => new WorkspaceDto(
|
.Select(workspace => new WorkspaceDto(
|
||||||
workspace.Id,
|
workspace.Id,
|
||||||
workspace.Name,
|
workspace.Name,
|
||||||
workspace.Slug,
|
workspace.Slug,
|
||||||
workspace.LogoUrl,
|
workspace.LogoUrl,
|
||||||
workspace.TimeZone,
|
workspace.TimeZone,
|
||||||
|
workspace.ApprovalMode,
|
||||||
|
workspace.SchedulePostsAutomaticallyOnApproval,
|
||||||
|
workspace.LockContentAfterApproval,
|
||||||
|
workspace.SendAutomaticApprovalReminders,
|
||||||
|
approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty<ApprovalStepConfigurationDto>(),
|
||||||
workspace.CreatedAt))
|
workspace.CreatedAt))
|
||||||
.ToListAsync(ct);
|
.ToList();
|
||||||
|
|
||||||
await SendOkAsync(workspaces, ct);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,52 @@ using FastEndpoints;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Approvals.Data;
|
||||||
|
using Socialize.Api.Modules.Approvals.Services;
|
||||||
using Socialize.Api.Modules.Workspaces.Data;
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.Workspaces.Handlers;
|
namespace Socialize.Api.Modules.Workspaces.Handlers;
|
||||||
|
|
||||||
|
public record UpdateApprovalStepConfigurationRequest(
|
||||||
|
string Name,
|
||||||
|
int SortOrder,
|
||||||
|
string TargetType,
|
||||||
|
string TargetValue,
|
||||||
|
int RequiredApproverCount);
|
||||||
|
|
||||||
public record UpdateWorkspaceRequest(
|
public record UpdateWorkspaceRequest(
|
||||||
string Name,
|
string Name,
|
||||||
string TimeZone);
|
string TimeZone,
|
||||||
|
string? ApprovalMode,
|
||||||
|
bool? SchedulePostsAutomaticallyOnApproval,
|
||||||
|
bool? LockContentAfterApproval,
|
||||||
|
bool? SendAutomaticApprovalReminders,
|
||||||
|
IReadOnlyCollection<UpdateApprovalStepConfigurationRequest>? ApprovalSteps);
|
||||||
|
|
||||||
public class UpdateWorkspaceRequestValidator
|
public class UpdateWorkspaceRequestValidator
|
||||||
: Validator<UpdateWorkspaceRequest>
|
: Validator<UpdateWorkspaceRequest>
|
||||||
{
|
{
|
||||||
|
private static readonly string[] AllowedApprovalModes = ["None", "Optional", "Required", "Multi-level"];
|
||||||
|
|
||||||
public UpdateWorkspaceRequestValidator()
|
public UpdateWorkspaceRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||||
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string nextApprovalMode = string.IsNullOrWhiteSpace(request.ApprovalMode)
|
||||||
|
? workspace.ApprovalMode
|
||||||
|
: request.ApprovalMode.Trim();
|
||||||
|
List<UpdateApprovalStepConfigurationRequest>? 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.Name = request.Name.Trim();
|
||||||
workspace.TimeZone = request.TimeZone.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<WorkspaceApprovalStepConfiguration> existingSteps = await dbContext.WorkspaceApprovalStepConfigurations
|
||||||
|
.Where(step => step.WorkspaceId == workspace.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
dbContext.WorkspaceApprovalStepConfigurations.RemoveRange(existingSteps);
|
||||||
|
|
||||||
|
List<WorkspaceApprovalStepConfiguration> 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);
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
List<ApprovalStepConfigurationDto> 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(
|
WorkspaceDto dto = new(
|
||||||
workspace.Id,
|
workspace.Id,
|
||||||
workspace.Name,
|
workspace.Name,
|
||||||
workspace.Slug,
|
workspace.Slug,
|
||||||
workspace.LogoUrl,
|
workspace.LogoUrl,
|
||||||
workspace.TimeZone,
|
workspace.TimeZone,
|
||||||
|
workspace.ApprovalMode,
|
||||||
|
workspace.SchedulePostsAutomaticallyOnApproval,
|
||||||
|
workspace.LockContentAfterApproval,
|
||||||
|
workspace.SendAutomaticApprovalReminders,
|
||||||
|
approvalSteps,
|
||||||
workspace.CreatedAt);
|
workspace.CreatedAt);
|
||||||
|
|
||||||
await SendOkAsync(dto, ct);
|
await SendOkAsync(dto, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<bool> ValidateApprovalStepsAsync(
|
||||||
|
Guid workspaceId,
|
||||||
|
IReadOnlyCollection<UpdateApprovalStepConfigurationRequest> 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<Guid> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Guid> 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<AppDbContext>()
|
||||||
|
.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<AppDbContext>()
|
||||||
|
.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<AppDbContext>()
|
||||||
|
.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),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
245
docs/FEATURES/approval-workflow.md
Normal file
245
docs/FEATURES/approval-workflow.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# Approval Workflow
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Draft
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Define how a workspace decides whether a `ContentItem` is approved.
|
||||||
|
|
||||||
|
Approval workflow is one part of the normal `ContentItem` lifecycle:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Production -> Approval -> Publication
|
||||||
|
```
|
||||||
|
|
||||||
|
Approval workflow is separate from production workflow. Production covers how content is planned, created, revised, and prepared before approval. Publication may later become its own workflow for scheduling, publishing, and post-publish corrections.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
- As a workspace owner, I want to configure the approval mode for my brand workspace so that approval fits the way my organization works.
|
||||||
|
- As a manager, I want to require approval before content can be scheduled or published so that unapproved content does not go out.
|
||||||
|
- As a manager, I want optional approval for simpler workflows so that teams can collect sign-off without blocking publication.
|
||||||
|
- As a manager, I want multi-level approval steps so that different people or groups can approve content in sequence.
|
||||||
|
- As an approver, I want to review the content, discuss changes, and approve when ready so that the approval decision is clear.
|
||||||
|
- As a collaborator, I want approval discussions with mentions so that I can involve the right people without changing the approval state.
|
||||||
|
- As an internal team member, I want team-only comments so that internal discussion can happen without exposing it to client users.
|
||||||
|
- As a publisher, I want to be notified when final approval is complete so that I can schedule or publish the content.
|
||||||
|
- As a workspace owner or admin, I want to reopen approval after changes so that approval history remains intact while edge cases can still be handled.
|
||||||
|
- As an external approver, I want to review and approve a specific content item through a secure magic link so that I do not need a full workspace account.
|
||||||
|
|
||||||
|
## Reviewable Object
|
||||||
|
|
||||||
|
The object being approved is a `ContentItem`.
|
||||||
|
|
||||||
|
Approval decisions, comments, mentions, notifications, timestamps, audit history, and approved content snapshots must remain traceable to the `ContentItem`.
|
||||||
|
|
||||||
|
A `ContentItem` can have only one active approval workflow instance at a time.
|
||||||
|
|
||||||
|
When approval completes, the approval workflow instance remains attached to the `ContentItem` as history. If an admin reopens approval later, a special approval step is appended to the same history instead of deleting prior approval history or restarting the workflow.
|
||||||
|
|
||||||
|
Approval workflow history is visible to all users who can view the `ContentItem`. Approval actions remain permission-gated.
|
||||||
|
|
||||||
|
## Lifecycle States
|
||||||
|
|
||||||
|
`ContentItem` uses a fixed set of lifecycle states:
|
||||||
|
|
||||||
|
- `Draft`
|
||||||
|
- `In production`
|
||||||
|
- `In approval`
|
||||||
|
- `Approved`
|
||||||
|
- `Scheduled`
|
||||||
|
- `Published`
|
||||||
|
|
||||||
|
Lifecycle state meanings:
|
||||||
|
|
||||||
|
- `Draft`: the content item exists after a client or team orders, requests, or creates the publication.
|
||||||
|
- `In production`: the content item is in production workflow. This applies when authored media or preparation work is required, such as video, photos, copywriting, design, or other production tasks.
|
||||||
|
- `In approval`: the content item is in approval workflow.
|
||||||
|
- `Approved`: the approval workflow is complete.
|
||||||
|
- `Scheduled`: the content item is on the calendar and ready to be published at its scheduled date and time.
|
||||||
|
- `Published`: the content item was successfully published to all intended channels.
|
||||||
|
|
||||||
|
Production workflow is optional for a `ContentItem`. Simple content can move from `Draft` directly to `In approval` when no full production workflow is needed.
|
||||||
|
|
||||||
|
## Workspace Configuration
|
||||||
|
|
||||||
|
Approval behavior is configured per workspace.
|
||||||
|
|
||||||
|
A workspace is the brand boundary. The approval workflow configuration is workspace-wide in v1.
|
||||||
|
|
||||||
|
Configuration changes apply to new content and to existing content currently in approval workflow. Content that is already `Approved`, `Scheduled`, or `Published` is not recalculated.
|
||||||
|
|
||||||
|
When configuration changes affect existing content in approval:
|
||||||
|
|
||||||
|
- previous approval decisions are preserved in history
|
||||||
|
- current required approval steps are recalculated from the new configuration
|
||||||
|
- if preserved approvals already satisfy the new requirements, the workflow completes and the `ContentItem` becomes `Approved`
|
||||||
|
- if recalculation creates a new pending current step, that step's approvers are notified immediately
|
||||||
|
|
||||||
|
## Approval Modes
|
||||||
|
|
||||||
|
Each workspace supports these approval modes in v1:
|
||||||
|
|
||||||
|
- `None`: no approval workflow is used. Content skips only the approval workflow and can become `Approved` without approval actions. This does not bypass production workflow when production work is required.
|
||||||
|
- `Optional`: a basic one-step approval workflow is created automatically. Approval actions are available, but approval is not required before publication workflow.
|
||||||
|
- `Required`: a basic one-step approval workflow is created automatically. At least one approval is required from any workspace member with approve content permission before the item can become `Approved` or `Scheduled`.
|
||||||
|
- `Multi-level`: approval is split into one or more ordered steps, and each step defines who can approve that step.
|
||||||
|
|
||||||
|
`Optional` and `Required` use one approval step. More flexible approver targeting only applies to `Multi-level`. If a workspace needs custom approver targeting, it should use `Multi-level` with one or more steps.
|
||||||
|
|
||||||
|
## Approval Steps
|
||||||
|
|
||||||
|
Approval steps have their own status:
|
||||||
|
|
||||||
|
- `Pending`
|
||||||
|
- `Approved`
|
||||||
|
|
||||||
|
`Multi-level` is step-based and sequential. Later steps cannot be approved until earlier steps are complete.
|
||||||
|
|
||||||
|
Each multi-level step can target:
|
||||||
|
|
||||||
|
- `Role`: a named workspace role with a set of permissions, such as Writer, Contributor, or Approver.
|
||||||
|
- `Membership`: a workspace membership category, such as Team or Client.
|
||||||
|
- `Member`: one specific workspace user.
|
||||||
|
|
||||||
|
For `Role` and `Membership` targets, the user must match the step target and have approve permission. Membership alone does not grant approval permission; for example, a Client member may be a contributor or an approver depending on their permissions.
|
||||||
|
|
||||||
|
For `Member` targets, the selected user can approve that step because they were explicitly assigned.
|
||||||
|
|
||||||
|
By default, one matching approver can complete a step. When a step targets a role or membership group, the workspace can optionally configure how many matching approvers are required for that step.
|
||||||
|
|
||||||
|
Admins can approve any approval step.
|
||||||
|
|
||||||
|
## Approval Decisions
|
||||||
|
|
||||||
|
Approval workflow supports one formal decision in v1:
|
||||||
|
|
||||||
|
- `Approve`
|
||||||
|
|
||||||
|
`Reject` and explicit `Request changes` decisions are not part of v1. Approvers should use comments, discussions, and mentions to coordinate changes until the current step can be approved.
|
||||||
|
|
||||||
|
Approval decisions must record:
|
||||||
|
|
||||||
|
- actor
|
||||||
|
- decision
|
||||||
|
- timestamp
|
||||||
|
- related `ContentItem`
|
||||||
|
- approval-controlled content version or snapshot that was approved
|
||||||
|
|
||||||
|
A pending step must not be treated as approved.
|
||||||
|
|
||||||
|
## Approval-Controlled Content
|
||||||
|
|
||||||
|
Approval-controlled content includes:
|
||||||
|
|
||||||
|
- caption, copy, and text
|
||||||
|
- attached media and assets
|
||||||
|
- selected channels and networks
|
||||||
|
- content title or name
|
||||||
|
- production output and revisions
|
||||||
|
|
||||||
|
Approval-controlled content excludes:
|
||||||
|
|
||||||
|
- scheduling date and time
|
||||||
|
- internal comments and discussions
|
||||||
|
|
||||||
|
If approval-controlled content is edited while the item is `In approval` or `Approved`, the approved status is removed from the current approval step and the step becomes pending again. If the item was `Approved`, it moves back to `In approval`.
|
||||||
|
|
||||||
|
When an edit invalidates approval, the current step's approvers are notified immediately.
|
||||||
|
|
||||||
|
If content is `Scheduled`, only an admin or workspace owner can change approval-controlled content. This scheduled-content edit policy needs validation with SaaS customers.
|
||||||
|
|
||||||
|
If content is `Published`, approval-controlled content is read-only except for admin or workspace owner corrections. Corrections to published content likely require an immediate force or push update through publication workflow.
|
||||||
|
|
||||||
|
## Approval Options
|
||||||
|
|
||||||
|
Each workspace can configure these approval options:
|
||||||
|
|
||||||
|
- schedule posts automatically on approval
|
||||||
|
- lock content after approval
|
||||||
|
- send automatic reminders for pending approvals
|
||||||
|
|
||||||
|
If schedule posts automatically on approval is enabled, final approval moves the `ContentItem` to `Scheduled` when it already has a planned publish date and time. If no planned publish date and time exists, the item remains `Approved`.
|
||||||
|
|
||||||
|
If lock content after approval is enabled, approval-controlled content is locked after final approval. Scheduling fields remain editable.
|
||||||
|
|
||||||
|
If automatic reminders for pending approvals is enabled, the current step's approvers are reminded daily while the step is pending. Daily reminders start after the original approval notification.
|
||||||
|
|
||||||
|
## Reopening Approval
|
||||||
|
|
||||||
|
Users with admin permission can reopen approved content for approval.
|
||||||
|
|
||||||
|
Reopening approval appends a special approval step to the existing workflow history. The special reopen step is approved by an admin. Admins can use comments and mentions to coordinate with the needed people before closing the approval workflow again.
|
||||||
|
|
||||||
|
## Discussions And Mentions
|
||||||
|
|
||||||
|
Approval comments and discussions are attached to the overall approval workflow instance, not to a specific step.
|
||||||
|
|
||||||
|
Any workspace user with access to the `ContentItem` can participate in approval discussions. Approval actions remain limited to the current step's approvers and admins.
|
||||||
|
|
||||||
|
Approval comments can be visible to everyone with access to the `ContentItem` or marked as team-only. Team-only comments are visible only to users with Team membership and are hidden from Client membership users.
|
||||||
|
|
||||||
|
Approval comments should support direct mentions, such as mentioning a person or group with `@`, to notify them directly.
|
||||||
|
|
||||||
|
Mentions must respect comment visibility. A comment cannot mention a user or group that cannot see that comment.
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
Notification rules:
|
||||||
|
|
||||||
|
- when a `ContentItem` enters `In approval`, the current approval step's approvers are notified immediately
|
||||||
|
- when an approval step is approved and another step remains, the next step's approvers are notified
|
||||||
|
- when the final approval step is approved, users with publish permission for the workflow are notified
|
||||||
|
- when configuration recalculation creates a new pending current step, that step's approvers are notified immediately
|
||||||
|
- when an edit invalidates approval, the current step's approvers are notified immediately
|
||||||
|
- when automatic reminders are enabled, the current step's approvers are reminded daily while the step is pending
|
||||||
|
- when a comment mentions a user or group, mentioned users are notified if they can see the comment
|
||||||
|
|
||||||
|
## Magic Approval Links
|
||||||
|
|
||||||
|
Approval workflow supports magic approval links for external approvers.
|
||||||
|
|
||||||
|
Magic links are intended for `Optional` and `Required` approval modes. They act on the current approval step when the link is opened.
|
||||||
|
|
||||||
|
A magic approval link:
|
||||||
|
|
||||||
|
- grants access only to the specific `ContentItem` for which the link was created
|
||||||
|
- does not grant broader workspace access
|
||||||
|
- allows the recipient to approve and comment on that approval workflow
|
||||||
|
- is created for a specific recipient email address
|
||||||
|
- is sent automatically by the app
|
||||||
|
- expires after 3 days
|
||||||
|
- expires immediately after it is used to approve
|
||||||
|
- is not consumed by commenting
|
||||||
|
- can be revoked by admins or workspace owners before it expires or is used to approve
|
||||||
|
|
||||||
|
Possession of a valid, unexpired magic approval link is sufficient to access the linked approval page. The recipient does not need to create an account.
|
||||||
|
|
||||||
|
For comment visibility, magic link recipients are treated as Client membership users. They cannot see or create team-only comments.
|
||||||
|
|
||||||
|
Magic link activity must record at least the recipient email for audit history.
|
||||||
|
|
||||||
|
Magic approval link events are recorded in approval workflow history, including created, sent, revoked, expired, and used events.
|
||||||
|
|
||||||
|
Magic approval links can be created or sent by users with publish permission, workspace owners, or admins.
|
||||||
|
|
||||||
|
## Business Rules
|
||||||
|
|
||||||
|
- `Approved` must come from explicit workflow rules, not optimistic UI state.
|
||||||
|
- Approval requirements are scoped to the workspace.
|
||||||
|
- A `ContentItem` has at most one active approval workflow instance.
|
||||||
|
- Approval mode `None` creates no approval workflow instance.
|
||||||
|
- Approval mode `Optional` creates a basic approval workflow instance but does not block publication workflow.
|
||||||
|
- Approval mode `Required` blocks approval and scheduling until its single approval step is approved.
|
||||||
|
- Approval mode `Multi-level` blocks approval and scheduling until every configured step is approved.
|
||||||
|
- Approval history must remain visible after revision, approval, scheduling, or publishing.
|
||||||
|
- Approval-controlled content changes must invalidate affected approval while the item is `In approval` or `Approved`.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should publishing behavior become a separate `PublishingWorkflow` feature?
|
||||||
|
- What is the exact data model for approval step configuration?
|
||||||
|
- How should scheduled-content edit policy work after validation with SaaS customers?
|
||||||
|
- What publication-system behavior is required for correcting already published content?
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# Content Approval Workflow
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Active
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Support the primary workflow from draft preparation through review, revision, approval decision, and readiness for publishing handoff.
|
|
||||||
|
|
||||||
## Actors
|
|
||||||
|
|
||||||
- Content contributor
|
|
||||||
- Provider
|
|
||||||
- Internal reviewer
|
|
||||||
- Manager
|
|
||||||
- Client approver
|
|
||||||
|
|
||||||
## Preconditions
|
|
||||||
|
|
||||||
- user is authenticated when acting as an internal team member
|
|
||||||
- work is scoped to a workspace
|
|
||||||
- content item exists inside a workspace context
|
|
||||||
|
|
||||||
## Trigger
|
|
||||||
|
|
||||||
A team member wants to send a content item through review and approval.
|
|
||||||
|
|
||||||
## Main Flow
|
|
||||||
|
|
||||||
1. A team member creates or updates a content item.
|
|
||||||
2. Assets are linked to the content item, including Google Drive references when appropriate.
|
|
||||||
3. The content item includes the relevant metadata:
|
|
||||||
- title
|
|
||||||
- publication message or caption
|
|
||||||
- networks
|
|
||||||
- channels
|
|
||||||
- due date
|
|
||||||
- notes
|
|
||||||
4. The item enters internal review or client review.
|
|
||||||
5. Reviewers leave comments and record decisions.
|
|
||||||
6. If changes are requested, the team links a new revision and continues the workflow.
|
|
||||||
7. Once required review is complete, the item can move to `Ready to publish`.
|
|
||||||
|
|
||||||
## Alternate Flows
|
|
||||||
|
|
||||||
- If a reviewer requests changes, the item should not be treated as approved.
|
|
||||||
- If the actor lacks required workspace access, the workflow action must be denied.
|
|
||||||
- If assets are missing, the item may still exist, but review readiness should remain explicit.
|
|
||||||
|
|
||||||
## Business Rules
|
|
||||||
|
|
||||||
- approvals and comments must remain attached to the content item context
|
|
||||||
- workflow state changes must be traceable
|
|
||||||
- revisions must not overwrite history invisibly
|
|
||||||
- “Ready to publish” must correspond to explicit workflow completion, not optimistic UI state
|
|
||||||
|
|
||||||
## Data / Entities
|
|
||||||
|
|
||||||
- Workspace
|
|
||||||
- ContentItem
|
|
||||||
- Asset
|
|
||||||
- AssetRevision
|
|
||||||
- CommentThread
|
|
||||||
- ApprovalRequest
|
|
||||||
- ApprovalDecision
|
|
||||||
- NotificationEvent
|
|
||||||
|
|
||||||
## API / UI Surface
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- `/app/content`
|
|
||||||
- `/app/content/:id`
|
|
||||||
- `/app/reviews`
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- content item handlers
|
|
||||||
- asset linkage / revision handlers
|
|
||||||
- approval handlers
|
|
||||||
- comment handlers
|
|
||||||
- notification handlers
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [ ] a content item can carry the metadata needed for review
|
|
||||||
- [ ] assets and revisions are visible in the item history
|
|
||||||
- [ ] reviewers can leave comments and decisions in one place
|
|
||||||
- [ ] the audit trail makes status transitions understandable
|
|
||||||
- [ ] the approved state is distinguishable from changes-requested and rejected states
|
|
||||||
- [ ] the workflow supports internal review before client approval
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- Should external review be account-based, magic-link-based, or both in version 1?
|
|
||||||
- Which approval states are mandatory before transition to `Ready to publish`?
|
|
||||||
- Should required approvers be modeled in version 1 or phase 2?
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# Feature: Agentic Platform Scaffold
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
In Progress
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Align Socialize with the structure generated by `bootstrap-vdp-agentic.sh` while preserving the current product implementation.
|
|
||||||
|
|
||||||
## Backend
|
|
||||||
|
|
||||||
The backend is located at:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
backend/src/Socialize.Api
|
|
||||||
```
|
|
||||||
|
|
||||||
The solution is:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
backend/Socialize.slnx
|
|
||||||
```
|
|
||||||
|
|
||||||
The test project is:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
backend/tests/Socialize.Tests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
The frontend remains the existing Vue 3 app. Feature-owned route views and stores live under `frontend/src/features/<feature>`, while shared app shell code stays under `frontend/src/layouts`, `frontend/src/components`, `frontend/src/plugins`, and `frontend/src/router`.
|
|
||||||
|
|
||||||
## API Contract
|
|
||||||
|
|
||||||
OpenAPI workflow:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/update-openapi.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Writes:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
shared/openapi/openapi.json
|
|
||||||
frontend/src/api/schema.d.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Done When
|
|
||||||
|
|
||||||
- [x] Backend code lives under `backend/src/Socialize.Api`
|
|
||||||
- [x] Backend solution exists at `backend/Socialize.slnx`
|
|
||||||
- [x] Test project exists under `backend/tests/Socialize.Tests`
|
|
||||||
- [x] Root scripts exist
|
|
||||||
- [x] Docker Compose and Caddy files exist
|
|
||||||
- [x] Agentic docs, specs, tasks, and prompts exist
|
|
||||||
- [ ] OpenAPI generation verified against a running backend
|
|
||||||
- [x] Backend build passes
|
|
||||||
- [x] Frontend build passes
|
|
||||||
54
docs/FEATURES/production-workflow.md
Normal file
54
docs/FEATURES/production-workflow.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Production Workflow
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Draft
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Define how a `ContentItem` is planned, created, revised, and prepared before or during approval.
|
||||||
|
|
||||||
|
This feature is intentionally separate from approval workflow. Approval workflow decides whether content is approved for publishing handoff. Production workflow describes how content gets made.
|
||||||
|
|
||||||
|
## Reviewable Object
|
||||||
|
|
||||||
|
Production work happens around a `ContentItem`.
|
||||||
|
|
||||||
|
Assets, revisions, comments, assignments, due dates, channels, notes, and content metadata are part of the production context.
|
||||||
|
|
||||||
|
## Initial Scope
|
||||||
|
|
||||||
|
Production workflow may eventually cover:
|
||||||
|
|
||||||
|
- draft creation
|
||||||
|
- copy/caption preparation
|
||||||
|
- media production
|
||||||
|
- asset linking
|
||||||
|
- revision history
|
||||||
|
- internal comments
|
||||||
|
- assignments and ownership
|
||||||
|
- due dates
|
||||||
|
- readiness for review
|
||||||
|
|
||||||
|
## Relationship To Approval Workflow
|
||||||
|
|
||||||
|
Production workflow can prepare a `ContentItem` for approval, but it does not define who must approve it.
|
||||||
|
|
||||||
|
A `ContentItem` may require production work before it can enter review, especially for content formats such as video where work includes planning, filming, editing, revisions, and asset delivery.
|
||||||
|
|
||||||
|
## Initial Business Rules
|
||||||
|
|
||||||
|
- Production history must remain attached to the `ContentItem`.
|
||||||
|
- Revisions must not overwrite prior work invisibly.
|
||||||
|
- The latest asset or revision must be clear.
|
||||||
|
- Production collaboration should support both simple posts and more involved media work.
|
||||||
|
- Approval decisions should remain distinguishable from production comments or internal readiness signals.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Which production states are needed beyond the fixed `ContentItem` lifecycle states?
|
||||||
|
- Should production have assignments or owners in v1?
|
||||||
|
- Should production readiness be required before approval can start?
|
||||||
|
- How should asset revisions be compared or marked as current?
|
||||||
|
- Should different content types have different production requirements?
|
||||||
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Review Workflows
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Active
|
|
||||||
|
|
||||||
## Use Case 1: Internal Review Before Client Review
|
|
||||||
|
|
||||||
### Actors
|
|
||||||
|
|
||||||
- Content contributor
|
|
||||||
- Provider
|
|
||||||
- Internal reviewer
|
|
||||||
- Manager
|
|
||||||
|
|
||||||
### Scenario
|
|
||||||
|
|
||||||
1. A contributor or provider creates or updates a draft.
|
|
||||||
2. The team links assets and updates the content item metadata.
|
|
||||||
3. An internal reviewer leaves comments or requests changes.
|
|
||||||
4. Revisions are linked or uploaded.
|
|
||||||
5. A manager decides the content item is ready for client review.
|
|
||||||
|
|
||||||
### Outcome
|
|
||||||
|
|
||||||
- the content item has an internal review history
|
|
||||||
- revisions are traceable
|
|
||||||
- the item advances to client review only after internal readiness
|
|
||||||
|
|
||||||
## Use Case 2: Client Approval
|
|
||||||
|
|
||||||
### Actors
|
|
||||||
|
|
||||||
- Social media manager
|
|
||||||
- Client approver
|
|
||||||
|
|
||||||
### Scenario
|
|
||||||
|
|
||||||
1. The team sends a content item for client review.
|
|
||||||
2. The client reviews assets, caption/copy, dates, and notes.
|
|
||||||
3. The client records a decision:
|
|
||||||
- approve
|
|
||||||
- reject
|
|
||||||
- request changes
|
|
||||||
4. The team responds with comments or revisions when necessary.
|
|
||||||
|
|
||||||
### Outcome
|
|
||||||
|
|
||||||
- the decision is captured in the system
|
|
||||||
- the audit trail shows who decided what and when
|
|
||||||
- the team knows whether the item is approved, blocked, or requires changes
|
|
||||||
|
|
||||||
## Use Case 3: Revision Loop
|
|
||||||
|
|
||||||
### Actors
|
|
||||||
|
|
||||||
- Provider or internal contributor
|
|
||||||
- Reviewer
|
|
||||||
|
|
||||||
### Scenario
|
|
||||||
|
|
||||||
1. A reviewer requests changes.
|
|
||||||
2. The owner of the work creates a revised asset or revised copy.
|
|
||||||
3. The new revision is linked to the content item.
|
|
||||||
4. The reviewer can compare current state against prior feedback context.
|
|
||||||
|
|
||||||
### Outcome
|
|
||||||
|
|
||||||
- the latest revision is identifiable
|
|
||||||
- older revisions remain traceable
|
|
||||||
- feedback does not get detached from the work item
|
|
||||||
|
|
||||||
## Use Case 4: Ready For Publishing Handoff
|
|
||||||
|
|
||||||
### Actors
|
|
||||||
|
|
||||||
- Manager
|
|
||||||
- Publishing owner
|
|
||||||
|
|
||||||
### Scenario
|
|
||||||
|
|
||||||
1. All required review and approval work is complete.
|
|
||||||
2. The content item transitions to `Ready to publish`.
|
|
||||||
3. The downstream publishing owner uses the item as the approved handoff package.
|
|
||||||
|
|
||||||
### Outcome
|
|
||||||
|
|
||||||
- publishing handoff is based on an approved state
|
|
||||||
- the approved revision and metadata are clear
|
|
||||||
- the workflow history remains visible
|
|
||||||
66
docs/FEATURES/workspace-invites.md
Normal file
66
docs/FEATURES/workspace-invites.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Feature: Workspace Invites
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Draft
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Allow workspace managers to invite teammates, clients, and providers into a workspace and allow invited people to accept access with the correct role and workspace scope.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
- As a workspace manager, I want to invite a person by email and role so that they can access the right workspace.
|
||||||
|
- As an invited person, I want to accept an invite from a link so that I can join the workspace without administrator help.
|
||||||
|
- As an invited person without an account, I want to create my account as part of accepting the invite.
|
||||||
|
- As an invited person with an account, I want the accepted workspace to appear after sign-in.
|
||||||
|
- As a workspace manager, I want to see pending, accepted, cancelled, and expired invites so that I understand who has access or still needs follow-up.
|
||||||
|
|
||||||
|
## Domain Rules
|
||||||
|
|
||||||
|
- Workspace invites belong to exactly one workspace.
|
||||||
|
- Invite email matching should use normalized email addresses.
|
||||||
|
- Pending invite tokens must be single-use and should expire.
|
||||||
|
- Accepted invites must grant the invited role and a workspace scope claim for the invite workspace.
|
||||||
|
- Signed-in users may accept invites only when their account email matches the invite email.
|
||||||
|
- New users may create an account during invite acceptance, then receive the invited role and workspace scope.
|
||||||
|
- Accepted, cancelled, and expired invites must not be accepted again.
|
||||||
|
- Managers can create, list, cancel, and resend invites only for workspaces they can manage.
|
||||||
|
- Managers must not be able to create duplicate pending invites for the same normalized email in the same workspace.
|
||||||
|
- Invite acceptance must be auditable through stored status and timestamp changes.
|
||||||
|
|
||||||
|
## Proposed Statuses
|
||||||
|
|
||||||
|
- `Pending`
|
||||||
|
- `Accepted`
|
||||||
|
- `Cancelled`
|
||||||
|
- `Expired`
|
||||||
|
|
||||||
|
## Backend Surface
|
||||||
|
|
||||||
|
- `POST /api/workspaces/{workspaceId:guid}/invites`
|
||||||
|
- `GET /api/workspaces/{workspaceId:guid}/invites`
|
||||||
|
- `POST /api/workspace-invites/{inviteId:guid}/resend`
|
||||||
|
- `POST /api/workspace-invites/{inviteId:guid}/cancel`
|
||||||
|
- `GET /api/workspace-invites/accept/{token}`
|
||||||
|
- `POST /api/workspace-invites/accept`
|
||||||
|
|
||||||
|
## Frontend Surface
|
||||||
|
|
||||||
|
- Workspace settings members tab for invite creation and invite management.
|
||||||
|
- Public invite acceptance route.
|
||||||
|
- Authenticated invite acceptance route for signed-in users.
|
||||||
|
- Registration/sign-in handoff for invited users without a usable session.
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [ ] Invite creation sends an email with an acceptance link.
|
||||||
|
- [ ] Acceptance link validates a pending, unexpired, single-use token.
|
||||||
|
- [ ] Signed-in users can accept matching-email invites.
|
||||||
|
- [ ] New users can register through the invite path.
|
||||||
|
- [ ] Accepted invites grant role and workspace scope.
|
||||||
|
- [ ] Accepted users see the workspace after token refresh or sign-in.
|
||||||
|
- [ ] Managers can cancel and resend pending invites.
|
||||||
|
- [ ] Invite statuses are represented without magic strings.
|
||||||
|
- [ ] Backend tests cover create, duplicate, accept, expired, cancelled, and email mismatch cases.
|
||||||
|
- [ ] OpenAPI and frontend API usage are updated after contract changes.
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Feature: Workspace Review Workflow
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Draft
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Support workspace-scoped social media content review from content creation through comments, revision, approval, and ready-to-publish handoff.
|
|
||||||
|
|
||||||
## User Stories
|
|
||||||
|
|
||||||
- As a social media manager, I want content items grouped by workspace, client, and project so that I can manage review work for multiple accounts.
|
|
||||||
- As a client approver, I want one clear place to review content, comment, and approve or request changes.
|
|
||||||
- As an account manager, I want notifications and review queues so that work does not stall silently.
|
|
||||||
|
|
||||||
## Backend Modules
|
|
||||||
|
|
||||||
- Identity
|
|
||||||
- Workspaces
|
|
||||||
- Clients
|
|
||||||
- Projects
|
|
||||||
- ContentItems
|
|
||||||
- Assets
|
|
||||||
- Comments
|
|
||||||
- Approvals
|
|
||||||
- Notifications
|
|
||||||
|
|
||||||
## Frontend Areas
|
|
||||||
|
|
||||||
- `/app`
|
|
||||||
- `/app/workspaces/new`
|
|
||||||
- `/app/clients`
|
|
||||||
- `/app/projects`
|
|
||||||
- `/app/content`
|
|
||||||
- `/app/content/:id`
|
|
||||||
- `/app/reviews`
|
|
||||||
- `/app/settings`
|
|
||||||
|
|
||||||
## Domain Rules
|
|
||||||
|
|
||||||
- Workspace is the top-level scoping boundary.
|
|
||||||
- Content items belong to a workspace and may belong to a client or project.
|
|
||||||
- Comments, approvals, assets, and notifications must remain traceable to the workflow entity they relate to.
|
|
||||||
- Ready-to-publish state should come from explicit approval workflow transitions.
|
|
||||||
|
|
||||||
## Done When
|
|
||||||
|
|
||||||
- [ ] Workspace access is enforced consistently
|
|
||||||
- [ ] Content item lifecycle is documented as a state machine
|
|
||||||
- [ ] Approval decisions create traceable notifications/events
|
|
||||||
- [ ] Review queue behavior is covered by tasks and validation
|
|
||||||
46
docs/TASKS/approval-workflow/001-define-approval-workflow.md
Normal file
46
docs/TASKS/approval-workflow/001-define-approval-workflow.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Task: Define approval workflow
|
||||||
|
|
||||||
|
## Feature
|
||||||
|
|
||||||
|
`docs/FEATURES/approval-workflow.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Define the v1 approval workflow behavior for `ContentItem` approval.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Approval workflow has been split from production workflow. The object being approved is a `ContentItem`, and workspaces configure approval behavior without defining a fully custom state machine.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Define approval modes.
|
||||||
|
- Define approval actors.
|
||||||
|
- Define approval decisions.
|
||||||
|
- Define the state transitions affected by approval.
|
||||||
|
- Define notification and audit side effects.
|
||||||
|
- Define open questions that remain outside v1.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Documentation-only task.
|
||||||
|
- Do not change backend or frontend code.
|
||||||
|
- Keep production workflow details out of this task except where needed to explain boundaries.
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [ ] approval modes are defined
|
||||||
|
- [ ] actor permissions are defined
|
||||||
|
- [ ] approval decisions are defined
|
||||||
|
- [ ] state transition behavior is defined
|
||||||
|
- [ ] workspace configuration fields are described
|
||||||
|
- [ ] notification side effects are listed
|
||||||
|
- [ ] audit requirements are listed
|
||||||
|
- [ ] out-of-scope production behavior is explicitly separated
|
||||||
|
|
||||||
|
## Validation Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff -- docs/FEATURES docs/TASKS
|
||||||
|
```
|
||||||
|
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# Task: Align repository with bootstrap scaffold
|
|
||||||
|
|
||||||
## Feature
|
|
||||||
|
|
||||||
`docs/FEATURES/platform-scaffold.md`
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Move the current Socialize repository into the structure that `bootstrap-vdp-agentic.sh` would have generated, without replacing the existing product implementation.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The script generates a simple .NET + Vue monorepo with:
|
|
||||||
|
|
||||||
- backend under `backend/src/<App>.Api`
|
|
||||||
- tests under `backend/tests/<App>.Tests`
|
|
||||||
- root scripts under `scripts/`
|
|
||||||
- Docker Compose and Caddy deployment files
|
|
||||||
- OpenAPI sync into `shared/openapi`
|
|
||||||
- agentic docs under `docs/FEATURES`, `docs/TASKS`, `docs/PROMPTS`, and `docs/DECISIONS`
|
|
||||||
|
|
||||||
Socialize already has a larger FastEndpoints backend and Vue app. Preserve that implementation while adopting the scaffold.
|
|
||||||
|
|
||||||
## Files Likely To Change
|
|
||||||
|
|
||||||
- `backend/Socialize.slnx`
|
|
||||||
- `backend/src/Socialize.Api/**`
|
|
||||||
- `backend/tests/Socialize.Tests/**`
|
|
||||||
- `scripts/**`
|
|
||||||
- `deploy/caddy/Caddyfile`
|
|
||||||
- `docker-compose.yml`
|
|
||||||
- `docs/**`
|
|
||||||
- `README.md`
|
|
||||||
- `AGENTS.md`
|
|
||||||
- `.github/workflows/backend-ci.yml`
|
|
||||||
- `frontend/package.json`
|
|
||||||
- `frontend/scripts/fetch-openapi.mjs`
|
|
||||||
- `frontend/src/api/schema.d.ts`
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Preserve existing product code.
|
|
||||||
- Do not convert the frontend to TypeScript in this task.
|
|
||||||
- Do not rewrite backend modules into minimal API folders in this task.
|
|
||||||
- Do not introduce new secrets.
|
|
||||||
|
|
||||||
## Done When
|
|
||||||
|
|
||||||
- [x] Backend implementation moved under `backend/src/Socialize.Api`
|
|
||||||
- [x] Backend solution points at the new project path
|
|
||||||
- [x] Test project scaffold exists
|
|
||||||
- [x] Root scripts exist
|
|
||||||
- [x] OpenAPI sync command exists
|
|
||||||
- [x] Agentic docs/specs/tasks/prompts exist
|
|
||||||
- [x] Backend build passes
|
|
||||||
- [x] Frontend build passes
|
|
||||||
|
|
||||||
## Validation Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet build backend/Socialize.slnx
|
|
||||||
dotnet test backend/Socialize.slnx
|
|
||||||
cd frontend && npm run build
|
|
||||||
```
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Task: Contain backend feature mapping
|
|
||||||
|
|
||||||
## Feature
|
|
||||||
|
|
||||||
`docs/FEATURES/platform-scaffold.md`
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Move backend feature-specific persistence mapping out of the shared `AppDbContext` body and into the owning `Modules/<Feature>/Data` folders.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Architecture docs state that current backend feature code stays under `Modules/<Feature>`. `AppDbContext` remains the shared EF Core composition point, but feature-owned model configuration should live with the feature entities.
|
|
||||||
|
|
||||||
## Files Likely To Change
|
|
||||||
|
|
||||||
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
|
|
||||||
- `backend/src/Socialize.Api/Modules/*/Data/*`
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Do not change API contracts.
|
|
||||||
- Do not change table names, indexes, or column constraints.
|
|
||||||
- Do not introduce a broader persistence refactor.
|
|
||||||
|
|
||||||
## Done When
|
|
||||||
|
|
||||||
- [x] Feature entity mappings live under the owning module folders.
|
|
||||||
- [x] `AppDbContext` delegates feature configuration to modules.
|
|
||||||
- [x] Backend build passes.
|
|
||||||
|
|
||||||
## Validation Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet build backend/Socialize.slnx
|
|
||||||
dotnet test backend/Socialize.slnx
|
|
||||||
```
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Task: Use local blob storage
|
|
||||||
|
|
||||||
## Feature
|
|
||||||
|
|
||||||
`docs/FEATURES/platform-scaffold.md`
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Store uploaded portraits and logos on the API server filesystem instead of Azure Blob Storage.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
User, client, and workspace portrait uploads already flow through `IBlobStorage`. The implementation can change without altering endpoint contracts or frontend behavior.
|
|
||||||
|
|
||||||
## Files Likely To Change
|
|
||||||
|
|
||||||
- `backend/src/Socialize.Api/Infrastructure/DependencyInjection.cs`
|
|
||||||
- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/*`
|
|
||||||
- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Configuration/*`
|
|
||||||
- `backend/src/Socialize.Api/Program.cs`
|
|
||||||
- `backend/src/Socialize.Api/appsettings.Development.json`
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Do not change API request or response contracts.
|
|
||||||
- Keep upload validation behavior consistent with the existing blob storage implementation.
|
|
||||||
- Serve returned blob URLs from the API host so the existing frontend can keep using `portraitUrl` and `logoUrl`.
|
|
||||||
|
|
||||||
## Done When
|
|
||||||
|
|
||||||
- [x] `IBlobStorage` resolves to local filesystem storage by default.
|
|
||||||
- [x] Uploaded files are served back from the API host.
|
|
||||||
- [x] Backend build passes.
|
|
||||||
|
|
||||||
## Validation Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet build backend/Socialize.slnx
|
|
||||||
dotnet test backend/Socialize.slnx
|
|
||||||
```
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Task: Improve UI Surface Contrast
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Increase contrast between the app background, panels, and form controls so inputs are easier to identify against white or near-white surfaces.
|
|
||||||
|
|
||||||
## Feature Spec
|
|
||||||
|
|
||||||
`docs/FEATURES/platform-scaffold.md`
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Update the shared frontend color tokens.
|
|
||||||
- Configure Vuetify to use the Socialize light theme colors.
|
|
||||||
- Add shared form control and surface defaults for native and Vuetify controls.
|
|
||||||
- Avoid feature-specific behavior changes.
|
|
||||||
|
|
||||||
## Likely Files
|
|
||||||
|
|
||||||
- `frontend/src/assets/main.css`
|
|
||||||
- `frontend/src/main.js`
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Task: Define production workflow
|
||||||
|
|
||||||
|
## Feature
|
||||||
|
|
||||||
|
`docs/FEATURES/production-workflow.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Define the v1 production workflow for how a `ContentItem` is planned, created, revised, and prepared for review.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Production workflow has been split from approval workflow. This task should be picked up later after approval workflow is defined.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Define production states or readiness markers.
|
||||||
|
- Define production actors.
|
||||||
|
- Define asset and revision expectations.
|
||||||
|
- Define assignment and ownership expectations.
|
||||||
|
- Define how production work prepares a `ContentItem` for review.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Documentation-only task.
|
||||||
|
- Do not change backend or frontend code.
|
||||||
|
- Do not redefine approval modes or approval decisions here.
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- [ ] production scope is defined
|
||||||
|
- [ ] production actors are defined
|
||||||
|
- [ ] asset and revision rules are defined
|
||||||
|
- [ ] readiness-for-review behavior is defined
|
||||||
|
- [ ] approval workflow boundaries are explicit
|
||||||
|
|
||||||
|
## Validation Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff -- docs/FEATURES docs/TASKS
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Task: Backend Workspace Invite Foundation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement the backend data model and endpoints needed to create, list, and accept workspace invites.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/workspace-invites.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add a `WorkspaceInvite` persistence model with workspace id, normalized email, role, status, inviter, token data, timestamps, and expiration.
|
||||||
|
- Add invite statuses for `Pending`, `Accepted`, `Cancelled`, and `Expired` without magic strings.
|
||||||
|
- Add a manager-only endpoint to create workspace invites.
|
||||||
|
- Add a manager-only endpoint to list workspace invites.
|
||||||
|
- Prevent duplicate pending invites for the same normalized email in the same workspace.
|
||||||
|
- Add a public endpoint to resolve an invite token for display-safe invite details.
|
||||||
|
- Add an accept endpoint that validates token, status, expiration, and email match.
|
||||||
|
- On acceptance, grant the invited role and `KnownClaims.WorkspaceScope` claim to the user.
|
||||||
|
- Mark the invite as accepted in the same transaction as access grants.
|
||||||
|
- Add backend tests for create, list, pending, accepted, expired, cancelled, duplicate, and email mismatch paths.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Keep backend code under `backend/src/Socialize.Api`.
|
||||||
|
- Keep workspace feature code under `Modules/Workspaces`.
|
||||||
|
- Do not expose raw token values in manager invite lists.
|
||||||
|
- Frontend invite screens are covered by task 003 and task 004.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceInvite.cs`
|
||||||
|
- `backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceInviteStatuses.cs`
|
||||||
|
- `backend/src/Socialize.Api/Modules/Workspaces/Data/WorkspaceModelConfiguration.cs`
|
||||||
|
- `backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs`
|
||||||
|
- `backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaceInvites.cs`
|
||||||
|
- `backend/src/Socialize.Api/Modules/Workspaces/Handlers/*Accept*Invite*.cs`
|
||||||
|
- `backend/tests/Socialize.Tests/`
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
```
|
||||||
37
docs/TASKS/workspace-invites/002-invite-email-delivery.md
Normal file
37
docs/TASKS/workspace-invites/002-invite-email-delivery.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Task: Invite Email Delivery
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Send invited users an acceptance link when a workspace invite is created or resent.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/workspace-invites.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Generate acceptance URLs from configured website options.
|
||||||
|
- Send an invite email after successful invite creation.
|
||||||
|
- Add a manager-only resend endpoint for pending, unexpired invites.
|
||||||
|
- Avoid sending email if invite creation fails.
|
||||||
|
- Do not include sensitive token values in logs.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Use the repository email infrastructure.
|
||||||
|
- Do not introduce a new email provider.
|
||||||
|
- Keep email copy concise and product-specific.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `backend/src/Socialize.Api/Modules/Workspaces/Handlers/CreateWorkspaceInvite.cs`
|
||||||
|
- `backend/src/Socialize.Api/Modules/Workspaces/Handlers/ResendWorkspaceInvite.cs`
|
||||||
|
- `backend/src/Socialize.Api/Infrastructure/Emailer/`
|
||||||
|
- `backend/src/Socialize.Api/Infrastructure/Configuration/WebsiteOptions.cs`
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
```
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Task: Frontend Invite Acceptance
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build the invite acceptance route and connect it to registration or sign-in.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/workspace-invites.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Add a public route for invite acceptance links.
|
||||||
|
- Load display-safe invite details from the token.
|
||||||
|
- If the user is signed in with the invited email, allow direct acceptance.
|
||||||
|
- If the user is signed in with a different email, show a clear mismatch state.
|
||||||
|
- If the user is signed out, route them to sign in or register and resume acceptance afterward.
|
||||||
|
- Refresh the current user profile after acceptance so the new workspace appears.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Frontend runtime config must flow through `frontend/src/config.js`.
|
||||||
|
- Feature-owned code belongs under `frontend/src/features/workspaces`.
|
||||||
|
- Do not add a marketing-style landing page for invite acceptance.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `frontend/src/router/router.js`
|
||||||
|
- `frontend/src/features/workspaces/`
|
||||||
|
- `frontend/src/features/workspaces/stores/workspaceStore.js`
|
||||||
|
- `frontend/src/locales/en.json`
|
||||||
|
- `frontend/src/locales/fr.json`
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
42
docs/TASKS/workspace-invites/004-invite-management-polish.md
Normal file
42
docs/TASKS/workspace-invites/004-invite-management-polish.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Task: Invite Management Polish
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make workspace invite management complete for managers after acceptance exists.
|
||||||
|
|
||||||
|
## Feature Spec
|
||||||
|
|
||||||
|
- `docs/FEATURES/workspace-invites.md`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Show invite statuses in workspace settings.
|
||||||
|
- Add manager actions to cancel and resend pending invites.
|
||||||
|
- Hide or disable actions for accepted, cancelled, and expired invites.
|
||||||
|
- Decide whether the default list shows all invites or only active pending invites.
|
||||||
|
- Ensure accepted users appear in the active members list after acceptance.
|
||||||
|
- Update OpenAPI and frontend API usage after backend contract changes.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Keep workspace settings within repository layout conventions.
|
||||||
|
- Avoid broad member-management refactors.
|
||||||
|
|
||||||
|
## Likely Files
|
||||||
|
|
||||||
|
- `backend/src/Socialize.Api/Modules/Workspaces/Handlers/GetWorkspaceInvites.cs`
|
||||||
|
- `backend/src/Socialize.Api/Modules/Workspaces/Handlers/CancelWorkspaceInvite.cs`
|
||||||
|
- `frontend/src/features/workspaces/views/WorkspaceSettingsView.vue`
|
||||||
|
- `frontend/src/features/workspaces/stores/workspaceStore.js`
|
||||||
|
- `shared/openapi/openapi.json`
|
||||||
|
- `frontend/src/api/schema.d.ts`
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build backend/Socialize.slnx
|
||||||
|
dotnet test backend/Socialize.slnx
|
||||||
|
./scripts/update-openapi.sh
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Task: Document content state machine
|
|
||||||
|
|
||||||
## Feature
|
|
||||||
|
|
||||||
`docs/FEATURES/workspace-review-workflow.md`
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Define the current and intended content item states, transitions, and approval side effects before further workflow implementation.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The code already contains content items, approvals, comments, assets, notifications, and review queue screens. The workflow needs one durable spec so future agents do not infer state transitions from scattered UI code.
|
|
||||||
|
|
||||||
## Files Likely To Change
|
|
||||||
|
|
||||||
- `docs/FEATURES/workspace-review-workflow.md`
|
|
||||||
- `docs/FEATURES/content-approval-workflow.md`
|
|
||||||
- optionally `docs/DECISIONS/*.md`
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Documentation-only task.
|
|
||||||
- Do not change backend or frontend code.
|
|
||||||
- Distinguish current behavior from proposed behavior.
|
|
||||||
|
|
||||||
## Done When
|
|
||||||
|
|
||||||
- [ ] States are listed
|
|
||||||
- [ ] Allowed transitions are listed
|
|
||||||
- [ ] Actor permissions are listed
|
|
||||||
- [ ] Notification side effects are listed
|
|
||||||
- [ ] Open questions are explicit
|
|
||||||
|
|
||||||
## Validation Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git diff -- docs/FEATURES docs/DECISIONS
|
|
||||||
```
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Task: Edit workspace settings
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Allow managers to update the active workspace name and time zone from the workspace settings page.
|
|
||||||
|
|
||||||
## Feature Spec
|
|
||||||
|
|
||||||
- `docs/FEATURES/workspace-review-workflow.md`
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add a backend workspace update endpoint for `name` and `timeZone`.
|
|
||||||
- Add a backend workspace logo upload endpoint.
|
|
||||||
- Add a frontend workspace store update action.
|
|
||||||
- Replace the workspace settings general summary with editable details and logo controls.
|
|
||||||
- Do not display workspace slug or workspace creation date on the workspace settings page.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet build backend/Socialize.slnx
|
|
||||||
cd frontend && npm run build
|
|
||||||
```
|
|
||||||
283
frontend/src/api/schema.d.ts
vendored
283
frontend/src/api/schema.d.ts
vendored
@@ -436,6 +436,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/my-feedback/{id}/screenshot": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -484,6 +516,22 @@ export interface paths {
|
|||||||
patch: operations["SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackHandler"];
|
patch: operations["SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackHandler"];
|
||||||
trace?: never;
|
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": {
|
"/api/feedback/{id}/screenshot": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -516,6 +564,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/feedback": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -818,6 +882,26 @@ export interface components {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
logoUrl?: string | null;
|
logoUrl?: string | null;
|
||||||
timeZone?: string;
|
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 */
|
/** Format: date-time */
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
};
|
};
|
||||||
@@ -853,6 +937,20 @@ export interface components {
|
|||||||
SocializeApiModulesWorkspacesHandlersUpdateWorkspaceRequest: {
|
SocializeApiModulesWorkspacesHandlersUpdateWorkspaceRequest: {
|
||||||
name: string;
|
name: string;
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
|
approvalMode?: string | null;
|
||||||
|
schedulePostsAutomaticallyOnApproval?: boolean | null;
|
||||||
|
lockContentAfterApproval?: boolean | null;
|
||||||
|
sendAutomaticApprovalReminders?: boolean | null;
|
||||||
|
approvalSteps?: components["schemas"]["SocializeApiModulesWorkspacesHandlersUpdateApprovalStepConfigurationRequest"][] | null;
|
||||||
|
};
|
||||||
|
SocializeApiModulesWorkspacesHandlersUpdateApprovalStepConfigurationRequest: {
|
||||||
|
name?: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
sortOrder?: number;
|
||||||
|
targetType?: string;
|
||||||
|
targetValue?: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
requiredApproverCount?: number;
|
||||||
};
|
};
|
||||||
SocializeApiModulesProjectsHandlersProjectDto: {
|
SocializeApiModulesProjectsHandlersProjectDto: {
|
||||||
/** Format: guid */
|
/** Format: guid */
|
||||||
@@ -1018,6 +1116,26 @@ export interface components {
|
|||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
SocializeApiModulesIdentityHandlersVerifyEmailRequest: Record<string, never>;
|
SocializeApiModulesIdentityHandlersVerifyEmailRequest: Record<string, never>;
|
||||||
|
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: {
|
SocializeApiModulesFeedbackContractsFeedbackReportDto: {
|
||||||
/** Format: guid */
|
/** Format: guid */
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -1032,6 +1150,7 @@ export interface components {
|
|||||||
context?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackContextDto"];
|
context?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackContextDto"];
|
||||||
screenshot?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackScreenshotDto"] | null;
|
screenshot?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackScreenshotDto"] | null;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
timeline?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][];
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -1322,6 +1441,14 @@ export interface components {
|
|||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
/** Format: guid */
|
/** Format: guid */
|
||||||
contentItemId?: string;
|
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;
|
stage?: string;
|
||||||
reviewerName?: string;
|
reviewerName?: string;
|
||||||
reviewerEmail?: string;
|
reviewerEmail?: string;
|
||||||
@@ -2286,6 +2413,97 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesFeedbackHandlersAddDeveloperFeedbackCommentHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SocializeApiModulesFeedbackHandlersAddMyFeedbackCommentHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler: {
|
SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2455,6 +2673,42 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesFeedbackHandlersGetDeveloperFeedbackTimelineHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler: {
|
SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2513,6 +2767,35 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesFeedbackHandlersGetMyFeedbackTimelineHandler: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
SocializeApiModulesFeedbackHandlersListDeveloperFeedbackHandler: {
|
SocializeApiModulesFeedbackHandlersListDeveloperFeedbackHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -69,10 +69,8 @@
|
|||||||
nextDueDate: matches
|
nextDueDate: matches
|
||||||
.filter(item => item.dueDate)
|
.filter(item => item.dueDate)
|
||||||
.sort((left, right) => new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime())[0]?.dueDate ?? null,
|
.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,
|
readyCount: matches.filter(item => ['Approved', 'Scheduled', 'Published'].includes(item.status)).length,
|
||||||
blockedCount: matches.filter(item =>
|
blockedCount: matches.filter(item => item.status === 'In approval').length,
|
||||||
['In internal review', 'Ready for client review', 'In client review', 'Changes requested by client'].includes(item.status)
|
|
||||||
).length,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const useContentItemsStore = defineStore('content-items', () => {
|
|||||||
const error = ref(null);
|
const error = ref(null);
|
||||||
|
|
||||||
const activeCount = computed(() =>
|
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
|
.length
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,14 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const decisionForms = reactive({});
|
const decisionForms = reactive({});
|
||||||
|
const manualStatuses = [
|
||||||
|
'Draft',
|
||||||
|
'In production',
|
||||||
|
'In approval',
|
||||||
|
'Approved',
|
||||||
|
'Scheduled',
|
||||||
|
'Published',
|
||||||
|
];
|
||||||
const saveError = reactive({
|
const saveError = reactive({
|
||||||
message: '',
|
message: '',
|
||||||
});
|
});
|
||||||
@@ -80,6 +88,7 @@
|
|||||||
new Map(projectsStore.projects.map(project => [project.id, project.name]))
|
new Map(projectsStore.projects.map(project => [project.id, project.name]))
|
||||||
);
|
);
|
||||||
const editorKey = computed(() => isCreateMode.value ? `new:${route.query.projectId ?? 'default'}` : String(route.params.id));
|
const editorKey = computed(() => isCreateMode.value ? `new:${route.query.projectId ?? 'default'}` : String(route.params.id));
|
||||||
|
const isMultiLevelApproval = computed(() => workspaceStore.activeWorkspace?.approvalMode === 'Multi-level');
|
||||||
|
|
||||||
function blankPlacement(channel = null) {
|
function blankPlacement(channel = null) {
|
||||||
return {
|
return {
|
||||||
@@ -116,6 +125,16 @@
|
|||||||
return decisionForms[approvalId];
|
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) {
|
function syncPlacementChannel(placement, value) {
|
||||||
const channel = availableChannels.value.find(candidate => candidate.id === value);
|
const channel = availableChannels.value.find(candidate => candidate.id === value);
|
||||||
placement.channelId = value;
|
placement.channelId = value;
|
||||||
@@ -488,33 +507,21 @@
|
|||||||
class="quick-actions"
|
class="quick-actions"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
v-for="status in manualStatuses"
|
||||||
|
:key="status"
|
||||||
class="secondary-button"
|
class="secondary-button"
|
||||||
:disabled="detailStore.actions.status"
|
:disabled="detailStore.actions.status || item.status === status"
|
||||||
@click="moveStatus('Ready to publish')"
|
@click="moveStatus(status)"
|
||||||
>
|
>
|
||||||
Ready to publish
|
{{ status }}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="secondary-button"
|
|
||||||
:disabled="detailStore.actions.status"
|
|
||||||
@click="moveStatus('Published')"
|
|
||||||
>
|
|
||||||
Published
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="secondary-button"
|
|
||||||
:disabled="detailStore.actions.status"
|
|
||||||
@click="moveStatus('Archived')"
|
|
||||||
>
|
|
||||||
Archive
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-grid">
|
<div class="editor-grid">
|
||||||
<aside class="panel side-panel">
|
<aside class="panel side-panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<strong>Approval</strong>
|
<strong>Approval</strong>
|
||||||
<span v-if="!isCreateMode">{{ detailStore.approvals.length }} requests</span>
|
<span v-if="!isCreateMode">{{ detailStore.approvals.length }} {{ isMultiLevelApproval ? 'steps' : 'requests' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -525,7 +532,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="panel-stack">
|
<div
|
||||||
|
v-if="isMultiLevelApproval"
|
||||||
|
class="empty-note"
|
||||||
|
>
|
||||||
|
Move this content to In approval to start the configured workflow steps.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="panel-stack"
|
||||||
|
>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Stage</span>
|
<span>Stage</span>
|
||||||
<select v-model="approvalForm.stage">
|
<select v-model="approvalForm.stage">
|
||||||
@@ -572,7 +589,7 @@
|
|||||||
<div class="sub-card-header">
|
<div class="sub-card-header">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ approval.reviewerName }}</strong>
|
<strong>{{ approval.reviewerName }}</strong>
|
||||||
<span>{{ approval.stage }} · {{ approval.state }}</span>
|
<span>{{ formatApprovalStepMeta(approval) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<small>{{ formatDate(approval.dueAt) }}</small>
|
<small>{{ formatDate(approval.dueAt) }}</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -607,8 +624,6 @@
|
|||||||
<span>Decision</span>
|
<span>Decision</span>
|
||||||
<select v-model="getDecisionForm(approval.id).decision">
|
<select v-model="getDecisionForm(approval.id).decision">
|
||||||
<option value="Approved">Approved</option>
|
<option value="Approved">Approved</option>
|
||||||
<option value="Changes requested">Changes requested</option>
|
|
||||||
<option value="Rejected">Rejected</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
|
|||||||
@@ -5,18 +5,11 @@ import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
|
|||||||
|
|
||||||
const stageByStatus = {
|
const stageByStatus = {
|
||||||
Draft: 'Draft',
|
Draft: 'Draft',
|
||||||
'In internal review': 'Internal review',
|
'In production': 'In production',
|
||||||
'Changes requested internally': 'Internal changes requested',
|
'In approval': 'In approval',
|
||||||
'Internal changes in progress': 'Internal revision',
|
|
||||||
'Ready for client review': 'Ready for client review',
|
|
||||||
'In client review': 'Client review',
|
|
||||||
'Changes requested by client': 'Client changes requested',
|
|
||||||
'Client changes in progress': 'Client revision',
|
|
||||||
Approved: 'Approved',
|
Approved: 'Approved',
|
||||||
Rejected: 'Rejected',
|
Scheduled: 'Scheduled',
|
||||||
'Ready to publish': 'Ready to publish',
|
|
||||||
Published: 'Published',
|
Published: 'Published',
|
||||||
Archived: 'Archived',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useReviewQueueStore = defineStore('review-queue', () => {
|
export const useReviewQueueStore = defineStore('review-queue', () => {
|
||||||
@@ -25,7 +18,7 @@ export const useReviewQueueStore = defineStore('review-queue', () => {
|
|||||||
|
|
||||||
const items = computed(() =>
|
const items = computed(() =>
|
||||||
contentItemsStore.items
|
contentItemsStore.items
|
||||||
.filter(item => item.status !== 'Draft' && item.status !== 'Published' && item.status !== 'Archived')
|
.filter(item => item.status === 'In approval')
|
||||||
.map(item => {
|
.map(item => {
|
||||||
const project = projectsStore.projects.find(candidate => candidate.id === item.projectId);
|
const project = projectsStore.projects.find(candidate => candidate.id === item.projectId);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
mdiArrowDown,
|
||||||
|
mdiArrowUp,
|
||||||
|
mdiDeleteOutline,
|
||||||
|
mdiPlus,
|
||||||
|
} from '@mdi/js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
'administrator',
|
||||||
|
'manager',
|
||||||
|
'workspace-member',
|
||||||
|
'client',
|
||||||
|
'provider',
|
||||||
|
];
|
||||||
|
const membershipOptions = ['Team', 'Client'];
|
||||||
|
const targetTypes = ['Role', 'Membership', 'Member'];
|
||||||
|
|
||||||
|
function emitSteps(steps) {
|
||||||
|
emit('update:modelValue', steps.map((step, index) => ({
|
||||||
|
...step,
|
||||||
|
sortOrder: index,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep() {
|
||||||
|
emitSteps([
|
||||||
|
...props.modelValue,
|
||||||
|
{
|
||||||
|
name: props.labels.defaultStepName(props.modelValue.length + 1),
|
||||||
|
sortOrder: props.modelValue.length,
|
||||||
|
targetType: 'Role',
|
||||||
|
targetValue: 'manager',
|
||||||
|
requiredApproverCount: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStep(index, updates) {
|
||||||
|
const steps = props.modelValue.map((step, stepIndex) => {
|
||||||
|
if (stepIndex !== index) {
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStep = {
|
||||||
|
...step,
|
||||||
|
...updates,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (updates.targetType) {
|
||||||
|
nextStep.targetValue = defaultTargetValue(updates.targetType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextStep;
|
||||||
|
});
|
||||||
|
|
||||||
|
emitSteps(steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTargetValue(targetType) {
|
||||||
|
if (targetType === 'Membership') {
|
||||||
|
return membershipOptions[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType === 'Member') {
|
||||||
|
return props.members[0]?.id ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return roleOptions[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedMemberIds(step) {
|
||||||
|
return (step.targetValue ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map(value => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMemberTargets(index, selectedOptions) {
|
||||||
|
const targetValue = Array.from(selectedOptions)
|
||||||
|
.map(option => option.value)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
updateStep(index, { targetValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveStep(index, offset) {
|
||||||
|
const nextIndex = index + offset;
|
||||||
|
|
||||||
|
if (nextIndex < 0 || nextIndex >= props.modelValue.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = [...props.modelValue];
|
||||||
|
const [step] = steps.splice(index, 1);
|
||||||
|
steps.splice(nextIndex, 0, step);
|
||||||
|
emitSteps(steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStep(index) {
|
||||||
|
emitSteps(props.modelValue.filter((_, stepIndex) => stepIndex !== index));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="approval-workflow-editor">
|
||||||
|
<div class="approval-editor-header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ labels.title }}</strong>
|
||||||
|
<span>{{ labels.description }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary-button"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="createStep"
|
||||||
|
>
|
||||||
|
<v-icon :icon="mdiPlus" />
|
||||||
|
<span>{{ labels.addStep }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!modelValue.length"
|
||||||
|
class="approval-empty"
|
||||||
|
>
|
||||||
|
{{ labels.empty }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="approval-step-list"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
v-for="(step, index) in modelValue"
|
||||||
|
:key="step.id ?? `${index}-${step.sortOrder}`"
|
||||||
|
class="approval-step-card"
|
||||||
|
>
|
||||||
|
<div class="approval-step-heading">
|
||||||
|
<div>
|
||||||
|
<small>{{ labels.stepNumber(index + 1) }}</small>
|
||||||
|
<strong>{{ step.name || labels.unnamedStep }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="approval-step-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:aria-label="labels.moveUp"
|
||||||
|
:disabled="disabled || index === 0"
|
||||||
|
@click="moveStep(index, -1)"
|
||||||
|
>
|
||||||
|
<v-icon :icon="mdiArrowUp" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:aria-label="labels.moveDown"
|
||||||
|
:disabled="disabled || index === modelValue.length - 1"
|
||||||
|
@click="moveStep(index, 1)"
|
||||||
|
>
|
||||||
|
<v-icon :icon="mdiArrowDown" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:aria-label="labels.removeStep"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="removeStep(index)"
|
||||||
|
>
|
||||||
|
<v-icon :icon="mdiDeleteOutline" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="approval-step-fields">
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ labels.fields.name }}</span>
|
||||||
|
<input
|
||||||
|
:value="step.name"
|
||||||
|
type="text"
|
||||||
|
:disabled="disabled"
|
||||||
|
@input="updateStep(index, { name: $event.target.value })"
|
||||||
|
/>
|
||||||
|
<small
|
||||||
|
v-if="errors[index]?.name"
|
||||||
|
class="field-error"
|
||||||
|
>
|
||||||
|
{{ errors[index].name }}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ labels.fields.targetType }}</span>
|
||||||
|
<select
|
||||||
|
:value="step.targetType"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="updateStep(index, { targetType: $event.target.value })"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="targetType in targetTypes"
|
||||||
|
:key="targetType"
|
||||||
|
:value="targetType"
|
||||||
|
>
|
||||||
|
{{ labels.targetTypes[targetType] }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ labels.fields.targetValue }}</span>
|
||||||
|
<select
|
||||||
|
v-if="step.targetType === 'Role'"
|
||||||
|
:value="step.targetValue"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="updateStep(index, { targetValue: $event.target.value })"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="role in roleOptions"
|
||||||
|
:key="role"
|
||||||
|
:value="role"
|
||||||
|
>
|
||||||
|
{{ labels.roles[role] }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
v-else-if="step.targetType === 'Membership'"
|
||||||
|
:value="step.targetValue"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="updateStep(index, { targetValue: $event.target.value })"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="membership in membershipOptions"
|
||||||
|
:key="membership"
|
||||||
|
:value="membership"
|
||||||
|
>
|
||||||
|
{{ labels.memberships[membership] }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
v-else
|
||||||
|
:value="getSelectedMemberIds(step)"
|
||||||
|
:disabled="disabled"
|
||||||
|
multiple
|
||||||
|
size="5"
|
||||||
|
@change="updateMemberTargets(index, $event.target.selectedOptions)"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="member in members"
|
||||||
|
:key="member.id"
|
||||||
|
:value="member.id"
|
||||||
|
>
|
||||||
|
{{ member.displayName }} - {{ member.email }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<small
|
||||||
|
v-if="step.targetType === 'Member'"
|
||||||
|
class="field-help"
|
||||||
|
>
|
||||||
|
{{ labels.selectMembers }}
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<small
|
||||||
|
v-if="errors[index]?.targetValue"
|
||||||
|
class="field-error"
|
||||||
|
>
|
||||||
|
{{ errors[index].targetValue }}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ labels.fields.requiredApproverCount }}</span>
|
||||||
|
<input
|
||||||
|
:value="step.requiredApproverCount"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
:disabled="disabled"
|
||||||
|
@input="updateStep(index, { requiredApproverCount: Number($event.target.value) })"
|
||||||
|
/>
|
||||||
|
<small
|
||||||
|
v-if="errors[index]?.requiredApproverCount"
|
||||||
|
class="field-error"
|
||||||
|
>
|
||||||
|
{{ errors[index].requiredApproverCount }}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.approval-workflow-editor {
|
||||||
|
@apply flex flex-col gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-editor-header {
|
||||||
|
@apply flex flex-col gap-3 rounded-[1rem] border px-4 py-4 sm:flex-row sm:items-center sm:justify-between;
|
||||||
|
background: #fffaf2;
|
||||||
|
border-color: rgba(23, 32, 51, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-editor-header div,
|
||||||
|
.approval-step-heading div:first-child {
|
||||||
|
@apply flex min-w-0 flex-col gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-editor-header strong,
|
||||||
|
.approval-step-heading strong {
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-editor-header span,
|
||||||
|
.approval-empty,
|
||||||
|
.approval-step-heading small {
|
||||||
|
@apply text-sm leading-6;
|
||||||
|
color: #526178;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-step-list {
|
||||||
|
@apply flex flex-col gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-empty,
|
||||||
|
.approval-step-card {
|
||||||
|
@apply rounded-[1rem] border px-4 py-4;
|
||||||
|
background: #fffaf2;
|
||||||
|
border-color: rgba(23, 32, 51, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-step-card {
|
||||||
|
@apply flex flex-col gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-step-heading {
|
||||||
|
@apply flex items-start justify-between gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-step-actions {
|
||||||
|
@apply flex flex-shrink-0 gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-step-actions button {
|
||||||
|
@apply inline-flex h-9 w-9 items-center justify-center rounded-full;
|
||||||
|
background: rgba(23, 32, 51, 0.08);
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-step-actions button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-step-fields {
|
||||||
|
@apply grid gap-3 md:grid-cols-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm font-semibold;
|
||||||
|
background: rgba(23, 32, 51, 0.08);
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
@apply flex flex-col gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
@apply text-sm font-semibold;
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
||||||
|
background: #fffdf8;
|
||||||
|
border-color: rgba(23, 32, 51, 0.1);
|
||||||
|
color: #172033;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
@apply text-sm leading-6;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-help {
|
||||||
|
@apply text-sm leading-6;
|
||||||
|
color: #526178;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,18 +17,11 @@
|
|||||||
|
|
||||||
const contentStatusMeta = {
|
const contentStatusMeta = {
|
||||||
Draft: { tone: 'production', readiness: 'building' },
|
Draft: { tone: 'production', readiness: 'building' },
|
||||||
'In internal review': { tone: 'approval', readiness: 'approval' },
|
'In production': { tone: 'production', readiness: 'building' },
|
||||||
'Changes requested internally': { tone: 'risk', readiness: 'rework' },
|
'In approval': { tone: 'approval', readiness: 'approval' },
|
||||||
'Internal changes in progress': { tone: 'production', readiness: 'building' },
|
|
||||||
'Ready for client review': { tone: 'approval', readiness: 'approval' },
|
|
||||||
'In client review': { tone: 'approval', readiness: 'approval' },
|
|
||||||
'Changes requested by client': { tone: 'risk', readiness: 'rework' },
|
|
||||||
'Client changes in progress': { tone: 'production', readiness: 'building' },
|
|
||||||
Approved: { tone: 'ready', readiness: 'ready' },
|
Approved: { tone: 'ready', readiness: 'ready' },
|
||||||
'Ready to publish': { tone: 'ready', readiness: 'ready' },
|
Scheduled: { tone: 'ready', readiness: 'scheduled' },
|
||||||
Published: { tone: 'published', readiness: 'published' },
|
Published: { tone: 'published', readiness: 'published' },
|
||||||
Rejected: { tone: 'risk', readiness: 'blocked' },
|
|
||||||
Archived: { tone: 'muted', readiness: 'archived' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const contentItemsByProjectId = computed(() => {
|
const contentItemsByProjectId = computed(() => {
|
||||||
@@ -49,7 +42,7 @@
|
|||||||
.map(project => buildProjectEntry(project));
|
.map(project => buildProjectEntry(project));
|
||||||
|
|
||||||
const contentEntries = contentItemsStore.items
|
const contentEntries = contentItemsStore.items
|
||||||
.filter(item => item.dueDate && item.status !== 'Archived')
|
.filter(item => item.dueDate)
|
||||||
.map(item => buildContentEntry(item));
|
.map(item => buildContentEntry(item));
|
||||||
|
|
||||||
return [...projectEntries, ...contentEntries].sort(sortByDate);
|
return [...projectEntries, ...contentEntries].sort(sortByDate);
|
||||||
@@ -164,7 +157,7 @@
|
|||||||
|
|
||||||
function buildProjectEntry(project) {
|
function buildProjectEntry(project) {
|
||||||
const projectItems = contentItemsByProjectId.value.get(project.id) ?? [];
|
const projectItems = contentItemsByProjectId.value.get(project.id) ?? [];
|
||||||
const approvedCount = projectItems.filter(item => ['Approved', 'Ready to publish', 'Published'].includes(item.status)).length;
|
const approvedCount = projectItems.filter(item => ['Approved', 'Scheduled', 'Published'].includes(item.status)).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
|
|||||||
@@ -32,9 +32,7 @@
|
|||||||
return startOfDay(item.dueDate) >= today.value;
|
return startOfDay(item.dueDate) >= today.value;
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
const blockingCount = workspaceContent.filter(item =>
|
const blockingCount = workspaceContent.filter(item => item.status === 'In approval').length;
|
||||||
['In internal review', 'Ready for client review', 'In client review', 'Changes requested by client'].includes(item.status)
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: workspace.id,
|
id: workspace.id,
|
||||||
@@ -79,7 +77,7 @@
|
|||||||
route: { name: 'content-item-detail', params: { id: item.id } },
|
route: { name: 'content-item-detail', params: { id: item.id } },
|
||||||
}))
|
}))
|
||||||
.filter(item =>
|
.filter(item =>
|
||||||
item.date < today.value && !['Approved', 'Ready to publish', 'Published', 'Archived'].includes(item.status)
|
item.date < today.value && !['Approved', 'Scheduled', 'Published'].includes(item.status)
|
||||||
)
|
)
|
||||||
.sort((left, right) => left.date.getTime() - right.date.getTime())
|
.sort((left, right) => left.date.getTime() - right.date.getTime())
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import AppAvatar from '@/components/AppAvatar.vue';
|
import AppAvatar from '@/components/AppAvatar.vue';
|
||||||
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
||||||
|
import ApprovalWorkflowEditor from '@/features/workspaces/components/ApprovalWorkflowEditor.vue';
|
||||||
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
|
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
|
||||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||||
import {
|
import {
|
||||||
@@ -20,16 +21,22 @@
|
|||||||
const settingsForm = reactive({
|
const settingsForm = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
timeZone: '',
|
timeZone: '',
|
||||||
|
approvalMode: 'Required',
|
||||||
|
schedulePostsAutomaticallyOnApproval: false,
|
||||||
|
lockContentAfterApproval: false,
|
||||||
|
sendAutomaticApprovalReminders: false,
|
||||||
|
approvalSteps: [],
|
||||||
});
|
});
|
||||||
const settingsError = ref(null);
|
const settingsError = ref(null);
|
||||||
const settingsStatus = ref(null);
|
const settingsStatus = ref(null);
|
||||||
|
const approvalStepErrors = ref([]);
|
||||||
const logoError = ref(null);
|
const logoError = ref(null);
|
||||||
const logoStatus = ref(null);
|
const logoStatus = ref(null);
|
||||||
const isLogoDialogOpen = ref(false);
|
const isLogoDialogOpen = ref(false);
|
||||||
|
|
||||||
const inviteForm = reactive({
|
const inviteForm = reactive({
|
||||||
email: '',
|
email: '',
|
||||||
role: 'workspaceMember',
|
role: 'workspace-member',
|
||||||
});
|
});
|
||||||
|
|
||||||
const pendingInvites = computed(() =>
|
const pendingInvites = computed(() =>
|
||||||
@@ -38,6 +45,7 @@
|
|||||||
const workspaceMembers = computed(() =>
|
const workspaceMembers = computed(() =>
|
||||||
workspaceStore.membersByWorkspace[workspaceStore.activeWorkspaceId] ?? []
|
workspaceStore.membersByWorkspace[workspaceStore.activeWorkspaceId] ?? []
|
||||||
);
|
);
|
||||||
|
const normalizedApprovalSteps = computed(() => normalizeApprovalSteps(settingsForm.approvalSteps));
|
||||||
const isSettingsDirty = computed(() => {
|
const isSettingsDirty = computed(() => {
|
||||||
const workspace = workspaceStore.activeWorkspace;
|
const workspace = workspaceStore.activeWorkspace;
|
||||||
|
|
||||||
@@ -45,7 +53,15 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return settingsForm.name.trim() !== workspace.name || settingsForm.timeZone.trim() !== workspace.timeZone;
|
const workspaceApprovalSteps = normalizeApprovalSteps(workspace.approvalSteps ?? []);
|
||||||
|
|
||||||
|
return settingsForm.name.trim() !== workspace.name ||
|
||||||
|
settingsForm.timeZone.trim() !== workspace.timeZone ||
|
||||||
|
settingsForm.approvalMode !== (workspace.approvalMode ?? 'Required') ||
|
||||||
|
settingsForm.schedulePostsAutomaticallyOnApproval !== Boolean(workspace.schedulePostsAutomaticallyOnApproval) ||
|
||||||
|
settingsForm.lockContentAfterApproval !== Boolean(workspace.lockContentAfterApproval) ||
|
||||||
|
settingsForm.sendAutomaticApprovalReminders !== Boolean(workspace.sendAutomaticApprovalReminders) ||
|
||||||
|
JSON.stringify(normalizedApprovalSteps.value) !== JSON.stringify(workspaceApprovalSteps);
|
||||||
});
|
});
|
||||||
const settingsTabs = computed(() => [
|
const settingsTabs = computed(() => [
|
||||||
{ key: 'general', label: t('workspaceSettings.tabs.general'), icon: mdiCogOutline },
|
{ key: 'general', label: t('workspaceSettings.tabs.general'), icon: mdiCogOutline },
|
||||||
@@ -53,29 +69,113 @@
|
|||||||
{ key: 'workflow', label: t('workspaceSettings.tabs.workflow'), icon: mdiTuneVariant },
|
{ key: 'workflow', label: t('workspaceSettings.tabs.workflow'), icon: mdiTuneVariant },
|
||||||
{ key: 'connectors', label: t('workspaceSettings.tabs.connectors'), icon: mdiFolderGoogleDrive },
|
{ key: 'connectors', label: t('workspaceSettings.tabs.connectors'), icon: mdiFolderGoogleDrive },
|
||||||
]);
|
]);
|
||||||
const workflowSteps = computed(() => [
|
const approvalModeOptions = computed(() => [
|
||||||
{
|
{ value: 'None', label: t('workspaceSettings.approvals.modes.none'), description: t('workspaceSettings.approvals.modeHelp.none') },
|
||||||
key: 'internal',
|
{ value: 'Optional', label: t('workspaceSettings.approvals.modes.optional'), description: t('workspaceSettings.approvals.modeHelp.optional') },
|
||||||
title: t('workspaceSettings.approvals.steps.internal'),
|
{ value: 'Required', label: t('workspaceSettings.approvals.modes.required'), description: t('workspaceSettings.approvals.modeHelp.required') },
|
||||||
detail: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
|
{ value: 'Multi-level', label: t('workspaceSettings.approvals.modes.multiLevel'), description: t('workspaceSettings.approvals.modeHelp.multiLevel') },
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'client',
|
|
||||||
title: t('workspaceSettings.approvals.steps.client'),
|
|
||||||
detail: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'publish',
|
|
||||||
title: t('workspaceSettings.approvals.steps.publish'),
|
|
||||||
detail: t('workspaceSettings.approvals.stepDetail.manualPublish'),
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
const activeApprovalModeOption = computed(() =>
|
||||||
|
approvalModeOptions.value.find(option => option.value === settingsForm.approvalMode) ?? approvalModeOptions.value[2]
|
||||||
|
);
|
||||||
|
const approvalWorkflowEditorLabels = computed(() => ({
|
||||||
|
title: t('workspaceSettings.approvals.editor.title'),
|
||||||
|
description: t('workspaceSettings.approvals.editor.description'),
|
||||||
|
addStep: t('workspaceSettings.approvals.editor.addStep'),
|
||||||
|
empty: t('workspaceSettings.approvals.editor.empty'),
|
||||||
|
unnamedStep: t('workspaceSettings.approvals.editor.unnamedStep'),
|
||||||
|
moveUp: t('workspaceSettings.approvals.editor.moveUp'),
|
||||||
|
moveDown: t('workspaceSettings.approvals.editor.moveDown'),
|
||||||
|
removeStep: t('workspaceSettings.approvals.editor.removeStep'),
|
||||||
|
selectMember: t('workspaceSettings.approvals.editor.selectMember'),
|
||||||
|
selectMembers: t('workspaceSettings.approvals.editor.selectMembers'),
|
||||||
|
defaultStepName: number => t('workspaceSettings.approvals.editor.defaultStepName', { number }),
|
||||||
|
stepNumber: number => t('workspaceSettings.approvals.editor.stepNumber', { number }),
|
||||||
|
fields: {
|
||||||
|
name: t('workspaceSettings.approvals.editor.fields.name'),
|
||||||
|
targetType: t('workspaceSettings.approvals.editor.fields.targetType'),
|
||||||
|
targetValue: t('workspaceSettings.approvals.editor.fields.targetValue'),
|
||||||
|
requiredApproverCount: t('workspaceSettings.approvals.editor.fields.requiredApproverCount'),
|
||||||
|
},
|
||||||
|
targetTypes: {
|
||||||
|
Role: t('workspaceSettings.approvals.editor.targetTypes.role'),
|
||||||
|
Membership: t('workspaceSettings.approvals.editor.targetTypes.membership'),
|
||||||
|
Member: t('workspaceSettings.approvals.editor.targetTypes.member'),
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
administrator: t('workspaceSettings.roles.administrator'),
|
||||||
|
manager: t('workspaceSettings.roles.manager'),
|
||||||
|
'workspace-member': t('workspaceSettings.roles.workspace-member'),
|
||||||
|
client: t('workspaceSettings.roles.client'),
|
||||||
|
provider: t('workspaceSettings.roles.provider'),
|
||||||
|
},
|
||||||
|
memberships: {
|
||||||
|
Team: t('workspaceSettings.approvals.editor.memberships.team'),
|
||||||
|
Client: t('workspaceSettings.approvals.editor.memberships.client'),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const workflowSteps = computed(() => {
|
||||||
|
if (settingsForm.approvalMode === 'None') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'none',
|
||||||
|
title: t('workspaceSettings.approvals.steps.none'),
|
||||||
|
detail: t('workspaceSettings.approvals.stepDetail.none'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsForm.approvalMode === 'Multi-level') {
|
||||||
|
const configuredSteps = normalizedApprovalSteps.value.map((step, index) => ({
|
||||||
|
key: `approval-${index}`,
|
||||||
|
title: step.name || t('workspaceSettings.approvals.editor.unnamedStep'),
|
||||||
|
detail: t('workspaceSettings.approvals.stepDetail.multiLevelTarget', {
|
||||||
|
count: step.requiredApproverCount,
|
||||||
|
target: formatApprovalTarget(step),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [
|
||||||
|
...configuredSteps,
|
||||||
|
{
|
||||||
|
key: 'publish',
|
||||||
|
title: t('workspaceSettings.approvals.steps.publish'),
|
||||||
|
detail: settingsForm.schedulePostsAutomaticallyOnApproval
|
||||||
|
? t('workspaceSettings.approvals.stepDetail.autoSchedule')
|
||||||
|
: t('workspaceSettings.approvals.stepDetail.manualSchedule'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'approval',
|
||||||
|
title: t('workspaceSettings.approvals.steps.approval'),
|
||||||
|
detail: settingsForm.approvalMode === 'Optional'
|
||||||
|
? t('workspaceSettings.approvals.stepDetail.optional')
|
||||||
|
: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'publish',
|
||||||
|
title: t('workspaceSettings.approvals.steps.publish'),
|
||||||
|
detail: settingsForm.schedulePostsAutomaticallyOnApproval
|
||||||
|
? t('workspaceSettings.approvals.stepDetail.autoSchedule')
|
||||||
|
: t('workspaceSettings.approvals.stepDetail.manualSchedule'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => workspaceStore.activeWorkspace,
|
() => workspaceStore.activeWorkspace,
|
||||||
workspace => {
|
workspace => {
|
||||||
settingsForm.name = workspace?.name ?? '';
|
settingsForm.name = workspace?.name ?? '';
|
||||||
settingsForm.timeZone = workspace?.timeZone ?? '';
|
settingsForm.timeZone = workspace?.timeZone ?? '';
|
||||||
|
settingsForm.approvalMode = workspace?.approvalMode ?? 'Required';
|
||||||
|
settingsForm.schedulePostsAutomaticallyOnApproval = Boolean(workspace?.schedulePostsAutomaticallyOnApproval);
|
||||||
|
settingsForm.lockContentAfterApproval = Boolean(workspace?.lockContentAfterApproval);
|
||||||
|
settingsForm.sendAutomaticApprovalReminders = Boolean(workspace?.sendAutomaticApprovalReminders);
|
||||||
|
settingsForm.approvalSteps = normalizeApprovalSteps(workspace?.approvalSteps ?? []);
|
||||||
|
approvalStepErrors.value = [];
|
||||||
settingsError.value = null;
|
settingsError.value = null;
|
||||||
settingsStatus.value = null;
|
settingsStatus.value = null;
|
||||||
},
|
},
|
||||||
@@ -117,12 +217,28 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settingsForm.approvalMode === 'Multi-level' && !validateApprovalSteps()) {
|
||||||
|
settingsError.value ||= t('workspaceSettings.approvals.editor.errors.fixInvalidSteps');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
approvalStepErrors.value = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await workspaceStore.updateWorkspace(workspace.id, {
|
await workspaceStore.updateWorkspace(workspace.id, {
|
||||||
name,
|
name,
|
||||||
timeZone,
|
timeZone,
|
||||||
|
approvalMode: settingsForm.approvalMode,
|
||||||
|
schedulePostsAutomaticallyOnApproval: settingsForm.schedulePostsAutomaticallyOnApproval,
|
||||||
|
lockContentAfterApproval: settingsForm.lockContentAfterApproval,
|
||||||
|
sendAutomaticApprovalReminders: settingsForm.sendAutomaticApprovalReminders,
|
||||||
|
approvalSteps: settingsForm.approvalMode === 'Multi-level'
|
||||||
|
? normalizedApprovalSteps.value
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
settingsStatus.value = t('workspaceSettings.general.saved');
|
settingsStatus.value = activeTab.value === 'workflow'
|
||||||
|
? t('workspaceSettings.approvals.saved')
|
||||||
|
: t('workspaceSettings.general.saved');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update workspace settings:', error);
|
console.error('Failed to update workspace settings:', error);
|
||||||
settingsError.value = t('workspaceSettings.errors.updateFailed');
|
settingsError.value = t('workspaceSettings.errors.updateFailed');
|
||||||
@@ -161,7 +277,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
inviteForm.email = '';
|
inviteForm.email = '';
|
||||||
inviteForm.role = 'workspaceMember';
|
inviteForm.role = 'workspace-member';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to invite workspace member:', error);
|
console.error('Failed to invite workspace member:', error);
|
||||||
}
|
}
|
||||||
@@ -183,6 +299,77 @@
|
|||||||
const normalizedRole = role.charAt(0).toLowerCase() + role.slice(1);
|
const normalizedRole = role.charAt(0).toLowerCase() + role.slice(1);
|
||||||
return t(`workspaceSettings.roles.${normalizedRole}`, role);
|
return t(`workspaceSettings.roles.${normalizedRole}`, role);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeApprovalSteps(steps) {
|
||||||
|
return [...steps]
|
||||||
|
.sort((left, right) => Number(left.sortOrder ?? 0) - Number(right.sortOrder ?? 0))
|
||||||
|
.map((step, index) => ({
|
||||||
|
name: step.name ?? '',
|
||||||
|
sortOrder: index,
|
||||||
|
targetType: step.targetType ?? 'Role',
|
||||||
|
targetValue: step.targetValue ?? '',
|
||||||
|
requiredApproverCount: Number(step.requiredApproverCount ?? 1),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateApprovalSteps() {
|
||||||
|
const errors = normalizedApprovalSteps.value.map(step => {
|
||||||
|
const stepErrors = {};
|
||||||
|
|
||||||
|
if (!step.name.trim()) {
|
||||||
|
stepErrors.name = t('workspaceSettings.approvals.editor.errors.nameRequired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!step.targetValue?.trim()) {
|
||||||
|
stepErrors.targetValue = t('workspaceSettings.approvals.editor.errors.targetRequired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.targetType === 'Member' && getMemberTargetIds(step).length < step.requiredApproverCount) {
|
||||||
|
stepErrors.targetValue = t('workspaceSettings.approvals.editor.errors.notEnoughMembers');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(step.requiredApproverCount) || step.requiredApproverCount < 1) {
|
||||||
|
stepErrors.requiredApproverCount = t('workspaceSettings.approvals.editor.errors.requiredApproverCount');
|
||||||
|
}
|
||||||
|
|
||||||
|
return stepErrors;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!errors.length) {
|
||||||
|
settingsError.value = t('workspaceSettings.approvals.editor.errors.atLeastOneStep');
|
||||||
|
approvalStepErrors.value = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
approvalStepErrors.value = errors;
|
||||||
|
settingsError.value = null;
|
||||||
|
return !errors.some(error => Object.keys(error).length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatApprovalTarget(step) {
|
||||||
|
if (step.targetType === 'Membership') {
|
||||||
|
return t(`workspaceSettings.approvals.editor.memberships.${step.targetValue.toLowerCase()}`, step.targetValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.targetType === 'Member') {
|
||||||
|
const selectedNames = getMemberTargetIds(step)
|
||||||
|
.map(memberId => workspaceMembers.value.find(candidate => candidate.id === memberId)?.displayName)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return selectedNames.length
|
||||||
|
? selectedNames.join(', ')
|
||||||
|
: t('workspaceSettings.approvals.editor.targetTypes.member');
|
||||||
|
}
|
||||||
|
|
||||||
|
return t(`workspaceSettings.roles.${step.targetValue}`, step.targetValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemberTargetIds(step) {
|
||||||
|
return (step.targetValue ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map(value => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -330,7 +517,7 @@
|
|||||||
<label class="field">
|
<label class="field">
|
||||||
<span>{{ t('workspaceSettings.fields.memberRole') }}</span>
|
<span>{{ t('workspaceSettings.fields.memberRole') }}</span>
|
||||||
<select v-model="inviteForm.role">
|
<select v-model="inviteForm.role">
|
||||||
<option value="workspaceMember">{{ t('workspaceSettings.roles.workspaceMember') }}</option>
|
<option value="workspace-member">{{ t('workspaceSettings.roles.workspace-member') }}</option>
|
||||||
<option value="client">{{ t('workspaceSettings.roles.client') }}</option>
|
<option value="client">{{ t('workspaceSettings.roles.client') }}</option>
|
||||||
<option value="provider">{{ t('workspaceSettings.roles.provider') }}</option>
|
<option value="provider">{{ t('workspaceSettings.roles.provider') }}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -432,19 +619,95 @@
|
|||||||
<p>{{ t('workspaceSettings.approvals.flowDescription') }}</p>
|
<p>{{ t('workspaceSettings.approvals.flowDescription') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="settingsError"
|
||||||
|
class="page-message error"
|
||||||
|
>
|
||||||
|
{{ settingsError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="settingsStatus"
|
||||||
|
class="page-message success"
|
||||||
|
>
|
||||||
|
{{ settingsStatus }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="workflow-rule-list">
|
<div class="workflow-rule-list">
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ t('workspaceSettings.approvals.fields.approvalMode') }}</span>
|
||||||
|
<select
|
||||||
|
v-model="settingsForm.approvalMode"
|
||||||
|
:disabled="workspaceStore.isUpdating"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in approvalModeOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="workflow-rule">
|
<div class="workflow-rule">
|
||||||
<strong>{{ t('workspaceSettings.approvals.fields.requireInternalReview') }}</strong>
|
<strong>{{ activeApprovalModeOption.label }}</strong>
|
||||||
<span>{{ t('workspaceSettings.approvals.fieldHelp.requireInternalReview') }}</span>
|
<span>{{ activeApprovalModeOption.description }}</span>
|
||||||
</div>
|
|
||||||
<div class="workflow-rule">
|
|
||||||
<strong>{{ t('workspaceSettings.approvals.fields.requireClientReview') }}</strong>
|
|
||||||
<span>{{ t('workspaceSettings.approvals.fieldHelp.requireClientReview') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="workflow-rule">
|
|
||||||
<strong>{{ t('workspaceSettings.approvals.fields.publishBehaviour') }}</strong>
|
|
||||||
<span>{{ t('workspaceSettings.approvals.publishBehaviour.manual') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ApprovalWorkflowEditor
|
||||||
|
v-if="settingsForm.approvalMode === 'Multi-level'"
|
||||||
|
v-model="settingsForm.approvalSteps"
|
||||||
|
:members="workspaceMembers"
|
||||||
|
:errors="approvalStepErrors"
|
||||||
|
:disabled="workspaceStore.isUpdating"
|
||||||
|
:labels="approvalWorkflowEditorLabels"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="workflow-toggle">
|
||||||
|
<input
|
||||||
|
v-model="settingsForm.schedulePostsAutomaticallyOnApproval"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="workspaceStore.isUpdating"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{{ t('workspaceSettings.approvals.fields.schedulePostsAutomaticallyOnApproval') }}</strong>
|
||||||
|
<small>{{ t('workspaceSettings.approvals.fieldHelp.schedulePostsAutomaticallyOnApproval') }}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="workflow-toggle">
|
||||||
|
<input
|
||||||
|
v-model="settingsForm.lockContentAfterApproval"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="workspaceStore.isUpdating"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{{ t('workspaceSettings.approvals.fields.lockContentAfterApproval') }}</strong>
|
||||||
|
<small>{{ t('workspaceSettings.approvals.fieldHelp.lockContentAfterApproval') }}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="workflow-toggle">
|
||||||
|
<input
|
||||||
|
v-model="settingsForm.sendAutomaticApprovalReminders"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="workspaceStore.isUpdating"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{{ t('workspaceSettings.approvals.fields.sendAutomaticApprovalReminders') }}</strong>
|
||||||
|
<small>{{ t('workspaceSettings.approvals.fieldHelp.sendAutomaticApprovalReminders') }}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="primary-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="workspaceStore.isUpdating || !isSettingsDirty"
|
||||||
|
@click="submitWorkspaceSettings"
|
||||||
|
>
|
||||||
|
{{ workspaceStore.isUpdating ? t('common.saving') : t('workspaceSettings.approvals.saveAction') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -683,6 +946,7 @@
|
|||||||
.empty-state,
|
.empty-state,
|
||||||
.connector-row,
|
.connector-row,
|
||||||
.workflow-rule,
|
.workflow-rule,
|
||||||
|
.workflow-toggle,
|
||||||
.workflow-step {
|
.workflow-step {
|
||||||
@apply rounded-[1rem] border px-4 py-4;
|
@apply rounded-[1rem] border px-4 py-4;
|
||||||
background: #fffaf2;
|
background: #fffaf2;
|
||||||
@@ -696,10 +960,19 @@
|
|||||||
.invite-row div,
|
.invite-row div,
|
||||||
.connector-copy,
|
.connector-copy,
|
||||||
.workflow-rule,
|
.workflow-rule,
|
||||||
|
.workflow-toggle span,
|
||||||
.workflow-step-copy {
|
.workflow-step-copy {
|
||||||
@apply flex flex-col gap-1;
|
@apply flex flex-col gap-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-toggle {
|
||||||
|
@apply flex items-start gap-3 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-toggle input {
|
||||||
|
@apply mt-1 h-4 w-4 accent-teal-700;
|
||||||
|
}
|
||||||
|
|
||||||
.connector-row {
|
.connector-row {
|
||||||
@apply flex flex-col gap-4 md:flex-row md:items-center md:justify-between;
|
@apply flex flex-col gap-4 md:flex-row md:items-center md:justify-between;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
mdiCalendarMonthOutline,
|
mdiCalendarMonthOutline,
|
||||||
mdiChevronDown,
|
mdiChevronDown,
|
||||||
mdiCogOutline,
|
mdiCogOutline,
|
||||||
|
mdiFileDocumentOutline,
|
||||||
mdiFolderOutline,
|
mdiFolderOutline,
|
||||||
mdiHomeOutline,
|
mdiHomeOutline,
|
||||||
mdiImageMultipleOutline,
|
mdiImageMultipleOutline,
|
||||||
@@ -401,6 +402,36 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-section-header">
|
||||||
|
<router-link
|
||||||
|
to="/app/content"
|
||||||
|
class="sidebar-link sidebar-link-section"
|
||||||
|
active-class="sidebar-link-active"
|
||||||
|
:title="!isExpanded ? t('nav.content') : null"
|
||||||
|
>
|
||||||
|
<span class="sidebar-link-main">
|
||||||
|
<v-icon :icon="mdiFileDocumentOutline" />
|
||||||
|
<span
|
||||||
|
v-if="isExpanded"
|
||||||
|
class="sidebar-link-label"
|
||||||
|
>
|
||||||
|
{{ t('nav.content') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link
|
||||||
|
v-if="isExpanded"
|
||||||
|
:to="{ name: 'content-item-create' }"
|
||||||
|
class="sidebar-section-action"
|
||||||
|
:title="t('contentItems.newItem')"
|
||||||
|
>
|
||||||
|
<v-icon :icon="mdiPlus" />
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<div class="sidebar-section-header">
|
<div class="sidebar-section-header">
|
||||||
<router-link
|
<router-link
|
||||||
|
|||||||
@@ -308,7 +308,7 @@
|
|||||||
"building": "In production",
|
"building": "In production",
|
||||||
"approval": "Awaiting approval",
|
"approval": "Awaiting approval",
|
||||||
"rework": "Needs revision",
|
"rework": "Needs revision",
|
||||||
"ready": "Ready to publish",
|
"ready": "Approved",
|
||||||
"published": "Published",
|
"published": "Published",
|
||||||
"blocked": "Blocked",
|
"blocked": "Blocked",
|
||||||
"archived": "Archived",
|
"archived": "Archived",
|
||||||
@@ -512,7 +512,7 @@
|
|||||||
"manager": "Manager",
|
"manager": "Manager",
|
||||||
"client": "Client reviewer",
|
"client": "Client reviewer",
|
||||||
"provider": "Subcontractor",
|
"provider": "Subcontractor",
|
||||||
"workspaceMember": "Workspace member"
|
"workspace-member": "Workspace member"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@@ -559,35 +559,83 @@
|
|||||||
},
|
},
|
||||||
"approvals": {
|
"approvals": {
|
||||||
"flowTitle": "Approval flow",
|
"flowTitle": "Approval flow",
|
||||||
"flowDescription": "Personalize how content moves through internal review, client review, and publishing for this workspace.",
|
"flowDescription": "Configure how content approval works across this workspace.",
|
||||||
"previewTitle": "Flow preview",
|
"previewTitle": "Flow preview",
|
||||||
"previewDescription": "This is the sequence the workspace will use based on the current configuration.",
|
"previewDescription": "This is the sequence the workspace will use based on the current configuration.",
|
||||||
"saved": "Approval flow saved for this workspace in this browser.",
|
"saved": "Approval flow saved.",
|
||||||
|
"saveAction": "Save approval flow",
|
||||||
"fields": {
|
"fields": {
|
||||||
"requireInternalReview": "Require internal review",
|
"approvalMode": "Approval mode",
|
||||||
"internalApproversRequired": "Internal approvers required",
|
"schedulePostsAutomaticallyOnApproval": "Schedule posts automatically on approval",
|
||||||
"requireClientReview": "Require client review",
|
"lockContentAfterApproval": "Lock content after approval",
|
||||||
"clientApproversRequired": "Client approvers required",
|
"sendAutomaticApprovalReminders": "Send automatic approval reminders"
|
||||||
"defaultReviewerRole": "Default reviewer role",
|
|
||||||
"publishBehaviour": "After final approval"
|
|
||||||
},
|
},
|
||||||
"fieldHelp": {
|
"fieldHelp": {
|
||||||
"requireInternalReview": "Content must be approved internally before client review can begin.",
|
"schedulePostsAutomaticallyOnApproval": "Final approval moves content to Scheduled when it already has a planned publish date.",
|
||||||
"requireClientReview": "Content must still pass through client approval before publication."
|
"lockContentAfterApproval": "Approval-controlled content becomes locked after final approval. Scheduling fields remain editable.",
|
||||||
|
"sendAutomaticApprovalReminders": "Current approvers receive daily reminders while an approval step is pending."
|
||||||
},
|
},
|
||||||
"publishBehaviour": {
|
"modes": {
|
||||||
"manual": "Mark ready to publish",
|
"none": "None",
|
||||||
"auto": "Auto-advance to ready"
|
"optional": "Optional",
|
||||||
|
"required": "Required",
|
||||||
|
"multiLevel": "Multi-level"
|
||||||
|
},
|
||||||
|
"modeHelp": {
|
||||||
|
"none": "Content skips approval workflow and can become Approved without approval actions.",
|
||||||
|
"optional": "A one-step approval workflow is available but does not block publication workflow.",
|
||||||
|
"required": "At least one approval is required before content can become Approved or Scheduled.",
|
||||||
|
"multiLevel": "Approval uses ordered steps with targeted approvers for each step."
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"title": "Multi-level steps",
|
||||||
|
"description": "Define who approves each ordered step before content reaches final approval.",
|
||||||
|
"addStep": "Add step",
|
||||||
|
"empty": "Add at least one approval step before saving multi-level approval.",
|
||||||
|
"unnamedStep": "Unnamed step",
|
||||||
|
"moveUp": "Move step up",
|
||||||
|
"moveDown": "Move step down",
|
||||||
|
"removeStep": "Remove step",
|
||||||
|
"selectMember": "Select a member",
|
||||||
|
"selectMembers": "Select one or more members. Hold Ctrl or Command to select multiple.",
|
||||||
|
"defaultStepName": "Approval step {number}",
|
||||||
|
"stepNumber": "Step {number}",
|
||||||
|
"fields": {
|
||||||
|
"name": "Display name",
|
||||||
|
"targetType": "Target type",
|
||||||
|
"targetValue": "Target",
|
||||||
|
"requiredApproverCount": "Required approvers"
|
||||||
|
},
|
||||||
|
"targetTypes": {
|
||||||
|
"role": "Role",
|
||||||
|
"membership": "Membership",
|
||||||
|
"member": "Member"
|
||||||
|
},
|
||||||
|
"memberships": {
|
||||||
|
"team": "Team",
|
||||||
|
"client": "Client"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"atLeastOneStep": "Multi-level approval requires at least one step.",
|
||||||
|
"fixInvalidSteps": "Fix the highlighted approval steps before saving.",
|
||||||
|
"nameRequired": "Enter a step name.",
|
||||||
|
"targetRequired": "Choose who can approve this step.",
|
||||||
|
"notEnoughMembers": "Select at least as many members as required approvers.",
|
||||||
|
"requiredApproverCount": "Enter at least 1 required approver."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"steps": {
|
"steps": {
|
||||||
"internal": "Internal review",
|
"none": "Approval skipped",
|
||||||
"client": "Client review",
|
"approval": "Approval",
|
||||||
"publish": "Publishing handoff"
|
"publish": "Publishing handoff"
|
||||||
},
|
},
|
||||||
"stepDetail": {
|
"stepDetail": {
|
||||||
|
"none": "No approval workflow is created for content in this workspace.",
|
||||||
|
"optional": "Approval can be collected, but it is not required before publication workflow.",
|
||||||
"approverCount": "{count} approver(s) required",
|
"approverCount": "{count} approver(s) required",
|
||||||
"autoPublish": "Content moves to ready automatically after the final approval.",
|
"multiLevelTarget": "{count} approver(s) from {target}",
|
||||||
"manualPublish": "Content stays in a manual ready-to-publish handoff after the final approval."
|
"autoSchedule": "Approved content with a planned publish date moves to Scheduled.",
|
||||||
|
"manualSchedule": "Approved content remains Approved until scheduling is handled."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -638,4 +686,4 @@
|
|||||||
"imageLoad": "Error loading image",
|
"imageLoad": "Error loading image",
|
||||||
"imageUpload": "Error uploading image"
|
"imageUpload": "Error uploading image"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,7 +308,7 @@
|
|||||||
"building": "En production",
|
"building": "En production",
|
||||||
"approval": "En attente d'approbation",
|
"approval": "En attente d'approbation",
|
||||||
"rework": "Révision requise",
|
"rework": "Révision requise",
|
||||||
"ready": "Prêt à publier",
|
"ready": "Approuvé",
|
||||||
"published": "Publié",
|
"published": "Publié",
|
||||||
"blocked": "Bloqué",
|
"blocked": "Bloqué",
|
||||||
"archived": "Archivé",
|
"archived": "Archivé",
|
||||||
@@ -512,7 +512,7 @@
|
|||||||
"manager": "Gestionnaire",
|
"manager": "Gestionnaire",
|
||||||
"client": "Réviseur client",
|
"client": "Réviseur client",
|
||||||
"provider": "Sous-traitant",
|
"provider": "Sous-traitant",
|
||||||
"workspaceMember": "Membre de l'espace"
|
"workspace-member": "Membre de l'espace"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
@@ -559,35 +559,83 @@
|
|||||||
},
|
},
|
||||||
"approvals": {
|
"approvals": {
|
||||||
"flowTitle": "Flux d'approbation",
|
"flowTitle": "Flux d'approbation",
|
||||||
"flowDescription": "Personnalisez le passage du contenu par la révision interne, la révision client et la mise en publication pour cet espace.",
|
"flowDescription": "Configurez le fonctionnement de l'approbation du contenu dans cet espace.",
|
||||||
"previewTitle": "Aperçu du flux",
|
"previewTitle": "Aperçu du flux",
|
||||||
"previewDescription": "Voici la séquence que l'espace utilisera selon la configuration actuelle.",
|
"previewDescription": "Voici la séquence que l'espace utilisera selon la configuration actuelle.",
|
||||||
"saved": "Le flux d'approbation a été enregistré pour cet espace dans ce navigateur.",
|
"saved": "Flux d'approbation enregistré.",
|
||||||
|
"saveAction": "Enregistrer le flux",
|
||||||
"fields": {
|
"fields": {
|
||||||
"requireInternalReview": "Exiger une révision interne",
|
"approvalMode": "Mode d'approbation",
|
||||||
"internalApproversRequired": "Approbateurs internes requis",
|
"schedulePostsAutomaticallyOnApproval": "Planifier automatiquement après approbation",
|
||||||
"requireClientReview": "Exiger une révision client",
|
"lockContentAfterApproval": "Verrouiller le contenu après approbation",
|
||||||
"clientApproversRequired": "Approbateurs client requis",
|
"sendAutomaticApprovalReminders": "Envoyer des rappels automatiques"
|
||||||
"defaultReviewerRole": "Rôle du réviseur par défaut",
|
|
||||||
"publishBehaviour": "Après l'approbation finale"
|
|
||||||
},
|
},
|
||||||
"fieldHelp": {
|
"fieldHelp": {
|
||||||
"requireInternalReview": "Le contenu doit être approuvé en interne avant de passer à la révision client.",
|
"schedulePostsAutomaticallyOnApproval": "L'approbation finale passe le contenu à Planifié quand une date de publication est déjà prévue.",
|
||||||
"requireClientReview": "Le contenu doit encore passer par une approbation client avant la publication."
|
"lockContentAfterApproval": "Le contenu contrôlé par l'approbation est verrouillé après l'approbation finale. Les champs de planification restent modifiables.",
|
||||||
|
"sendAutomaticApprovalReminders": "Les approbateurs courants reçoivent des rappels quotidiens tant qu'une étape est en attente."
|
||||||
},
|
},
|
||||||
"publishBehaviour": {
|
"modes": {
|
||||||
"manual": "Marquer prêt à publier",
|
"none": "Aucun",
|
||||||
"auto": "Passer automatiquement à prêt"
|
"optional": "Optionnel",
|
||||||
|
"required": "Requis",
|
||||||
|
"multiLevel": "Multi-niveaux"
|
||||||
|
},
|
||||||
|
"modeHelp": {
|
||||||
|
"none": "Le contenu saute le workflow d'approbation et peut devenir Approuvé sans action d'approbation.",
|
||||||
|
"optional": "Un workflow d'approbation en une étape est disponible, mais il ne bloque pas la publication.",
|
||||||
|
"required": "Au moins une approbation est requise avant que le contenu devienne Approuvé ou Planifié.",
|
||||||
|
"multiLevel": "L'approbation utilise des étapes ordonnées avec des approbateurs ciblés pour chaque étape."
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"title": "Étapes multi-niveaux",
|
||||||
|
"description": "Définissez qui approuve chaque étape ordonnée avant l'approbation finale du contenu.",
|
||||||
|
"addStep": "Ajouter une étape",
|
||||||
|
"empty": "Ajoutez au moins une étape d'approbation avant d'enregistrer le mode multi-niveaux.",
|
||||||
|
"unnamedStep": "Étape sans nom",
|
||||||
|
"moveUp": "Monter l'étape",
|
||||||
|
"moveDown": "Descendre l'étape",
|
||||||
|
"removeStep": "Supprimer l'étape",
|
||||||
|
"selectMember": "Sélectionner un membre",
|
||||||
|
"selectMembers": "Sélectionnez un ou plusieurs membres. Maintenez Ctrl ou Commande pour une sélection multiple.",
|
||||||
|
"defaultStepName": "Étape d'approbation {number}",
|
||||||
|
"stepNumber": "Étape {number}",
|
||||||
|
"fields": {
|
||||||
|
"name": "Nom affiché",
|
||||||
|
"targetType": "Type de cible",
|
||||||
|
"targetValue": "Cible",
|
||||||
|
"requiredApproverCount": "Approbateurs requis"
|
||||||
|
},
|
||||||
|
"targetTypes": {
|
||||||
|
"role": "Rôle",
|
||||||
|
"membership": "Appartenance",
|
||||||
|
"member": "Membre"
|
||||||
|
},
|
||||||
|
"memberships": {
|
||||||
|
"team": "Équipe",
|
||||||
|
"client": "Client"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"atLeastOneStep": "L'approbation multi-niveaux requiert au moins une étape.",
|
||||||
|
"fixInvalidSteps": "Corrigez les étapes d'approbation indiquées avant d'enregistrer.",
|
||||||
|
"nameRequired": "Saisissez un nom d'étape.",
|
||||||
|
"targetRequired": "Choisissez qui peut approuver cette étape.",
|
||||||
|
"notEnoughMembers": "Sélectionnez au moins autant de membres que d'approbateurs requis.",
|
||||||
|
"requiredApproverCount": "Saisissez au moins 1 approbateur requis."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"steps": {
|
"steps": {
|
||||||
"internal": "Révision interne",
|
"none": "Approbation ignorée",
|
||||||
"client": "Révision client",
|
"approval": "Approbation",
|
||||||
"publish": "Passage à la publication"
|
"publish": "Passage à la publication"
|
||||||
},
|
},
|
||||||
"stepDetail": {
|
"stepDetail": {
|
||||||
|
"none": "Aucun workflow d'approbation n'est créé pour le contenu de cet espace.",
|
||||||
|
"optional": "L'approbation peut être recueillie, mais elle n'est pas requise avant la publication.",
|
||||||
"approverCount": "{count} approbateur(s) requis",
|
"approverCount": "{count} approbateur(s) requis",
|
||||||
"autoPublish": "Le contenu passe automatiquement à prêt après l'approbation finale.",
|
"multiLevelTarget": "{count} approbateur(s) de {target}",
|
||||||
"manualPublish": "Le contenu reste dans une étape manuelle prêt à publier après l'approbation finale."
|
"autoSchedule": "Le contenu approuvé avec une date de publication prévue passe à Planifié.",
|
||||||
|
"manualSchedule": "Le contenu approuvé reste Approuvé jusqu'à sa planification."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -638,4 +686,4 @@
|
|||||||
"imageLoad": "Erreur lors du chargement de l'image",
|
"imageLoad": "Erreur lors du chargement de l'image",
|
||||||
"imageUpload": "Erreur lors du téléchargement de l'image"
|
"imageUpload": "Erreur lors du téléchargement de l'image"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,211 +27,211 @@ const DeveloperFeedbackListView = () => import('@/features/feedback/views/Develo
|
|||||||
const DeveloperFeedbackDetailView = () => import('@/features/feedback/views/DeveloperFeedbackDetailView.vue');
|
const DeveloperFeedbackDetailView = () => import('@/features/feedback/views/DeveloperFeedbackDetailView.vue');
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'landing',
|
name: 'landing',
|
||||||
component: Landing,
|
component: Landing,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app',
|
path: '/app',
|
||||||
redirect: { name: 'dashboard' },
|
redirect: { name: 'dashboard' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/dashboard',
|
path: '/app/dashboard',
|
||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
component: OverviewView,
|
component: OverviewView,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/workspace',
|
path: '/app/workspace',
|
||||||
name: 'workspace-dashboard',
|
name: 'workspace-dashboard',
|
||||||
component: DashboardView,
|
component: DashboardView,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/channels',
|
path: '/app/channels',
|
||||||
name: 'channels',
|
name: 'channels',
|
||||||
component: ChannelsView,
|
component: ChannelsView,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/media-library',
|
path: '/app/media-library',
|
||||||
name: 'media-library',
|
name: 'media-library',
|
||||||
component: MediaLibraryView,
|
component: MediaLibraryView,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/campaigns',
|
path: '/app/campaigns',
|
||||||
name: 'campaigns',
|
name: 'campaigns',
|
||||||
component: CampaignsView,
|
component: CampaignsView,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/campaigns/:projectId',
|
path: '/app/campaigns/:projectId',
|
||||||
name: 'campaign-detail',
|
name: 'campaign-detail',
|
||||||
component: CampaignDetailView,
|
component: CampaignDetailView,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/reviews',
|
path: '/app/reviews',
|
||||||
name: 'review-queue',
|
name: 'review-queue',
|
||||||
component: ReviewQueueView,
|
component: ReviewQueueView,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/my-feedback',
|
path: '/app/my-feedback',
|
||||||
name: 'my-feedback',
|
name: 'my-feedback',
|
||||||
component: MyFeedbackListView,
|
component: MyFeedbackListView,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/my-feedback/:id',
|
path: '/app/my-feedback/:id',
|
||||||
name: 'my-feedback-detail',
|
name: 'my-feedback-detail',
|
||||||
component: MyFeedbackDetailView,
|
component: MyFeedbackDetailView,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/feedback',
|
path: '/app/feedback',
|
||||||
name: 'developer-feedback',
|
name: 'developer-feedback',
|
||||||
component: DeveloperFeedbackListView,
|
component: DeveloperFeedbackListView,
|
||||||
meta: { requiresAuth: true, roles: ['Developer'] },
|
meta: { requiresAuth: true, roles: ['developer'] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/feedback/:id',
|
path: '/app/feedback/:id',
|
||||||
name: 'developer-feedback-detail',
|
name: 'developer-feedback-detail',
|
||||||
component: DeveloperFeedbackDetailView,
|
component: DeveloperFeedbackDetailView,
|
||||||
meta: { requiresAuth: true, roles: ['Developer'] },
|
meta: { requiresAuth: true, roles: ['developer'] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/workspace-settings',
|
path: '/app/workspace-settings',
|
||||||
name: 'workspace-settings',
|
name: 'workspace-settings',
|
||||||
|
component: WorkspaceSettingsView,
|
||||||
|
meta: { requiresAuth: true, roles: ['administrator', 'manager'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/app/workspaces/new',
|
||||||
|
name: 'workspace-create',
|
||||||
|
component: WorkspaceCreateView,
|
||||||
|
meta: { requiresAuth: true, roles: ['administrator', 'manager'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/app/settings',
|
||||||
|
component: SettingsLayoutView,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirect: { name: 'settings-user-information' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'user-information',
|
||||||
|
name: 'settings-user-information',
|
||||||
|
component: UserSettingsView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'workspaces',
|
||||||
|
name: 'settings-workspaces',
|
||||||
component: WorkspaceSettingsView,
|
component: WorkspaceSettingsView,
|
||||||
meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] },
|
meta: { requiresAuth: true, roles: ['administrator', 'manager'] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/workspaces/new',
|
path: 'integrations',
|
||||||
name: 'workspace-create',
|
name: 'settings-integrations',
|
||||||
component: WorkspaceCreateView,
|
component: IntegrationsSettingsView,
|
||||||
meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] },
|
meta: { requiresAuth: true, roles: ['administrator', 'manager'] },
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
path: '/app/settings',
|
},
|
||||||
component: SettingsLayoutView,
|
{
|
||||||
meta: { requiresAuth: true },
|
path: '/app/content',
|
||||||
children: [
|
name: 'content-items',
|
||||||
{
|
component: ContentItemsView,
|
||||||
path: '',
|
meta: { requiresAuth: true },
|
||||||
redirect: { name: 'settings-user-information' },
|
},
|
||||||
},
|
{
|
||||||
{
|
path: '/app/content/new',
|
||||||
path: 'user-information',
|
name: 'content-item-create',
|
||||||
name: 'settings-user-information',
|
component: ContentItemDetailView,
|
||||||
component: UserSettingsView,
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'workspaces',
|
path: '/app/content/:id',
|
||||||
name: 'settings-workspaces',
|
name: 'content-item-detail',
|
||||||
component: WorkspaceSettingsView,
|
component: ContentItemDetailView,
|
||||||
meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'integrations',
|
path: '/login',
|
||||||
name: 'settings-integrations',
|
name: 'login',
|
||||||
component: IntegrationsSettingsView,
|
component: LoginView,
|
||||||
meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] },
|
meta: { notAuthenticated: true },
|
||||||
},
|
props: route => ({ returnUrl: route.query.returnUrl || '/app/dashboard' }),
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
path: '/profile',
|
||||||
path: '/app/content',
|
redirect: { name: 'dashboard' },
|
||||||
name: 'content-items',
|
},
|
||||||
component: ContentItemsView,
|
{
|
||||||
meta: { requiresAuth: true },
|
path: '/register',
|
||||||
},
|
name: 'register',
|
||||||
{
|
component: RegisterView,
|
||||||
path: '/app/content/new',
|
meta: { requiresAuth: false },
|
||||||
name: 'content-item-create',
|
},
|
||||||
component: ContentItemDetailView,
|
{
|
||||||
meta: { requiresAuth: true },
|
path: '/forgot-password',
|
||||||
},
|
name: 'forgot-password',
|
||||||
{
|
component: ForgotPasswordView,
|
||||||
path: '/app/content/:id',
|
meta: { notAuthenticated: true },
|
||||||
name: 'content-item-detail',
|
},
|
||||||
component: ContentItemDetailView,
|
{
|
||||||
meta: { requiresAuth: true },
|
path: '/reset-password',
|
||||||
},
|
name: 'reset-password',
|
||||||
{
|
component: ResetPasswordView,
|
||||||
path: '/login',
|
meta: { notAuthenticated: true },
|
||||||
name: 'login',
|
props: route => ({ email: route.query.email, token: route.query.token }),
|
||||||
component: LoginView,
|
},
|
||||||
meta: { notAuthenticated: true },
|
{
|
||||||
props: route => ({ returnUrl: route.query.returnUrl || '/app/dashboard' }),
|
path: '/verify-email',
|
||||||
},
|
name: 'verify-email',
|
||||||
{
|
component: VerifyEmailView,
|
||||||
path: '/profile',
|
meta: { notAuthenticated: true },
|
||||||
redirect: { name: 'dashboard' },
|
},
|
||||||
},
|
{
|
||||||
{
|
path: '/:pathMatch(.*)*',
|
||||||
path: '/register',
|
redirect: { name: 'landing' },
|
||||||
name: 'register',
|
},
|
||||||
component: RegisterView,
|
|
||||||
meta: { requiresAuth: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/forgot-password',
|
|
||||||
name: 'forgot-password',
|
|
||||||
component: ForgotPasswordView,
|
|
||||||
meta: { notAuthenticated: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/reset-password',
|
|
||||||
name: 'reset-password',
|
|
||||||
component: ResetPasswordView,
|
|
||||||
meta: { notAuthenticated: true },
|
|
||||||
props: route => ({ email: route.query.email, token: route.query.token }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/verify-email',
|
|
||||||
name: 'verify-email',
|
|
||||||
component: VerifyEmailView,
|
|
||||||
meta: { notAuthenticated: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/:pathMatch(.*)*',
|
|
||||||
redirect: { name: 'landing' },
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes,
|
routes,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigation guards
|
// Navigation guards
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
if (to.matched.some(record => record.meta.requiresAuth)) {
|
if (to.matched.some(record => record.meta.requiresAuth)) {
|
||||||
if (!authStore.isAuthenticated) {
|
if (!authStore.isAuthenticated) {
|
||||||
next({
|
next({
|
||||||
name: 'login',
|
name: 'login',
|
||||||
query: { returnUrl: to.fullPath },
|
query: { returnUrl: to.fullPath },
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
const requiredRoles = to.matched.flatMap(record => record.meta.roles ?? []);
|
|
||||||
if (requiredRoles.length > 0 && !authStore.hasAnyRole(requiredRoles)) {
|
|
||||||
next({ name: 'dashboard' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
} else if (to.matched.some(record => record.meta.notAuthenticated)) {
|
|
||||||
if (authStore.isAuthenticated) next({ name: 'dashboard' });
|
|
||||||
else next();
|
|
||||||
} else {
|
} else {
|
||||||
next();
|
const requiredRoles = to.matched.flatMap(record => record.meta.roles ?? []);
|
||||||
|
if (requiredRoles.length > 0 && !authStore.hasAnyRole(requiredRoles)) {
|
||||||
|
next({ name: 'dashboard' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
}
|
}
|
||||||
|
} else if (to.matched.some(record => record.meta.notAuthenticated)) {
|
||||||
|
if (authStore.isAuthenticated) next({ name: 'dashboard' });
|
||||||
|
else next();
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1221,6 +1221,135 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/feedback/{id}/comments": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Feedback",
|
||||||
|
"Api"
|
||||||
|
],
|
||||||
|
"operationId": "SocializeApiModulesFeedbackHandlersAddDeveloperFeedbackCommentHandler",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"x-name": "AddFeedbackCommentRequest",
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"x-position": 1
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"JWTBearerAuth": [
|
||||||
|
"developer"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/my-feedback/{id}/comments": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Feedback",
|
||||||
|
"Api"
|
||||||
|
],
|
||||||
|
"operationId": "SocializeApiModulesFeedbackHandlersAddMyFeedbackCommentHandler",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"x-name": "AddFeedbackCommentRequest",
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"x-position": 1
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"JWTBearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/my-feedback/{id}/screenshot": {
|
"/api/my-feedback/{id}/screenshot": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1383,7 +1512,7 @@
|
|||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"JWTBearerAuth": [
|
"JWTBearerAuth": [
|
||||||
"Developer"
|
"developer"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1448,7 +1577,54 @@
|
|||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"JWTBearerAuth": [
|
"JWTBearerAuth": [
|
||||||
"Developer"
|
"developer"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/feedback/{id}/timeline": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Feedback",
|
||||||
|
"Api"
|
||||||
|
],
|
||||||
|
"operationId": "SocializeApiModulesFeedbackHandlersGetDeveloperFeedbackTimelineHandler",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"JWTBearerAuth": [
|
||||||
|
"developer"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1532,6 +1708,48 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/my-feedback/{id}/timeline": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Feedback",
|
||||||
|
"Api"
|
||||||
|
],
|
||||||
|
"operationId": "SocializeApiModulesFeedbackHandlersGetMyFeedbackTimelineHandler",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"JWTBearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/feedback": {
|
"/api/feedback": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1563,7 +1781,7 @@
|
|||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"JWTBearerAuth": [
|
"JWTBearerAuth": [
|
||||||
"Developer"
|
"developer"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1650,7 +1868,7 @@
|
|||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"JWTBearerAuth": [
|
"JWTBearerAuth": [
|
||||||
"Developer"
|
"developer"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2727,6 +2945,59 @@
|
|||||||
"timeZone": {
|
"timeZone": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"approvalMode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"schedulePostsAutomaticallyOnApproval": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"lockContentAfterApproval": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"sendAutomaticApprovalReminders": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"approvalSteps": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesWorkspacesHandlersApprovalStepConfigurationDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SocializeApiModulesWorkspacesHandlersApprovalStepConfigurationDto": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "guid"
|
||||||
|
},
|
||||||
|
"workspaceId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "guid"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sortOrder": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"targetType": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"targetValue": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"requiredApproverCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
@@ -2858,6 +3129,52 @@
|
|||||||
"maxLength": 128,
|
"maxLength": 128,
|
||||||
"minLength": 0,
|
"minLength": 0,
|
||||||
"nullable": false
|
"nullable": false
|
||||||
|
},
|
||||||
|
"approvalMode": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"schedulePostsAutomaticallyOnApproval": {
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"lockContentAfterApproval": {
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"sendAutomaticApprovalReminders": {
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"approvalSteps": {
|
||||||
|
"type": "array",
|
||||||
|
"nullable": true,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesWorkspacesHandlersUpdateApprovalStepConfigurationRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SocializeApiModulesWorkspacesHandlersUpdateApprovalStepConfigurationRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sortOrder": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"targetType": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"targetValue": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"requiredApproverCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3388,6 +3705,72 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "guid"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"actorUserId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "guid"
|
||||||
|
},
|
||||||
|
"actorDisplayName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"actorEmail": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"actorRole": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"activityType": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"fromValue": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"toValue": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"note": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"body"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"body": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 8000,
|
||||||
|
"minLength": 0,
|
||||||
|
"nullable": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"SocializeApiModulesFeedbackContractsFeedbackReportDto": {
|
"SocializeApiModulesFeedbackContractsFeedbackReportDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -3435,6 +3818,12 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"timeline": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
@@ -4419,6 +4808,29 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "guid"
|
"format": "guid"
|
||||||
},
|
},
|
||||||
|
"workflowInstanceId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "guid",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"workflowStepSortOrder": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"workflowStepTargetType": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"workflowStepTargetValue": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"workflowStepRequiredApproverCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"stage": {
|
"stage": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4563,8 +4975,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"decision": {
|
"decision": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 64,
|
"minLength": 1,
|
||||||
"minLength": 0,
|
|
||||||
"nullable": false
|
"nullable": false
|
||||||
},
|
},
|
||||||
"comment": {
|
"comment": {
|
||||||
|
|||||||
Reference in New Issue
Block a user