Refine content approval workflow rail
This commit is contained in:
@@ -1,139 +0,0 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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;
|
||||
|
||||
public record CreateApprovalRequestRequest(
|
||||
Guid WorkspaceId,
|
||||
Guid ContentItemId,
|
||||
string Stage,
|
||||
string ReviewerName,
|
||||
string ReviewerEmail,
|
||||
DateTimeOffset? DueAt);
|
||||
|
||||
public class CreateApprovalRequestRequestValidator
|
||||
: Validator<CreateApprovalRequestRequest>
|
||||
{
|
||||
public CreateApprovalRequestRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.ContentItemId).NotEmpty();
|
||||
RuleFor(x => x.Stage).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.ReviewerName).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.ReviewerEmail).NotEmpty().MaximumLength(256).EmailAddress();
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateApprovalRequestHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
: Endpoint<CreateApprovalRequestRequest, ApprovalRequestDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/approvals");
|
||||
Options(o => o.WithTags("Approvals"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateApprovalRequestRequest request, CancellationToken ct)
|
||||
{
|
||||
var contentItem = await dbContext
|
||||
.ContentItems
|
||||
.SingleOrDefaultAsync(
|
||||
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
|
||||
ct);
|
||||
|
||||
if (contentItem is null)
|
||||
{
|
||||
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanManageWorkspace(User, contentItem.WorkspaceId))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
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(),
|
||||
WorkspaceId = request.WorkspaceId,
|
||||
ContentItemId = request.ContentItemId,
|
||||
Stage = request.Stage.Trim(),
|
||||
ReviewerName = request.ReviewerName.Trim(),
|
||||
ReviewerEmail = request.ReviewerEmail.Trim(),
|
||||
RequestedByUserId = User.GetUserId(),
|
||||
DueAt = request.DueAt,
|
||||
State = "Pending",
|
||||
AccessToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
dbContext.ApprovalRequests.Add(approval);
|
||||
|
||||
contentItem.Status = "In approval";
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await notificationEventWriter.WriteAsync(
|
||||
new NotificationEventWriteModel(
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
"approval.requested",
|
||||
"ApprovalRequest",
|
||||
approval.Id,
|
||||
$"Approval requested from {approval.ReviewerName} for {contentItem.Title}.",
|
||||
null,
|
||||
approval.ReviewerEmail,
|
||||
$$"""{"stage":"{{approval.Stage}}","accessToken":"{{approval.AccessToken}}"}"""),
|
||||
ct);
|
||||
|
||||
ApprovalRequestDto dto = new(
|
||||
approval.Id,
|
||||
approval.WorkspaceId,
|
||||
approval.ContentItemId,
|
||||
approval.WorkflowInstanceId,
|
||||
approval.WorkflowStepSortOrder,
|
||||
approval.WorkflowStepTargetType,
|
||||
approval.WorkflowStepTargetValue,
|
||||
approval.WorkflowStepRequiredApproverCount,
|
||||
approval.Stage,
|
||||
approval.ReviewerName,
|
||||
approval.ReviewerEmail,
|
||||
approval.RequestedByUserId,
|
||||
approval.DueAt,
|
||||
approval.State,
|
||||
approval.AccessToken,
|
||||
approval.SentAt,
|
||||
approval.CompletedAt,
|
||||
[]);
|
||||
|
||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ namespace Socialize.Api.Modules.Approvals.Handlers;
|
||||
|
||||
public record SubmitApprovalDecisionRequest(
|
||||
string Decision,
|
||||
string? Comment,
|
||||
string? ReviewerName,
|
||||
string? ReviewerEmail);
|
||||
|
||||
@@ -25,7 +24,6 @@ public class SubmitApprovalDecisionRequestValidator
|
||||
.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));
|
||||
}
|
||||
@@ -90,7 +88,7 @@ public class SubmitApprovalDecisionHandler(
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalRequestId = approval.Id,
|
||||
Decision = normalizedDecision,
|
||||
Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(),
|
||||
Comment = null,
|
||||
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
|
||||
DecidedByName = decidedByName,
|
||||
DecidedByEmail = decidedByEmail,
|
||||
|
||||
@@ -12,11 +12,6 @@ public static class ApprovalModes
|
||||
|
||||
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;
|
||||
|
||||
@@ -7,18 +7,6 @@ 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)]
|
||||
|
||||
Reference in New Issue
Block a user