wip
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
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,9 +21,15 @@
|
|||||||
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);
|
||||||
@@ -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');
|
||||||
@@ -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>
|
||||||
@@ -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",
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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é",
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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