202 lines
7.8 KiB
C#
202 lines
7.8 KiB
C#
namespace KArtSell.Modules.ModelOperations.Features.ApprovalWorkflow;
|
|
|
|
using FastEndpoints;
|
|
using KArtSell.BuildingBlocks.Time;
|
|
using KArtSell.Modules.ModelOperations.Domain.ApprovalWorkflow;
|
|
using Microsoft.AspNetCore.Http;
|
|
|
|
public record CreateApprovalRequest(Guid ModelId, DateOnly EffectiveAt, string Justification);
|
|
public record CreateApprovalResponse(Guid Id, string Status, DateTime CreatedAt);
|
|
|
|
public class CreateApprovalEndpoint : EndpointWithoutRequest<CreateApprovalResponse>
|
|
{
|
|
private readonly CreateApprovalProposalHandler _handler;
|
|
private readonly ApprovalWorkflowSql _sql;
|
|
|
|
public CreateApprovalEndpoint(CreateApprovalProposalHandler handler, ApprovalWorkflowSql sql)
|
|
{
|
|
_handler = handler;
|
|
_sql = sql;
|
|
}
|
|
|
|
public override void Configure()
|
|
{
|
|
Post("/approvals");
|
|
Roles("Maker");
|
|
}
|
|
|
|
public override async Task HandleAsync(CancellationToken ct)
|
|
{
|
|
var request = await HttpContext.Request.ReadFromJsonAsync<CreateApprovalRequest>(cancellationToken: ct);
|
|
var userEmail = HttpContext.User.FindFirst("email")?.Value ?? "anonymous";
|
|
var userRole = HttpContext.User.FindFirst("role")?.Value ?? "Guest";
|
|
|
|
var proposalId = await _handler.Handle(userEmail, userRole, request!.ModelId, request.EffectiveAt, request.Justification, Guid.NewGuid(), ct);
|
|
var proposal = await _sql.GetProposalAsync(proposalId, ct);
|
|
|
|
await Send.CreatedAtAsync<CreateApprovalEndpoint>(new { id = proposalId }, new CreateApprovalResponse(proposalId, "DRAFT", proposal!.CreatedAt), cancellation: ct);
|
|
}
|
|
}
|
|
|
|
public record GetApprovalsRequest(string? Status, Guid? ModelId, int Limit = 50, int Offset = 0);
|
|
public record ApprovalDto(Guid Id, Guid ModelId, string Status, string CreatedBy, DateTime CreatedAt, string Justification);
|
|
public record GetApprovalsResponse(List<ApprovalDto> Items, int Total, int Pages);
|
|
|
|
public class GetApprovalsEndpoint : Endpoint<GetApprovalsRequest, GetApprovalsResponse>
|
|
{
|
|
private readonly ApprovalWorkflowSql _sql;
|
|
|
|
public GetApprovalsEndpoint(ApprovalWorkflowSql sql) => _sql = sql;
|
|
|
|
public override void Configure()
|
|
{
|
|
Get("/approvals");
|
|
Roles("Maker", "Checker", "SRE");
|
|
}
|
|
|
|
public override async Task HandleAsync(GetApprovalsRequest req, CancellationToken ct)
|
|
{
|
|
ApprovalStatus? status = req.Status != null ? Enum.Parse<ApprovalStatus>(req.Status, ignoreCase: true) : null;
|
|
var proposals = await _sql.ListProposalsAsync(status, req.ModelId, req.Limit, req.Offset, ct);
|
|
|
|
var items = proposals.Select(p => new ApprovalDto(p.Id, p.ModelId, p.Status.ToString(), p.CreatedBy, p.CreatedAt, p.Justification)).ToList();
|
|
|
|
await Send.OkAsync(new GetApprovalsResponse(items, items.Count, (items.Count + req.Limit - 1) / req.Limit), ct);
|
|
}
|
|
}
|
|
|
|
public record ProposeForReviewResponse(Guid Id, string Status);
|
|
|
|
public class ProposeForReviewEndpoint : EndpointWithoutRequest<ProposeForReviewResponse>
|
|
{
|
|
private readonly ProposeForReviewHandler _handler;
|
|
|
|
public ProposeForReviewEndpoint(ProposeForReviewHandler handler) => _handler = handler;
|
|
|
|
public override void Configure()
|
|
{
|
|
Post("/approvals/{id}/propose");
|
|
Roles("Maker");
|
|
}
|
|
|
|
public override async Task HandleAsync(CancellationToken ct)
|
|
{
|
|
var proposalId = Route<Guid>("id");
|
|
var userEmail = HttpContext.User.FindFirst("email")?.Value ?? "anonymous";
|
|
|
|
await _handler.Handle(proposalId, userEmail, Guid.NewGuid(), ct);
|
|
|
|
await Send.OkAsync(new ProposeForReviewResponse(proposalId, "PROPOSED"), ct);
|
|
}
|
|
}
|
|
|
|
public record ActivateApprovalResponse(Guid Id, string Status, DateTime? ActivatedAt);
|
|
|
|
/// <summary>
|
|
/// DEBT-028: before this endpoint existed, ActivateModelHandler was registered in DI and fully
|
|
/// implemented but had no HTTP entry point — an Approved proposal could never reach Active, the
|
|
/// final step of the maker-checker gate this whole slice exists for.
|
|
/// </summary>
|
|
public class ActivateApprovalEndpoint : EndpointWithoutRequest<ActivateApprovalResponse>
|
|
{
|
|
private readonly ActivateModelHandler _handler;
|
|
private readonly ApprovalWorkflowSql _sql;
|
|
|
|
public ActivateApprovalEndpoint(ActivateModelHandler handler, ApprovalWorkflowSql sql)
|
|
{
|
|
_handler = handler;
|
|
_sql = sql;
|
|
}
|
|
|
|
public override void Configure()
|
|
{
|
|
Post("/approvals/{id}/activate");
|
|
Roles("SRE");
|
|
}
|
|
|
|
public override async Task HandleAsync(CancellationToken ct)
|
|
{
|
|
var proposalId = Route<Guid>("id");
|
|
var userEmail = HttpContext.User.FindFirst("email")?.Value ?? "anonymous";
|
|
var userRole = HttpContext.User.FindFirst("role")?.Value ?? "Guest";
|
|
|
|
await _handler.Handle(proposalId, userEmail, userRole, Guid.NewGuid(), ct);
|
|
|
|
var proposal = await _sql.GetProposalAsync(proposalId, ct);
|
|
await Send.OkAsync(new ActivateApprovalResponse(proposalId, "ACTIVE", proposal!.ActivatedAt), ct);
|
|
}
|
|
}
|
|
|
|
public record ApprovalDetailResponse(Guid Id, Guid ModelId, string Status, string CreatedBy, DateTime CreatedAt,
|
|
string Justification, DateOnly EffectiveAt, string? ApprovedBy, DateTime? ApprovedAt, string? ApprovalNotes,
|
|
string? ActivatedBy, DateTime? ActivatedAt, List<EvidenceDto> Evidence);
|
|
|
|
public class GetApprovalByIdEndpoint : EndpointWithoutRequest<ApprovalDetailResponse>
|
|
{
|
|
private readonly ApprovalWorkflowSql _sql;
|
|
|
|
public GetApprovalByIdEndpoint(ApprovalWorkflowSql sql) => _sql = sql;
|
|
|
|
public override void Configure()
|
|
{
|
|
Get("/approvals/{id}");
|
|
Roles("Maker", "Checker", "SRE");
|
|
}
|
|
|
|
public override async Task HandleAsync(CancellationToken ct)
|
|
{
|
|
var proposalId = Route<Guid>("id");
|
|
var proposal = await _sql.GetProposalAsync(proposalId, ct);
|
|
if (proposal is null)
|
|
{
|
|
await Send.NotFoundAsync(ct);
|
|
return;
|
|
}
|
|
|
|
var evidence = await _sql.GetEvidenceForProposalAsync(proposalId, ct);
|
|
var evidenceDtos = evidence.Select(e => new EvidenceDto(e.EvidenceType, e.EvidenceUrl, e.ReviewerComment)).ToList();
|
|
|
|
await Send.OkAsync(new ApprovalDetailResponse(
|
|
proposal.Id, proposal.ModelId, proposal.Status.ToString(), proposal.CreatedBy, proposal.CreatedAt,
|
|
proposal.Justification, proposal.EffectiveAt, proposal.ApprovedBy, proposal.ApprovedAt,
|
|
proposal.ApprovalNotes, proposal.ActivatedBy, proposal.ActivatedAt, evidenceDtos), ct);
|
|
}
|
|
}
|
|
|
|
public record ApproveApprovalRequest(string ApprovalNotes, List<EvidenceDto> Evidence);
|
|
public record EvidenceDto(string Type, string Url, string? Comment);
|
|
public record ApproveApprovalResponse(Guid Id, string Status, DateTime ApprovedAt);
|
|
|
|
public class ApproveApprovalEndpoint : Endpoint<ApproveApprovalRequest, ApproveApprovalResponse>
|
|
{
|
|
private readonly ApproveApprovalHandler _handler;
|
|
private readonly ApprovalWorkflowSql _sql;
|
|
private readonly IClock _clock;
|
|
|
|
public ApproveApprovalEndpoint(ApproveApprovalHandler handler, ApprovalWorkflowSql sql, IClock clock)
|
|
{
|
|
_handler = handler;
|
|
_sql = sql;
|
|
_clock = clock;
|
|
}
|
|
|
|
public override void Configure()
|
|
{
|
|
Post("/approvals/{id}/approve");
|
|
Roles("Checker");
|
|
}
|
|
|
|
public override async Task HandleAsync(ApproveApprovalRequest req, CancellationToken ct)
|
|
{
|
|
var proposalId = Route<Guid>("id");
|
|
var userEmail = HttpContext.User.FindFirst("email")?.Value ?? "anonymous";
|
|
var userRole = HttpContext.User.FindFirst("role")?.Value ?? "Guest";
|
|
|
|
var evidence = req.Evidence.Select(e => (e.Type, e.Url, e.Comment)).ToList();
|
|
await _handler.Handle(proposalId, userEmail, userRole, req.ApprovalNotes, evidence, Guid.NewGuid(), ct);
|
|
|
|
var proposal = await _sql.GetProposalAsync(proposalId, ct);
|
|
await Send.OkAsync(new ApproveApprovalResponse(proposalId, "APPROVED", proposal!.ApprovedAt ?? _clock.UtcNow.UtcDateTime), ct);
|
|
}
|
|
}
|