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 { 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 { 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("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 decisions = await dbContext.ApprovalDecisions .Where(candidate => candidate.ApprovalRequestId == approval.Id) .OrderByDescending(candidate => candidate.CreatedAt) .ToListAsync(ct); List decidedByUserIds = decisions .Where(candidate => candidate.DecidedByUserId.HasValue) .Select(candidate => candidate.DecidedByUserId!.Value) .Distinct() .ToList(); Dictionary decisionPortraits = await dbContext.Users .Where(user => decidedByUserIds.Contains(user.Id)) .ToDictionaryAsync(user => user.Id, user => user.PortraitUrl, ct); List 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); } }