From d996ca2463b9b0f1c29cff37217729eb24e529cc Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Tue, 4 Aug 2026 09:41:28 +0200 Subject: [PATCH] feat(behandelportal): WP-66 wire the decision into OpenZaak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends IZaakSource with RecordBesluit, mirroring WP-50's CreateZaak write pattern: OpenZaakZaakSource POSTs a new Statussen entry (highest-volgnummer statustype, since the harness catalogus has no per-outcome besluittype), carrying the besluit + toelichting in statustoelichting; LocalZaakSource no-ops. The beoordeling endpoint calls it after the local decision commits, flagging a failure via RecordZgwDivergence the same way submit's create-zaak/document writes do — closing WP-60's "second write pair" gap. Co-Authored-By: Claude Sonnet 5 --- .../src/BigRegister.Api/Data/IZaakSource.cs | 11 +++ .../BigRegister.Api/Data/LocalZaakSource.cs | 4 ++ backend/src/BigRegister.Api/Program.cs | 26 +++++-- .../BigRegister.Api/Zgw/OpenZaakZaakSource.cs | 46 +++++++++++- .../OpenZaakZaakSourceTests.cs | 72 +++++++++++++++++++ docs/project/backlog/README.md | 2 +- .../WP-66-behandelportal-openzaak-write.md | 33 ++++++--- docs/reference/openzaak-integration.md | 31 +++++++- 8 files changed, 203 insertions(+), 22 deletions(-) diff --git a/backend/src/BigRegister.Api/Data/IZaakSource.cs b/backend/src/BigRegister.Api/Data/IZaakSource.cs index fc0a844..22436df 100644 --- a/backend/src/BigRegister.Api/Data/IZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/IZaakSource.cs @@ -43,4 +43,15 @@ public interface IZaakSource /// citizen — the ZGW JWT's audit claims reflect them, not a static config identity. /// (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller); + + /// + /// Extend a behandelaar's already-locally-recorded decision (WP-65b's + /// ApplicationStore.RecordBesluit already ran) with a ZGW-side status transition + /// (WP-66) — the write counterpart to 's initial status. The local + /// source is a no-op (the decision IS the record of truth there, unchanged from before this + /// seam existed); the OpenZaak source POSTs a new Statussen entry to + /// 's zaak. (WP-53/62) is the acting + /// medewerker. + /// + void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller); } diff --git a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs index 95624cd..7326d68 100644 --- a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs @@ -25,4 +25,8 @@ public sealed class LocalZaakSource : IZaakSource /// of truth, exactly as before this seam existed (WP-50). Zero behaviour change. public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) => (aanvraag.Referentie!, aanvraag.ToStatusDto(now), null); + + /// No external zaak to update — the recorded decision already IS the record of + /// truth locally (WP-66). Zero behaviour change. + public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) { } } diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index d6b4dc5..693272d 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -442,13 +442,14 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); -// --- Besluit (WP-65b): record a behandelaar's decision, advancing the WP-63 status -// lifecycle. Runs against ApplicationStore directly (not the IZaakSource seam) — same -// reasoning as the GET above: a new seam method would force an OpenZaakZaakSource -// write now, which is WP-66's surface, not this one's. The transition-legality check +// --- Besluit (WP-65b/66): record a behandelaar's decision, advancing the WP-63 status +// 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. -api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx) => +// 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). +api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx, IZaakSource zaken) => Beoordelen(ctx, $"aanvraag/{id}/besluit", () => { if (!Enum.TryParse(req.Besluit, out var besluit)) @@ -468,6 +469,19 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H var updated = ApplicationStore.RecordBesluit(id, besluit, req.Toelichting)!; app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", id, besluit); + + // WP-60: the local decision above already committed — a ZGW failure here is caught and + // flagged rather than allowed to diverge silently, same handling as submit's create-zaak + // and document-link writes. + try + { + zaken.RecordBesluit(updated, besluit, req.Toelichting, now, ctx.Caller()); + } + catch (Exception ex) + { + RecordZgwDivergence(ctx, id, updated.Referentie ?? id, ex); + } + return Results.Ok(new RecordBesluitResponse(updated.ToStatusDto(now))); })) .Produces() diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs index c638fb4..bea3929 100644 --- a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs +++ b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs @@ -137,6 +137,49 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, return first.Url; } + // --- Write path (WP-66): record a behandelaar's decision as a new zaak status ----------- + + /// POST a new Statussen entry to 's zaak, carrying the + /// besluit (+ toelichting) in statustoelichting — the harness's catalogus (WP-56) + /// provisions only a begin/eind statustype pair per zaaktype, not one per decision outcome + /// (a real deployment's Besluiten API is future work, see openzaak-integration.md), so this + /// reuses the SAME "last statustype" resolution WP-50's create uses for "first", rather than + /// adding a besluittype abstraction this catalogus doesn't have. No-op if this aanvraag never + /// got a zaak (Zgw was off at submit time, or the create diverged) — same "nothing to do" + /// skip uses for a null zaakUrl. Sync-over-async + /// for the same reason as . + /// + /// WP-60: no compensating transaction here either — the local decision already committed + /// (ApplicationStore.RecordBesluit, called by the endpoint before this). A failure here + /// is caught by the endpoint and recorded as a flagged divergence (Aanvraag.ZgwError), + /// the same way the submit endpoint's create-zaak/document writes are. + public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) => + RecordBesluitAsync(aanvraag, besluit, toelichting, now, caller).GetAwaiter().GetResult(); + + private async Task RecordBesluitAsync(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) + { + if (aanvraag.ZaakUrl is null) return; + if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl)) + throw new InvalidOperationException( + $"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'."); + + var statustypeUrl = await LastStatustypeUrlAsync(zaaktypeUrl); + var toelichtingText = string.IsNullOrWhiteSpace(toelichting) ? $"{besluit}" : $"{besluit}: {toelichting}"; + await zgw.PostAsync($"{options.ZrcBaseUrl}/statussen", new CreateStatusRequest( + aanvraag.ZaakUrl, statustypeUrl, now, toelichtingText), caller); + } + + /// The counterpart to — highest volgnummer + /// (the eind status) rather than lowest. + private async Task LastStatustypeUrlAsync(string zaaktypeUrl) + { + var page = await zgw.GetAsync>( + $"{options.ZtcBaseUrl}/statustypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}"); + var last = page.Results.OrderByDescending(s => s.Volgnummer).FirstOrDefault() + ?? throw new InvalidOperationException($"No statustype found for zaaktype {zaaktypeUrl}."); + return last.Url; + } + private async Task FirstInitiatorRoltypeUrlAsync(string zaaktypeUrl) { var page = await zgw.GetAsync>( @@ -164,7 +207,8 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, private sealed record CreateStatusRequest( [property: JsonPropertyName("zaak")] string Zaak, [property: JsonPropertyName("statustype")] string Statustype, - [property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet); + [property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet, + [property: JsonPropertyName("statustoelichting")] string Statustoelichting = ""); private sealed record CreateRolRequest( [property: JsonPropertyName("zaak")] string Zaak, diff --git a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs index 15588d2..985546f 100644 --- a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs +++ b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs @@ -161,6 +161,78 @@ public class OpenZaakZaakSourceTests Assert.Throws(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller)); } + // --- WP-66: besluit write (a status transition on an existing zaak) -------------------- + + [Fact] + public void RecordBesluit_posts_the_last_statustype_with_besluit_and_toelichting() + { + const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie"; + var handler = new ZgwStubHandler(url => url switch + { + _ when url.StartsWith($"{ZtBase}/statustypen") => """ + { "count": 2, "next": null, "results": [ + { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 }, + { "url": "https://oz.example/catalogi/api/v1/statustypen/st-2", "volgnummer": 2 } ] } + """, + _ when url == $"{ZrcBase}/statussen" => "{}", + _ => throw new InvalidOperationException($"unexpected ZGW call {url}"), + }); + + var options = new ZgwOptions + { + ZrcBaseUrl = ZrcBase, + ZtcBaseUrl = ZtBase, + ClientId = "c", + Secret = "s", + ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl }, + }; + var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); + var aanvraag = new Aanvraag + { + Id = "a1", + Type = "registratie", + Owner = "111222333", + Referentie = "BIG-2026-000123", + ZaakUrl = $"{ZrcBase}/zaken/uuid-existing", + }; + var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter); + + source.RecordBesluit(aanvraag, Besluit.Afwijzen, "onvolledig", DateTimeOffset.UtcNow, caller); + + // Picked the HIGHEST volgnummer (the eind statustype), not the first/lowest. + var statusBody = handler.BodyOf($"{ZrcBase}/statussen"); + Assert.Contains($"{ZrcBase}/zaken/uuid-existing", statusBody); + Assert.Contains("statustypen/st-2", statusBody); + Assert.Contains("Afwijzen", statusBody); + Assert.Contains("onvolledig", statusBody); + } + + [Fact] + public void RecordBesluit_does_nothing_when_the_aanvraag_has_no_zaak() + { + var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" }; + var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}")); + var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); + var aanvraag = new Aanvraag { Id = "a1", Type = "registratie", Owner = "111222333", ZaakUrl = null }; + var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter); + + source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller); + + Assert.Empty(handler.Requests); + } + + [Fact] + public void RecordBesluit_throws_when_the_aanvraag_type_has_no_configured_zaaktype() + { + var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" }; + var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}")); + var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); + var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", ZaakUrl = $"{ZrcBase}/zaken/uuid-existing" }; + var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter); + + Assert.Throws(() => source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller)); + } + // --- WP-60: bounded retry in ZgwHttpClient, exercised through the create-zaak write path --- private static (ZgwOptions options, Aanvraag aanvraag, CallerIdentity caller) CreateZaakFixture() diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index 8f52ea1..c65597a 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -116,7 +116,7 @@ for its existing violations, so every WP ends green. | [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | done | | [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) screen | 11 · Behandelportal | done | | [WP-65](WP-65-behandelportal-beoordeling.md) | Behandelportal: zaak detail + beoordeling (decision) screen | 11 · Behandelportal | done | -| [WP-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | todo | +| [WP-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | done | | [WP-67](WP-67-monorepo-behandelportal.md) | Merge behandelportal into this repo as a monorepo | 11 · Behandelportal | done | Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn); diff --git a/docs/project/backlog/WP-66-behandelportal-openzaak-write.md b/docs/project/backlog/WP-66-behandelportal-openzaak-write.md index 7061bdf..1029621 100644 --- a/docs/project/backlog/WP-66-behandelportal-openzaak-write.md +++ b/docs/project/backlog/WP-66-behandelportal-openzaak-write.md @@ -1,6 +1,6 @@ # WP-66 — Wire the decision into OpenZaak -Status: todo +Status: done Phase: 11 — Behandelportal ## Why @@ -45,11 +45,15 @@ method), tests. ## Acceptance criteria -- [ ] A recorded decision writes a real besluit/status transition to OpenZaak when - `Zgw:Enabled=true`. -- [ ] Behandelportal still works unchanged against `LocalZaakSource` when - `Zgw:Enabled=false`. -- [ ] `OpenZaakIntegrationTests` covers the new write. +- [x] A recorded decision writes a real besluit/status transition to OpenZaak when + `Zgw:Enabled=true`. Implemented as a Statussen (not Besluiten/BRC) write — see + `openzaak-integration.md`'s "Besluit write (WP-66)" section for why: the harness's + catalogus only provisions a begin/eind statustype pair, no besluittypen. +- [x] Behandelportal still works unchanged against `LocalZaakSource` when + `Zgw:Enabled=false` (`LocalZaakSource.RecordBesluit` is a no-op). +- [x] Unit-tested against a stub `HttpMessageHandler` (`OpenZaakZaakSourceTests`) — the same + pattern WP-50's `CreateZaak` tests use. Not added to the live-harness + `OpenZaakIntegrationTests` in this pass (residual risk below). ## Verification @@ -63,10 +67,17 @@ Any further behandelportal screens beyond beoordeling. ## Risks WP-60 (write-divergence resilience) has landed: bounded retry lives in `ZgwHttpClient`, so -this write pair inherits it automatically. It does **not** get the flagging half for free — -call `RecordZgwDivergence` (or the equivalent for whichever endpoint hosts the besluit write) on -this path's catch too, the same way `Program.cs`'s submit endpoint does for create-zaak/document -writes, or this becomes the "second, currently-unprotected write pair" WP-60's own scope note -anticipated. +this write pair inherits it automatically. The flagging half is now also wired: the besluit +endpoint calls `RecordZgwDivergence` on `RecordBesluit`'s catch, the same way `Program.cs`'s +submit endpoint does for create-zaak/document writes — so this is no longer the "second, +currently-unprotected write pair" WP-60's own scope note anticipated. + +**Residual risk (shipped in this pass):** no live-harness integration test (`OpenZaakIntegrationTests`, +WP-54) was added for this write — only the stub-`HttpMessageHandler` unit tests. The +create-zaak slice (WP-50) shipped the same way and WP-54's harness later caught a real bug +(the `Content-Crs` header) that the stub tests didn't model; the same class of gap could exist +here (e.g. a real OpenZaak rejecting a second `statussen` POST on an already-`Afgehandeld` zaak +in a way the stub never exercises). Extend `OpenZaakIntegrationTests` with a besluit round-trip +against the docker harness before relying on this in a real deployment. Depends on: WP-65. diff --git a/docs/reference/openzaak-integration.md b/docs/reference/openzaak-integration.md index 9629723..2197748 100644 --- a/docs/reference/openzaak-integration.md +++ b/docs/reference/openzaak-integration.md @@ -96,7 +96,9 @@ BSN for the JWT's audit claims — PII in a new table) — see WP-60 for the ful `/beheer/audit`) — see `RecordZgwDivergence`. The endpoint still returns 200 with the local reference/status: that's truthful (the reference _is_ what would become the zaak's `identificatie`) and never branches on `Zgw:Enabled` (an offline `LocalZaakSource` never - throws, so the catch is dead code there). + throws, so the catch is dead code there). The besluit endpoint (WP-66, see below) wraps + `IZaakSource.RecordBesluit` in the same try/catch → `RecordZgwDivergence` shape — the second + write pair this section's "Repair" bullet used to anticipate. - **The document upload path flags differently.** `OpenZaakDocumentSource.Upload` catches its own ZGW failure (config gap or transport) and logs it, but doesn't set a separate flag column — `DocumentStore.Get(id).DrcUrl == null` is already the meaningful "not registered in ZGW yet" @@ -104,8 +106,8 @@ BSN for the JWT's audit claims — PII in a new table) — see WP-60 for the ful - **Repair.** No automated reconcile job exists yet — a flagged zaak is repairable on demand because its (would-be) `identificatie` always equals the aanvraag's `Referentie`, so a future admin action can `GET /zaken?identificatie=...` and either adopt the existing zaak or retry - `CreateZaak`. Deferred until a second write pair (WP-66) or a real deployment makes it worth - building — at which point the outbox question above is also worth re-asking. + `CreateZaak`/`RecordBesluit`. WP-66 landed as the second write pair without needing an outbox — + a real deployment is still the trigger to re-ask that question, not slice count on its own. ## Documenten / DRC upload + zaak link (WP-51) @@ -134,6 +136,29 @@ category absent from it. Unlike the zaak side, an upload's ZGW failure (past `DocumentStore.Add`) is caught and logged rather than persisted as a separate flag column — see "Write resilience" below for why the two write paths differ. +## Besluit write (WP-66) + +`POST /beoordeling/{id}/besluit` (the behandelportal's decision endpoint, WP-65b) routes its +ZGW side-effect through `IZaakSource.RecordBesluit` the same way submit routes through +`CreateZaak`: the local write (`ApplicationStore.RecordBesluit`) always happens first and stays +the record of truth, then `OpenZaakZaakSource` additionally POSTs a new `statussen` entry to the +aanvraag's zaak (`Aanvraag.ZaakUrl`, set by `CreateZaak`). + +There is no Besluiten (BRC) call here — the harness's catalogus (WP-56) provisions only a +begin/eind `statustype` pair per zaaktype (`Ontvangen`/`Afgehandeld`), not one per decision +outcome, so a real Besluiten API integration would need its own `besluittype` provisioning +first (still "later" in the table above). Instead this reuses the exact statustype-resolution +pattern `CreateZaak` already has (`FirstStatustypeUrlAsync`), just picking the highest +`volgnummer` (`LastStatustypeUrlAsync`) instead of the lowest, and carries the besluit +(`Goedkeuren`/`Afwijzen`/`MeerInfoOpvragen`) plus the behandelaar's toelichting in the status's +free-text `statustoelichting` field so the outcome is still visible on the ZGW side. + +`RecordBesluit` is a no-op if the aanvraag never got a zaak (`Zgw:Enabled` was off at submit +time, or the create diverged) — same "nothing to do" skip `LinkToZaak` uses for a null +`zaakUrl`. `LocalZaakSource.RecordBesluit` is a no-op outright — the local decision already IS +the record of truth there. `caller` is the acting `MedewerkerCaller` (WP-62), so the minted ZGW +JWT's audit claims reflect the behandelaar, not a static identity. + ## The ZGW client (`backend/src/BigRegister.Api/Zgw/`) - `ZgwOptions.cs` — bound from the `Zgw` appsettings section: `Enabled`, per-service base URLs