This commit is contained in:
2026-05-01 14:23:37 -04:00
parent 5077f557f4
commit df0409d7f6
47 changed files with 7800 additions and 194 deletions

View File

@@ -26,8 +26,10 @@ public class AppDbContext(
public DbSet<Asset> Assets => Set<Asset>();
public DbSet<AssetRevision> AssetRevisions => Set<AssetRevision>();
public DbSet<Comment> Comments => Set<Comment>();
public DbSet<ApprovalWorkflowInstance> ApprovalWorkflowInstances => Set<ApprovalWorkflowInstance>();
public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();
public DbSet<ApprovalDecision> ApprovalDecisions => Set<ApprovalDecision>();
public DbSet<WorkspaceApprovalStepConfiguration> WorkspaceApprovalStepConfigurations => Set<WorkspaceApprovalStepConfiguration>();
public DbSet<NotificationEvent> NotificationEvents => Set<NotificationEvent>();
public DbSet<FeedbackReport> FeedbackReports => Set<FeedbackReport>();
public DbSet<FeedbackTag> FeedbackTags => Set<FeedbackTag>();

View File

@@ -307,7 +307,7 @@ public static class DevelopmentSeedExtensions
"Spring launch hero video",
"Fresh seasonal menu launch across Instagram and TikTok.",
"Instagram Reel, TikTok",
"In client review",
"In approval",
DateTimeOffset.UtcNow.AddDays(3),
"v3",
3,

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -216,6 +216,23 @@ namespace Socialize.Api.Migrations
.HasMaxLength(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")
.HasColumnType("uuid");
@@ -225,11 +242,103 @@ namespace Socialize.Api.Migrations
b.HasIndex("ReviewerEmail");
b.HasIndex("WorkflowInstanceId");
b.HasIndex("WorkspaceId");
b.ToTable("ApprovalRequests", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Approvals.Data.ApprovalWorkflowInstance", b =>
{
b.Property<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 =>
{
b.Property<Guid>("Id")
@@ -1100,11 +1209,23 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApprovalMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasDefaultValue("Required");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<bool>("LockContentAfterApproval")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("LogoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
@@ -1117,6 +1238,16 @@ namespace Socialize.Api.Migrations
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.Property<bool>("SchedulePostsAutomaticallyOnApproval")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("SendAutomaticApprovalReminders")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)

View File

@@ -6,10 +6,28 @@ public static class ApprovalModelConfiguration
{
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 =>
{
approvalRequest.ToTable("ApprovalRequests");
approvalRequest.HasKey(x => x.Id);
approvalRequest.Property(x => x.WorkflowStepTargetType).HasMaxLength(32);
approvalRequest.Property(x => x.WorkflowStepTargetValue).HasMaxLength(128);
approvalRequest.Property(x => x.Stage).HasMaxLength(64).IsRequired();
approvalRequest.Property(x => x.ReviewerName).HasMaxLength(256).IsRequired();
approvalRequest.Property(x => x.ReviewerEmail).HasMaxLength(256).IsRequired();
@@ -20,6 +38,7 @@ public static class ApprovalModelConfiguration
.HasDefaultValueSql("CURRENT_TIMESTAMP");
approvalRequest.HasIndex(x => x.WorkspaceId);
approvalRequest.HasIndex(x => x.ContentItemId);
approvalRequest.HasIndex(x => x.WorkflowInstanceId);
approvalRequest.HasIndex(x => x.ReviewerEmail);
});
@@ -37,6 +56,21 @@ public static class ApprovalModelConfiguration
approvalDecision.HasIndex(x => x.ApprovalRequestId);
});
modelBuilder.Entity<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;
}
}

View File

@@ -5,6 +5,11 @@ public class ApprovalRequest
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public Guid ContentItemId { get; set; }
public Guid? WorkflowInstanceId { get; set; }
public int? WorkflowStepSortOrder { get; set; }
public string? WorkflowStepTargetType { get; set; }
public string? WorkflowStepTargetValue { get; set; }
public int? WorkflowStepRequiredApproverCount { get; set; }
public required string Stage { get; set; }
public required string ReviewerName { get; set; }
public required string ReviewerEmail { get; set; }

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -1,4 +1,4 @@
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
namespace Socialize.Api.Modules.Approvals;
@@ -7,6 +7,8 @@ public static class DependencyInjection
public static WebApplicationBuilder AddApprovalsModule(
this WebApplicationBuilder builder)
{
builder.Services.AddScoped<ApprovalWorkflowRuntimeService>();
return builder;
}
}

View File

