refactor(backend): move aanvraag status lifecycle into the domain (WP-68 F3)
The status was derived in Contracts/Mappers.ToStatusDto, not the domain; Concept was a magic "Concept" string with no AanvraagStatusTag member; and the besluit endpoint re-derived its own guard by reading the status back out of the DTO and Enum.Parse-ing it. New Domain/Applications/AanvraagStatus.cs models the full status (Concept included, via a null Tag rather than a sixth enum member) as a closed type, constructible only through its factories. Aanvraag.StatusAt(now) carries the logic verbatim; Mappers.ToStatusDto and ZgwZaakMapper's two status producers become one-line projections onto the same wire DTO, so the wire shape is unchanged (gen:api shows zero diff beyond F1's). The one remaining Enum.Parse (the beoordeling GET, which crosses the IZaakSource wire boundary) is now non-throwing on an unrecognised tag. Also, WP-68 F2: the besluit transition-legality check now runs inside ApplicationStore.RecordBesluit's write lock instead of in the endpoint beforehand — two concurrent besluiten used to both pass the check before either wrote, letting the second silently overwrite a terminal decision. RecordBesluit returns an Ok/NotFound/Conflict outcome, mirroring DocumentStore.DeleteResult. Also, WP-68 F6: the "toelichting required" rule moves from an inline endpoint check into BeoordelingRules.RequiresToelichting, alongside CanDecide. The three tests naming this refactor's regression net (AanvraagStatusTag_covers_the_published_lifecycle, AutoApprovable_flips_to_goedgekeurd_after_the_window, ZgwZaakMapperTests) pass unmodified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Applications;
|
||||
using BigRegister.Domain.Authorization;
|
||||
using BigRegister.Domain.Beoordeling;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
@@ -452,8 +453,10 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
|
||||
var docs = DocumentStore.ByIds(c.DocumentIds)
|
||||
.Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList();
|
||||
var masked = c with { Owner = MaskTail(c.Owner!, 3) };
|
||||
var decisions = new BeoordelingDecisionsDto(
|
||||
BeoordelingRules.CanDecide(Enum.Parse<AanvraagStatusTag>(c.Status.Tag)));
|
||||
// WP-68 (F3): non-throwing — c.Status.Tag crosses the IZaakSource wire boundary, so an
|
||||
// unrecognised tag degrades to "cannot decide" instead of a 500.
|
||||
var canBesluiten = Enum.TryParse<AanvraagStatusTag>(c.Status.Tag, out var tag) && BeoordelingRules.CanDecide(tag);
|
||||
var decisions = new BeoordelingDecisionsDto(canBesluiten);
|
||||
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
|
||||
}))
|
||||
.Produces<BeoordelingViewDto>()
|
||||
@@ -464,14 +467,19 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
|
||||
// lifecycle. The local write runs against ApplicationStore directly (not the IZaakSource
|
||||
// seam) — same reasoning as the GET above. The transition-legality check
|
||||
// (BeoordelingRules.CanDecide) is the SAME function the GET's canBesluiten flag uses,
|
||||
// so the two can never drift. WP-66: once the local decision has committed, IZaakSource
|
||||
// also gets a chance to advance the ZGW-side zaak status — LocalZaakSource no-ops,
|
||||
// OpenZaakZaakSource POSTs a new Statussen entry (see its RecordBesluit).
|
||||
// so the two can never drift — and (WP-68 F2) it now runs inside ApplicationStore.RecordBesluit's
|
||||
// write lock rather than here, so two concurrent besluiten can't both pass it before either
|
||||
// writes. WP-66: once the local decision has committed, IZaakSource also gets a chance to
|
||||
// advance the ZGW-side zaak status — LocalZaakSource no-ops, OpenZaakZaakSource POSTs a new
|
||||
// Statussen entry (see its RecordBesluit).
|
||||
api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx, IZaakSource zaken) =>
|
||||
Beoordelen(ctx, $"aanvraag/{id}/besluit", () =>
|
||||
{
|
||||
if (!Enum.TryParse<Besluit>(req.Besluit, out var besluit))
|
||||
return Results.Problem(detail: $"Onbekend besluit '{req.Besluit}'.", statusCode: StatusCodes.Status400BadRequest);
|
||||
// WP-68 (F6): moved to BeoordelingRules.RequiresToelichting — same rule, now unit-testable.
|
||||
if (BeoordelingRules.RequiresToelichting(besluit) && string.IsNullOrWhiteSpace(req.Toelichting))
|
||||
return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
// Real bug fix (WP-66): `id` is the FE-facing case id from IZaakSource.ListCases — under
|
||||
@@ -481,17 +489,15 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
|
||||
// (see ApplicationStore.GetByReferentie).
|
||||
var c = zaken.ListCases(now).FirstOrDefault(x => x.Id == id);
|
||||
var a = c?.Status.Referentie is { } referentie ? ApplicationStore.GetByReferentie(referentie) : null;
|
||||
var statusTag = a?.ToStatusDto(now).Tag;
|
||||
if (a is null || statusTag == "Concept") return Results.NotFound();
|
||||
var current = Enum.Parse<AanvraagStatusTag>(statusTag!);
|
||||
if (!BeoordelingRules.CanDecide(current))
|
||||
if (a is null) return Results.NotFound();
|
||||
|
||||
var (outcome, updated) = ApplicationStore.RecordBesluit(a.Id, besluit, req.Toelichting, now);
|
||||
if (outcome == ApplicationStore.RecordBesluitOutcome.NotFound) return Results.NotFound();
|
||||
if (outcome == ApplicationStore.RecordBesluitOutcome.Conflict)
|
||||
return Results.Problem(
|
||||
detail: "Deze aanvraag staat geen besluit meer toe in de huidige status.",
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
if (besluit != Besluit.Goedkeuren && string.IsNullOrWhiteSpace(req.Toelichting))
|
||||
return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
var updated = ApplicationStore.RecordBesluit(a.Id, besluit, req.Toelichting)!;
|
||||
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", a.Id, besluit);
|
||||
|
||||
// WP-60: the local decision above already committed — a ZGW failure here is caught and
|
||||
@@ -499,14 +505,14 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
|
||||
// and document-link writes.
|
||||
try
|
||||
{
|
||||
zaken.RecordBesluit(updated, besluit, req.Toelichting, now, ctx.Caller());
|
||||
zaken.RecordBesluit(updated!, besluit, req.Toelichting, now, ctx.Caller());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RecordZgwDivergence(ctx, a.Id, updated.Referentie ?? a.Id, ex);
|
||||
RecordZgwDivergence(ctx, a.Id, updated!.Referentie ?? a.Id, ex);
|
||||
}
|
||||
|
||||
return Results.Ok(new RecordBesluitResponse(updated.ToStatusDto(now)));
|
||||
return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now)));
|
||||
}))
|
||||
.Produces<RecordBesluitResponse>()
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
|
||||
Reference in New Issue
Block a user