feat(backend): expand stamdata + OpenZaak-ready cases seam (WP-49)
CI / frontend (push) Successful in 2m59s
CI / backend (push) Successful in 1m27s
CI / semgrep (push) Successful in 58s
CI / e2e (push) Successful in 2m30s
CI / api-client-drift (push) Canceled after 1m14s
CI / storybook-a11y (push) Canceled after 29m8s

Stamdata: add beroepen, opleidingen (temporal), and specialismen tables to the
schema-driven catalog (zero UI code). opleidingen.beroep and specialismen.beroep
both reference beroepen.code — the first stamdata->stamdata references, enforced by
two new StamdataRef entries in the CI gate.

OpenZaak/ZGW (WP-49, slice 1 — read-only zaken): introduce IZaakSource as the cases
read seam. Default LocalZaakSource reads the local SQLite store (offline); an
OpenZaakZaakSource (Zgw/ client: HS256 per-call JWT, ZGW->existing-DTO mapper,
paginating HTTP source) is selected behind Zgw:Enabled (default false). The FE never
changes — same ApplicationSummaryDto, no api-client drift. Unit-tested with fixtures
+ a stub HttpMessageHandler; no live OpenZaak needed.

Docs: ADR-0005, reference/openzaak-integration.md, WP-49..52 roadmap, stamdata.md
update, README index rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-24 15:01:06 +02:00
co-authored by Claude Opus 4.8
parent cff711504f
commit 1c3c195d32
28 changed files with 974 additions and 8 deletions
+8
View File
@@ -93,6 +93,10 @@ for its existing violations, so every WP ends green.
| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done |
| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done |
| [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done |
| [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | todo |
| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | todo |
| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | todo |
Sequencing dependencies (stated in the WPs too): 01 before 1015 (axe covers story churn);
03/04 before 0509 (boundaries stop new violations during refactors); 06 before 07 (typed
@@ -114,6 +118,10 @@ WP-40 (P2) → WP-43 (P3) → WP-41 → WP-42 → WP-44 → WP-45 (P4). Ordering
make the generator simple) + 43; 45 (`create-ssp`) needs 43+44. 37/38/39/40/43/46 are otherwise
independent. Two open tool forks, decided as step 1 of their WP: 38 dependency-cruiser vs Sheriff;
43 plop vs Angular schematics.
Phase 9 (OpenZaak/ZGW integration, WP-49..52) is strictly ordered 49 → 50 → 51 → 52: 49 lands
the source seam + ZGW client the rest reuse; 50 (create) needs a zaak to exist; 51 (documents)
links to 50's zaak; 52 (notificaties) reacts to changes on the zaken 4951 manage. All slices
stay behind `Zgw:Enabled` (default off) so the POC keeps running offline.
## WP template
@@ -0,0 +1,59 @@
# WP-49 — OpenZaak zaken read seam (slice 1)
Status: done
Phase: 9 — OpenZaak / ZGW integration
## Why
The POC serves cases from local SQLite. To grow toward production it must be able to source
them from a real ZGW backend (OpenZaak) **without changing the frontend** (BFF-lite, ADR-0001).
The backend had no data-access abstraction to swap behind, no outbound HTTP, and no JWT. This is
the first thin vertical slice: read-only zaken.
## Read first
- [ADR-0005 — OpenZaak behind the BFF](../reference/architecture/0005-openzaak-behind-bff.md)
- [openzaak-integration.md](../reference/openzaak-integration.md)
## Decisions (pre-made, don't relitigate)
- OpenZaak's anti-corruption layer lives in the **BFF**, never the browser.
- Source selected by config (`Zgw:Enabled`, default false) → POC still runs offline.
- Each source maps into the **existing** `ApplicationSummaryDto` → no api-client drift, no FE change.
- Fresh HS256 JWT **per call** (no refresh flow). Hand-rolled (no new NuGet).
- `IZaakSource` is sync (matches the endpoint + local store); OpenZaak source does sync-over-async.
## Files
- `Data/IZaakSource.cs`, `Data/LocalZaakSource.cs`
- `Zgw/{ZgwOptions,ZgwTokenProvider,ZgwZaakMapper,OpenZaakZaakSource}.cs`
- `Program.cs` (DI + resolve `IZaakSource` in `/admin/cases`), `appsettings.json` (`Zgw` section)
- tests: `ZgwTokenProviderTests`, `ZgwZaakMapperTests`, `OpenZaakZaakSourceTests`
## Steps
1. Extract the cases read into `IZaakSource`; `LocalZaakSource` delegates to `ApplicationStore`.
2. Build the `Zgw/` client (options, JWT minter, ZGW→DTO mapper, paginating HTTP source).
3. Wire DI by config; refactor `/admin/cases` to resolve `IZaakSource`.
4. Unit-test the minter, mapper, and source (fixtures + stub `HttpMessageHandler`).
## Acceptance criteria
- [x] `/admin/cases` serves identical DTOs via `LocalZaakSource` (default, offline).
- [x] `OpenZaakZaakSource` follows pagination, resolves+caches zaaktype labels, sends a Bearer token.
- [x] JWT verifies (HS256) with the required claims.
- [x] `dotnet test` green (142); `npm run ci` green with **no api-client drift** (FE untouched).
## Verification
`cd backend && dotnet test`; `npm run ci`; manual: `/beheer/zaken` still lists cases with `Zgw:Enabled=false`.
## Out of scope
Create-zaak (WP-50), Documenten/DRC (WP-51), Notificaties (WP-52), real inbound OIDC/JWT,
OpenZaak in docker-compose.
## Risks
Sync-over-async blocks a thread under load if OpenZaak becomes the default → make the read path
async then (noted at the call site).
@@ -0,0 +1,52 @@
# WP-50 — OpenZaak create-zaak (first write slice)
Status: todo
Phase: 9 — OpenZaak / ZGW integration
## Why
WP-49 made the cases **read** path source-swappable. The next slice is the first **write**:
create a Zaak in OpenZaak when an aanvraag is submitted, still behind the config gate, still
without changing the FE contract.
## Read first
- [openzaak-integration.md](../reference/openzaak-integration.md) — "How to add the next slice"
- [ADR-0005](../reference/architecture/0005-openzaak-behind-bff.md), [ADR-0001](../reference/architecture/0001-bff-lite-decision-dtos.md)
## Decisions (pre-made, don't relitigate)
- Route the create through the existing submit/mutation seam; keep the FE response DTO identical.
- A create needs a `zaaktype` **URL** from Catalogi (OpenZaak validates it by fetching) — map
the aanvraag `type` → a configured zaaktype URL.
- Follow the create with a `status` + a `rol` (initiator/betrokkene by BSN) as ZGW expects.
## Files
- Extend `IZaakSource` (or add a write method) + `OpenZaakZaakSource`; `LocalZaakSource` keeps
the current local submit.
- `ZgwOptions`: a `type → zaaktype URL` map + `bronorganisatie`/`verantwoordelijkeOrganisatie` (RSIN).
## Steps
1. Add `CreateZaak` to the source seam; local impl = current submit, OpenZaak impl = POST to ZRC.
2. Map aanvraag `type` → zaaktype URL; POST zaak, then status + rol.
3. Map the created Zaak back into the existing submit response DTO.
## Acceptance criteria
- [ ] Submitting with `Zgw:Enabled=true` creates a Zaak (+ status + rol) in OpenZaak.
- [ ] FE submit response DTO unchanged; no api-client drift.
- [ ] Covered by tests (stub handler asserts the POST bodies + type→zaaktype mapping).
## Verification
`dotnet test`; against a docker OpenZaak if available.
## Out of scope
Documenten (WP-51), Notificaties (WP-52).
## Risks
Create needs read scope on Catalogi (type-URL validation) — provision AC scopes accordingly.
@@ -0,0 +1,52 @@
# WP-51 — OpenZaak Documenten (DRC) upload + link
Status: todo
Phase: 9 — OpenZaak / ZGW integration
## Why
Uploaded documents currently persist as bytes in local SQLite (`DocumentStore`). To be
production-ready they must live in OpenZaak's **Documenten API (DRC)** as
`enkelvoudiginformatieobjecten`, linked to a Zaak via `zaakinformatieobject` — behind the same
config gate, still without a FE contract change.
## Read first
- [openzaak-integration.md](../reference/openzaak-integration.md)
- WP-49 (the seam pattern), WP-50 (create-zaak, the zaak to link to)
## Decisions (pre-made, don't relitigate)
- Introduce an `IDocumentSource` sibling of `IZaakSource`; local impl = `DocumentStore`,
OpenZaak impl = DRC. FE upload DTOs unchanged.
- A document needs an `informatieobjecttype` **URL** from Catalogi (like zaaktype for a zaak).
- Upload → returns document URL → `zaakinformatieobject` links it to the zaak URL.
## Files
- `Data/IDocumentSource.cs`, `Data/LocalDocumentSource.cs`, `Zgw/OpenZaakDocumentSource.cs`
- `ZgwOptions`: `informatieobjecttype` URL(s) + `DrcBaseUrl`.
## Steps
1. Abstract the upload/read/link paths behind `IDocumentSource`.
2. OpenZaak impl: POST `enkelvoudiginformatieobjecten` (content), then POST `zaakinformatieobjecten`.
3. Map DRC document metadata back into the existing document DTOs.
## Acceptance criteria
- [ ] With `Zgw:Enabled=true`, an upload lands in DRC and is linked to its zaak.
- [ ] FE upload/list DTOs unchanged; no api-client drift.
- [ ] Tests cover the DRC POST bodies + the zaak-link step (stub handler).
## Verification
`dotnet test`; against a docker OpenZaak if available.
## Out of scope
Notificaties (WP-52), content virus-scanning / blob-storage tuning.
## Risks
Large file content over base64/multipart — mind memory; stream if needed.
@@ -0,0 +1,51 @@
# WP-52 — OpenZaak Notificaties (NRC) live status
Status: todo
Phase: 9 — OpenZaak / ZGW integration
## Why
With cases in OpenZaak, case status changes in the backoffice, not in this app. Production
"live" status needs the **Notificaties API (NRC)**: subscribe to zaak events and update on
webhook, rather than polling. Last slice of the ZGW integration arc.
## Read first
- [openzaak-integration.md](../reference/openzaak-integration.md)
- WP-49/50/51 (the read/write/document slices this builds on)
## Decisions (pre-made, don't relitigate)
- The BFF exposes a webhook endpoint that NRC calls; it validates an Authorization header the
BFF issued, then invalidates any cached case data / notifies the FE.
- Subscription (`abonnement` on the `zaken` kanaal) is provisioning/config, not runtime code.
- FE update mechanism reuses the existing RemoteData reload — no new FE contract.
## Files
- A new BFF webhook endpoint (`POST /zgw/notificaties`) + Authorization validation.
- `ZgwOptions`: `NrcBaseUrl` + the webhook shared secret.
## Steps
1. Add the webhook endpoint (auth-checked, no PII logged — reuse the audit seam).
2. On a zaak event, invalidate cache / push an update to the FE.
3. Document the `abonnement` provisioning (out-of-band, one-time).
## Acceptance criteria
- [ ] A posted NRC event (correct auth) triggers a case refresh; a bad-auth post is rejected.
- [ ] No PII in the webhook logs.
- [ ] Tests cover auth accept/reject + the refresh trigger.
## Verification
`dotnet test`; against a docker OpenZaak + NRC if available.
## Out of scope
Full event fan-out / real-time push infra beyond a simple cache-invalidation + reload.
## Risks
Webhook must be reachable from NRC in prod (network/ingress) — a deployment concern, not code.