Files
social-media/backend/Modules/Approvals/Handlers/SubmitApprovalDecision.cs
Jonathan Bourdon df3e602015
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
feat: pivot to social media workflow app
2026-04-24 12:58:35 -04:00

170 lines
6.2 KiB
C#

using Socialize.Infrastructure.Security;
using Socialize.Modules.Notifications.Contracts;
namespace Socialize.Modules.Approvals.Handlers;
public record SubmitApprovalDecisionRequest(
string Decision,
string? Comment,
string? ReviewerName,
string? ReviewerEmail);
public class SubmitApprovalDecisionRequestValidator
: Validator<SubmitApprovalDecisionRequest>
{
public SubmitApprovalDecisionRequestValidator()
{
RuleFor(x => x.Decision).NotEmpty().MaximumLength(64);
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));
}
}
public class SubmitApprovalDecisionHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
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 &&
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.ProjectId))
{
await SendForbiddenAsync(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 = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(),
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
DecidedByName = decidedByName,
DecidedByEmail = decidedByEmail,
CreatedAt = DateTimeOffset.UtcNow,
};
approval.State = normalizedDecision;
approval.CompletedAt = DateTimeOffset.UtcNow;
if (approval.Stage == "Internal")
{
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,
};
}
dbContext.ApprovalDecisions.Add(decision);
await dbContext.SaveChangesAsync(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.Stage,
approval.ReviewerName,
approval.ReviewerEmail,
approval.RequestedByUserId,
approval.DueAt,
approval.State,
approval.AccessToken,
approval.SentAt,
approval.CompletedAt,
decisionDtos);
await SendOkAsync(dto, ct);
}
}