@@ -4,7 +4,9 @@ using System.Security.Cryptography;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Approvals.Handlers;
@@ -62,6 +64,22 @@ public class CreateApprovalRequestHandler(
return;
}
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!ApprovalWorkflowRules.CanCreateSingleStepApprovalRequest(workspace.ApprovalMode))
{
AddError(request => request.WorkspaceId, workspace.ApprovalMode == ApprovalModes.None
? "Approval workflow is disabled for this workspace."
: "Move content to In approval to start the configured multi-level approval workflow.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
var approval = new ApprovalRequest()
{
Id = Guid.NewGuid(),
@@ -79,14 +97,7 @@ public class CreateApprovalRequestHandler(
dbContext.ApprovalRequests.Add(approval);
if (approval.Stage == "Internal")
{
contentItem.Status = "In internal review";
}
else if (approval.Stage == "Client")
{
contentItem.Status = "In client review";
}
contentItem.Status = "In approval";
await dbContext.SaveChangesAsync(ct);
@@ -107,6 +118,11 @@ public class CreateApprovalRequestHandler(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.WorkflowInstanceId,
approval.WorkflowStepSortOrder,
approval.WorkflowStepTargetType,
approval.WorkflowStepTargetValue,
approval.WorkflowStepRequiredApproverCount,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,

View File

@@ -24,6 +24,11 @@ public record ApprovalRequestDto(
Guid Id,
Guid WorkspaceId,
Guid ContentItemId,
Guid? WorkflowInstanceId,
int? WorkflowStepSortOrder,
string? WorkflowStepTargetType,
string? WorkflowStepTargetValue,
int? WorkflowStepRequiredApproverCount,
string Stage,
string ReviewerName,
string ReviewerEmail,
@@ -65,6 +70,7 @@ public class GetApprovalsHandler(
List<ApprovalRequest> approvals = await dbContext.ApprovalRequests
.Where(approval => approval.ContentItemId == request.ContentItemId)
.OrderByDescending(approval => approval.SentAt)
.ThenBy(approval => approval.WorkflowStepSortOrder)
.ToListAsync(ct);
List<Guid> approvalIds = approvals
@@ -91,6 +97,11 @@ public class GetApprovalsHandler(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.WorkflowInstanceId,
approval.WorkflowStepSortOrder,
approval.WorkflowStepTargetType,
approval.WorkflowStepTargetValue,
approval.WorkflowStepRequiredApproverCount,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,

View File

@@ -4,7 +4,9 @@ using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Approvals.Handlers;
@@ -19,7 +21,10 @@ public class SubmitApprovalDecisionRequestValidator
{
public SubmitApprovalDecisionRequestValidator()
{
RuleFor(x => x.Decision).NotEmpty().MaximumLength(64);
RuleFor(x => x.Decision)
.NotEmpty()
.Equal("Approved")
.WithMessage("Only approved decisions are supported.");
RuleFor(x => x.Comment).MaximumLength(2048);
RuleFor(x => x.ReviewerName).MaximumLength(256);
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
@@ -29,6 +34,7 @@ public class SubmitApprovalDecisionRequestValidator
public class SubmitApprovalDecisionHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
{
@@ -64,6 +70,13 @@ public class SubmitApprovalDecisionHandler(
return;
}
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
string normalizedDecision = request.Decision.Trim();
string decidedByName = User?.Identity?.IsAuthenticated == true
? User.GetAlias() ?? User.GetName()
@@ -84,28 +97,26 @@ public class SubmitApprovalDecisionHandler(
CreatedAt = DateTimeOffset.UtcNow,
};
ApprovalWorkflowDecisionResult workflowDecisionResult = await approvalWorkflowRuntimeService
.ApplyWorkflowStepDecisionAsync(approval, contentItem, workspace, User!, decision, ct);
if (!workflowDecisionResult.Succeeded)
{
AddError(request => request.Decision, workflowDecisionResult.ErrorMessage ?? "The approval decision could not be recorded.");
await SendErrorsAsync(workflowDecisionResult.StatusCode, ct);
return;
}
if (!workflowDecisionResult.IsWorkflowStep)
{
approval.State = normalizedDecision;
approval.CompletedAt = DateTimeOffset.UtcNow;
if (approval.Stage == "Internal")
if (normalizedDecision == "Approved")
{
contentItem.Status = normalizedDecision switch
{
"Approved" => "Ready for client review",
"Changes requested" => "Changes requested internally",
"Rejected" => "Rejected",
_ => contentItem.Status,
};
}
else if (approval.Stage == "Client")
{
contentItem.Status = normalizedDecision switch
{
"Approved" => "Approved",
"Changes requested" => "Changes requested by client",
"Rejected" => "Rejected",
_ => contentItem.Status,
};
contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus(
workspace.SchedulePostsAutomaticallyOnApproval,
contentItem.DueDate);
}
dbContext.ApprovalDecisions.Add(decision);
@@ -123,6 +134,7 @@ public class SubmitApprovalDecisionHandler(
decidedByEmail,
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
ct);
}
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
@@ -158,6 +170,11 @@ public class SubmitApprovalDecisionHandler(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.WorkflowInstanceId,
approval.WorkflowStepSortOrder,
approval.WorkflowStepTargetType,
approval.WorkflowStepTargetValue,
approval.WorkflowStepRequiredApproverCount,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,

View File

@@ -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());
}
}

View File

@@ -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,
};
}
}

View File

@@ -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);
}

View File

@@ -66,15 +66,6 @@ public class CreateContentItemRevisionHandler(
item.CurrentRevisionNumber = revisionNumber;
item.CurrentRevisionLabel = revisionLabel;
if (item.Status == "Changes requested internally")
{
item.Status = "Internal changes in progress";
}
else if (item.Status == "Changes requested by client")
{
item.Status = "Client changes in progress";
}
ContentItemRevision revision = new()
{
Id = Guid.NewGuid(),

View File

@@ -2,8 +2,10 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.ContentItems.Handlers;
@@ -21,24 +23,18 @@ public class UpdateContentItemStatusRequestValidator
public class UpdateContentItemStatusHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<UpdateContentItemStatusRequest, ContentItemDetailDto>
{
private static readonly HashSet<string> AllowedStatuses =
[
"Draft",
"In internal review",
"Changes requested internally",
"Internal changes in progress",
"Ready for client review",
"In client review",
"Changes requested by client",
"Client changes in progress",
"In production",
"In approval",
"Approved",
"Rejected",
"Ready to publish",
"Scheduled",
"Published",
"Archived",
];
public override void Configure()
@@ -72,7 +68,64 @@ public class UpdateContentItemStatusHandler(
return;
}
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == item.WorkspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
if (normalizedStatus == "In approval" && workspace.ApprovalMode == ApprovalModes.MultiLevel)
{
ApprovalWorkflowStartResult startResult = await approvalWorkflowRuntimeService.StartMultiLevelWorkflowAsync(
item,
workspace,
User.GetUserId(),
ct);
if (!startResult.Succeeded)
{
AddError(request => request.Status, startResult.ErrorMessage ?? "The approval workflow could not be started.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
}
else if (ApprovalWorkflowRules.IsApprovalCompletionStatus(normalizedStatus) &&
ApprovalWorkflowRules.BlocksManualApprovedOrScheduledStatus(workspace.ApprovalMode))
{
if (workspace.ApprovalMode == ApprovalModes.MultiLevel)
{
bool hasCompletedWorkflow = await approvalWorkflowRuntimeService.HasCompletedMultiLevelWorkflowAsync(item.Id, ct);
if (!hasCompletedWorkflow)
{
AddError(request => request.Status, "This workspace requires the multi-level approval workflow to complete before content can be approved or scheduled.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
}
else
{
bool hasApprovedDecision = await dbContext.ApprovalRequests.AnyAsync(
approval => approval.ContentItemId == item.Id &&
approval.WorkspaceId == item.WorkspaceId &&
approval.State == "Approved" &&
approval.CompletedAt.HasValue,
ct);
if (!hasApprovedDecision)
{
AddError(request => request.Status, "This workspace requires approval before content can be approved or scheduled.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
}
}
if (item.Status != "In approval" || normalizedStatus != "In approval")
{
item.Status = normalizedStatus;
}
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(

View File

@@ -8,5 +8,9 @@ public class Workspace
public string? LogoUrl { get; set; }
public Guid OwnerUserId { get; set; }
public required string TimeZone { get; set; }
public string ApprovalMode { get; set; } = "Required";
public bool SchedulePostsAutomaticallyOnApproval { get; set; }
public bool LockContentAfterApproval { get; set; }
public bool SendAutomaticApprovalReminders { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -14,6 +14,10 @@ public static class WorkspaceModelConfiguration
workspace.Property(x => x.Slug).HasMaxLength(128).IsRequired();
workspace.Property(x => x.LogoUrl).HasMaxLength(2048);
workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired();
workspace.Property(x => x.ApprovalMode).HasMaxLength(32).IsRequired().HasDefaultValue("Required");
workspace.Property(x => x.SchedulePostsAutomaticallyOnApproval).HasDefaultValue(false);
workspace.Property(x => x.LockContentAfterApproval).HasDefaultValue(false);
workspace.Property(x => x.SendAutomaticApprovalReminders).HasDefaultValue(false);
workspace.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");

View File

@@ -77,6 +77,11 @@ public class CreateWorkspaceHandler(
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
[],
workspace.CreatedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);

View File

@@ -2,16 +2,32 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record ApprovalStepConfigurationDto(
Guid Id,
Guid WorkspaceId,
string Name,
int SortOrder,
string TargetType,
string TargetValue,
int RequiredApproverCount,
DateTimeOffset CreatedAt);
public record WorkspaceDto(
Guid Id,
string Name,
string Slug,
string? LogoUrl,
string TimeZone,
string ApprovalMode,
bool SchedulePostsAutomaticallyOnApproval,
bool LockContentAfterApproval,
bool SendAutomaticApprovalReminders,
IReadOnlyCollection<ApprovalStepConfigurationDto> ApprovalSteps,
DateTimeOffset CreatedAt);
internal class GetWorkspacesHandler(
@@ -35,17 +51,53 @@ internal class GetWorkspacesHandler(
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
}
var workspaces = await query
var workspaceRows = await query
.OrderBy(workspace => workspace.Name)
.ToListAsync(ct);
var workspaceIds = workspaceRows.Select(workspace => workspace.Id).ToList();
List<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(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty<ApprovalStepConfigurationDto>(),
workspace.CreatedAt))
.ToListAsync(ct);
.ToList();
await SendOkAsync(workspaces, ct);
}
public static ApprovalStepConfigurationDto ToApprovalStepConfigurationDto(WorkspaceApprovalStepConfiguration step)
{
return new ApprovalStepConfigurationDto(
step.Id,
step.WorkspaceId,
step.Name,
step.SortOrder,
step.TargetType,
step.TargetValue,
step.RequiredApproverCount,
step.CreatedAt);
}
}

View File

@@ -2,21 +2,52 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record UpdateApprovalStepConfigurationRequest(
string Name,
int SortOrder,
string TargetType,
string TargetValue,
int RequiredApproverCount);
public record UpdateWorkspaceRequest(
string Name,
string TimeZone);
string TimeZone,
string? ApprovalMode,
bool? SchedulePostsAutomaticallyOnApproval,
bool? LockContentAfterApproval,
bool? SendAutomaticApprovalReminders,
IReadOnlyCollection<UpdateApprovalStepConfigurationRequest>? ApprovalSteps);
public class UpdateWorkspaceRequestValidator
: Validator<UpdateWorkspaceRequest>
{
private static readonly string[] AllowedApprovalModes = ["None", "Optional", "Required", "Multi-level"];
public UpdateWorkspaceRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
RuleFor(x => x.ApprovalMode)
.Must(mode => string.IsNullOrWhiteSpace(mode) || AllowedApprovalModes.Contains(mode.Trim()))
.WithMessage("A valid approval mode should be specified.");
RuleFor(x => x.ApprovalSteps)
.Must(steps => steps is null || steps.Select(step => step.SortOrder).Distinct().Count() == steps.Count)
.WithMessage("Approval step sort orders must be unique.");
RuleForEach(x => x.ApprovalSteps).ChildRules(step =>
{
step.RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
step.RuleFor(x => x.TargetType)
.Must(ApprovalStepConfigurationRules.IsValidTargetType)
.WithMessage("A valid approval step target type should be specified.");
step.RuleFor(x => x.TargetValue).NotEmpty().MaximumLength(128);
step.RuleFor(x => x.RequiredApproverCount).GreaterThanOrEqualTo(1);
});
}
}
@@ -48,19 +79,162 @@ public class UpdateWorkspaceHandler(
return;
}
string nextApprovalMode = string.IsNullOrWhiteSpace(request.ApprovalMode)
? workspace.ApprovalMode
: request.ApprovalMode.Trim();
List<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.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);
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(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalSteps,
workspace.CreatedAt);
await SendOkAsync(dto, ct);
}
private async Task<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;
}
}

View File

@@ -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),
]));
}
}

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -436,6 +436,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/feedback/{id}/comments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesFeedbackHandlersAddDeveloperFeedbackCommentHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/my-feedback/{id}/comments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesFeedbackHandlersAddMyFeedbackCommentHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/my-feedback/{id}/screenshot": {
parameters: {
query?: never;
@@ -484,6 +516,22 @@ export interface paths {
patch: operations["SocializeApiModulesFeedbackHandlersUpdateDeveloperFeedbackHandler"];
trace?: never;
};
"/api/feedback/{id}/timeline": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesFeedbackHandlersGetDeveloperFeedbackTimelineHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/feedback/{id}/screenshot": {
parameters: {
query?: never;
@@ -516,6 +564,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/my-feedback/{id}/timeline": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesFeedbackHandlersGetMyFeedbackTimelineHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/feedback": {
parameters: {
query?: never;
@@ -818,6 +882,26 @@ export interface components {
slug?: string;
logoUrl?: string | null;
timeZone?: string;
approvalMode?: string;
schedulePostsAutomaticallyOnApproval?: boolean;
lockContentAfterApproval?: boolean;
sendAutomaticApprovalReminders?: boolean;
approvalSteps?: components["schemas"]["SocializeApiModulesWorkspacesHandlersApprovalStepConfigurationDto"][];
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesWorkspacesHandlersApprovalStepConfigurationDto: {
/** Format: guid */
id?: string;
/** Format: guid */
workspaceId?: string;
name?: string;
/** Format: int32 */
sortOrder?: number;
targetType?: string;
targetValue?: string;
/** Format: int32 */
requiredApproverCount?: number;
/** Format: date-time */
createdAt?: string;
};
@@ -853,6 +937,20 @@ export interface components {
SocializeApiModulesWorkspacesHandlersUpdateWorkspaceRequest: {
name: string;
timeZone: string;
approvalMode?: string | null;
schedulePostsAutomaticallyOnApproval?: boolean | null;
lockContentAfterApproval?: boolean | null;
sendAutomaticApprovalReminders?: boolean | null;
approvalSteps?: components["schemas"]["SocializeApiModulesWorkspacesHandlersUpdateApprovalStepConfigurationRequest"][] | null;
};
SocializeApiModulesWorkspacesHandlersUpdateApprovalStepConfigurationRequest: {
name?: string;
/** Format: int32 */
sortOrder?: number;
targetType?: string;
targetValue?: string;
/** Format: int32 */
requiredApproverCount?: number;
};
SocializeApiModulesProjectsHandlersProjectDto: {
/** Format: guid */
@@ -1018,6 +1116,26 @@ export interface components {
message?: string;
};
SocializeApiModulesIdentityHandlersVerifyEmailRequest: Record<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: {
/** Format: guid */
id?: string;
@@ -1032,6 +1150,7 @@ export interface components {
context?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackContextDto"];
screenshot?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackScreenshotDto"] | null;
tags?: string[];
timeline?: components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][];
/** Format: date-time */
createdAt?: string;
/** Format: date-time */
@@ -1322,6 +1441,14 @@ export interface components {
workspaceId?: string;
/** Format: guid */
contentItemId?: string;
/** Format: guid */
workflowInstanceId?: string | null;
/** Format: int32 */
workflowStepSortOrder?: number | null;
workflowStepTargetType?: string | null;
workflowStepTargetValue?: string | null;
/** Format: int32 */
workflowStepRequiredApproverCount?: number | null;
stage?: string;
reviewerName?: string;
reviewerEmail?: string;
@@ -2286,6 +2413,97 @@ export interface operations {
};
};
};
SocializeApiModulesFeedbackHandlersAddDeveloperFeedbackCommentHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesFeedbackHandlersAddMyFeedbackCommentHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackHandlersAddFeedbackCommentRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesFeedbackHandlersAttachMyFeedbackScreenshotHandler: {
parameters: {
query?: never;
@@ -2455,6 +2673,42 @@ export interface operations {
};
};
};
SocializeApiModulesFeedbackHandlersGetDeveloperFeedbackTimelineHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesFeedbackHandlersGetFeedbackScreenshotHandler: {
parameters: {
query?: never;
@@ -2513,6 +2767,35 @@ export interface operations {
};
};
};
SocializeApiModulesFeedbackHandlersGetMyFeedbackTimelineHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesFeedbackHandlersListDeveloperFeedbackHandler: {
parameters: {
query?: never;

View File

@@ -69,10 +69,8 @@
nextDueDate: matches
.filter(item => item.dueDate)
.sort((left, right) => new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime())[0]?.dueDate ?? null,
readyCount: matches.filter(item => ['Approved', 'Ready to publish', 'Published'].includes(item.status)).length,
blockedCount: matches.filter(item =>
['In internal review', 'Ready for client review', 'In client review', 'Changes requested by client'].includes(item.status)
).length,
readyCount: matches.filter(item => ['Approved', 'Scheduled', 'Published'].includes(item.status)).length,
blockedCount: matches.filter(item => item.status === 'In approval').length,
};
}

View File

@@ -15,7 +15,7 @@ export const useContentItemsStore = defineStore('content-items', () => {
const error = ref(null);
const activeCount = computed(() =>
items.value.filter(item => item.status !== 'Approved' && item.status !== 'Published' && item.status !== 'Archived')
items.value.filter(item => !['Approved', 'Scheduled', 'Published'].includes(item.status))
.length
);

View File

@@ -45,6 +45,14 @@
});
const decisionForms = reactive({});
const manualStatuses = [
'Draft',
'In production',
'In approval',
'Approved',
'Scheduled',
'Published',
];
const saveError = reactive({
message: '',
});
@@ -80,6 +88,7 @@
new Map(projectsStore.projects.map(project => [project.id, project.name]))
);
const editorKey = computed(() => isCreateMode.value ? `new:${route.query.projectId ?? 'default'}` : String(route.params.id));
const isMultiLevelApproval = computed(() => workspaceStore.activeWorkspace?.approvalMode === 'Multi-level');
function blankPlacement(channel = null) {
return {
@@ -116,6 +125,16 @@
return decisionForms[approvalId];
}
function formatApprovalStepMeta(approval) {
if (!approval.workflowInstanceId) {
return `${approval.stage} · ${approval.state}`;
}
const stepNumber = Number(approval.workflowStepSortOrder ?? 0) + 1;
const requiredCount = approval.workflowStepRequiredApproverCount ?? 1;
return `Step ${stepNumber} · ${approval.state} · ${requiredCount} required`;
}
function syncPlacementChannel(placement, value) {
const channel = availableChannels.value.find(candidate => candidate.id === value);
placement.channelId = value;
@@ -488,25 +507,13 @@
class="quick-actions"
>
<button
v-for="status in manualStatuses"
:key="status"
class="secondary-button"
:disabled="detailStore.actions.status"
@click="moveStatus('Ready to publish')"
:disabled="detailStore.actions.status || item.status === status"
@click="moveStatus(status)"
>
Ready to publish
</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
{{ status }}
</button>
</div>
@@ -514,7 +521,7 @@
<aside class="panel side-panel">
<div class="panel-heading">
<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
@@ -525,7 +532,17 @@
</div>
<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">
<span>Stage</span>
<select v-model="approvalForm.stage">
@@ -572,7 +589,7 @@
<div class="sub-card-header">
<div>
<strong>{{ approval.reviewerName }}</strong>
<span>{{ approval.stage }} · {{ approval.state }}</span>
<span>{{ formatApprovalStepMeta(approval) }}</span>
</div>
<small>{{ formatDate(approval.dueAt) }}</small>
</div>
@@ -607,8 +624,6 @@
<span>Decision</span>
<select v-model="getDecisionForm(approval.id).decision">
<option value="Approved">Approved</option>
<option value="Changes requested">Changes requested</option>
<option value="Rejected">Rejected</option>
</select>
</label>
<label class="field">

View File

@@ -5,18 +5,11 @@ import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
const stageByStatus = {
Draft: 'Draft',
'In internal review': 'Internal review',
'Changes requested internally': 'Internal changes requested',
'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',
'In production': 'In production',
'In approval': 'In approval',
Approved: 'Approved',
Rejected: 'Rejected',
'Ready to publish': 'Ready to publish',
Scheduled: 'Scheduled',
Published: 'Published',
Archived: 'Archived',
};
export const useReviewQueueStore = defineStore('review-queue', () => {
@@ -25,7 +18,7 @@ export const useReviewQueueStore = defineStore('review-queue', () => {
const items = computed(() =>
contentItemsStore.items
.filter(item => item.status !== 'Draft' && item.status !== 'Published' && item.status !== 'Archived')
.filter(item => item.status === 'In approval')
.map(item => {
const project = projectsStore.projects.find(candidate => candidate.id === item.projectId);

View File

@@ -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>

View File

@@ -17,18 +17,11 @@
const contentStatusMeta = {
Draft: { tone: 'production', readiness: 'building' },
'In internal review': { tone: 'approval', readiness: 'approval' },
'Changes requested internally': { tone: 'risk', readiness: 'rework' },
'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' },
'In production': { tone: 'production', readiness: 'building' },
'In approval': { tone: 'approval', readiness: 'approval' },
Approved: { tone: 'ready', readiness: 'ready' },
'Ready to publish': { tone: 'ready', readiness: 'ready' },
Scheduled: { tone: 'ready', readiness: 'scheduled' },
Published: { tone: 'published', readiness: 'published' },
Rejected: { tone: 'risk', readiness: 'blocked' },
Archived: { tone: 'muted', readiness: 'archived' },
};
const contentItemsByProjectId = computed(() => {
@@ -49,7 +42,7 @@
.map(project => buildProjectEntry(project));
const contentEntries = contentItemsStore.items
.filter(item => item.dueDate && item.status !== 'Archived')
.filter(item => item.dueDate)
.map(item => buildContentEntry(item));
return [...projectEntries, ...contentEntries].sort(sortByDate);
@@ -164,7 +157,7 @@
function buildProjectEntry(project) {
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 {
id: project.id,

View File

@@ -32,9 +32,7 @@
return startOfDay(item.dueDate) >= today.value;
}).length;
const blockingCount = workspaceContent.filter(item =>
['In internal review', 'Ready for client review', 'In client review', 'Changes requested by client'].includes(item.status)
).length;
const blockingCount = workspaceContent.filter(item => item.status === 'In approval').length;
return {
id: workspace.id,
@@ -79,7 +77,7 @@
route: { name: 'content-item-detail', params: { id: item.id } },
}))
.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())
.slice(0, 6)

View File

@@ -3,6 +3,7 @@
import { useI18n } from 'vue-i18n';
import AppAvatar from '@/components/AppAvatar.vue';
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
import ApprovalWorkflowEditor from '@/features/workspaces/components/ApprovalWorkflowEditor.vue';
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import {
@@ -20,9 +21,15 @@
const settingsForm = reactive({
name: '',
timeZone: '',
approvalMode: 'Required',
schedulePostsAutomaticallyOnApproval: false,
lockContentAfterApproval: false,
sendAutomaticApprovalReminders: false,
approvalSteps: [],
});
const settingsError = ref(null);
const settingsStatus = ref(null);
const approvalStepErrors = ref([]);
const logoError = ref(null);
const logoStatus = ref(null);
const isLogoDialogOpen = ref(false);
@@ -38,6 +45,7 @@
const workspaceMembers = computed(() =>
workspaceStore.membersByWorkspace[workspaceStore.activeWorkspaceId] ?? []
);
const normalizedApprovalSteps = computed(() => normalizeApprovalSteps(settingsForm.approvalSteps));
const isSettingsDirty = computed(() => {
const workspace = workspaceStore.activeWorkspace;
@@ -45,7 +53,15 @@
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(() => [
{ key: 'general', label: t('workspaceSettings.tabs.general'), icon: mdiCogOutline },
@@ -53,29 +69,113 @@
{ key: 'workflow', label: t('workspaceSettings.tabs.workflow'), icon: mdiTuneVariant },
{ key: 'connectors', label: t('workspaceSettings.tabs.connectors'), icon: mdiFolderGoogleDrive },
]);
const workflowSteps = computed(() => [
{
key: 'internal',
title: t('workspaceSettings.approvals.steps.internal'),
detail: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
const approvalModeOptions = computed(() => [
{ value: 'None', label: t('workspaceSettings.approvals.modes.none'), description: t('workspaceSettings.approvals.modeHelp.none') },
{ value: 'Optional', label: t('workspaceSettings.approvals.modes.optional'), description: t('workspaceSettings.approvals.modeHelp.optional') },
{ value: 'Required', label: t('workspaceSettings.approvals.modes.required'), description: t('workspaceSettings.approvals.modeHelp.required') },
{ value: 'Multi-level', label: t('workspaceSettings.approvals.modes.multiLevel'), description: t('workspaceSettings.approvals.modeHelp.multiLevel') },
]);
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: 'client',
title: t('workspaceSettings.approvals.steps.client'),
detail: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
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: t('workspaceSettings.approvals.stepDetail.manualPublish'),
detail: settingsForm.schedulePostsAutomaticallyOnApproval
? t('workspaceSettings.approvals.stepDetail.autoSchedule')
: t('workspaceSettings.approvals.stepDetail.manualSchedule'),
},
]);
];
});
watch(
() => workspaceStore.activeWorkspace,
workspace => {
settingsForm.name = workspace?.name ?? '';
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;
settingsStatus.value = null;
},
@@ -117,12 +217,28 @@
return;
}
if (settingsForm.approvalMode === 'Multi-level' && !validateApprovalSteps()) {
settingsError.value ||= t('workspaceSettings.approvals.editor.errors.fixInvalidSteps');
return;
}
approvalStepErrors.value = [];
try {
await workspaceStore.updateWorkspace(workspace.id, {
name,
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) {
console.error('Failed to update workspace settings:', error);
settingsError.value = t('workspaceSettings.errors.updateFailed');
@@ -183,6 +299,77 @@
const normalizedRole = role.charAt(0).toLowerCase() + role.slice(1);
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>
<template>
@@ -432,19 +619,95 @@
<p>{{ t('workspaceSettings.approvals.flowDescription') }}</p>
</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">
<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">
<strong>{{ t('workspaceSettings.approvals.fields.requireInternalReview') }}</strong>
<span>{{ t('workspaceSettings.approvals.fieldHelp.requireInternalReview') }}</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>
<strong>{{ activeApprovalModeOption.label }}</strong>
<span>{{ activeApprovalModeOption.description }}</span>
</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>
</article>
@@ -683,6 +946,7 @@
.empty-state,
.connector-row,
.workflow-rule,
.workflow-toggle,
.workflow-step {
@apply rounded-[1rem] border px-4 py-4;
background: #fffaf2;
@@ -696,10 +960,19 @@
.invite-row div,
.connector-copy,
.workflow-rule,
.workflow-toggle span,
.workflow-step-copy {
@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 {
@apply flex flex-col gap-4 md:flex-row md:items-center md:justify-between;
}

View File

@@ -16,6 +16,7 @@
mdiCalendarMonthOutline,
mdiChevronDown,
mdiCogOutline,
mdiFileDocumentOutline,
mdiFolderOutline,
mdiHomeOutline,
mdiImageMultipleOutline,
@@ -401,6 +402,36 @@
</router-link>
</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-header">
<router-link

View File

@@ -308,7 +308,7 @@
"building": "In production",
"approval": "Awaiting approval",
"rework": "Needs revision",
"ready": "Ready to publish",
"ready": "Approved",
"published": "Published",
"blocked": "Blocked",
"archived": "Archived",
@@ -559,35 +559,83 @@
},
"approvals": {
"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",
"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": {
"requireInternalReview": "Require internal review",
"internalApproversRequired": "Internal approvers required",
"requireClientReview": "Require client review",
"clientApproversRequired": "Client approvers required",
"defaultReviewerRole": "Default reviewer role",
"publishBehaviour": "After final approval"
"approvalMode": "Approval mode",
"schedulePostsAutomaticallyOnApproval": "Schedule posts automatically on approval",
"lockContentAfterApproval": "Lock content after approval",
"sendAutomaticApprovalReminders": "Send automatic approval reminders"
},
"fieldHelp": {
"requireInternalReview": "Content must be approved internally before client review can begin.",
"requireClientReview": "Content must still pass through client approval before publication."
"schedulePostsAutomaticallyOnApproval": "Final approval moves content to Scheduled when it already has a planned publish date.",
"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": {
"manual": "Mark ready to publish",
"auto": "Auto-advance to ready"
"modes": {
"none": "None",
"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": {
"internal": "Internal review",
"client": "Client review",
"none": "Approval skipped",
"approval": "Approval",
"publish": "Publishing handoff"
},
"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",
"autoPublish": "Content moves to ready automatically after the final approval.",
"manualPublish": "Content stays in a manual ready-to-publish handoff after the final approval."
"multiLevelTarget": "{count} approver(s) from {target}",
"autoSchedule": "Approved content with a planned publish date moves to Scheduled.",
"manualSchedule": "Approved content remains Approved until scheduling is handled."
}
}
},

View File

@@ -308,7 +308,7 @@
"building": "En production",
"approval": "En attente d'approbation",
"rework": "Révision requise",
"ready": "Prêt à publier",
"ready": "Approuvé",
"published": "Publié",
"blocked": "Bloqué",
"archived": "Archivé",
@@ -559,35 +559,83 @@
},
"approvals": {
"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",
"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": {
"requireInternalReview": "Exiger une révision interne",
"internalApproversRequired": "Approbateurs internes requis",
"requireClientReview": "Exiger une révision client",
"clientApproversRequired": "Approbateurs client requis",
"defaultReviewerRole": "Rôle du réviseur par défaut",
"publishBehaviour": "Après l'approbation finale"
"approvalMode": "Mode d'approbation",
"schedulePostsAutomaticallyOnApproval": "Planifier automatiquement après approbation",
"lockContentAfterApproval": "Verrouiller le contenu après approbation",
"sendAutomaticApprovalReminders": "Envoyer des rappels automatiques"
},
"fieldHelp": {
"requireInternalReview": "Le contenu doit être approuvé en interne avant de passer à la révision client.",
"requireClientReview": "Le contenu doit encore passer par une approbation client avant la publication."
"schedulePostsAutomaticallyOnApproval": "L'approbation finale passe le contenu à Planifié quand une date de publication est déjà prévue.",
"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": {
"manual": "Marquer prêt à publier",
"auto": "Passer automatiquement à prêt"
"modes": {
"none": "Aucun",
"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": {
"internal": "Révision interne",
"client": "Révision client",
"none": "Approbation ignorée",
"approval": "Approbation",
"publish": "Passage à la publication"
},
"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",
"autoPublish": "Le contenu passe automatiquement à prêt après l'approbation finale.",
"manualPublish": "Le contenu reste dans une étape manuelle prêt à publier après l'approbation finale."
"multiLevelTarget": "{count} approbateur(s) de {target}",
"autoSchedule": "Le contenu approuvé avec une date de publication prévue passe à Planifié.",
"manualSchedule": "Le contenu approuvé reste Approuvé jusqu'à sa planification."
}
}
},

View File

@@ -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": {
"post": {
"tags": [
@@ -1383,7 +1512,7 @@
"security": [
{
"JWTBearerAuth": [
"Developer"
"developer"
]
}
]
@@ -1448,7 +1577,54 @@
"security": [
{
"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": {
"get": {
"tags": [
@@ -1563,7 +1781,7 @@
"security": [
{
"JWTBearerAuth": [
"Developer"
"developer"
]
}
]
@@ -1650,7 +1868,7 @@
"security": [
{
"JWTBearerAuth": [
"Developer"
"developer"
]
}
]
@@ -2727,6 +2945,59 @@
"timeZone": {
"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": {
"type": "string",
"format": "date-time"
@@ -2858,6 +3129,52 @@
"maxLength": 128,
"minLength": 0,
"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",
"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": {
"type": "object",
"additionalProperties": false,
@@ -3435,6 +3818,12 @@
"type": "string"
}
},
"timeline": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SocializeApiModulesFeedbackContractsFeedbackTimelineItemDto"
}
},
"createdAt": {
"type": "string",
"format": "date-time"
@@ -4419,6 +4808,29 @@
"type": "string",
"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": {
"type": "string"
},
@@ -4563,8 +4975,7 @@
"properties": {
"decision": {
"type": "string",
"maxLength": 64,
"minLength": 0,
"minLength": 1,
"nullable": false
},
"comment": {