fix: DEBT-025/026 - wire Draft->Proposed transition and GET /approvals/{id}
DEBT-026 (high impact): ProposeForReviewHandler + POST /approvals/{id}/propose
wires ApprovalWorkflowPolicy.CanProposeForReview, which previously had no
Handler/Endpoint calling it. Before this, a proposal created via POST
/approvals could never reach Approved/Active through the running application
- the maker-checker gate was not completable end-to-end via HTTP.
DEBT-025 (medium impact): GetApprovalByIdEndpoint (GET /approvals/{id}) +
ApprovalWorkflowSql.GetEvidenceForProposalAsync make evidence attached during
approval (PBO/DSR/OOS artifact links) readable via HTTP instead of only by
querying model_operations.approval_evidence directly.
Both discovered while resolving DEBT-017 earlier the same session. 4 new
tests added. dotnet build -c Release clean. Not verified against a live
database (no SSH tunnel open in this environment) - see
TECH_DEBT_REGISTER.md and WBS_PROGRESS_TRACKER.csv AEG-VS-26-01 for the
honest verification status; do not mark COMPLETED until a real Postgres
run passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,67 @@ public class GetApprovalsEndpoint : Endpoint<GetApprovalsRequest, GetApprovalsRe
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
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 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}");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -43,6 +43,35 @@ public class CreateApprovalProposalHandler
|
||||
}
|
||||
}
|
||||
|
||||
public class ProposeForReviewHandler
|
||||
{
|
||||
private readonly ApprovalWorkflowSql _sql;
|
||||
private readonly IClock _clock;
|
||||
|
||||
public ProposeForReviewHandler(ApprovalWorkflowSql sql, IClock clock)
|
||||
{
|
||||
_sql = sql;
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
public async Task Handle(Guid proposalId, string userEmail, Guid correlationId, CancellationToken ct = default)
|
||||
{
|
||||
var proposal = await _sql.GetProposalAsync(proposalId, ct)
|
||||
?? throw new KeyNotFoundException($"Proposal {proposalId} not found");
|
||||
|
||||
if (!ApprovalWorkflowPolicy.CanProposeForReview(proposal, userEmail))
|
||||
throw new UnauthorizedAccessException("Only the proposal's Maker can move it from Draft to Proposed");
|
||||
|
||||
ApprovalWorkflowPolicy.ValidateProposalState(proposal.Status, ApprovalStatus.Proposed);
|
||||
|
||||
await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Proposed, ct: ct);
|
||||
|
||||
var now = _clock.UtcNow.UtcDateTime;
|
||||
var proposeEvent = ApprovalWorkflowPolicy.CreateStateChangeEvent(proposalId, ApprovalStatus.Proposed, userEmail, correlationId, now);
|
||||
await _sql.InsertEventAsync(proposeEvent, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public class ApproveApprovalHandler
|
||||
{
|
||||
private readonly ApprovalWorkflowSql _sql;
|
||||
|
||||
@@ -99,6 +99,21 @@ public class ApprovalWorkflowSql
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<List<ApprovalEvidence>> GetEvidenceForProposalAsync(Guid proposalId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, approval_proposal_id, evidence_type, evidence_url, reviewer_comment,
|
||||
published_at, correlation_id
|
||||
FROM model_operations.approval_evidence
|
||||
WHERE approval_proposal_id = @proposalId
|
||||
ORDER BY published_at
|
||||
""";
|
||||
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
var evidence = await conn.QueryAsync<ApprovalEvidence>(sql, new { proposalId });
|
||||
return evidence.ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> InsertEvidenceAsync(ApprovalEvidence evidence, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
|
||||
Reference in New Issue
Block a user