170 lines
6.2 KiB
C#
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);
|
|
}
|
|
}
|