211 lines
8.0 KiB
C#
211 lines
8.0 KiB
C#
using FastEndpoints;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Socialize.Api.Data;
|
|
using Socialize.Api.Infrastructure.Security;
|
|
using Socialize.Api.Modules.ContentItems.Data;
|
|
using Socialize.Api.Modules.ContentItems.Contracts;
|
|
using Socialize.Api.Modules.Approvals.Data;
|
|
using Socialize.Api.Modules.Approvals.Services;
|
|
using Socialize.Api.Modules.Notifications.Contracts;
|
|
using Socialize.Api.Modules.Workspaces.Data;
|
|
using System.Text.Json;
|
|
|
|
namespace Socialize.Api.Modules.Approvals.Handlers;
|
|
|
|
public record SubmitApprovalDecisionRequest(
|
|
string Decision,
|
|
string? ReviewerName,
|
|
string? ReviewerEmail);
|
|
|
|
public class SubmitApprovalDecisionRequestValidator
|
|
: Validator<SubmitApprovalDecisionRequest>
|
|
{
|
|
public SubmitApprovalDecisionRequestValidator()
|
|
{
|
|
RuleFor(x => x.Decision)
|
|
.NotEmpty()
|
|
.Equal("Approved")
|
|
.WithMessage("Only approved decisions are supported.");
|
|
RuleFor(x => x.ReviewerName).MaximumLength(256);
|
|
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
|
|
}
|
|
}
|
|
|
|
public class SubmitApprovalDecisionHandler(
|
|
AppDbContext dbContext,
|
|
AccessScopeService accessScopeService,
|
|
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
|
|
IContentItemActivityWriter activityWriter,
|
|
INotificationEventWriter notificationEventWriter)
|
|
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
|
|
{
|
|
public override void Configure()
|
|
{
|
|
Post("/api/approvals/{id}/decisions");
|
|
AllowAnonymous();
|
|
Options(o => o.WithTags("Approvals"));
|
|
}
|
|
|
|
public override async Task HandleAsync(SubmitApprovalDecisionRequest request, CancellationToken ct)
|
|
{
|
|
Guid id = Route<Guid>("id");
|
|
|
|
ApprovalRequest? approval = await dbContext.ApprovalRequests.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
|
if (approval is null)
|
|
{
|
|
await SendNotFoundAsync(ct);
|
|
return;
|
|
}
|
|
|
|
ContentItem? contentItem = await dbContext.ContentItems.SingleOrDefaultAsync(candidate => candidate.Id == approval.ContentItemId, ct);
|
|
if (contentItem is null)
|
|
{
|
|
await SendNotFoundAsync(ct);
|
|
return;
|
|
}
|
|
|
|
if (User?.Identity?.IsAuthenticated == true &&
|
|
!await accessScopeService.CanReviewContentAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
|
|
{
|
|
await SendForbiddenAsync(ct);
|
|
return;
|
|
}
|
|
|
|
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
|
|
if (workspace is null)
|
|
{
|
|
await SendNotFoundAsync(ct);
|
|
return;
|
|
}
|
|
|
|
string normalizedDecision = request.Decision.Trim();
|
|
string decidedByName = User?.Identity?.IsAuthenticated == true
|
|
? User.GetAlias() ?? User.GetName()
|
|
: string.IsNullOrWhiteSpace(request.ReviewerName) ? approval.ReviewerName : request.ReviewerName.Trim();
|
|
string decidedByEmail = User?.Identity?.IsAuthenticated == true
|
|
? User.GetEmail()
|
|
: string.IsNullOrWhiteSpace(request.ReviewerEmail) ? approval.ReviewerEmail : request.ReviewerEmail.Trim();
|
|
|
|
ApprovalDecision decision = new()
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ApprovalRequestId = approval.Id,
|
|
Decision = normalizedDecision,
|
|
Comment = null,
|
|
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
|
|
DecidedByName = decidedByName,
|
|
DecidedByEmail = decidedByEmail,
|
|
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 (normalizedDecision == "Approved")
|
|
{
|
|
contentItem.Status = ApprovalWorkflowRules.GetFinalApprovalStatus(
|
|
workspace.SchedulePostsAutomaticallyOnApproval,
|
|
contentItem.DueDate);
|
|
}
|
|
|
|
dbContext.ApprovalDecisions.Add(decision);
|
|
await dbContext.SaveChangesAsync(ct);
|
|
|
|
await activityWriter.WriteAsync(
|
|
new ContentItemActivityWriteModel(
|
|
approval.WorkspaceId,
|
|
approval.ContentItemId,
|
|
"approval.decision.recorded",
|
|
"ApprovalDecision",
|
|
decision.Id,
|
|
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.",
|
|
decision.DecidedByUserId,
|
|
decidedByEmail,
|
|
JsonSerializer.Serialize(new
|
|
{
|
|
stage = approval.Stage,
|
|
status = contentItem.Status,
|
|
decision = normalizedDecision,
|
|
})),
|
|
ct);
|
|
|
|
await notificationEventWriter.WriteAsync(
|
|
new NotificationEventWriteModel(
|
|
approval.WorkspaceId,
|
|
approval.ContentItemId,
|
|
"approval.decision.recorded",
|
|
"ApprovalDecision",
|
|
decision.Id,
|
|
$"{decidedByName} recorded {normalizedDecision} for {contentItem.Title}.",
|
|
null,
|
|
decidedByEmail,
|
|
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
|
|
ct);
|
|
}
|
|
|
|
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
|
|
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
|
|
.OrderByDescending(candidate => candidate.CreatedAt)
|
|
.ToListAsync(ct);
|
|
|
|
List<Guid> decidedByUserIds = decisions
|
|
.Where(candidate => candidate.DecidedByUserId.HasValue)
|
|
.Select(candidate => candidate.DecidedByUserId!.Value)
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
Dictionary<Guid, string?> decisionPortraits = await dbContext.Users
|
|
.Where(user => decidedByUserIds.Contains(user.Id))
|
|
.ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct);
|
|
|
|
List<ApprovalDecisionDto> decisionDtos = decisions
|
|
.Select(candidate => new ApprovalDecisionDto(
|
|
candidate.Id,
|
|
candidate.ApprovalRequestId,
|
|
candidate.Decision,
|
|
candidate.Comment,
|
|
candidate.DecidedByUserId,
|
|
candidate.DecidedByName,
|
|
candidate.DecidedByEmail,
|
|
candidate.DecidedByUserId.HasValue
|
|
? decisionPortraits.GetValueOrDefault(candidate.DecidedByUserId.Value)
|
|
: null,
|
|
candidate.CreatedAt))
|
|
.ToList();
|
|
|
|
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,
|
|
decisionDtos);
|
|
|
|
await SendOkAsync(dto, ct);
|
|
}
|
|
}
|