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:
eho
2026-08-05 15:34:10 +02:00
co-authored by Claude Opus 5
parent fd04221d2f
commit fc6e73806a
13 changed files with 159 additions and 78 deletions
+21 -15
View File
@@ -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)