From de3bff0d7f6201c03edb4066cf1068d7b7032f23 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 29 Jul 2026 09:03:13 +0200 Subject: [PATCH] feat(zgw): OpenZaak create-zaak, first write slice (WP-50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the IZaakSource seam (WP-49, read-only) with CreateZaak: submitting an aanvraag now also registers a Zaak + Status + Rol in OpenZaak when Zgw:Enabled=true, routed through the existing /applications/{id}/submit endpoint with the FE response DTO unchanged (ADR-0001/ADR-0005 — the endpoint never branches on the config flag itself, DI already picked the implementation). - ZgwOptions gains a Type→zaaktype-URL map + the two RSINs a Zaak needs. - LocalZaakSource.CreateZaak is a pure passthrough of what the endpoint already computes locally (zero behaviour change for the offline default). - OpenZaakZaakSource.CreateZaak POSTs the zaak (identificatie = the same local reference, so both stay in sync), resolves + POSTs the initial status and the initiator rol (BSN) via Catalogi lookups, and maps the result back into the submit response. - Marked ponytail shortcuts: first-statustype/roltype-Catalogi-returns (no per-type config) and no compensating transaction on partial failure — both fine for a first slice against a demo backend. Verified: full `npm run ci` green, zero api-client drift, 144/144 backend tests (142 existing + 2 new stub-handler tests asserting the POST bodies + type→zaaktype mapping per the acceptance criteria). Co-Authored-By: Claude Sonnet 5 --- .../src/BigRegister.Api/Data/IZaakSource.cs | 15 ++- .../BigRegister.Api/Data/LocalZaakSource.cs | 5 + backend/src/BigRegister.Api/Program.cs | 10 +- .../BigRegister.Api/Zgw/OpenZaakZaakSource.cs | 113 +++++++++++++++++- backend/src/BigRegister.Api/Zgw/ZgwOptions.cs | 12 ++ .../src/BigRegister.Api/Zgw/ZgwZaakMapper.cs | 5 + backend/src/BigRegister.Api/appsettings.json | 11 +- .../OpenZaakZaakSourceTests.cs | 83 +++++++++++++ .../architecture/0005-openzaak-behind-bff.md | 8 +- docs/reference/openzaak-integration.md | 66 ++++++++-- 10 files changed, 303 insertions(+), 25 deletions(-) diff --git a/backend/src/BigRegister.Api/Data/IZaakSource.cs b/backend/src/BigRegister.Api/Data/IZaakSource.cs index 623388f..9c29b0d 100644 --- a/backend/src/BigRegister.Api/Data/IZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/IZaakSource.cs @@ -9,8 +9,8 @@ namespace BigRegister.Api.Data; /// contract — so the frontend never changes (BFF-lite anti-corruption, ADR-0001). /// /// Default binding is (offline). Setting Zgw:Enabled=true -/// swaps in OpenZaakZaakSource. Slice 1 is read-only; create/update stay on the -/// local write path until WP-50. The interface returns the wire DTO (not the domain +/// swaps in OpenZaakZaakSource. Slice 1 (WP-49) was read-only; +/// (WP-50) is the first write. The interface returns the wire DTO (not the domain /// ) precisely so each source owns its own mapping — the OpenZaak /// source maps a ZGW Zaak into this shape, the local source maps the stored aanvraag. /// @@ -18,4 +18,15 @@ public interface IZaakSource { /// Every case, newest-first (the admin cross-owner list, WP-36). IReadOnlyList ListCases(DateTimeOffset now); + + /// + /// Register a just-submitted as a zaak (WP-50). The aanvraag is + /// already persisted locally (ApplicationStore.Submit already ran) — this is the + /// integration side-effect, and its return value is what the submit endpoint hands back to + /// the FE (ADR-0001: route the create through the existing submit response DTO, don't add a + /// second one). The local source is a pure passthrough of the already-computed local + /// reference/status; the OpenZaak source creates a Zaak (+ status + rol) and maps the result + /// back into the same shape. + /// + (string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now); } diff --git a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs index 3d24f0f..7dad750 100644 --- a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs @@ -12,4 +12,9 @@ public sealed class LocalZaakSource : IZaakSource { public IReadOnlyList ListCases(DateTimeOffset now) => ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList(); + + /// No external zaak to create — the aanvraag's local submit already IS the record + /// of truth, exactly as before this seam existed (WP-50). Zero behaviour change. + public (string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) => + (aanvraag.Referentie!, aanvraag.ToStatusDto(now)); } diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 57c1b4c..b2d252a 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -299,7 +299,7 @@ api.MapDelete("/applications/{id}", (string id) => // Submit runs the server-owned rules, sets autoApprovable, and transitions the // aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case. -api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) => +api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx, IZaakSource zaken) => { var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner); if (existing is null) return Results.NotFound(); @@ -324,7 +324,13 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re app.Logger.LogInformation( "aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}", id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie); - return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow))); + + // WP-50: route the create through the IZaakSource seam — LocalZaakSource is a passthrough + // of what was computed above; OpenZaakZaakSource (Zgw:Enabled=true) also registers a zaak + // in OpenZaak and maps its result back into this same response shape (ADR-0001/ADR-0005: + // zero FE contract change either way). + var (referentie, status) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow); + return Results.Ok(new SubmitApplicationResponse(referentie, status)); }) .Produces() .ProducesProblem(StatusCodes.Status409Conflict) diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs index b32761a..e095d30 100644 --- a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs +++ b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs @@ -15,13 +15,15 @@ public sealed record ZgwPage( [property: JsonPropertyName("results")] IReadOnlyList Results); /// -/// The backed by a real OpenZaak / ZGW Zaken API (WP-49). Reads -/// zaken (following pagination), resolves each zaaktype's human label from the Catalogi API -/// (cached), and maps into via . +/// The backed by a real OpenZaak / ZGW Zaken API (WP-49 read, WP-50 +/// write). Reads zaken (following pagination), resolves each zaaktype's human label from the +/// Catalogi API (cached), and maps into via +/// . Creates a zaak + status + rol for a just-submitted aanvraag. /// Selected only when Zgw:Enabled=true; the default stays . /// /// Auth: a fresh HS256 JWT per request () on the Authorization -/// header. Reading a zaak needs read scope on BOTH Zaken and Catalogi (zaaktype resolution). +/// header. Reading a zaak needs read scope on BOTH Zaken and Catalogi (zaaktype resolution); +/// creating one additionally needs write scope on Zaken. /// public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource { @@ -78,5 +80,108 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ?? throw new InvalidOperationException($"ZGW GET {url} returned null body."); } + // --- Write path (WP-50): create a Zaak, then a Status, then a Rol ------------------------ + + /// Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the + /// initial status → resolve + POST the initiator rol (BSN). Sync-over-async for the same + /// reason as (see the ponytail note there) — a submit is already a + /// single request/response round trip, so no extra concurrency concern. + /// + /// ponytail: no compensating transaction — if any ZGW call here throws, the aanvraag is + /// already marked Submitted locally (ApplicationStore.Submit already ran) but has no zaak. + /// Acceptable for a first write slice against a demo backend; a production arc would need a + /// retry/reconciliation story (or an outbox) before this dual-write can be trusted. + public (string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) => + CreateZaakAsync(aanvraag, now).GetAwaiter().GetResult(); + + private async Task<(string Referentie, AanvraagStatusDto Status)> CreateZaakAsync(Aanvraag aanvraag, DateTimeOffset now) + { + if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl)) + throw new InvalidOperationException( + $"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'."); + + var zaak = await PostAsync($"{options.ZrcBaseUrl}/zaken", new CreateZaakRequest( + Zaaktype: zaaktypeUrl, + Bronorganisatie: options.Bronorganisatie, + VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie, + Startdatum: DateOnly.FromDateTime(now.UtcDateTime), + Identificatie: aanvraag.Referentie + ?? throw new InvalidOperationException("Aanvraag has no Referentie yet — submit it locally first."))); + + var statustypeUrl = await FirstStatustypeUrlAsync(zaaktypeUrl); + await PostAsync($"{options.ZrcBaseUrl}/statussen", + new CreateStatusRequest(zaak.Url, statustypeUrl, now)); + + var roltypeUrl = await FirstInitiatorRoltypeUrlAsync(zaaktypeUrl); + await PostAsync($"{options.ZrcBaseUrl}/rollen", new CreateRolRequest( + Zaak: zaak.Url, + BetrokkeneType: "natuurlijk_persoon", + Roltype: roltypeUrl, + Roltoelichting: "Initiator", + BetrokkeneIdentificatie: new BetrokkeneIdentificatie(aanvraag.Owner))); + + return (zaak.Identificatie, ZgwZaakMapper.ToCreatedStatusDto(zaak.Identificatie)); + } + + // ponytail: takes the first statustype (lowest volgnummer) / the first "initiator" roltype + // Catalogi returns for the zaaktype, rather than a fully-configured per-type mapping like + // ZaaktypeUrls — good enough while a zaaktype has exactly one initial status and one + // initiator role (the normal case); add per-type config if that ever stops holding. + private async Task FirstStatustypeUrlAsync(string zaaktypeUrl) + { + var page = await GetAsync>( + $"{options.ZtcBaseUrl}/statustypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}"); + var first = page.Results.OrderBy(s => s.Volgnummer).FirstOrDefault() + ?? throw new InvalidOperationException($"No statustype found for zaaktype {zaaktypeUrl}."); + return first.Url; + } + + private async Task FirstInitiatorRoltypeUrlAsync(string zaaktypeUrl) + { + var page = await GetAsync>( + $"{options.ZtcBaseUrl}/roltypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}&omschrijvingGeneriek=initiator"); + var first = page.Results.FirstOrDefault() + ?? throw new InvalidOperationException($"No 'initiator' roltype found for zaaktype {zaaktypeUrl}."); + return first.Url; + } + + private async Task PostAsync(string url, object body) + { + using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) }; + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Mint()); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + using var res = await http.SendAsync(req); + res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync()) + ?? throw new InvalidOperationException($"ZGW POST {url} returned null body."); + } + private sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving); + + private sealed record Statustype( + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("volgnummer")] int Volgnummer); + + private sealed record Roltype([property: JsonPropertyName("url")] string Url); + + private sealed record CreateZaakRequest( + [property: JsonPropertyName("zaaktype")] string Zaaktype, + [property: JsonPropertyName("bronorganisatie")] string Bronorganisatie, + [property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie, + [property: JsonPropertyName("startdatum")] DateOnly Startdatum, + [property: JsonPropertyName("identificatie")] string Identificatie); + + private sealed record CreateStatusRequest( + [property: JsonPropertyName("zaak")] string Zaak, + [property: JsonPropertyName("statustype")] string Statustype, + [property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet); + + private sealed record CreateRolRequest( + [property: JsonPropertyName("zaak")] string Zaak, + [property: JsonPropertyName("betrokkeneType")] string BetrokkeneType, + [property: JsonPropertyName("roltype")] string Roltype, + [property: JsonPropertyName("roltoelichting")] string Roltoelichting, + [property: JsonPropertyName("betrokkeneIdentificatie")] BetrokkeneIdentificatie BetrokkeneIdentificatie); + + private sealed record BetrokkeneIdentificatie([property: JsonPropertyName("inpBsn")] string InpBsn); } diff --git a/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs b/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs index 2a51c6b..8f1d495 100644 --- a/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs +++ b/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs @@ -31,4 +31,16 @@ public sealed class ZgwOptions /// Human-readable end-user name for the audit trail (JWT user_representation). public string UserRepresentation { get; init; } = "BIG-register BFF"; + + /// Aanvraag Type (registratie/herregistratie/intake) → zaaktype URL (Catalogi), + /// so create-zaak (WP-50) knows which zaaktype to open per wizard. OpenZaak validates the URL + /// by fetching it, so an unconfigured or wrong entry fails loudly at create time. + public Dictionary ZaaktypeUrls { get; init; } = new(); + + /// RSIN of the organisation registering the zaak (bronorganisatie, WP-50). + public string Bronorganisatie { get; init; } = ""; + + /// RSIN of the organisation responsible for the zaak (verantwoordelijkeOrganisatie, + /// WP-50) — usually the same RSIN as . + public string VerantwoordelijkeOrganisatie { get; init; } = ""; } diff --git a/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs b/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs index 327d521..1569889 100644 --- a/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs +++ b/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs @@ -55,4 +55,9 @@ public static class ZgwZaakMapper // becomes midnight UTC so the FE's date parsing sees the same format either backend. private static string Iso(DateOnly d) => d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("o"); + + /// Status for a zaak that was JUST created (WP-50) — always the open/InBehandeling + /// coarse status (no einddatum yet), same convention as . + public static AanvraagStatusDto ToCreatedStatusDto(string identificatie) => + new("InBehandeling", Referentie: identificatie, Manual: true); } diff --git a/backend/src/BigRegister.Api/appsettings.json b/backend/src/BigRegister.Api/appsettings.json index 96cc344..1b2ac0d 100644 --- a/backend/src/BigRegister.Api/appsettings.json +++ b/backend/src/BigRegister.Api/appsettings.json @@ -6,7 +6,7 @@ } }, "AllowedHosts": "*", - "_Zgw": "WP-49: set Enabled=true + the URLs/credentials to source cases from a real OpenZaak. Off = local SQLite store (offline POC default).", + "_Zgw": "WP-49/50: set Enabled=true + the URLs/credentials/RSINs/zaaktype map to source + create cases against a real OpenZaak. Off = local SQLite store (offline POC default).", "Zgw": { "Enabled": false, "ZrcBaseUrl": "", @@ -14,6 +14,13 @@ "ClientId": "", "Secret": "", "UserId": "big-register-bff", - "UserRepresentation": "BIG-register BFF" + "UserRepresentation": "BIG-register BFF", + "Bronorganisatie": "", + "VerantwoordelijkeOrganisatie": "", + "ZaaktypeUrls": { + "registratie": "", + "herregistratie": "", + "intake": "" + } } } diff --git a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs index 4a774f5..3edeaac 100644 --- a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs +++ b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text; +using BigRegister.Api.Data; using BigRegister.Api.Zgw; namespace BigRegister.Tests; @@ -59,16 +60,98 @@ public class OpenZaakZaakSourceTests Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s)); } + [Fact] + public void CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back() + { + const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie"; + var handler = new StubHandler(url => url switch + { + _ when url == $"{ZrcBase}/zaken" => $$""" + { "url": "{{ZrcBase}}/zaken/uuid-new", "identificatie": "BIG-2026-000123", + "zaaktype": "{{zaaktypeUrl}}", "startdatum": "2026-07-28", + "einddatum": null, "registratiedatum": "2026-07-28" } + """, + _ when url.StartsWith($"{ZtBase}/statustypen") => """ + { "count": 1, "next": null, + "results": [ { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 } ] } + """, + _ when url.StartsWith($"{ZtBase}/roltypen") => """ + { "count": 1, "next": null, + "results": [ { "url": "https://oz.example/catalogi/api/v1/roltypen/rt-initiator" } ] } + """, + _ when url == $"{ZrcBase}/statussen" => "{}", + _ when url == $"{ZrcBase}/rollen" => "{}", + _ => throw new InvalidOperationException($"unexpected ZGW call {url}"), + }); + + var options = new ZgwOptions + { + ZrcBaseUrl = ZrcBase, + ZtcBaseUrl = ZtBase, + ClientId = "c", + Secret = "s", + Bronorganisatie = "123443210", + VerantwoordelijkeOrganisatie = "123443210", + 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", + }; + + var (referentie, status) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero)); + + Assert.Equal("BIG-2026-000123", referentie); + Assert.Equal("InBehandeling", status.Tag); + Assert.Equal("BIG-2026-000123", status.Referentie); + + string BodyOf(string url) => handler.Bodies[handler.Requests.LastIndexOf(url)]; + + // Zaak: mapped zaaktype + configured RSINs + the local reference as identificatie. + var zaakBody = BodyOf($"{ZrcBase}/zaken"); + Assert.Contains(zaaktypeUrl, zaakBody); + Assert.Contains("123443210", zaakBody); + Assert.Contains("BIG-2026-000123", zaakBody); + + // Status: points at the created zaak's URL and the resolved statustype. + var statusBody = BodyOf($"{ZrcBase}/statussen"); + Assert.Contains($"{ZrcBase}/zaken/uuid-new", statusBody); + Assert.Contains("statustypen/st-1", statusBody); + + // Rol: points at the created zaak, the resolved initiator roltype, and the BSN. + var rolBody = BodyOf($"{ZrcBase}/rollen"); + Assert.Contains($"{ZrcBase}/zaken/uuid-new", rolBody); + Assert.Contains("roltypen/rt-initiator", rolBody); + Assert.Contains("111222333", rolBody); + } + + [Fact] + public void CreateZaak_throws_when_the_aanvraag_type_has_no_configured_zaaktype() + { + var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" }; + var handler = new StubHandler(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", Referentie = "BIG-2026-000123" }; + + Assert.Throws(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow)); + } + private sealed class StubHandler(Func respond) : HttpMessageHandler { public List Requests { get; } = new(); public List AuthSchemes { get; } = new(); + public List Bodies { get; } = new(); protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var url = request.RequestUri!.ToString(); Requests.Add(url); AuthSchemes.Add(request.Headers.Authorization?.Scheme); + Bodies.Add(request.Content?.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult() ?? ""); return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(respond(url), Encoding.UTF8, "application/json"), diff --git a/docs/reference/architecture/0005-openzaak-behind-bff.md b/docs/reference/architecture/0005-openzaak-behind-bff.md index 08ce418..6f2b8c0 100644 --- a/docs/reference/architecture/0005-openzaak-behind-bff.md +++ b/docs/reference/architecture/0005-openzaak-behind-bff.md @@ -64,6 +64,8 @@ up front — the migration stance ADR-0001 already prescribes. - **Shipped with this ADR (WP-49):** `IZaakSource` + `LocalZaakSource` (default) + `OpenZaakZaakSource` (config-gated), the `Zgw/` client (`ZgwOptions`, `ZgwTokenProvider`, `ZgwZaakMapper`), and the reference guide [openzaak-integration.md](../openzaak-integration.md). -- **Deferred:** real inbound OIDC/JWT auth (still header-stubbed), create-zaak (WP-50), - Documenten/DRC upload + link (WP-51), Notificaties/NRC webhooks (WP-52), adding OpenZaak to - docker-compose. +- **Also shipped (WP-50):** `IZaakSource.CreateZaak` — the first write. Submitting an aanvraag + now also creates a Zaak + Status + Rol in OpenZaak when `Zgw:Enabled=true`, routed through the + existing submit endpoint with zero DTO change (same seam, same anti-corruption boundary). +- **Deferred:** real inbound OIDC/JWT auth (still header-stubbed), Documenten/DRC upload + link + (WP-51), Notificaties/NRC webhooks (WP-52), adding OpenZaak to docker-compose. diff --git a/docs/reference/openzaak-integration.md b/docs/reference/openzaak-integration.md index 63f0095..0d87f89 100644 --- a/docs/reference/openzaak-integration.md +++ b/docs/reference/openzaak-integration.md @@ -1,9 +1,10 @@ # OpenZaak / ZGW integration — how the BFF connects (& how to extend) -How the BFF sources cases from a real **OpenZaak** (ZGW APIs) while the frontend stays -unchanged. For the _why_, see [ADR-0005](architecture/0005-openzaak-behind-bff.md); this page -is _how the seam is built and how to add the next slice_. Built in -[WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (read-only zaken). +How the BFF sources (and now creates) cases against a real **OpenZaak** (ZGW APIs) while the +frontend stays unchanged. For the _why_, see [ADR-0005](architecture/0005-openzaak-behind-bff.md); +this page is _how the seam is built and how to add the next slice_. Built in +[WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (read-only zaken) and +[WP-50](../project/backlog/WP-50-openzaak-create-zaak.md) (the first write: create-zaak). ## The one rule: OpenZaak sits behind the BFF, never in the browser @@ -14,14 +15,48 @@ with **zero frontend change and no api-client drift**. ## The seam (data source by config) -- `Data/IZaakSource.cs` — the cases READ interface. Returns the existing - `ApplicationSummaryDto`, so each implementation owns its own mapping. +- `Data/IZaakSource.cs` — the cases READ + (WP-50) WRITE interface: `ListCases` and + `CreateZaak`. Both return the existing DTOs, so each implementation owns its own mapping. - `Data/LocalZaakSource.cs` — **default**; reads the local SQLite `ApplicationStore` - (offline, unchanged behaviour). + (offline, unchanged behaviour). `CreateZaak` is a pure passthrough of what the submit + endpoint already computed locally — no external call. - `Zgw/OpenZaakZaakSource.cs` — the OpenZaak client; selected only when `Zgw:Enabled=true`. + `CreateZaak` posts a Zaak, then a Status, then a Rol (see below). - Wiring (`Program.cs`): `if (Zgw:Enabled) AddHttpClient() -else AddSingleton()`. The `/admin/cases` endpoint resolves - `IZaakSource` from DI — routes + DTOs untouched. +else AddSingleton()`. The `/admin/cases` GET and the + `/applications/{id}/submit` POST both resolve `IZaakSource` from DI — routes + DTOs + untouched either way. + +## Create-zaak (WP-50) — the first write + +`POST /applications/{id}/submit` already persists the aanvraag locally (`ApplicationStore.Submit` +— unconditionally, regardless of `Zgw:Enabled`, since draft/step/document bookkeeping stays +local either way) and only THEN calls `zaken.CreateZaak(submitted, now)`. The submit endpoint +never branches on `Zgw:Enabled` itself — DI already picked the implementation, so the endpoint +just asks the seam for `(Referentie, Status)` and returns exactly that in the unchanged +`SubmitApplicationResponse`. Under the default (local) source this returns precisely what was +just computed; under OpenZaak, three calls happen in order: + +1. **POST zaak** (`{ZrcBaseUrl}/zaken`) — `zaaktype` resolved from `Zgw:ZaaktypeUrls[aanvraag.Type]` + (OpenZaak validates the URL by fetching it), `bronorganisatie`/`verantwoordelijkeOrganisatie` + (RSIN) from config, `identificatie` set to the **same** reference `ApplicationStore.Submit` + already generated — so the human-readable reference matches in both places, not two + independently-generated ones. +2. **POST status** (`{ZrcBaseUrl}/statussen`) — `statustype` resolved via a Catalogi GET + (`statustypen?zaaktype=...`, lowest `volgnummer`); marks the zaak as freshly opened. +3. **POST rol** (`{ZrcBaseUrl}/rollen`) — `roltype` resolved via a Catalogi GET + (`roltypen?zaaktype=...&omschrijvingGeneriek=initiator`); `betrokkeneIdentificatie.inpBsn` + set to the aanvraag's owner (BSN) — the current stand-in for real identity (WP-53). + +The created zaak's `identificatie` becomes the returned `Referentie`; its status maps to the +same coarse `InBehandeling` shape `ZgwZaakMapper` already uses for a freshly-opened zaak +(`ZgwZaakMapper.ToCreatedStatusDto`). + +ponytail shortcuts, marked at the call sites: (a) "first statustype/roltype Catalogi returns" +rather than a fully-configured per-type map — fine while a zaaktype has exactly one initial +status and initiator role; (b) no compensating transaction — if any ZGW call throws, the +aanvraag is already `Submitted` locally with no matching zaak (acceptable for a demo backend; +a production arc needs retry/reconciliation or an outbox before trusting this dual-write). ## The ZGW client (`backend/src/BigRegister.Api/Zgw/`) @@ -75,7 +110,14 @@ path async if OpenZaak becomes the default. "ZrcBaseUrl": "https://open-zaak.example/zaken/api/v1", "ZtcBaseUrl": "https://open-zaak.example/catalogi/api/v1", "ClientId": "big-register", "Secret": "", - "UserId": "", "UserRepresentation": "" + "UserId": "", "UserRepresentation": "", + // WP-50 (create-zaak): RSINs + the aanvraag-type → zaaktype URL map. + "Bronorganisatie": "", "VerantwoordelijkeOrganisatie": "", + "ZaaktypeUrls": { + "registratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/", + "herregistratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/", + "intake": "https://open-zaak.example/catalogi/api/v1/zaaktypen/" + } } ``` @@ -111,9 +153,9 @@ Principles this demonstrates: comment in `ZgwZaakMapper` show where the ACL is deliberately thin — an ACL need not be complete on day one, but its shortcuts should be visible. -Caveat: today only the cases **read** path has a source interface (`IZaakSource`). Other BFF +Caveat: `IZaakSource` now covers the cases **read + create** path (WP-49/50). Other BFF endpoints still read `SeedData`/static stores directly — ACL-ready (the DTO seam exists) but not -yet swappable. That is the WP-50/51/52 roadmap, plus the two cross-cutting WPs the arc needs for +yet swappable. That is the WP-51/52 roadmap, plus the two cross-cutting WPs the arc needs for production: **WP-53** (a real per-request identity seam + citizen-scoping — today the owner/BSN is stubbed) and **WP-54** (a docker OpenZaak harness + opt-in integration test — today everything is fixture/mock-tested against no live instance).