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 { 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 { 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 && !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 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.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); } }