Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88af769d45 | ||
|
|
bf234e1322 | ||
|
|
c8fdfbb699 | ||
|
|
0904df8db0 | ||
|
|
4777ff2b1d | ||
|
|
ccae27b3da | ||
|
|
7bcbc726ce | ||
|
|
8a537edd6c | ||
|
|
e7bed37cda | ||
|
|
94699f3603 | ||
|
|
951bdd8364 | ||
|
|
2397d9196a | ||
|
|
a34caba9ea | ||
|
|
1f1c944a8b | ||
|
|
3abf8f7ccf | ||
|
|
d226b6402d | ||
|
|
9c3da48d8e | ||
|
|
4085bdead7 | ||
|
|
d4ed0ffc22 | ||
|
|
3023bb6fbe | ||
|
|
9997da8beb | ||
|
|
1c185e6686 | ||
|
|
bc9831c113 | ||
|
|
7e8c5d7b51 | ||
|
|
2b9eb5eb41 | ||
|
|
60df0845aa | ||
|
|
2a746736dc | ||
|
|
986e36bc7d | ||
|
|
7e152e4432 | ||
|
|
5bf25f094d | ||
|
|
0e6c7d2066 | ||
|
|
39923e0e68 | ||
|
|
2e00ad38ba | ||
|
|
be016f920c | ||
|
|
d3f23a4da3 | ||
|
|
490e7347b0 | ||
|
|
4f311c9b5a | ||
|
|
a55ba1160d | ||
|
|
4416d1f4ed |
@@ -23,6 +23,16 @@ jobs:
|
|||||||
- uses: https://github.com/actions/setup-dotnet@v4
|
- uses: https://github.com/actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
# Cache the NuGet package store so each .NET job restores from disk, not the network. There are
|
||||||
|
# no lock files (so setup-dotnet's built-in cache doesn't apply); key on the project files. @v3
|
||||||
|
# avoids the GHES guard that breaks @v4 on Gitea (gitea-actions-gotchas.md); cache is best-effort
|
||||||
|
# — a miss just restores from the network. See issue #73.
|
||||||
|
- uses: https://github.com/actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
|
||||||
|
restore-keys: |
|
||||||
|
nuget-${{ runner.os }}-
|
||||||
- run: make lint
|
- run: make lint
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@@ -32,6 +42,12 @@ jobs:
|
|||||||
- uses: https://github.com/actions/setup-dotnet@v4
|
- uses: https://github.com/actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
- uses: https://github.com/actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
|
||||||
|
restore-keys: |
|
||||||
|
nuget-${{ runner.os }}-
|
||||||
- run: make build
|
- run: make build
|
||||||
|
|
||||||
unit:
|
unit:
|
||||||
@@ -41,6 +57,12 @@ jobs:
|
|||||||
- uses: https://github.com/actions/setup-dotnet@v4
|
- uses: https://github.com/actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
- uses: https://github.com/actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
|
||||||
|
restore-keys: |
|
||||||
|
nuget-${{ runner.os }}-
|
||||||
- run: make unit
|
- run: make unit
|
||||||
|
|
||||||
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
||||||
@@ -64,6 +86,12 @@ jobs:
|
|||||||
- uses: https://github.com/actions/setup-dotnet@v4
|
- uses: https://github.com/actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
- uses: https://github.com/actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
|
||||||
|
restore-keys: |
|
||||||
|
nuget-${{ runner.os }}-
|
||||||
- run: make mutation
|
- run: make mutation
|
||||||
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
|
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
|
||||||
# ratchet fails — that is exactly when you want to inspect the survivors.
|
# ratchet fails — that is exactly when you want to inspect the survivors.
|
||||||
@@ -124,10 +152,12 @@ jobs:
|
|||||||
run: make verify-domain
|
run: make verify-domain
|
||||||
- name: BFF → Keycloak + domain + projection
|
- name: BFF → Keycloak + domain + projection
|
||||||
run: make verify-bff
|
run: make verify-bff
|
||||||
|
- name: Self-service e2e (Playwright, login → submit → success)
|
||||||
|
run: make verify-e2e
|
||||||
# Log dump must precede teardown (which removes the containers).
|
# Log dump must precede teardown (which removes the containers).
|
||||||
- name: Dump container logs on failure
|
- name: Dump container logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api 2>&1 || true
|
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel 2>&1 || true
|
||||||
- name: Tear down
|
- name: Tear down
|
||||||
if: always()
|
if: always()
|
||||||
run: make down
|
run: make down
|
||||||
|
|||||||
@@ -52,3 +52,8 @@ vite.config.*.timestamp*
|
|||||||
vitest.config.*.timestamp*
|
vitest.config.*.timestamp*
|
||||||
|
|
||||||
.angular
|
.angular
|
||||||
|
|
||||||
|
# Playwright e2e (installed/generated in-container or on local runs)
|
||||||
|
tests/e2e/node_modules/
|
||||||
|
tests/e2e/test-results/
|
||||||
|
tests/e2e/playwright-report/
|
||||||
|
|||||||
+47
-9
@@ -162,20 +162,36 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
|
|||||||
|
|
||||||
**Out of scope (whole of S-08):** document upload, status tracking page.
|
**Out of scope (whole of S-08):** document upload, status tracking page.
|
||||||
|
|
||||||
### S-09 · Openbaar Register portal — public lookup
|
### S-09 · Openbaar Register portal — public lookup *(#10)*
|
||||||
|
|
||||||
**Outcome:** The openbaar Angular app shows a search box. Anonymous. Queries the BFF's `/openbaar/register` which reads only the projection's **public-safe** fields. Confirms the walking skeleton end-to-end.
|
**Outcome:** The openbaar Angular app shows a search box. Anonymous. Queries the BFF's `/openbaar/register` which reads only the projection's **public-safe** fields. Shows the public-visibility half of the walking skeleton.
|
||||||
|
|
||||||
|
_Split from the original S-09 — scoped to the portal only; the approval flow is **S-09b (#75)**._
|
||||||
|
|
||||||
**Acceptance:**
|
**Acceptance:**
|
||||||
|
|
||||||
- E2E test: zorgprofessional registers via self-service (S-08), behandelaar approves via a temporary admin endpoint (no behandel-portal yet), openbaar register shows the entry.
|
- E2E test: after a zorgprofessional registers via self-service (S-08), the openbaar register shows the entry (as `INGEDIEND`).
|
||||||
- Public-safe field whitelist enforced and tested.
|
- Public-safe field whitelist enforced and tested (already in the BFF; add a portal component test + a11y check).
|
||||||
|
|
||||||
**Touches:** `apps/openbaar/`, projection-api hardening, tests.
|
**Touches:** `apps/openbaar/`, compose serving, e2e, docs.
|
||||||
|
|
||||||
**Out of scope:** advanced search filters, sorting.
|
**Out of scope:** approval/status transition (S-09b), advanced search filters, sorting.
|
||||||
|
|
||||||
**End of walking skeleton.** Demo: submit → process → projection → public visibility. All CI gates green on Gitea Actions. Cut release `vYYYY.MM.0` and publish via Gitea Releases.
|
### S-09b · Approval flow — temp admin endpoint + status transition to projection *(#75)*
|
||||||
|
|
||||||
|
**Outcome:** A behandelaar approves a submitted registration via a temporary admin endpoint (no behandel-portal yet — S-12). The approval transitions the zaak status through the ACL → NRC → event-subscriber → projection, and the openbaar register then shows the entry as approved.
|
||||||
|
|
||||||
|
**Acceptance:**
|
||||||
|
|
||||||
|
- A new terminal/approved status (e.g. `INGESCHREVEN`) exists and is projected.
|
||||||
|
- Temporary admin approve endpoint transitions a registration via a real ZGW status set (behind the ACL, §8).
|
||||||
|
- E2E: register (S-08) → approve → openbaar shows the entry as approved.
|
||||||
|
|
||||||
|
**Touches:** `services/domain`, `services/acl`, `services/event-subscriber`, `services/projection-api`, e2e.
|
||||||
|
|
||||||
|
**Out of scope:** behandel-portal UI (S-12), assessment logic (S-13), escalation (S-15).
|
||||||
|
|
||||||
|
**End of walking skeleton** (S-09 + S-09b). Demo: submit → process → projection → public visibility. All CI gates green on Gitea Actions. Cut release `vYYYY.MM.0` and publish via Gitea Releases.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -183,9 +199,25 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
|
|||||||
|
|
||||||
### S-10 · Document upload + boundary timer for document timeout (Flow 2)
|
### S-10 · Document upload + boundary timer for document timeout (Flow 2)
|
||||||
|
|
||||||
**Outcome:** BPMN extended with a "wacht op documenten" user task with a 30-day boundary timer. Self-service portal supports diploma upload. On timeout the case is cancelled.
|
Split (issue #11 closed) into two independently-demoable slices per §13 — the original spanned six net-new surfaces including a new ZGW boundary:
|
||||||
|
|
||||||
**Acceptance:** BDD scenarios for both branches; integration tests for the timer firing.
|
#### S-10a · Document-wait task + 30-day timeout cancellation + provision trigger — #102
|
||||||
|
|
||||||
|
**Outcome:** BPMN gains a `WachtOpDocumenten` user task with a 30-day (P30D) interrupting boundary timer. On timeout the case is cancelled — the timer runs to a dedicated cancel end-event and the domain aggregate moves to a new terminal status `Verlopen` via an external-worker (mirrors S-14 escalation / S-11 withdrawal). "Documents received" is wired end-to-end (domain endpoint + BFF + a "Documenten aanleveren" button on the self-service page) so the walking-skeleton e2e stays green — but the document is **not yet stored** in ZGW; that is S-10b.
|
||||||
|
|
||||||
|
**Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom; the registration e2e provides documents before the behandelaar step.
|
||||||
|
|
||||||
|
#### S-10b · Real diploma upload stored via the ACL Documenten API — #103
|
||||||
|
|
||||||
|
**Outcome:** the self-service "Documenten aanleveren" action becomes a real file upload; the file (base64-encoded end-to-end) is stored in the ZGW Documenten (DRC) API as an `enkelvoudiginformatieobject` and related to the zaak, with all document calls routed through the ACL (§8.1, ADR-0018). Builds on the S-10a trigger/wait. Depends on #102.
|
||||||
|
|
||||||
|
**Acceptance:** ACL Documenten gateway integration test (real OpenZaak); Playwright e2e uploads a real PDF.
|
||||||
|
|
||||||
|
#### S-10c · Close the ZGW zaak on document-timeout expiry — #106
|
||||||
|
|
||||||
|
**Outcome:** when the 30-day term lapses (S-10a `RegistratieVerlopen`), the ZGW zaak is set to a distinct non-terminal `Geannuleerd` status + `Vervallen` resultaat (not just the domain aggregate → `Verlopen`), resolved by name in the ACL. Adds the cancellation statustype/resultaattype to the seed + an ACL `CancelZaakAsync`/`POST /annuleringen` + expiry-worker wiring. Carved from S-10b (ADR-0017/0018/0019). Depends on #103.
|
||||||
|
|
||||||
|
**Acceptance:** ACL↔OpenZaak integration test (cancellation records `Geannuleerd` + a resultaat, live); the domain verify script fires the P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD asserts the zaak is cancelled on timeout but untouched when documents arrive in time.
|
||||||
|
|
||||||
### S-11 · Withdrawal (Flow 3)
|
### S-11 · Withdrawal (Flow 3)
|
||||||
|
|
||||||
@@ -207,6 +239,12 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
|
|||||||
|
|
||||||
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
|
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
|
||||||
|
|
||||||
|
### S-26 · Self-service — resume an existing registration after refresh — #111
|
||||||
|
|
||||||
|
**Outcome:** a signed-in zorgprofessional who reloads the self-service portal (or returns later) gets back to their in-flight registration and its actions (Documenten aanleveren, Trek aanvraag in), instead of a blank submit form with the reference lost. Today all post-submit state lives in in-memory signals, the reference is not in the URL, and there is no self-service read endpoint — so a reload strands the registration. Adds an owner-scoped (DigiD bsn) `GET /self-service/registrations` on the BFF/domain and a load-on-init/route restore in the portal.
|
||||||
|
|
||||||
|
**Acceptance:** BDD — resume after refresh shows the existing registration; lookup is owner-scoped (never another citizen's); a user with no in-flight registration still sees the submit form. Playwright e2e reloads mid-flow and asserts the actions remain reachable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
|
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
|
||||||
|
|||||||
+112
-1
@@ -2,19 +2,130 @@
|
|||||||
|
|
||||||
All notable changes to this project. Generated from Conventional Commits by git-cliff.
|
All notable changes to this project. Generated from Conventional Commits by git-cliff.
|
||||||
|
|
||||||
## Unreleased
|
## v2026.07.0 — 2026-07-14
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- ADR-0005 adopt Stryker.NET for mutation testing (refs #47)
|
||||||
|
- ADR-0006 — provision the ACL integration test against the compose stack (refs #46)
|
||||||
|
- ADR-0007 + runbooks for the OZ→NRC notification wiring (refs #56)
|
||||||
|
- ADR-0009 external-task job-worker pattern (refs #6, #60)
|
||||||
|
- ADR-0010 BFF OIDC validation + downstream boundaries (refs #8, #63)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Pin OpenZaak/NRC image tags; add smoke log capture on failure (refs #30)
|
||||||
|
- Harden oz-db healthcheck and raise compose-up timeout (refs #30)
|
||||||
|
- Bake config into images so compose-smoke passes on CI (refs #30)
|
||||||
|
- Nrc-init runs migrations only, not setup_configuration (refs #30)
|
||||||
|
- Smoke waits on durable services, not the whole project (refs #30)
|
||||||
|
- Portable health poll instead of compose --wait (refs #30)
|
||||||
|
- Pin upload-artifact to @v3 — @v4 refuses to run on Gitea (refs #47)
|
||||||
|
- Buffer the zaak POST body so OpenZaak accepts it (refs #46)
|
||||||
|
- Keep dotnet format green under the shared .editorconfig (refs #65)
|
||||||
|
- Re-export the full Utrecht package from libs/ui (refs #67)
|
||||||
|
- Run checkAuth() at startup to end the login redirect loop (refs #67)
|
||||||
|
- Health-check nginx over IPv4 (127.0.0.1) (refs #68)
|
||||||
|
- Treat the http portal origin as secure so DigiD PKCE login works (refs #68)
|
||||||
|
- Attach the DigiD token to relative BFF calls (refs #68)
|
||||||
|
|
||||||
|
### Build
|
||||||
|
- Pin Stryker.NET as a local dotnet tool (refs #47)
|
||||||
|
|
||||||
### CI
|
### CI
|
||||||
- Gitea Actions pipeline + runner runbook (refs #30) (#37)
|
- Gitea Actions pipeline + runner runbook (refs #30) (#37)
|
||||||
|
- ACL Dockerfile + full compose stack for smoke test (refs #30)
|
||||||
|
- Switch runner label to ubuntu-latest (refs #30)
|
||||||
|
- Run the mutation ratchet as a parallel CI job (refs #47)
|
||||||
|
- Publish the Stryker HTML report as a CI artifact (refs #47)
|
||||||
|
- Run the ACL integration test as a Gitea Actions job (refs #46)
|
||||||
|
- Keep the integration lane local-only; document the runner gap (refs #46)
|
||||||
|
- Run the ACL integration test in CI inside the compose network (closes #55) (refs #46)
|
||||||
|
- Run the Event Subscriber + projection-api in compose and verify end-to-end (refs #7)
|
||||||
|
- Containerize, wire into compose, and verify end-to-end (refs #6)
|
||||||
|
- Make Stryker report upload best-effort (refs #62)
|
||||||
|
- Retrigger after runner cleanup (refs #6)
|
||||||
|
- Retrigger CI (refs #6)
|
||||||
|
- Retrigger CI after gitea restart (refs #6)
|
||||||
|
- Compose wiring, verify-bff live check, mutation baseline (refs #8)
|
||||||
|
- Nx frontend lane (lint/test/build) (refs #65)
|
||||||
|
- Serve the self-service app in compose (refs #68)
|
||||||
|
- Run Vitest ahead of the production build to stop worker-start timeout (refs #68)
|
||||||
|
- Cache the NuGet package store across the .NET jobs (refs #73)
|
||||||
|
- Run Playwright from the prebuilt image instead of downloading browsers (refs #73)
|
||||||
|
|
||||||
### Chores
|
### Chores
|
||||||
- Add idempotent Gitea backlog seeder
|
- Add idempotent Gitea backlog seeder
|
||||||
- Remove bootstrap scripts from main (#35)
|
- Remove bootstrap scripts from main (#35)
|
||||||
|
- Contributor workflow — templates, git-cliff, gitea-workflow doc (closes #31) (#38)
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
- Split S-00 into sub-slices (refs #1) (#33)
|
- Split S-00 into sub-slices (refs #1) (#33)
|
||||||
|
- MkDocs scaffold + ADR-0001 + README quickstart (closes #32) (#39)
|
||||||
|
- Tighten gitea-actions-gotchas, add local compose (refs #30)
|
||||||
|
- ADR-0008 read projection store + demo note for the event path (refs #7)
|
||||||
|
- Demo note for submitting a registration (S-05) (refs #6)
|
||||||
|
- Demo note for the BFF front door (S-07) (refs #8)
|
||||||
|
- Split S-08 into S-08a-d (refs #65)
|
||||||
|
- Frontend-decisions + demo note for S-08a (refs #65)
|
||||||
|
- Record the orval generator choice (refs #66)
|
||||||
|
- Record NL DS + DigiD decisions and demo note (refs #67)
|
||||||
|
- Serving/e2e decisions + walking-skeleton demo note (refs #68)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
- Placeholder BFF + /health endpoint (closes #28) (#34)
|
- Placeholder BFF + /health endpoint (closes #28) (#34)
|
||||||
- Containerize BFF + compose-up smoke (closes #29) (#36)
|
- Containerize BFF + compose-up smoke (closes #29) (#36)
|
||||||
|
- OpenZaak + Postgres + Redis up in compose (refs #10) (#40)
|
||||||
|
- Seed BIG catalogus + JWT client for OpenZaak (refs #2) (#41)
|
||||||
|
- Open Notificaties up + shared network (closes #2) (#42)
|
||||||
|
- Keycloak with four mock realms (closes #3) (#43)
|
||||||
|
- Flowable + registratie.bpmn external task (closes #4) (#44)
|
||||||
|
- ACL skeleton — OpenZaak default-fill (refs #5) (#45)
|
||||||
|
- Add bind-mount local compose for no-make/Windows dev (refs #30)
|
||||||
|
- Publish the BIG zaaktype on demand via OZ_PUBLISH (refs #46)
|
||||||
|
- Wire OpenZaak → Open Notificaties notifications (refs #56)
|
||||||
|
- Project zaak-created notifications into the read projection (refs #7)
|
||||||
|
- Persist the read projection and expose webhook + read APIs (refs #7)
|
||||||
|
- Enforce the callback bearer before reading the body (refs #7)
|
||||||
|
- Implement the Registration aggregate invariants (refs #6)
|
||||||
|
- Implement SubmitRegistration and OpenZaakWorker (refs #6)
|
||||||
|
- Implement the Flowable Workflow Client and ACL client (refs #6)
|
||||||
|
- Expose POST /registrations and the read endpoint (refs #6)
|
||||||
|
- Implement self-service submit and openbaar lookup (refs #8)
|
||||||
|
- Committed OpenAPI contract + drift guard (refs #8)
|
||||||
|
- Self-service portal placeholder page (refs #65)
|
||||||
|
- Expose the generated BFF client + repeatable generate target (refs #66)
|
||||||
|
- Implement the DigiD registration submit page (refs #67)
|
||||||
|
- Runtime config + nginx serve/proxy image (refs #68)
|
||||||
|
- Surface submit failures with a retryable alert (refs #68)
|
||||||
|
- One citizen reference across self-service and the openbaar register (#79)
|
||||||
|
|
||||||
|
### Other
|
||||||
|
- Openbaar Register portal — public lookup (#76)
|
||||||
|
- Approval flow — temp admin endpoint + status transition to projection (#77)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
- Bake config via dockerfile_inline, drop Dockerfile files (refs #30)
|
||||||
|
- Use upstream images verbatim, seed config via docker cp (refs #30)
|
||||||
|
- One verify-stack stage for all live-stack checks (closes #58) (refs #46 #56)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- BDD acceptance scenario for opening a zaak (closes #5) (#49)
|
||||||
|
- Kill surviving mutants — assert CRS headers, guards, error paths, JWT claims (refs #47)
|
||||||
|
- Add Stryker config + mutation make target recording the 95% baseline (refs #47)
|
||||||
|
- Integration test opens a real zaak against OpenZaak (refs #46)
|
||||||
|
- Verify-notifications smoke + CI job for the OZ→NRC path (refs #56)
|
||||||
|
- Project zaak-created notifications into the read projection (refs #7)
|
||||||
|
- Ratchet projector mutation baseline to 100% (refs #7)
|
||||||
|
- Registration aggregate invariants (refs #6)
|
||||||
|
- SubmitRegistration + OpenZaakWorker use cases (refs #6)
|
||||||
|
- Workflow Client, ACL client, store and job processor (refs #6)
|
||||||
|
- Acceptance scenario for submitting a registration (refs #6)
|
||||||
|
- Mutation baseline 90 (achieved 97.7%) + CI/Makefile wiring (refs #6)
|
||||||
|
- Endpoints, JWT auth and public-safe projection (refs #8)
|
||||||
|
- Acceptance scenario for BFF access (valid/invalid tokens) (refs #8)
|
||||||
|
- Self-service portal placeholder renders (refs #65)
|
||||||
|
- Generated BFF client is exposed and calls the endpoints (refs #66)
|
||||||
|
- DigiD-guarded registration submit page (refs #67)
|
||||||
|
- Walking-skeleton Playwright happy path + verify-e2e lane (refs #68)
|
||||||
|
- Submit surfaces BFF failures instead of swallowing them (refs #68)
|
||||||
|
- Guard that the DigiD token attaches to relative BFF calls (refs #68)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
|
|||||||
# Long-running services with a healthcheck — the smoke polls these for readiness
|
# Long-running services with a healthcheck — the smoke polls these for readiness
|
||||||
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
|
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
|
||||||
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
|
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
|
||||||
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api
|
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel
|
||||||
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
||||||
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
|
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
|
||||||
# bind-mounted, because bind mounts don't reach sibling containers on the
|
# bind-mounted, because bind mounts don't reach sibling containers on the
|
||||||
@@ -50,9 +50,16 @@ endif
|
|||||||
ci: lint build unit mutation frontend verify
|
ci: lint build unit mutation frontend verify
|
||||||
|
|
||||||
## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required)
|
## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required)
|
||||||
|
# Tests run in their own phase, ahead of the build. The @angular/build:unit-test
|
||||||
|
# (Vitest) runner spawns a worker with a hard-coded 60s/90s startup timeout that is
|
||||||
|
# not configurable. When the ~5min production build shares the run-many pool, it
|
||||||
|
# starves that worker of CPU on constrained CI runners and Vitest fails with
|
||||||
|
# "Timeout waiting for worker to respond". Splitting the phases keeps tests off the
|
||||||
|
# heavy build's back so the worker starts well inside its window.
|
||||||
frontend:
|
frontend:
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm nx run-many -t lint test build
|
pnpm nx run-many -t lint test
|
||||||
|
pnpm nx run-many -t build
|
||||||
|
|
||||||
## lint: verify formatting (no changes)
|
## lint: verify formatting (no changes)
|
||||||
lint:
|
lint:
|
||||||
@@ -153,6 +160,11 @@ verify-domain:
|
|||||||
verify-bff:
|
verify-bff:
|
||||||
bash infra/run-bff-check.sh
|
bash infra/run-bff-check.sh
|
||||||
|
|
||||||
|
## verify-e2e: walking-skeleton Playwright e2e (S-08d) against the up stack — DigiD login →
|
||||||
|
## submit → confirmation, driven inside the compose network.
|
||||||
|
verify-e2e:
|
||||||
|
bash infra/run-e2e-check.sh
|
||||||
|
|
||||||
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
||||||
## tear down (always). For fast single-concern local iteration use `integration`
|
## tear down (always). For fast single-concern local iteration use `integration`
|
||||||
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
||||||
@@ -165,7 +177,8 @@ verify:
|
|||||||
&& bash infra/run-notification-check.sh \
|
&& bash infra/run-notification-check.sh \
|
||||||
&& bash infra/run-projection-check.sh \
|
&& bash infra/run-projection-check.sh \
|
||||||
&& bash infra/run-domain-check.sh \
|
&& bash infra/run-domain-check.sh \
|
||||||
&& bash infra/run-bff-check.sh || rc=$$?; \
|
&& bash infra/run-bff-check.sh \
|
||||||
|
&& bash infra/run-e2e-check.sh || rc=$$?; \
|
||||||
docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
|
docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
|
||||||
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
|
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
|
||||||
exit $$rc'
|
exit $$rc'
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Multi-stage build for the behandel portal (Angular → nginx).
|
||||||
|
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
|
||||||
|
FROM node:24-slim AS build
|
||||||
|
WORKDIR /src
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
|
||||||
|
|
||||||
|
# Restore first (cached unless the manifests change).
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Sources (only what the app + its libs need).
|
||||||
|
COPY apps/behandel apps/behandel
|
||||||
|
COPY libs libs
|
||||||
|
RUN pnpm nx build behandel
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY apps/behandel/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /src/dist/apps/behandel/browser /usr/share/nginx/html
|
||||||
|
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
||||||
|
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
|
||||||
|
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import nx from '@nx/eslint-plugin';
|
||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...nx.configs['flat/angular'],
|
||||||
|
...nx.configs['flat/angular-template'],
|
||||||
|
...baseConfig,
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@angular-eslint/directive-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'attribute',
|
||||||
|
prefix: 'app',
|
||||||
|
style: 'camelCase',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
prefix: 'app',
|
||||||
|
style: 'kebab-case',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.html'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
|
||||||
|
# even before the BFF is up and picks up restarts — instead of failing to load the config.
|
||||||
|
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||||
|
|
||||||
|
# Same-origin API: proxy the behandel endpoint group to the bff service. The api-client uses
|
||||||
|
# relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the
|
||||||
|
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
|
||||||
|
location /behandel/ {
|
||||||
|
set $bff http://bff:8080;
|
||||||
|
proxy_pass $bff;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — Angular client-side routing.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"name": "behandel",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"projectType": "application",
|
||||||
|
"prefix": "app",
|
||||||
|
"sourceRoot": "apps/behandel/src",
|
||||||
|
"tags": [],
|
||||||
|
"targets": {
|
||||||
|
"build": {
|
||||||
|
"executor": "@angular/build:application",
|
||||||
|
"outputs": ["{options.outputPath}"],
|
||||||
|
"defaultConfiguration": "production",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/apps/behandel",
|
||||||
|
"browser": "apps/behandel/src/main.ts",
|
||||||
|
"tsConfig": "apps/behandel/tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "apps/behandel/public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["apps/behandel/src/styles.css"]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1mb",
|
||||||
|
"maximumError": "2mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kb",
|
||||||
|
"maximumError": "8kb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"continuous": true,
|
||||||
|
"executor": "@angular/build:dev-server",
|
||||||
|
"defaultConfiguration": "development",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "behandel:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "behandel:build:development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"executor": "@angular/build:unit-test",
|
||||||
|
"options": {
|
||||||
|
"watch": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve-static": {
|
||||||
|
"continuous": true,
|
||||||
|
"executor": "@nx/web:file-server",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "behandel:build",
|
||||||
|
"staticFilePath": "dist/apps/behandel/browser",
|
||||||
|
"spa": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"authority": "http://localhost:8180/realms/medewerker"
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,73 @@
|
|||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { BffApiV1Service } from 'api-client';
|
||||||
|
import { authInterceptor } from 'auth';
|
||||||
|
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
|
||||||
|
import { SECURE_API_ROUTES } from './app.config';
|
||||||
|
|
||||||
|
// Guards the medewerker token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and
|
||||||
|
// the angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a
|
||||||
|
// configured secureRoute. A regression to an absolute origin makes the relative URL never match, so
|
||||||
|
// the behandel calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor
|
||||||
|
// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config
|
||||||
|
// source and token storage are faked, so the assertion turns on the actual route-matching.
|
||||||
|
describe('behandel medewerker token wiring', () => {
|
||||||
|
let http: HttpTestingController;
|
||||||
|
let bff: BffApiV1Service;
|
||||||
|
const token = 'medewerker-access-token';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{
|
||||||
|
provide: ConfigurationService,
|
||||||
|
useValue: {
|
||||||
|
hasAtLeastOneConfig: () => true,
|
||||||
|
getAllConfigurations: () => [{ configId: 'medewerker', secureRoutes: SECURE_API_ROUTES }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A signed-in session: the storage the interceptor's token lookup reads from.
|
||||||
|
provide: AbstractSecurityStorage,
|
||||||
|
useValue: {
|
||||||
|
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
|
||||||
|
write: () => undefined,
|
||||||
|
remove: () => undefined,
|
||||||
|
clear: () => undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
http = TestBed.inject(HttpTestingController);
|
||||||
|
bff = TestBed.inject(BffApiV1Service);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
it('attaches the bearer token to the relative werkbak call', () => {
|
||||||
|
bff.getBehandelWerkbak().subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne('/behandel/werkbak');
|
||||||
|
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches the bearer token to the relative decide call', () => {
|
||||||
|
bff.postBehandelRegistrationsIdDecide('reg-1', { besluit: 'goedkeuren' }).subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne('/behandel/registrations/reg-1/decide');
|
||||||
|
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||||
|
req.flush(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the anonymous openbaar register call unauthenticated', () => {
|
||||||
|
bff.getOpenbaarRegister().subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne((r) => r.url === '/openbaar/register');
|
||||||
|
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { authInterceptor, provideMedewerkerAuth } from 'auth';
|
||||||
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
|
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
|
||||||
|
export interface RuntimeConfig {
|
||||||
|
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
|
||||||
|
authority: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
|
||||||
|
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
|
||||||
|
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
|
||||||
|
* unattached. Only `/behandel/` is secured; the app calls no other endpoint group.
|
||||||
|
*/
|
||||||
|
export const SECURE_API_ROUTES = ['/behandel/'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
|
||||||
|
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
|
||||||
|
*/
|
||||||
|
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
|
||||||
|
return {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(appRoutes),
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||||
|
provideMedewerkerAuth({
|
||||||
|
authority: runtime.authority,
|
||||||
|
redirectUrl: origin,
|
||||||
|
secureRoutes: SECURE_API_ROUTES,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { authenticatedGuard } from 'auth';
|
||||||
|
import { WerkbakPage } from './werkbak/werkbak-page';
|
||||||
|
|
||||||
|
export const appRoutes: Route[] = [
|
||||||
|
{ path: '', component: WerkbakPage, canActivate: [authenticatedGuard] },
|
||||||
|
];
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { render, screen } from '@testing-library/angular';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('renders the router outlet shell', async () => {
|
||||||
|
const { container } = await render(App, {
|
||||||
|
providers: [provideRouter([])],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shell is a thin host for routed pages (the WerkbakPage owns the heading).
|
||||||
|
expect(container.querySelector('router-outlet')).toBeTruthy();
|
||||||
|
expect(screen).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [RouterModule],
|
||||||
|
selector: 'app-root',
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.css',
|
||||||
|
})
|
||||||
|
export class App {
|
||||||
|
protected title = 'behandel';
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<main utrecht-document class="utrecht-theme">
|
||||||
|
<utrecht-article>
|
||||||
|
<utrecht-heading-1>Werkbak</utrecht-heading-1>
|
||||||
|
<p utrecht-paragraph>
|
||||||
|
Registraties die wachten op beoordeling. Keur elke registratie goed of wijs deze af.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
||||||
|
} @else if (failed()) {
|
||||||
|
<p utrecht-paragraph role="alert">
|
||||||
|
Kon de werkbak niet laden. Controleer of je als behandelaar bent ingelogd en probeer het
|
||||||
|
opnieuw.
|
||||||
|
</p>
|
||||||
|
} @else if (loaded() && items().length === 0) {
|
||||||
|
<p utrecht-paragraph role="status">De werkbak is leeg.</p>
|
||||||
|
} @else if (items().length > 0) {
|
||||||
|
<table utrecht-table>
|
||||||
|
<caption>
|
||||||
|
Registraties in behandeling
|
||||||
|
</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Referentie</th>
|
||||||
|
<th scope="col">BSN</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Actie</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (item of items(); track item.registrationId) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.registrationId }}</td>
|
||||||
|
<td>{{ item.bsn }}</td>
|
||||||
|
<td>{{ item.status }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
utrecht-button
|
||||||
|
appearance="primary-action-button"
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="'Goedkeuren ' + item.registrationId"
|
||||||
|
[disabled]="deciding() === item.registrationId"
|
||||||
|
(click)="decide(item.registrationId, 'goedkeuren')"
|
||||||
|
>
|
||||||
|
Goedkeuren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
utrecht-button
|
||||||
|
appearance="secondary-action-button"
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="'Afwijzen ' + item.registrationId"
|
||||||
|
[disabled]="deciding() === item.registrationId"
|
||||||
|
(click)="decide(item.registrationId, 'afwijzen')"
|
||||||
|
>
|
||||||
|
Afwijzen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</utrecht-article>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/angular';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
||||||
|
import { AuthService } from 'auth';
|
||||||
|
import { axe } from 'vitest-axe';
|
||||||
|
import { WerkbakPage } from './werkbak-page';
|
||||||
|
|
||||||
|
const sample: WerkbakItem[] = [
|
||||||
|
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
|
||||||
|
{ registrationId: 'reg-2', bsn: '111222333', status: 'InBehandeling' },
|
||||||
|
];
|
||||||
|
|
||||||
|
class FakeAuth extends AuthService {
|
||||||
|
readonly isAuthenticated = signal(true);
|
||||||
|
readonly bsn = signal<string | undefined>(undefined);
|
||||||
|
override readonly roles = signal<readonly string[]>(['behandelaar']);
|
||||||
|
login(): void {
|
||||||
|
/* not exercised here */
|
||||||
|
}
|
||||||
|
logout(): void {
|
||||||
|
/* spied in tests */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
overrides: {
|
||||||
|
getBehandelWerkbak?: ReturnType<typeof vi.fn>;
|
||||||
|
postBehandelRegistrationsIdDecide?: ReturnType<typeof vi.fn>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const getBehandelWerkbak =
|
||||||
|
overrides.getBehandelWerkbak ?? vi.fn().mockReturnValue(of(sample));
|
||||||
|
const postBehandelRegistrationsIdDecide =
|
||||||
|
overrides.postBehandelRegistrationsIdDecide ?? vi.fn().mockReturnValue(of(undefined));
|
||||||
|
return {
|
||||||
|
getBehandelWerkbak,
|
||||||
|
postBehandelRegistrationsIdDecide,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: BffApiV1Service,
|
||||||
|
useValue: { getBehandelWerkbak, postBehandelRegistrationsIdDecide },
|
||||||
|
},
|
||||||
|
{ provide: AuthService, useClass: FakeAuth },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WerkbakPage', () => {
|
||||||
|
it('lists the registrations awaiting beoordeling on open', async () => {
|
||||||
|
const { getBehandelWerkbak, providers } = setup();
|
||||||
|
await render(WerkbakPage, { providers });
|
||||||
|
|
||||||
|
expect(getBehandelWerkbak).toHaveBeenCalled();
|
||||||
|
expect(await screen.findByText('reg-1')).toBeTruthy();
|
||||||
|
expect(screen.getByText('123456782')).toBeTruthy();
|
||||||
|
expect(screen.getByText('reg-2')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('approves a registration (goedkeuren) and refreshes the werkbak', async () => {
|
||||||
|
const { getBehandelWerkbak, postBehandelRegistrationsIdDecide, providers } = setup();
|
||||||
|
await render(WerkbakPage, { providers });
|
||||||
|
|
||||||
|
fireEvent.click((await screen.findAllByRole('button', { name: /goedkeuren/i }))[0]);
|
||||||
|
|
||||||
|
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
|
||||||
|
besluit: 'goedkeuren',
|
||||||
|
});
|
||||||
|
// Reloaded after the decision: once on open, once after deciding.
|
||||||
|
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a registration (afwijzen) via the decide endpoint', async () => {
|
||||||
|
const { postBehandelRegistrationsIdDecide, providers } = setup();
|
||||||
|
await render(WerkbakPage, { providers });
|
||||||
|
|
||||||
|
fireEvent.click((await screen.findAllByRole('button', { name: /afwijzen/i }))[0]);
|
||||||
|
|
||||||
|
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
|
||||||
|
besluit: 'afwijzen',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an empty state when the werkbak has no items', async () => {
|
||||||
|
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
|
||||||
|
await render(WerkbakPage, { providers });
|
||||||
|
|
||||||
|
expect(await screen.findByText(/werkbak is leeg/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a load failure instead of swallowing it', async () => {
|
||||||
|
const { providers } = setup({
|
||||||
|
getBehandelWerkbak: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
||||||
|
});
|
||||||
|
await render(WerkbakPage, { providers });
|
||||||
|
|
||||||
|
expect(await screen.findByText(/kon de werkbak niet laden/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no WCAG 2.1 AA violations', async () => {
|
||||||
|
document.documentElement.lang = 'nl';
|
||||||
|
const { container } = await render(WerkbakPage, { providers: setup().providers });
|
||||||
|
|
||||||
|
const results = await axe(container, {
|
||||||
|
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
||||||
|
import { UtrechtComponentsModule } from 'ui';
|
||||||
|
|
||||||
|
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
|
||||||
|
type Besluit = 'goedkeuren' | 'afwijzen';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The behandel werkbak: a signed-in behandelaar sees the registrations awaiting beoordeling (the open
|
||||||
|
* Flowable `Beoordelen` tasks, read through the domain) and decides each — goedkeuren or afwijzen. A
|
||||||
|
* decision posts to the BFF, which applies the domain transition and completes the workflow task
|
||||||
|
* (ADR-0013; S-12). After a decision the werkbak refreshes so the handled item drops off the list.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-werkbak-page',
|
||||||
|
imports: [UtrechtComponentsModule],
|
||||||
|
templateUrl: './werkbak-page.html',
|
||||||
|
})
|
||||||
|
export class WerkbakPage {
|
||||||
|
private readonly bff = inject(BffApiV1Service);
|
||||||
|
|
||||||
|
protected readonly items = signal<WerkbakItem[]>([]);
|
||||||
|
protected readonly loading = signal(false);
|
||||||
|
protected readonly loaded = signal(false);
|
||||||
|
protected readonly failed = signal(false);
|
||||||
|
protected readonly deciding = signal<string | undefined>(undefined);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.failed.set(false);
|
||||||
|
this.bff.getBehandelWerkbak().subscribe({
|
||||||
|
next: (rows: WerkbakItem[]) => {
|
||||||
|
this.items.set(rows);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.loaded.set(true);
|
||||||
|
},
|
||||||
|
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
|
||||||
|
error: () => {
|
||||||
|
this.items.set([]);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.loaded.set(true);
|
||||||
|
this.failed.set(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
decide(registrationId: string, besluit: Besluit): void {
|
||||||
|
this.deciding.set(registrationId);
|
||||||
|
this.bff.postBehandelRegistrationsIdDecide(registrationId, { besluit }).subscribe({
|
||||||
|
// Refresh so the decided registration drops off the werkbak (its task is now completed).
|
||||||
|
next: () => {
|
||||||
|
this.deciding.set(undefined);
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.deciding.set(undefined);
|
||||||
|
this.failed.set(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Behandelportaal BIG-register</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { App } from './app/app';
|
||||||
|
import { appConfig, type RuntimeConfig } from './app/app.config';
|
||||||
|
|
||||||
|
// Load environment config before bootstrap so the OIDC authority is set per environment
|
||||||
|
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
|
||||||
|
fetch('config.json')
|
||||||
|
.then((response) => response.json() as Promise<RuntimeConfig>)
|
||||||
|
.then((config) => bootstrapApplication(App, appConfig(config)))
|
||||||
|
.catch((err) => console.error(err));
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
|
||||||
|
@import '@utrecht/design-tokens/dist/index.css';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"emitDecoratorMetadata": false,
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Multi-stage build for the openbaar portal (Angular → nginx).
|
||||||
|
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
|
||||||
|
FROM node:24-slim AS build
|
||||||
|
WORKDIR /src
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
|
||||||
|
|
||||||
|
# Restore first (cached unless the manifests change).
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Sources (only what the app + its libs need).
|
||||||
|
COPY apps/openbaar apps/openbaar
|
||||||
|
COPY libs libs
|
||||||
|
RUN pnpm nx build openbaar
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
|
||||||
|
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import nx from '@nx/eslint-plugin';
|
||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...nx.configs['flat/angular'],
|
||||||
|
...nx.configs['flat/angular-template'],
|
||||||
|
...baseConfig,
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@angular-eslint/directive-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'attribute',
|
||||||
|
prefix: 'app',
|
||||||
|
style: 'camelCase',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
prefix: 'app',
|
||||||
|
style: 'kebab-case',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.html'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
|
||||||
|
# even before the BFF is up and picks up restarts — instead of failing to load the config.
|
||||||
|
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||||
|
|
||||||
|
# Same-origin API: proxy the anonymous openbaar endpoint group to the bff service. The api-client
|
||||||
|
# uses relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS.
|
||||||
|
location /openbaar/ {
|
||||||
|
set $bff http://bff:8080;
|
||||||
|
proxy_pass $bff;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — Angular client-side routing.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"name": "openbaar",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"projectType": "application",
|
||||||
|
"prefix": "app",
|
||||||
|
"sourceRoot": "apps/openbaar/src",
|
||||||
|
"tags": [],
|
||||||
|
"targets": {
|
||||||
|
"build": {
|
||||||
|
"executor": "@angular/build:application",
|
||||||
|
"outputs": ["{options.outputPath}"],
|
||||||
|
"defaultConfiguration": "production",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/apps/openbaar",
|
||||||
|
"browser": "apps/openbaar/src/main.ts",
|
||||||
|
"tsConfig": "apps/openbaar/tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "apps/openbaar/public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["apps/openbaar/src/styles.css"]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1mb",
|
||||||
|
"maximumError": "2mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kb",
|
||||||
|
"maximumError": "8kb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"continuous": true,
|
||||||
|
"executor": "@angular/build:dev-server",
|
||||||
|
"defaultConfiguration": "development",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "openbaar:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "openbaar:build:development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"executor": "@angular/build:unit-test",
|
||||||
|
"options": {
|
||||||
|
"watch": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve-static": {
|
||||||
|
"continuous": true,
|
||||||
|
"executor": "@nx/web:file-server",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "openbaar:build",
|
||||||
|
"staticFilePath": "dist/apps/openbaar/browser",
|
||||||
|
"spa": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
ApplicationConfig,
|
||||||
|
provideBrowserGlobalErrorListeners,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The openbaar register is a public, anonymous read: no DigiD, no auth interceptor. The app is served
|
||||||
|
* same-origin as the BFF (nginx proxies /openbaar), so the api-client's relative calls stay same-origin.
|
||||||
|
*/
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(appRoutes),
|
||||||
|
provideHttpClient(),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { RegisterPage } from './register/register-page';
|
||||||
|
|
||||||
|
export const appRoutes: Route[] = [{ path: '', component: RegisterPage }];
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { render } from '@testing-library/angular';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('renders the router outlet shell', async () => {
|
||||||
|
const { container } = await render(App, {
|
||||||
|
providers: [provideRouter([])],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shell is a thin host for routed pages (the RegisterPage owns the heading).
|
||||||
|
expect(container.querySelector('router-outlet')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [RouterModule],
|
||||||
|
selector: 'app-root',
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.css',
|
||||||
|
})
|
||||||
|
export class App {
|
||||||
|
protected title = 'openbaar';
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<main utrecht-document class="utrecht-theme">
|
||||||
|
<utrecht-article>
|
||||||
|
<utrecht-heading-1>Openbaar BIG-register</utrecht-heading-1>
|
||||||
|
<p utrecht-paragraph>
|
||||||
|
Zoek in het openbare register van BIG-registraties. Alleen publieke gegevens worden getoond.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div role="search">
|
||||||
|
<label for="register-search" utrecht-form-label>Zoek op referentie</label>
|
||||||
|
<input
|
||||||
|
id="register-search"
|
||||||
|
type="search"
|
||||||
|
utrecht-textbox
|
||||||
|
[ngModel]="query()"
|
||||||
|
(ngModelChange)="query.set($event)"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
(keyup.enter)="search()"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
utrecht-button
|
||||||
|
appearance="primary-action-button"
|
||||||
|
type="button"
|
||||||
|
[disabled]="loading()"
|
||||||
|
(click)="search()"
|
||||||
|
>
|
||||||
|
Zoeken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
||||||
|
} @else if (searched() && entries().length === 0) {
|
||||||
|
<p utrecht-paragraph role="status">Geen inschrijvingen gevonden.</p>
|
||||||
|
} @else if (entries().length > 0) {
|
||||||
|
<table utrecht-table>
|
||||||
|
<caption>Inschrijvingen in het openbaar register</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Referentie</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (entry of entries(); track entry.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.reference }}</td>
|
||||||
|
<td>{{ entry.status }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</utrecht-article>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/angular';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
|
||||||
|
import { axe } from 'vitest-axe';
|
||||||
|
import { RegisterPage } from './register-page';
|
||||||
|
|
||||||
|
const sample: OpenbaarEntry[] = [
|
||||||
|
{ id: 'zaak-abc', status: 'INGEDIEND', reference: 'REG-abc' },
|
||||||
|
{ id: 'zaak-def', status: 'INGESCHREVEN', reference: 'REG-def' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function providers(get = vi.fn().mockReturnValue(of(sample))) {
|
||||||
|
return {
|
||||||
|
get,
|
||||||
|
providers: [{ provide: BffApiV1Service, useValue: { getOpenbaarRegister: get } }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RegisterPage', () => {
|
||||||
|
it('lists the public register entries from the BFF on open', async () => {
|
||||||
|
const { get } = providers();
|
||||||
|
await render(RegisterPage, { providers: providers(get).providers });
|
||||||
|
|
||||||
|
expect(get).toHaveBeenCalled();
|
||||||
|
// The Referentie column shows the citizen's reference (matches the submit confirmation, #78),
|
||||||
|
// not the internal zaak id.
|
||||||
|
expect(await screen.findByText(/REG-abc/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/INGEDIEND/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/REG-def/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches by the entered term', async () => {
|
||||||
|
const get = vi.fn().mockReturnValue(of(sample));
|
||||||
|
await render(RegisterPage, { providers: providers(get).providers });
|
||||||
|
|
||||||
|
fireEvent.input(screen.getByRole('searchbox'), { target: { value: 'zaak-abc' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /zoek/i }));
|
||||||
|
|
||||||
|
expect(get).toHaveBeenLastCalledWith({ q: 'zaak-abc' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an empty-state message when the register has no matches', async () => {
|
||||||
|
const get = vi.fn().mockReturnValue(of([] as OpenbaarEntry[]));
|
||||||
|
await render(RegisterPage, { providers: providers(get).providers });
|
||||||
|
|
||||||
|
expect(await screen.findByText(/geen inschrijvingen gevonden/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no WCAG 2.1 AA violations', async () => {
|
||||||
|
document.documentElement.lang = 'nl';
|
||||||
|
const { container } = await render(RegisterPage, { providers: providers().providers });
|
||||||
|
|
||||||
|
const results = await axe(container, {
|
||||||
|
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
|
||||||
|
import { UtrechtComponentsModule } from 'ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The openbaar (public) BIG-register: an anonymous search over the read projection's public-safe
|
||||||
|
* view (id + status only — bsn/naam never leave the BFF; ADR-0010). Loads the full register on open
|
||||||
|
* and filters by the search term via the BFF's `/openbaar/register?q=` endpoint (S-09).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-register-page',
|
||||||
|
imports: [FormsModule, UtrechtComponentsModule],
|
||||||
|
templateUrl: './register-page.html',
|
||||||
|
})
|
||||||
|
export class RegisterPage {
|
||||||
|
private readonly bff = inject(BffApiV1Service);
|
||||||
|
|
||||||
|
protected readonly query = signal('');
|
||||||
|
protected readonly entries = signal<OpenbaarEntry[]>([]);
|
||||||
|
protected readonly loading = signal(false);
|
||||||
|
protected readonly searched = signal(false);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Show the full register on open; the search box narrows it.
|
||||||
|
this.search();
|
||||||
|
}
|
||||||
|
|
||||||
|
search(): void {
|
||||||
|
const q = this.query().trim();
|
||||||
|
this.loading.set(true);
|
||||||
|
this.bff.getOpenbaarRegister(q ? { q } : {}).subscribe({
|
||||||
|
next: (rows: OpenbaarEntry[]) => {
|
||||||
|
this.entries.set(rows);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.searched.set(true);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.entries.set([]);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.searched.set(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Openbaar BIG-register</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { App } from './app/app';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
|
||||||
|
// The openbaar register is anonymous (no DigiD, no runtime config) — bootstrap directly.
|
||||||
|
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
|
||||||
|
@import '@utrecht/design-tokens/dist/index.css';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"emitDecoratorMetadata": false,
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Point nginx's reverse-proxy `resolver` at THIS container's real DNS server.
|
||||||
|
#
|
||||||
|
# The portal nginx configs use a variable proxy_pass, which needs a `resolver` so the BFF hostname is
|
||||||
|
# resolved at request time (nginx can start before the BFF is up). The config hardcodes Docker's
|
||||||
|
# embedded DNS (127.0.0.11) — correct on Docker/Docker Desktop, but rootless podman uses a
|
||||||
|
# network-specific address (aardvark, e.g. 10.89.0.1), so proxied calls 502 there. Read the actual
|
||||||
|
# nameserver from /etc/resolv.conf and substitute it, so the reverse proxy works on any engine.
|
||||||
|
#
|
||||||
|
# Runs from the nginx image's /docker-entrypoint.d/ before nginx starts. On Docker the nameserver IS
|
||||||
|
# 127.0.0.11, so the substitution is a no-op. Guarded (no `set -e`) so it's safe whether the nginx
|
||||||
|
# entrypoint executes or sources it.
|
||||||
|
ns="$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null)"
|
||||||
|
if [ -n "$ns" ] && [ "$ns" != "127.0.0.11" ]; then
|
||||||
|
sed -i "s/resolver 127\.0\.0\.11/resolver $ns/" /etc/nginx/conf.d/default.conf 2>/dev/null || true
|
||||||
|
echo "portal-nginx-resolver: set resolver to $ns"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Multi-stage build for the self-service portal (Angular → nginx).
|
||||||
|
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
|
||||||
|
FROM node:24-slim AS build
|
||||||
|
WORKDIR /src
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
|
||||||
|
|
||||||
|
# Restore first (cached unless the manifests change).
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Sources (only what the app + its libs need).
|
||||||
|
COPY apps/self-service apps/self-service
|
||||||
|
COPY libs libs
|
||||||
|
RUN pnpm nx build self-service
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY apps/self-service/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
|
||||||
|
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
||||||
|
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
|
||||||
|
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
|
||||||
|
# even before the BFF is up and picks up restarts — instead of failing to load the config.
|
||||||
|
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||||
|
|
||||||
|
# Same-origin API: proxy the BFF endpoint groups to the bff service. The api-client uses relative
|
||||||
|
# URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the DigiD
|
||||||
|
# token (same-origin) is attached by the app's interceptor (S-08d/ADR-0010).
|
||||||
|
location /self-service/ {
|
||||||
|
set $bff http://bff:8080;
|
||||||
|
proxy_pass $bff;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
location /openbaar/ {
|
||||||
|
set $bff http://bff:8080;
|
||||||
|
proxy_pass $bff;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — Angular client-side routing.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"authority": "http://localhost:8180/realms/digid"
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { BffApiV1Service } from 'api-client';
|
||||||
|
import { authInterceptor } from 'auth';
|
||||||
|
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
|
||||||
|
import { SECURE_API_ROUTES } from './app.config';
|
||||||
|
|
||||||
|
// Guards the DigiD token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and the
|
||||||
|
// angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a configured
|
||||||
|
// secureRoute. A regression to an absolute origin (as once shipped) makes the relative URL never match,
|
||||||
|
// so the submit goes out unauthenticated and fails silently. This drives the REAL interceptor and the
|
||||||
|
// REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config source
|
||||||
|
// and the token storage are faked, so the assertion turns on the actual route-matching.
|
||||||
|
describe('self-service DigiD token wiring', () => {
|
||||||
|
let http: HttpTestingController;
|
||||||
|
let bff: BffApiV1Service;
|
||||||
|
const token = 'digid-access-token';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{
|
||||||
|
provide: ConfigurationService,
|
||||||
|
useValue: {
|
||||||
|
hasAtLeastOneConfig: () => true,
|
||||||
|
getAllConfigurations: () => [{ configId: 'digid', secureRoutes: SECURE_API_ROUTES }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A signed-in session: the storage the interceptor's token lookup reads from.
|
||||||
|
provide: AbstractSecurityStorage,
|
||||||
|
useValue: {
|
||||||
|
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
|
||||||
|
write: () => undefined,
|
||||||
|
remove: () => undefined,
|
||||||
|
clear: () => undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
http = TestBed.inject(HttpTestingController);
|
||||||
|
bff = TestBed.inject(BffApiV1Service);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
it('attaches the bearer token to the relative self-service BFF call', () => {
|
||||||
|
bff.postSelfServiceRegistrations().subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne('/self-service/registrations');
|
||||||
|
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||||
|
req.flush({ registrationId: 'reg-1', status: 'Ingediend' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the anonymous openbaar register call unauthenticated', () => {
|
||||||
|
bff.getOpenbaarRegister().subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne((r) => r.url === '/openbaar/register');
|
||||||
|
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,16 +7,36 @@ import { provideRouter } from '@angular/router';
|
|||||||
import { authInterceptor, provideDigiadAuth } from 'auth';
|
import { authInterceptor, provideDigiadAuth } from 'auth';
|
||||||
import { appRoutes } from './app.routes';
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
|
||||||
providers: [
|
export interface RuntimeConfig {
|
||||||
provideBrowserGlobalErrorListeners(),
|
/** The Keycloak `digid` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
|
||||||
provideRouter(appRoutes),
|
authority: string;
|
||||||
provideHttpClient(withInterceptors([authInterceptor()])),
|
}
|
||||||
// Dev defaults (host ports). The compose-served app overrides these for the stack (S-08d).
|
|
||||||
provideDigiadAuth({
|
/**
|
||||||
authority: 'http://localhost:8180/realms/digid',
|
* Route prefixes whose requests carry the DigiD token. These MUST match the **relative** URLs the
|
||||||
redirectUrl: typeof window !== 'undefined' ? window.location.origin : '/',
|
* api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on `req.url`,
|
||||||
secureApiOrigin: 'http://localhost:8080',
|
* which stays relative, so an absolute origin would never match and the token would go unattached.
|
||||||
}),
|
* `/openbaar/` is deliberately excluded: it is the anonymous public register.
|
||||||
],
|
*/
|
||||||
};
|
export const SECURE_API_ROUTES = ['/self-service/'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
|
||||||
|
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
|
||||||
|
*/
|
||||||
|
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
|
||||||
|
return {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(appRoutes),
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||||
|
provideDigiadAuth({
|
||||||
|
authority: runtime.authority,
|
||||||
|
redirectUrl: origin,
|
||||||
|
secureRoutes: SECURE_API_ROUTES,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,63 @@
|
|||||||
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
||||||
|
|
||||||
@if (submitted()) {
|
@if (submitted()) {
|
||||||
<p utrecht-paragraph role="status">
|
@if (withdrawn()) {
|
||||||
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
<p utrecht-paragraph role="status">
|
||||||
</p>
|
Uw registratie met referentie {{ reference() }} is ingetrokken.
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<p utrecht-paragraph role="status">
|
||||||
|
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
||||||
|
</p>
|
||||||
|
@if (documentsProvided()) {
|
||||||
|
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
|
||||||
|
} @else {
|
||||||
|
@if (provideDocumentsFailed()) {
|
||||||
|
<p utrecht-paragraph role="alert">
|
||||||
|
Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<p utrecht-paragraph>Lever uw diploma aan (PDF).</p>
|
||||||
|
<label utrecht-form-label for="diploma">Diploma</label>
|
||||||
|
<input
|
||||||
|
id="diploma"
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
[disabled]="providingDocuments()"
|
||||||
|
(change)="onFileSelected($event)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
utrecht-button
|
||||||
|
appearance="primary-action-button"
|
||||||
|
type="button"
|
||||||
|
[disabled]="providingDocuments() || !selectedFile()"
|
||||||
|
(click)="provideDocuments()"
|
||||||
|
>
|
||||||
|
Documenten aanleveren
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (withdrawFailed()) {
|
||||||
|
<p utrecht-paragraph role="alert">
|
||||||
|
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
utrecht-button
|
||||||
|
appearance="secondary-action-button"
|
||||||
|
type="button"
|
||||||
|
[disabled]="withdrawing()"
|
||||||
|
(click)="withdraw()"
|
||||||
|
>
|
||||||
|
Trek aanvraag in
|
||||||
|
</button>
|
||||||
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
||||||
|
@if (failed()) {
|
||||||
|
<p utrecht-paragraph role="alert">
|
||||||
|
Er ging iets mis bij het indienen van uw registratie. Probeer het opnieuw.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
utrecht-button
|
utrecht-button
|
||||||
appearance="primary-action-button"
|
appearance="primary-action-button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
import { fireEvent, render, screen } from '@testing-library/angular';
|
import { fireEvent, render, screen } from '@testing-library/angular';
|
||||||
import { of } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
import { AuthService } from 'auth';
|
import { AuthService } from 'auth';
|
||||||
import { BffApiV1Service } from 'api-client';
|
import { BffApiV1Service } from 'api-client';
|
||||||
import { axe } from 'vitest-axe';
|
import { axe } from 'vitest-axe';
|
||||||
@@ -17,12 +17,25 @@ class FakeAuth extends AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
|
function providers(
|
||||||
|
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
post,
|
post,
|
||||||
|
withdraw,
|
||||||
|
provideDocuments,
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: AuthService, useClass: FakeAuth },
|
{ provide: AuthService, useClass: FakeAuth },
|
||||||
{ provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
|
{
|
||||||
|
provide: BffApiV1Service,
|
||||||
|
useValue: {
|
||||||
|
postSelfServiceRegistrations: post,
|
||||||
|
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||||
|
postSelfServiceRegistrationsIdDocuments: provideDocuments,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -43,6 +56,89 @@ describe('RegistrationPage', () => {
|
|||||||
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows an error and keeps the submit available when the BFF call fails', async () => {
|
||||||
|
const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected'))));
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
// The failure is surfaced (not swallowed), the confirmation is not shown, and the user can retry.
|
||||||
|
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/ontvangen/i)).toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers to withdraw after submitting, and withdrawing confirms', async () => {
|
||||||
|
const { withdraw, providers: p } = providers();
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||||
|
|
||||||
|
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
|
||||||
|
expect(withdraw).toHaveBeenCalledWith('reg-9');
|
||||||
|
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A small PDF file the citizen "uploads"; the component base64-encodes it client-side.
|
||||||
|
const diploma = () => new File([new Uint8Array([1, 2, 3])], 'diploma.pdf', { type: 'application/pdf' });
|
||||||
|
|
||||||
|
it('uploads a chosen diploma after submitting, and doing so confirms', async () => {
|
||||||
|
const { provideDocuments, providers: p } = providers();
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
|
||||||
|
// Choose the file, then upload it.
|
||||||
|
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
|
||||||
|
|
||||||
|
// The upload is keyed by the reference and carries the base64 file + its name; the page confirms.
|
||||||
|
expect(await screen.findByText(/documenten.*aangeleverd/i)).toBeTruthy();
|
||||||
|
expect(provideDocuments).toHaveBeenCalledWith(
|
||||||
|
'reg-9',
|
||||||
|
expect.objectContaining({ fileName: 'diploma.pdf', contentType: 'application/pdf', contentBase64: expect.any(String) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a diploma-upload failure and keeps the action available', async () => {
|
||||||
|
const { providers: p } = providers(
|
||||||
|
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
vi.fn().mockReturnValue(throwError(() => new Error('documents rejected'))),
|
||||||
|
);
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/aangeleverd/i)).toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a withdraw failure and keeps the action available', async () => {
|
||||||
|
const { providers: p } = providers(
|
||||||
|
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
|
||||||
|
);
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
||||||
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
|
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
|
||||||
// html-has-lang rule reflects the app, not the bare jsdom document.
|
// html-has-lang rule reflects the app, not the bare jsdom document.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { UtrechtComponentsModule } from 'ui';
|
|||||||
/**
|
/**
|
||||||
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
|
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
|
||||||
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
||||||
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
|
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
|
||||||
|
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-registration-page',
|
selector: 'app-registration-page',
|
||||||
@@ -21,13 +22,99 @@ export class RegistrationPage {
|
|||||||
protected readonly submitting = signal(false);
|
protected readonly submitting = signal(false);
|
||||||
protected readonly reference = signal<string | undefined>(undefined);
|
protected readonly reference = signal<string | undefined>(undefined);
|
||||||
protected readonly submitted = signal(false);
|
protected readonly submitted = signal(false);
|
||||||
|
protected readonly failed = signal(false);
|
||||||
|
protected readonly withdrawing = signal(false);
|
||||||
|
protected readonly withdrawn = signal(false);
|
||||||
|
protected readonly withdrawFailed = signal(false);
|
||||||
|
protected readonly providingDocuments = signal(false);
|
||||||
|
protected readonly documentsProvided = signal(false);
|
||||||
|
protected readonly provideDocumentsFailed = signal(false);
|
||||||
|
protected readonly selectedFile = signal<File | undefined>(undefined);
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
this.submitting.set(true);
|
this.submitting.set(true);
|
||||||
this.bff.postSelfServiceRegistrations().subscribe((accepted: SubmitAccepted) => {
|
this.failed.set(false);
|
||||||
this.reference.set(accepted.registrationId);
|
this.bff.postSelfServiceRegistrations().subscribe({
|
||||||
this.submitted.set(true);
|
next: (accepted: SubmitAccepted) => {
|
||||||
this.submitting.set(false);
|
this.reference.set(accepted.registrationId);
|
||||||
|
this.submitted.set(true);
|
||||||
|
this.submitting.set(false);
|
||||||
|
},
|
||||||
|
// Surface the failure instead of swallowing it: re-enable the button so the user can retry.
|
||||||
|
error: () => {
|
||||||
|
this.failed.set(true);
|
||||||
|
this.submitting.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
this.selectedFile.set(input.files?.[0] ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async provideDocuments(): Promise<void> {
|
||||||
|
const reference = this.reference();
|
||||||
|
const file = this.selectedFile();
|
||||||
|
if (!reference || !file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.providingDocuments.set(true);
|
||||||
|
this.provideDocumentsFailed.set(false);
|
||||||
|
let contentBase64: string;
|
||||||
|
try {
|
||||||
|
contentBase64 = await readAsBase64(file);
|
||||||
|
} catch {
|
||||||
|
this.provideDocumentsFailed.set(true);
|
||||||
|
this.providingDocuments.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.bff
|
||||||
|
.postSelfServiceRegistrationsIdDocuments(reference, {
|
||||||
|
contentBase64,
|
||||||
|
fileName: file.name,
|
||||||
|
contentType: file.type || 'application/pdf',
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.documentsProvided.set(true);
|
||||||
|
this.providingDocuments.set(false);
|
||||||
|
},
|
||||||
|
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||||
|
error: () => {
|
||||||
|
this.provideDocumentsFailed.set(true);
|
||||||
|
this.providingDocuments.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
withdraw(): void {
|
||||||
|
const reference = this.reference();
|
||||||
|
if (!reference) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.withdrawing.set(true);
|
||||||
|
this.withdrawFailed.set(false);
|
||||||
|
this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.withdrawn.set(true);
|
||||||
|
this.withdrawing.set(false);
|
||||||
|
},
|
||||||
|
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||||
|
error: () => {
|
||||||
|
this.withdrawFailed.set(true);
|
||||||
|
this.withdrawing.set(false);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read a file's bytes as a base64 string (without the `data:...;base64,` prefix). */
|
||||||
|
function readAsBase64(file: File): Promise<string> {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(((reader.result as string) ?? '').split(',', 2)[1] ?? '');
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error('Could not read the file.'));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { appConfig } from './app/app.config';
|
|
||||||
import { App } from './app/app';
|
import { App } from './app/app';
|
||||||
|
import { appConfig, type RuntimeConfig } from './app/app.config';
|
||||||
|
|
||||||
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
// Load environment config before bootstrap so the OIDC authority is set per environment
|
||||||
|
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
|
||||||
|
fetch('config.json')
|
||||||
|
.then((response) => response.json() as Promise<RuntimeConfig>)
|
||||||
|
.then((config) => bootstrapApplication(App, appConfig(config)))
|
||||||
|
.catch((err) => console.error(err));
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# ADR-0011: Approval sets the zaak eindstatus via the ACL and projects INGESCHREVEN from the notification alone
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-13
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-09b (#75); split from S-09 (#10); builds on ADR-0001 (§8 loose coupling), ADR-0003 (ACL default-fill), ADR-0007 (OZ→NRC wiring), ADR-0008 (read projection), ADR-0009 (external-task worker)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The walking skeleton could submit a registration (INGEDIEND) and show it in the openbaar register,
|
||||||
|
but nothing could **approve** it. S-09b adds a behandelaar approval that must make the entry publicly
|
||||||
|
visible as a terminal status. There is no behandel-portal yet (S-12), so approval is triggered by a
|
||||||
|
**temporary admin endpoint** on the Domain Service.
|
||||||
|
|
||||||
|
Two decisions are non-obvious (§14) and cross service boundaries:
|
||||||
|
|
||||||
|
1. **Who resolves the ZGW statustype?** Approval means "set the zaak to its final status", but the
|
||||||
|
domain must stay ZGW-ignorant (§8.1 — only the ACL talks to ZGW) and does not know statustype URLs.
|
||||||
|
2. **How does the projection learn the new status?** The status is set in OpenZaak, which notifies over
|
||||||
|
NRC; the Event Subscriber projects it. But the subscriber **may not read OpenZaak** (§8.1), and an
|
||||||
|
NRC `status`/`create` notification's `resourceUrl` is the *status* resource, not the zaak, and does
|
||||||
|
not carry the statustype.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Approval flows Domain → ACL → OpenZaak → NRC → Event Subscriber → projection, using only the
|
||||||
|
notification's own fields on the read side.**
|
||||||
|
|
||||||
|
- **Domain.** `Registration.Approve()` advances INGEDIEND → INGESCHREVEN (requires an opened zaak; a
|
||||||
|
repeat is a no-op). The `ApproveRegistration` use case calls the ACL to set the zaak status, then
|
||||||
|
advances the aggregate. A temporary `POST /registrations/{id}/approve` endpoint drives it.
|
||||||
|
- **ACL.** A new `POST /statussen` operation takes only the zaak URL. The ACL resolves the zaaktype's
|
||||||
|
**eindstatus** from the catalogus (`isEindstatus`, falling back to the highest `volgnummer`) and
|
||||||
|
POSTs a ZGW status against the zaak. The domain never names statustypen — the ACL owns the ZGW
|
||||||
|
translation (§8.1, ADR-0003).
|
||||||
|
- **Event Subscriber.** It binds the NRC `hoofdObject` (always the zaak URL) and keys the projection on
|
||||||
|
it, so a `zaken`/`status`/`create` notification updates the **same** row the zaak-create created,
|
||||||
|
flipping it to INGESCHREVEN. It takes **any** status-create as the approval — in the walking skeleton
|
||||||
|
the only status ever set after creation is the approval — so it never has to read OpenZaak to learn
|
||||||
|
the statustype. The ZGW `resource` is retained in the notification log (new column) so a rebuild
|
||||||
|
reproduces the right status.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The domain↔ACL boundary stays clean: the domain hands over a zaak URL and says "approve"; ZGW
|
||||||
|
statustype knowledge lives only in the ACL.
|
||||||
|
- The projection remains rebuildable without OpenZaak (§8.1, ADR-0008): the log now records the ZGW
|
||||||
|
resource, which is all a rebuild needs to reproject the status.
|
||||||
|
- The openbaar register shows real lifecycle: INGEDIEND on submit, INGESCHREVEN on approval.
|
||||||
|
- **Walking-skeleton assumption:** "any status-create ⇒ INGESCHREVEN" holds only while approval is the
|
||||||
|
sole post-creation status transition. When more transitions arrive (beoordeling, afwijzing — S-12+),
|
||||||
|
the subscriber must distinguish statustypen. The honest options then are to carry the statustype
|
||||||
|
omschrijving in the notification `kenmerken`, or to have the ACL resolve it and re-notify — recorded
|
||||||
|
here so future-me revisits this rather than assuming it generalises.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Inject the approved statustype URL into the ACL as config** (like the zaaktype URL). Rejected:
|
||||||
|
couples ACL config to seed output and adds compose/run-domain-check plumbing; runtime eindstatus
|
||||||
|
discovery keeps the ACL self-contained for one extra ZGW GET per approval.
|
||||||
|
- **Have the Event Subscriber GET the status/statustype from OpenZaak** to map precisely. Rejected:
|
||||||
|
violates §8.1 (only the ACL talks to ZGW) and makes the projection depend on OpenZaak being up.
|
||||||
|
- **Record the derived status in the notification log** instead of the ZGW resource. Rejected: the log
|
||||||
|
should retain notification *facts*, not projection semantics; the mapping stays in the projector.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# ADR-0012: One citizen-facing reference across self-service and the openbaar register
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-14
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** #78 (adr-proposal); builds on ADR-0008 (read projection), ADR-0001 (loose coupling), ADR-0009 (external-task worker / zaak creation)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A citizen submits through the self-service portal and is shown a confirmation with a
|
||||||
|
**reference** so they can find their registration back in the public register. But the two
|
||||||
|
sides showed **different identifiers**:
|
||||||
|
|
||||||
|
- The self-service confirmation shows the **domain `registrationId`** — a GUID minted by the
|
||||||
|
domain aggregate (`RegistrationId.New()`) when the registration is created, before any zaak
|
||||||
|
exists.
|
||||||
|
- The openbaar register showed the **zaak id** — the UUID from the NRC `hoofdObject` URL,
|
||||||
|
assigned by OpenZaak when the ACL opens the zaak.
|
||||||
|
|
||||||
|
These never match, so the reference on the confirmation was useless for looking the entry up.
|
||||||
|
The two identifiers live on opposite sides of the ACL boundary and are generated by different
|
||||||
|
systems at different times, so there is no way to reconcile them after the fact without a
|
||||||
|
correlating value carried across the boundary.
|
||||||
|
|
||||||
|
The NRC notification the Event Subscriber consumes carries only the zaak URL plus the fixed
|
||||||
|
`kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`) — **not** the
|
||||||
|
`registrationId`, the bsn, or the `identificatie`. ADR-0008 already recorded that filling any
|
||||||
|
such field means reading the zaak **through the ACL** (§8.1) and deferred it as a follow-up.
|
||||||
|
This is that follow-up, scoped to the one field the citizen actually needs.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Use the domain `registrationId` as the zaak's `identificatie`, and surface that single value
|
||||||
|
as the citizen-facing `reference` on both portals. The Event Subscriber enriches the projection
|
||||||
|
with the reference by reading the zaak through the ACL, and stores it in the replay log so
|
||||||
|
rebuild stays log-only.**
|
||||||
|
|
||||||
|
Concretely, following the request path:
|
||||||
|
|
||||||
|
1. **Domain → ACL (write).** When the OpenZaak worker opens a zaak, it passes
|
||||||
|
`registration.Id` to the ACL (`IAclClient.OpenZaakAsync(bsn, reference, …)`). The ACL sets
|
||||||
|
it as the zaak's `identificatie` on `POST /zaken`. OpenZaak's `identificatie` is unique per
|
||||||
|
`bronorganisatie` and ≤ 40 chars — a GUID string fits. The ACL remains the only code that
|
||||||
|
constructs ZGW payloads (§8.1); the domain never sees a ZGW URL.
|
||||||
|
2. **Event Subscriber → ACL (read).** On a notification, the subscriber asks the ACL for the
|
||||||
|
zaak's reference via a new `POST /zaken/reference` endpoint (`{ zaakUrl } → { reference }`),
|
||||||
|
which reads the zaak's `identificatie` through the ACL's OpenZaak gateway. The subscriber
|
||||||
|
still never talks to ZGW itself (§8.1) — it depends only on the ACL, over HTTP.
|
||||||
|
3. **Projection + replay log.** The reference is written both to the `register_projection` row
|
||||||
|
**and** to the `processed_notifications` replay log (a new nullable `reference` column on
|
||||||
|
each). Storing it in the log is what keeps ADR-0008's "**rebuild replays the log, not
|
||||||
|
OpenZaak**" invariant true: `POST /admin/rebuild` reproduces the reference from the log
|
||||||
|
without re-reading the ACL.
|
||||||
|
4. **BFF + openbaar.** The public view (`OpenbaarProjection.PublicView`) exposes
|
||||||
|
`id`, `status`, and `reference` (never bsn/naam), and the openbaar search matches on either
|
||||||
|
`id` or `reference`. The openbaar register's "Referentie" column now renders `reference`.
|
||||||
|
|
||||||
|
The end-to-end guarantee is asserted in the Playwright walking-skeleton: the reference captured
|
||||||
|
from the submit confirmation must appear as a cell in the public register.
|
||||||
|
|
||||||
|
### Why HTTP to the ACL, not the ACL as a library
|
||||||
|
|
||||||
|
ADR-0008 floated "extend the ACL with a zaak-read operation, consumed as a library." We instead
|
||||||
|
call the ACL **over HTTP**, consistent with every other cross-service hop in this system
|
||||||
|
(portals→BFF, domain→ACL). Sharing the ACL as a library would couple the subscriber to the
|
||||||
|
ACL's infrastructure assembly and its ZGW client configuration, defeating the anti-corruption
|
||||||
|
boundary. The HTTP endpoint keeps the ACL the single owner of ZGW access and its config.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- One reference, end to end: the citizen's confirmation value is exactly what the public
|
||||||
|
register shows and searches by.
|
||||||
|
- §8.1 stays intact — only the ACL reads or writes ZGW; the subscriber depends on the ACL, not
|
||||||
|
OpenZaak.
|
||||||
|
- Rebuild stays log-only (ADR-0008): the reference is replayed from `processed_notifications`,
|
||||||
|
so `/admin/rebuild` needs no ACL/ZGW access.
|
||||||
|
- The column additions are nullable and additive; older rows without a reference are tolerated.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- A new coupling: the Event Subscriber now depends on the ACL being reachable
|
||||||
|
(`Acl__BaseUrl`, compose `depends_on: acl`). A registration whose reference read fails will
|
||||||
|
need the notification redelivered (NRC already redelivers; the projection upsert is
|
||||||
|
idempotent).
|
||||||
|
- One extra HTTP hop per notification (subscriber→ACL→OpenZaak) on the projection path. Bounded:
|
||||||
|
one small GET per zaak, off the citizen's request path.
|
||||||
|
- `identificatie` now carries semantic meaning (it equals the `registrationId`). If OpenZaak
|
||||||
|
were ever configured to auto-generate `identificatie`, the correlation would break; the ACL
|
||||||
|
setting it explicitly is now load-bearing.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Carry the `registrationId` in the notification** — rejected: NRC `kenmerken` are fixed and
|
||||||
|
the notification content is not ours to extend; it would also couple the projection to a
|
||||||
|
bespoke notification shape.
|
||||||
|
- **Show the zaak id on the confirmation instead** — rejected: the zaak does not exist yet when
|
||||||
|
the confirmation is returned (the worker opens it asynchronously, ADR-0009), so the domain has
|
||||||
|
no zaak id to show at submit time.
|
||||||
|
- **Store only on the projection row, re-read the ACL on rebuild** — rejected: it would make
|
||||||
|
rebuild depend on the ACL/ZGW, breaking ADR-0008's log-only rebuild invariant.
|
||||||
|
- **Reconcile the two ids in a lookup table** — rejected: adds write-only state and a second
|
||||||
|
source of truth for a value that can simply be the same on both sides.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# ADR-0013: Behandel-portal wiring — multi-realm BFF auth, werkbak from Flowable tasks, decision completes the task
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-15
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** #84 (adr-proposal), S-12 (#13); builds on ADR-0010 (BFF OIDC), ADR-0011 (approval status flow), ADR-0009 (external-task worker), ADR-0008 (read projection)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-12 adds the behandel-portal: a behandelaar logs in, sees a **werkbak** of registrations awaiting
|
||||||
|
beoordeling, and decides each (goedkeuren/afwijzen). Three questions had no obvious answer and shape
|
||||||
|
the whole slice.
|
||||||
|
|
||||||
|
1. **Which realm authenticates behandelaars, and how does the BFF accept it?** Citizens use the
|
||||||
|
`digid` realm (ADR-0010); staff use a separate `medewerker` realm with roles (`behandelaar`,
|
||||||
|
`teamlead`). Keycloak realms are distinct issuers with distinct signing keys, so the BFF's single
|
||||||
|
`digid`-realm JWT validation rejects a medewerker token outright.
|
||||||
|
2. **Where does the werkbak get its data?** The registrations awaiting beoordeling could come from
|
||||||
|
the read projection (status-filtered rows) or from the Flowable `Beoordelen` user tasks (S-12b).
|
||||||
|
3. **How does a decision correlate to the workflow?** The process parks at the `Beoordelen` user
|
||||||
|
task; the decision must advance it, and also apply the domain transition (ADR-0011).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The BFF validates a second realm for behandel endpoints; the werkbak is the set of open Flowable
|
||||||
|
`Beoordelen` tasks (read through the domain); and a decision both applies the domain transition and
|
||||||
|
completes the Flowable task.**
|
||||||
|
|
||||||
|
- **Multi-realm BFF auth.** The BFF registers a second JWT bearer scheme (`medewerker`, authority =
|
||||||
|
the medewerker realm) alongside the default `digid` scheme. `/behandel/*` endpoints require an
|
||||||
|
authorization policy bound to the `medewerker` scheme **and** the `behandelaar` role. Keycloak puts
|
||||||
|
realm roles in the nested `realm_access.roles` claim, which ASP.NET does not map automatically, so
|
||||||
|
the scheme's `OnTokenValidated` lifts those roles onto the principal as role claims. Self-service
|
||||||
|
keeps the `digid` scheme. Audience validation stays off (ADR-0010's deferred hardening).
|
||||||
|
- **Werkbak = Flowable user tasks (via the domain).** The domain's `Werkbak` query reads the open
|
||||||
|
`Beoordelen` tasks from the Workflow Client (§8.2, `IUserTaskClient`) and enriches each with its
|
||||||
|
aggregate's bsn + status; `GET /behandel/werkbak` exposes it and the BFF proxies it behind the
|
||||||
|
behandelaar policy. The list **is** the authoritative set of claimable/decidable work items, so a
|
||||||
|
decision acts on a real task with no separate correlation store. The read projection stays the
|
||||||
|
anonymous openbaar model — we do **not** project `IN_BEHANDELING` or populate staff-only personal
|
||||||
|
data (both deferred in ADR-0008) just to render a staff view.
|
||||||
|
- **Decision completes the task (S-12c-2).** A behandelaar decision applies the domain transition
|
||||||
|
(aggregate + ACL for approval, per ADR-0011) **and** completes the Flowable `Beoordelen` task
|
||||||
|
(looked up by registrationId), so the process advances. Implemented in the next sub-slice; recorded
|
||||||
|
here so the boundary is decided up front.
|
||||||
|
|
||||||
|
Delivery is split: **S-12c-1** (this PR) = multi-realm auth + werkbak read; **S-12c-2** = the decide
|
||||||
|
endpoint + task completion.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- Staff and citizens are cleanly separated by realm; the `behandelaar` role gates the behandel API.
|
||||||
|
- The werkbak reflects exactly what a behandelaar can act on; claim/decide need no extra correlation.
|
||||||
|
- No premature projection changes — the openbaar read model stays focused and personal-data-free.
|
||||||
|
- Only the ACL/Workflow Client talk to their peers; the BFF still fans out only to domain/projection
|
||||||
|
(§8.3).
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- The BFF now depends on two Keycloak realms being reachable (`Keycloak:MedewerkerAuthority`).
|
||||||
|
- Rendering the werkbak fans out to Flowable (one task query) plus a store read per task — acceptable
|
||||||
|
for the caseload sizes here; a denormalized staff read model is an additive follow-up if needed.
|
||||||
|
- Realm separation (distinct issuers/keys) is validated live, not in the BFF unit tests, where issuer
|
||||||
|
validation is off and one test key signs both realms; the tests exercise the role-based authorization.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Werkbak from the read projection** — rejected for now: needs new plumbing to project
|
||||||
|
`IN_BEHANDELING` and to populate staff-only bsn/naam (deferred, ADR-0008), plus a separate way to
|
||||||
|
find the Flowable task at decide-time. Revisit if a high-volume denormalized staff view is needed.
|
||||||
|
- **One JWT scheme accepting both realms (issuer validation off)** — rejected: trusting multiple
|
||||||
|
issuers without validation is a security regression; two schemes keep each realm's issuer/key checked.
|
||||||
|
- **A dedicated behandel BFF/service** — rejected as premature; one BFF with per-endpoint policies is
|
||||||
|
enough at this size and keeps §8.3 simple.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# ADR-0014: Withdrawal cancels the registratie process via a BPMN message event
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-16
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-11 (#12); builds on ADR-0009 (external-task worker / Workflow Client), ADR-0013
|
||||||
|
(behandel-portal wiring, the Beoordelen user task)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-11 lets a zorgprofessional withdraw a still-open registration ("trek aanvraag in"). S-11a already
|
||||||
|
advances the aggregate to INGETROKKEN (domain state). But the registratie process is still running in
|
||||||
|
Flowable — parked at the `Beoordelen` user task — so without a second step the withdrawn registration
|
||||||
|
would linger as work for a behandelaar. The withdrawal must also **cancel the running process**.
|
||||||
|
|
||||||
|
Two questions shape this sub-slice.
|
||||||
|
|
||||||
|
1. **How does the case get cancelled — in code, or in the BPMN model?**
|
||||||
|
2. **How does a withdrawal correlate to the right running process instance?**
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The BPMN models the cancellation as an interrupting message boundary event on the `Beoordelen`
|
||||||
|
task; the Workflow Client correlates a `RegistratieIngetrokken` message to the task's execution.**
|
||||||
|
|
||||||
|
- **Modelled in BPMN, not deleted from code.** The `Beoordelen` user task carries an interrupting
|
||||||
|
message boundary event (`RegistratieIngetrokken`) that routes to a dedicated "Registratie
|
||||||
|
ingetrokken" end event. The process's own model says *how* a withdrawal ends it — the Workflow
|
||||||
|
Client only delivers the message; it never reaches into Flowable to delete an instance. This keeps
|
||||||
|
the workflow's control flow in the workflow (§8.2) and leaves an audit trail in Flowable history
|
||||||
|
(the process ended via the ingetrokken path, not a raw delete).
|
||||||
|
- **Correlated by the registration's own process instance.** The aggregate records its Flowable
|
||||||
|
process instance id at submit, so the `WithdrawRegistration` handler correlates directly by that
|
||||||
|
id — no task lookup. The Workflow Client asks Flowable for the execution **subscribed to** the
|
||||||
|
`RegistratieIngetrokken` message in that instance and delivers `messageEventReceived` to it.
|
||||||
|
Targeting the subscribed execution (not the user task's execution — a message boundary event's
|
||||||
|
subscription lives on its own execution) is what makes the correlation land.
|
||||||
|
- **Best-effort, mirroring the beoordeling.** If no open `Beoordelen` task is found (the process has
|
||||||
|
not yet parked there — the `OpenZaakAanmaken` window — or has already ended), the withdrawal still
|
||||||
|
stands: the aggregate is INGETROKKEN and the werkbak filters it out regardless (S-11b). We complete
|
||||||
|
the domain transition first and cancel the workflow best-effort, exactly as `BeoordeelRegistratie`
|
||||||
|
completes its task best-effort.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- The cancellation path is visible in `registratie.bpmn`; the Workflow Client stays the only code
|
||||||
|
that talks to Flowable and does not delete instances behind the model's back.
|
||||||
|
- Reuses the existing task-query correlation — no new plumbing, no correlation store.
|
||||||
|
- A withdrawn case leaves the werkbak (its `Beoordelen` task is cancelled), and the werkbak also
|
||||||
|
filters non-open registrations as a belt-and-braces for the brief window before cancellation lands.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- A withdrawal raced ahead of the process reaching `Beoordelen` (during `OpenZaakAanmaken`, seconds)
|
||||||
|
finds no task to cancel, so that process instance runs on to `Beoordelen` and parks there with no
|
||||||
|
one to act on it (it is hidden from the werkbak by the status filter). Acceptable for this
|
||||||
|
reference at these volumes; a process-level interrupting event subprocess would close the gap and
|
||||||
|
is an additive follow-up if it matters.
|
||||||
|
- The Flowable message-correlation REST shape is validated live (verify-stack), not in the
|
||||||
|
Workflow Client's unit tests, which stub the HTTP exchange and assert only the request shape
|
||||||
|
(consistent with ADR-0009).
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Delete the process instance from the Workflow Client** (`DELETE /runtime/process-instances/{id}`)
|
||||||
|
— rejected: it cancels the case but hides the reason from the BPMN model; the "why" lives in code,
|
||||||
|
not the process. The message event keeps the cancellation a first-class part of the workflow.
|
||||||
|
- **Interrupting message event subprocess at process level** — more robust (correlates anytime,
|
||||||
|
closing the `OpenZaakAanmaken`-race gap), but a heavier BPMN construct; deferred as an additive
|
||||||
|
change if the race proves to matter.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# ADR-0015: Beoordeling escalation reassigns via an external-worker task
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-17
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-14 (#15); proposal #98. Builds on ADR-0009 (external-task worker / Workflow
|
||||||
|
Client), ADR-0013 (behandel-portal wiring, the `Beoordelen` user task), ADR-0014 (the boundary-event
|
||||||
|
pattern on `Beoordelen`).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-14 escalates a beoordeling that a behandelaar does not pick up in time: after 14 days the case must
|
||||||
|
move to the `teamlead` role (PRD §5, flow 5). The `Beoordelen` user task already exists, claimable by
|
||||||
|
the `behandelaar` candidate group; the teamlead role is seeded in the medewerker realm.
|
||||||
|
|
||||||
|
Two forces shape this.
|
||||||
|
|
||||||
|
1. **The task must stay open.** Escalation changes *who may claim* an unclaimed beoordeling, not the
|
||||||
|
work itself — so the timer must be **non-interrupting**: the `Beoordelen` task keeps running while
|
||||||
|
escalation happens alongside it.
|
||||||
|
2. **Reassigning an open task's candidate group needs code.** Flowable cannot rewrite the candidate
|
||||||
|
groups of an already-open user task from BPMN XML alone — that requires either a Java delegate/listener
|
||||||
|
embedded in the engine, or an out-of-process actor driving the REST API. The repository has held a
|
||||||
|
"stock Flowable image, no custom jars; the Workflow Client is the only code that talks to Flowable
|
||||||
|
(§8.2)" posture since ADR-0009.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**A non-interrupting `P14D` boundary timer on `Beoordelen` fires an external-worker task
|
||||||
|
(`BeoordelingEscaleren`); the Workflow Client reassigns the still-open `Beoordelen` task from the
|
||||||
|
behandelaar group to teamlead.**
|
||||||
|
|
||||||
|
- **Modelled in BPMN, driven by an external worker.** The timer routes a parallel token to an
|
||||||
|
`external-worker` service task on the `BeoordelingEscaleren` topic, ending at a dedicated "Beoordeling
|
||||||
|
geëscaleerd" end event. The model owns *when* escalation happens; the Workflow Client — the only code
|
||||||
|
that talks to Flowable (§8.2) — owns *how* the reassignment is applied, exactly as `OpenZaakAanmaken`
|
||||||
|
delegates the ZGW call (ADR-0009). No custom code runs inside Flowable.
|
||||||
|
- **Reassignment is a candidate-group swap.** The escalation worker finds the still-open `Beoordelen`
|
||||||
|
task in the escalating instance (task query by `processInstanceId` + `taskDefinitionKey`), adds
|
||||||
|
`teamlead` as a candidate group via the task identity links, then removes `behandelaar`. The task now
|
||||||
|
belongs to the teamlead; its history and variables are untouched.
|
||||||
|
- **Best-effort, mirroring beoordeling and withdrawal.** If the task is no longer open — the behandelaar
|
||||||
|
completed it in the window before the timer fired — the reassignment is a no-op. A failed reassignment
|
||||||
|
leaves the escalation job un-completed so Flowable redelivers it (§8.6), consistent with the
|
||||||
|
`OpenZaakAanmaken` worker.
|
||||||
|
- **Segregated interface.** The escalation methods live on `IBeoordelingEscalatieClient`, separate from
|
||||||
|
the `OpenZaakAanmaken` worker's `IExternalWorkerClient`, so the OpenZaak worker never sees escalation
|
||||||
|
(interface segregation). Both are implemented by the one `FlowableWorkflowClient`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- The escalation trigger is visible in `registratie.bpmn`; Flowable stays a stock image, and the
|
||||||
|
Workflow Client remains the sole Flowable client (§8.2 upheld, not bent).
|
||||||
|
- Reuses the external-worker mechanics (topic acquire/complete, hosted pump, per-tick scope,
|
||||||
|
redelivery-on-failure) wholesale — the new code is one client capability, one processor, one pump.
|
||||||
|
- Escalation latency is bounded by the worker's poll interval (seconds) — negligible against a 14-day
|
||||||
|
timer.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- Escalation is two REST hops (add teamlead, remove behandelaar) rather than one atomic update; between
|
||||||
|
them the task is briefly claimable by both groups. Harmless at these volumes, and the pair is idempotent
|
||||||
|
on redelivery.
|
||||||
|
- The Flowable identity-link and management-job REST shapes are validated live (verify-domain fires the
|
||||||
|
timer early via the management API), not in the Workflow Client's unit tests, which stub the HTTP
|
||||||
|
exchange and assert only the request shape — consistent with ADR-0009 and ADR-0014.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Flowable timer/task listener (Java delegate).** Reassign in-engine when the timer fires. Rejected:
|
||||||
|
it needs a custom jar in Flowable, breaking the stock-image, REST-only posture and adding a build/deploy
|
||||||
|
surface to the engine for no capability the external-worker route lacks.
|
||||||
|
- **Interrupting timer that re-creates the task for teamlead.** Cancel `Beoordelen` and start a fresh
|
||||||
|
teamlead task. Rejected: it loses the task's identity/history and complicates correlation, where a
|
||||||
|
candidate-group swap on the same task expresses "the same work, now the teamlead's" directly.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# ADR-0016: Diploma eligibility is a DMN evaluated inline as a BPMN DMN service task
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-17
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-13 (#14); proposal #100. Builds on ADR-0009 (external-task worker / Workflow
|
||||||
|
Client), ADR-0014/0015 (the boundary-event and routing constructs on the registratie process).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-13 adds flow 4: a foreign diploma must get an extra CBGV-advies assessment before beoordeling
|
||||||
|
(PRD §5). The eligibility decision — domestic goes straight to beoordeling, foreign routes through
|
||||||
|
CBGV-advies — needs a home. The Flowable REST app bundles a DMN engine, and the same
|
||||||
|
`repository/deployments` machinery that deploys `registratie.bpmn` can deploy a `.dmn`. §8.2 makes
|
||||||
|
the Workflow Client the only code that talks to Flowable; the PRD frames the workflow as "BPMN + DMN
|
||||||
|
governing the registration workflow" (Flowable as a peer orchestration module).
|
||||||
|
|
||||||
|
The issue's wording ("a DMN decision table evaluated by the Domain Service via Workflow Client")
|
||||||
|
suggests the domain reaches into Flowable's DMN API to evaluate the decision and feeds the result
|
||||||
|
back. That is one option; it is not the only one, and it is not the cleanest.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The diploma-eligibility DMN is deployed to Flowable and evaluated inline by the registratie process
|
||||||
|
as a DMN service task (`flowable:type="dmn"`); an exclusive gateway routes on its output. The domain's
|
||||||
|
only new job is to carry the diploma origin and pass it into the process as a start variable.**
|
||||||
|
|
||||||
|
- **The decision lives in the workflow.** `workflows/diploma-eligibility.dmn` maps `diplomaOrigin`
|
||||||
|
→ `route` (`Buitenlands` ⇒ `CBGV_ADVIES`, otherwise `DIRECT`). A DMN service task
|
||||||
|
(`flowable:type="dmn"`, `decisionTableReferenceKey=diploma-eligibility`) runs it between
|
||||||
|
`OpenZaakAanmaken` and `Beoordelen`, and an exclusive gateway sends `CBGV_ADVIES` through a new
|
||||||
|
`CBGVAdvies` user task before `Beoordelen`, `DIRECT` straight there. (A `businessRuleTask` would
|
||||||
|
bind Flowable's legacy Drools/KIE implementation, which `flowable-rest` does not bundle — its parse
|
||||||
|
handler throws `NoClassDefFoundError` at deploy time; the DMN service task is the supported route.)
|
||||||
|
- **The domain carries the input, not the decision.** The `Registration` aggregate gains a
|
||||||
|
`DiplomaOrigin` (Binnenlands/Buitenlands); `SubmitRegistration` passes it to
|
||||||
|
`StartRegistrationProcessAsync`, which sets it as the `diplomaOrigin` start variable. The domain
|
||||||
|
never evaluates the DMN and never learns the route — that is the process's concern.
|
||||||
|
- **Deployed as its own DMN-engine deployment, separate from the BPMN.** The DMN is version-controlled
|
||||||
|
in `workflows/` and `flowable-init` deploys it to the DMN engine via the `dmn-api`
|
||||||
|
(`/dmn-api/dmn-repository/deployments`), while `registratie.bpmn` goes to the process engine via
|
||||||
|
`/service/repository/deployments`. Two things were learned the hard way here (both cost a CI cycle):
|
||||||
|
(1) `flowable-rest` does **not** cascade a `.dmn` bundled inside a process `.bar` into the DMN engine
|
||||||
|
— the resource is stored but no decision is created, so the service task fails at runtime with
|
||||||
|
`FlowableObjectNotFoundException: No decision found for key`; the DMN must go through `dmn-api`.
|
||||||
|
(2) Flowable's DMN XML converter rejects an XML comment placed between the `<?xml?>` declaration and
|
||||||
|
the root `<definitions>` element (`XMLStreamReader not in START_DOCUMENT or START_ELEMENT state`),
|
||||||
|
unlike its BPMN converter — so the DMN's documentation comment lives *inside* `<definitions>`.
|
||||||
|
With the decision present in the DMN repository, the process's DMN service task resolves it across
|
||||||
|
deployments by key (verified live), so no shared parent deployment id is needed.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- The eligibility rule is a first-class, inspectable workflow artefact (matching the PRD's BPMN+DMN
|
||||||
|
framing); business users can read/adjust the decision table without touching domain code.
|
||||||
|
- §8.2 stays clean: the Workflow Client remains the only code talking to Flowable, and the decision
|
||||||
|
runs inside the process the client already started — no domain→Flowable round-trip for a decision.
|
||||||
|
- The domain change is minimal and additive: one value on the aggregate, one start variable.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- Deviates from #14's literal "evaluated by the Domain Service via Workflow Client" wording (noted on
|
||||||
|
the issue). The outcome — DMN decides eligibility, foreign diplomas get the CBGV step — is unchanged.
|
||||||
|
- The DMN and its service-task wiring are validated live (verify-domain drives a foreign
|
||||||
|
registration through CBGV-advies and a domestic one straight to beoordeling, exercising both
|
||||||
|
branches), not in unit tests — consistent with ADR-0009/0014/0015. The domain unit/acceptance tests
|
||||||
|
cover only that the origin is carried into the process.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Domain evaluates the DMN via the Workflow Client** (the issue's wording). Rejected: it couples
|
||||||
|
the domain to Flowable for a decision and splits the routing across two places (domain computes,
|
||||||
|
BPMN branches), for no benefit over letting the engine that owns the process own the decision.
|
||||||
|
- **Eligibility rules in domain C#.** Rejected: it moves a governable business decision out of the
|
||||||
|
DMN the PRD calls for, and hard-codes what the reference app is meant to demonstrate as data.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# ADR-0017: A document-wait task with a 30-day interrupting timer cancels the registration
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-20
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-10a (#102); proposal #104; split from S-10 (#11). Builds on ADR-0009 (external-task
|
||||||
|
worker / Workflow Client), ADR-0014 (withdrawal cancels the process), ADR-0015 (beoordeling
|
||||||
|
escalation — the boundary-timer + external-worker pattern), ADR-0016 (diploma-eligibility DMN).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Flow 2 (PRD §5) requires the citizen to supply documents (their diploma) after submitting. The
|
||||||
|
registratie process must park waiting for those documents and, if they do not arrive within 30 days,
|
||||||
|
cancel the case. S-10 was split (§13): **S-10a** is this workflow/timeout spine (backend only);
|
||||||
|
**S-10b** wires the actual upload (portal → BFF → domain → ACL → Documenten API) that completes the
|
||||||
|
wait. This ADR records the spine: where the wait sits, how the timeout cancels, and how the domain
|
||||||
|
aggregate stays in sync.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**A `WachtOpDocumenten` user task is inserted immediately after `OpenZaakAanmaken`, carrying an
|
||||||
|
`cancelActivity="true"` (interrupting) `P30D` boundary timer. "Documents received" completes the task
|
||||||
|
and the process continues into the diploma-eligibility routing; on timeout the timer cancels the task,
|
||||||
|
runs a `RegistratieVerlopen` external-worker task, and ends the process at `endVerlopen`. A domain
|
||||||
|
worker expires the correlated aggregate to a new terminal status `Verlopen`.**
|
||||||
|
|
||||||
|
- **Where the wait sits.** Right after the zaak is opened, before the diploma-eligibility DMN: the zaak
|
||||||
|
exists, then the process waits for documents; on receipt it continues to the DMN routing → Beoordelen
|
||||||
|
(ADR-0016). The wait gates the whole assessment, so it precedes the routing rather than sitting
|
||||||
|
between the gateway and Beoordelen.
|
||||||
|
- **Interrupting timer, mirroring the existing constructs.** Unlike the S-14 escalation timer
|
||||||
|
(non-interrupting — the Beoordelen task stays open), this timer is interrupting: when it fires the
|
||||||
|
wait token is consumed and the case is cancelled, like the S-11 withdrawal boundary (ADR-0014). The
|
||||||
|
timeout branch runs a `RegistratieVerlopen` external-worker task (topic mirrors
|
||||||
|
`OpenZaakAanmaken`/`BeoordelingEscaleren`) → `endVerlopen`.
|
||||||
|
- **The domain stays authoritative.** The `RegistratieVerlopen` job carries the `registrationId`; the
|
||||||
|
`RegistratieVerlopenProcessor` drains it and the `ExpireRegistrationWorker` loads the aggregate and
|
||||||
|
calls `Registration.Expire()`, moving it to the new terminal status `Verlopen`. This keeps the
|
||||||
|
aggregate — which the projection/openbaar view reads — the source of truth, exactly as escalation and
|
||||||
|
withdrawal do. Idempotent per §8.6: a redelivered job whose aggregate is already `Verlopen` completes
|
||||||
|
without persisting again; an unknown registration throws so the job is redelivered.
|
||||||
|
- **Documents-in-time transition.** `IWorkflowClient.CompleteDocumentWaitAsync(processInstanceId)`
|
||||||
|
completes the `WachtOpDocumenten` task (the Workflow Client remains the only code that talks to
|
||||||
|
Flowable, §8.2). It is best-effort — a no-op if the instance already left the wait (continued, or
|
||||||
|
timed out). The trigger is wired end-to-end in S-10a: a `ProvideDocuments` application use case behind
|
||||||
|
an owner-scoped domain endpoint `POST /registrations/{id}/documents`, a BFF passthrough
|
||||||
|
`POST /self-service/registrations/{id}/documents` (bsn from the DigiD token), and a "Documenten
|
||||||
|
aanleveren" action on the self-service page — so the walking-skeleton e2e stays green (a registration
|
||||||
|
can still reach the behandelaar). **S-10b replaces the stub trigger with a real file upload stored in
|
||||||
|
the ZGW Documenten (DRC) API via the ACL**; the completion of the wait is unchanged.
|
||||||
|
- *Why the trigger lives here, not in S-10b:* inserting the `WachtOpDocumenten` gate without any way
|
||||||
|
to pass it breaks the submit→beoordeling e2e (a merge gate). Splitting "gate" from "means to pass
|
||||||
|
the gate" across slices would leave `main` red, so S-10a owns both; S-10b is purely the ZGW storage
|
||||||
|
behind the same action.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- The wait/timeout is a first-class workflow construct that reuses the boundary-timer + external-worker
|
||||||
|
pattern already proven by S-14, so the domain change is small and additive: one terminal status, one
|
||||||
|
worker trio (worker + processor + pump), one Workflow Client method.
|
||||||
|
- §8 stays clean: the Workflow Client is still the only Flowable caller, and no new ZGW boundary is
|
||||||
|
introduced in S-10a.
|
||||||
|
- The timeout is verified live (verify-domain fires the P30D timer via the management-API "move" idiom
|
||||||
|
and asserts the domain reaches `Verlopen`), consistent with ADR-0009/0014/0015.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- Every registration now parks at `WachtOpDocumenten` before Beoordelen, so the other flows must supply
|
||||||
|
documents first: the live-check blocks (S-11/S-12b/S-13/S-14) complete the task via Flowable, and the
|
||||||
|
registration e2e clicks "Documenten aanleveren". A small, explicit step, but it touches every path
|
||||||
|
through the process.
|
||||||
|
- On expiry S-10a cancels the *process* and marks the aggregate `Verlopen` but does **not** set the ZGW
|
||||||
|
*zaak* to a cancellation status — that needs a new ACL method + statustype seeding, which overlaps
|
||||||
|
S-10b's ACL/infra work. Deferred to S-10b (or a follow-up); noted here as the S-10a/S-10b boundary.
|
||||||
|
- Withdrawing while parked at `WachtOpDocumenten` marks the aggregate `Ingetrokken` but does not cancel
|
||||||
|
the process (the withdrawal message boundary is on `Beoordelen`); the timeout worker tolerates this
|
||||||
|
by no-op'ing on an already-resolved aggregate. Extending withdrawal to the wait state is a follow-up.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Pure-BPMN cancellation (timer → end event, no worker).** Rejected: the domain aggregate would then
|
||||||
|
be out of sync with the cancelled process, and the openbaar/projection view reads the aggregate's
|
||||||
|
status — the case would still look open.
|
||||||
|
- **Wait task between the gateway and Beoordelen.** Rejected: documents gate the whole assessment
|
||||||
|
(including the CBGV-advies routing), so the wait belongs before the DMN, not after it.
|
||||||
|
- **A dedicated timeout status per branch vs. reusing an open-state guard.** `Expire()` reuses the same
|
||||||
|
`RequireOpenForDecision` guard as withdrawal/decision, so only an `INGEDIEND`/`IN_BEHANDELING`
|
||||||
|
registration can lapse and the terminal states stay mutually exclusive — no new guard logic.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# ADR-0018: Diploma upload is stored in the ZGW Documenten API, fronted by the ACL
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-20
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-10b (#103); proposal #107. Builds on ADR-0001 (ACL is the only ZGW caller),
|
||||||
|
ADR-0003 (ACL default-fill), ADR-0017 (document-wait + provision trigger). Carves the zaak-close on
|
||||||
|
expiry to #106 (S-10c).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-10a wired the "documenten aanleveren" trigger (portal → BFF → domain → complete the WachtOpDocumenten
|
||||||
|
wait) with the file itself stubbed. S-10b makes the upload real: the diploma must be **stored in the
|
||||||
|
ZGW Documenten (DRC) API** and related to the zaak. §8.1 makes the ACL the only code that talks to ZGW.
|
||||||
|
The DRC API is served by the same OpenZaak container as the Zaken/Catalogi APIs.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The ACL fronts the Documenten API: it creates an `enkelvoudiginformatieobject` and relates it to the
|
||||||
|
zaak. The file travels base64-encoded in JSON across every hop (the portal encodes it client-side); a
|
||||||
|
"Diploma" `informatieobjecttype` is seeded in the catalogus and injected into the ACL like the
|
||||||
|
zaaktype.**
|
||||||
|
|
||||||
|
- **ACL gateway.** `OpenZaakGateway.StoreDocumentAsync` POSTs the `enkelvoudiginformatieobject`
|
||||||
|
(`/documenten/api/v1/enkelvoudiginformatieobjecten`, base64 `inhoud`, `bestandsomvang`,
|
||||||
|
`status=definitief`) then relates it to the zaak (`/zaken/api/v1/zaakinformatieobjecten`), reusing the
|
||||||
|
established gateway patterns (ZGW Bearer JWT, buffered non-chunked body for uwsgi, **no CRS headers** —
|
||||||
|
the Documenten API is not geo, unlike zaak-create). `AclService.StoreDiplomaAsync` default-fills the
|
||||||
|
ZGW-mandatory fields (informatieobjecttype, bronorganisatie, vertrouwelijkheidaanduiding, `taal=nld`,
|
||||||
|
creatiedatum); the domain hands over only the zaak, the bytes, and the file's name/type. No new ZGW
|
||||||
|
scopes were needed — the seed applicatie holds `heeft_alle_autorisaties`.
|
||||||
|
- **The file travels as base64 JSON end-to-end.** The portal reads the chosen file client-side
|
||||||
|
(`FileReader`) and posts `{ contentBase64, fileName, contentType }` as JSON to the BFF; the BFF
|
||||||
|
forwards it to the domain, and the domain to the ACL, all as JSON. This deviates from proposal #107's
|
||||||
|
"multipart on the portal→BFF hop": base64 JSON keeps **one** contract shape across all four services
|
||||||
|
(no `IFormFile`/antiforgery plumbing, no multipart in the generated client), and a diploma is a small
|
||||||
|
placeholder PDF, so the ~33% base64 overhead is immaterial. The ACL turns the base64 back into the
|
||||||
|
ZGW `inhoud`.
|
||||||
|
- **Storing precedes completing the wait.** `ProvideDocuments` (from S-10a) now stores the diploma via
|
||||||
|
the ACL — once the zaak is opened — and then completes the `WachtOpDocumenten` task, so a registration
|
||||||
|
reaches beoordeling only after its diploma is stored. Both steps stay best-effort about missing
|
||||||
|
preconditions (no zaak yet → skip storage; no process yet → skip completion), mirroring withdrawal.
|
||||||
|
- **Catalogus.** `seed_catalogus.py` (OZ_PUBLISH) creates a "Diploma" `informatieobjecttype`, relates it
|
||||||
|
to the zaaktype (`zaaktype-informatieobjecttypen`, while both concept), publishes both, and prints
|
||||||
|
`INFORMATIEOBJECTTYPE_URL`; verify-domain injects it as `Acl__Defaults__InformatieobjecttypeUrl`
|
||||||
|
(a zeros-uuid placeholder otherwise, so the ACL still boots).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- §8.1 stays intact: the ACL is still the only ZGW caller; the portal only talks to the BFF; the domain
|
||||||
|
only crosses the ACL boundary. Adding a document was almost entirely additive (one gateway method, one
|
||||||
|
default, one seed block).
|
||||||
|
- One JSON contract shape across portal/BFF/domain/ACL keeps the generated client and the service
|
||||||
|
contracts uniform; the upload is exercised live (ACL integration test against real OpenZaak; the
|
||||||
|
Playwright journey uploads a real PDF).
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- Base64 inflates the payload ~33% and holds the whole file in memory at each hop — fine for a small
|
||||||
|
diploma, but not a pattern to reuse for large documents without streaming/multipart.
|
||||||
|
- The zaak is **not** set to a cancellation status when the 30-day term lapses — carved to #106 (S-10c),
|
||||||
|
which adds the cancellation statustype/resultaattype + ACL method + expiry-worker wiring.
|
||||||
|
- Providing documents before the zaak is opened silently skips storage (best-effort); the e2e/live flow
|
||||||
|
avoids this by uploading only after the openbaar register shows the zaak (INGEDIEND).
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Multipart on the portal→BFF hop** (proposal #107). Rejected: it splits the transport into two shapes
|
||||||
|
(multipart then JSON), needs `IFormFile` + antiforgery handling and a multipart method in the generated
|
||||||
|
client, for no benefit at diploma size.
|
||||||
|
- **The domain talks to the Documenten API directly.** Rejected outright: violates §8.1 (only the ACL
|
||||||
|
talks to ZGW).
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# ADR-0019: A timed-out zaak is cancelled with a distinct status + resultaat, resolved by name
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-21
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-10c (#106). Completes the S-10a/S-10b boundary noted in ADR-0017 (§Consequences) and
|
||||||
|
reuses the ACL close-zaak machinery from S-09b (approval) and the Documenten work in ADR-0018.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0017 (S-10a) cancels the *process* and marks the domain aggregate `Verlopen` when the 30-day
|
||||||
|
document term lapses, but explicitly deferred setting the ZGW **zaak** to a cancellation status. Left
|
||||||
|
open, a timed-out zaak stays open in OpenZaak while the register shows the registration as lapsed — the
|
||||||
|
two diverge. S-10c closes that gap: on expiry the domain must also cancel the zaak through the ACL
|
||||||
|
(§8.1, the only code that talks to ZGW).
|
||||||
|
|
||||||
|
The non-obvious part is *how to represent "cancelled" in ZGW* alongside the existing "approved" close.
|
||||||
|
The approval path (S-09b) sets the zaak's **eindstatus** (the terminal statustype) plus a resultaat. In
|
||||||
|
ZGW a zaaktype has exactly one eindstatus — the highest-`volgnummer` statustype — and setting it is what
|
||||||
|
closes the zaak (`einddatum`). A second *terminal* status would collide with that single-eindstatus rule.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Model cancellation as a distinct, non-terminal `Geannuleerd` statustype plus a distinct `Vervallen`
|
||||||
|
resultaat, and resolve both the approval and cancellation statustype/resultaat by their omschrijving
|
||||||
|
(name) rather than by position or the eindstatus flag alone.**
|
||||||
|
|
||||||
|
- **Seed.** `Geannuleerd` is seeded at `volgnummer` 2 — between `Ontvangen` (1) and the `Afgehandeld`
|
||||||
|
eindstatus (3) — so it is a *non-terminal* status and never displaces the eindstatus the approval path
|
||||||
|
resolves. A second resultaattype `Vervallen` (archiefnominatie `vernietigen`) is seeded beside the
|
||||||
|
approval `Geregistreerd` (`blijvend_bewaren`); both draw their `selectielijstklasse` from the
|
||||||
|
zaaktype's single `selectielijstProcestype` so they validate on publish.
|
||||||
|
- **The ACL owns the mapping.** `OpenZaakGateway.SetZaakToCancellationStatusAsync` resolves `Geannuleerd`
|
||||||
|
+ `Vervallen` by omschrijving and POSTs the resultaat then the status (OpenZaak requires a resultaat
|
||||||
|
before a closing/terminal status), mirroring `SetZaakToEindstatusAsync`. Exposed as
|
||||||
|
`AclService.CancelZaakAsync` behind the ACL endpoint `POST /annuleringen`. The omschrijvingen live as
|
||||||
|
constants in the gateway — the ACL, not the domain, knows which ZGW status means what (§8.1).
|
||||||
|
- **Approval now resolves its resultaat by name too.** With two resultaattypen present, taking the first
|
||||||
|
is ambiguous (the Zaken API does not guarantee order), so the approval path resolves `Geregistreerd`
|
||||||
|
by omschrijving. Its statustype resolution is unchanged (still the eindstatus).
|
||||||
|
- **Domain wiring.** The `ExpireRegistrationWorker` calls `IAclClient.CancelZaakAsync(zaakUrl)` **before**
|
||||||
|
advancing the aggregate to `Verlopen` (ACL-first, mirroring approval): if the ACL call fails the job is
|
||||||
|
redelivered (§8.6) rather than leaving the aggregate `Verlopen` with an open zaak. The existing
|
||||||
|
open-state guard stops a redelivered job from cancelling twice (a second resultaat would be a 400); a
|
||||||
|
registration that lapsed before its zaak was opened has nothing to cancel.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- The domain aggregate and the ZGW zaak no longer diverge on timeout — both reflect the cancellation.
|
||||||
|
- Reuses the approval close machinery (resultaat-then-status, ACL endpoint shape, ACL-first ordering), so
|
||||||
|
the change is additive and §8 stays clean (only the ACL talks to ZGW).
|
||||||
|
- Verified at two levels: an ACL↔OpenZaak integration test asserts the live zaak reaches `Geannuleerd`
|
||||||
|
with a resultaat, and the domain verify script fires the real P30D timer and confirms the zaak is
|
||||||
|
cancelled end-to-end.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- `Geannuleerd` is non-terminal, so the cancelled zaak's `einddatum` is not set — it carries a
|
||||||
|
cancellation status + resultaat but is not formally "closed" in ZGW. Accepted: the register reads the
|
||||||
|
domain aggregate's status, and a single eindstatus per zaaktype is a ZGW constraint we chose not to
|
||||||
|
fight. Formally closing a cancelled zaak (a second eindstatus, or reusing `Afgehandeld` with a
|
||||||
|
`Vervallen` resultaat) is a possible follow-up.
|
||||||
|
- The ACL couples to the seeded omschrijvingen (`Geregistreerd`/`Geannuleerd`/`Vervallen`) by string
|
||||||
|
constants. This mirrors the existing implicit coupling to the catalogus (zaaktype URL, eindstatus) and
|
||||||
|
is documented in the gateway.
|
||||||
|
- Renumbering `Afgehandeld` from `volgnummer` 2 to 3 means a *stale* local catalogus must have its
|
||||||
|
OpenZaak volumes reset for the change to take effect; CI reseeds a fresh catalogus each run.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Shared eindstatus, distinct resultaat only** (reuse `Afgehandeld`, distinguish approval vs
|
||||||
|
cancellation purely by the resultaat). ZGW-idiomatic and would set `einddatum` on cancellation too, but
|
||||||
|
the register would show no visibly distinct cancellation *status*. Rejected in favour of the issue's
|
||||||
|
explicit "distinct statustype + resultaattype" outcome, which makes the cancellation legible in ZGW.
|
||||||
|
- **A second terminal (eindstatus) `Geannuleerd`.** Rejected: ZGW allows only one eindstatus per
|
||||||
|
zaaktype (highest volgnummer); a second terminal status would either not close the zaak or collide with
|
||||||
|
the approval eindstatus resolution.
|
||||||
|
- **Passing the target omschrijvingen from the domain.** Rejected: which ZGW status means "cancelled" is
|
||||||
|
ZGW vocabulary the ACL owns (§8.1); the domain says only "cancel this zaak".
|
||||||
@@ -5,6 +5,28 @@ copy-pasteable walkthrough against a local `make up` stack.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## S-08d — Walking skeleton complete: browser → submit, end-to-end
|
||||||
|
|
||||||
|
**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
|
||||||
|
runs in a real browser — **mock DigiD login → submit → confirmation** — closing the walking skeleton
|
||||||
|
(portal → BFF → domain → Flowable → ACL → OpenZaak, with the openbaar register reading the projection).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Bring the whole stack up (portal served on :8140, BFF :8080, Keycloak :8180).
|
||||||
|
make up
|
||||||
|
|
||||||
|
# 2. Automated happy path — Playwright, inside the compose network (issuer-consistent):
|
||||||
|
make verify-e2e # → login as jan-burger → submit → "ontvangen" confirmation
|
||||||
|
|
||||||
|
# 3. By hand: open the portal, log in as jan-burger / test123, click "Registratie indienen".
|
||||||
|
open http://localhost:8140
|
||||||
|
```
|
||||||
|
|
||||||
|
> The portal is served same-origin with the BFF (nginx proxies `/self-service` + `/openbaar`), so no
|
||||||
|
> CORS; the OIDC authority comes from `/config.json` at runtime. See `docs/frontend-decisions.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## S-08c — Self-service submit form (NL Design System + DigiD)
|
## S-08c — Self-service submit form (NL Design System + DigiD)
|
||||||
|
|
||||||
**Outcome:** a zorgprofessional logs in via mock DigiD and submits a BIG registration through the
|
**Outcome:** a zorgprofessional logs in via mock DigiD and submits a BIG registration through the
|
||||||
@@ -146,3 +168,317 @@ curl -fsS http://localhost:8120/register | jq 'length' # → unchanged
|
|||||||
|
|
||||||
> `bsn` / `naam_placeholder` are deferred (ADR-0008) — the notification doesn't carry them and
|
> `bsn` / `naam_placeholder` are deferred (ADR-0008) — the notification doesn't carry them and
|
||||||
> the subscriber may not read OpenZaak directly (§8.1). They surface in a later slice.
|
> the subscriber may not read OpenZaak directly (§8.1). They surface in a later slice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-09 — Openbaar Register portal (public visibility)
|
||||||
|
|
||||||
|
**Outcome:** the entry a zorgprofessional submits via self-service becomes publicly visible in the
|
||||||
|
anonymous openbaar register portal — closing the walking-skeleton loop (submit → process → projection
|
||||||
|
→ public visibility).
|
||||||
|
|
||||||
|
**The path:** self-service submit → BFF → domain → (zaak) OpenZaak → NRC → Event Subscriber →
|
||||||
|
projection → openbaar portal reads the BFF's public-safe `GET /openbaar/register`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Bring the full stack up (self-service :8140, openbaar :8141).
|
||||||
|
make up
|
||||||
|
|
||||||
|
# 2. Submit a registration via the self-service portal (mock DigiD: jan-burger / test123),
|
||||||
|
# or drive the whole happy path automatically (login → submit → public visibility):
|
||||||
|
make verify-e2e
|
||||||
|
|
||||||
|
# 3. Open the public register — no login. It lists the submitted entry (id + status only).
|
||||||
|
# Only public-safe fields cross the BFF: bsn / naam never appear.
|
||||||
|
open http://localhost:8141/ # search box; searches the BFF by referentie
|
||||||
|
curl -fsS http://localhost:8140/openbaar/register | jq # same public-safe view via the BFF proxy
|
||||||
|
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND" } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
> The register shows `INGEDIEND` on submit; approval flips it to `INGESCHREVEN` — see S-09b below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-09b — Approval flow (public visibility flips to INGESCHREVEN)
|
||||||
|
|
||||||
|
**Outcome:** a behandelaar approves a submitted registration via a temporary admin endpoint (no
|
||||||
|
behandel-portal yet — S-12). The approval sets the zaak's final status through the ACL, which flows
|
||||||
|
back to the projection over NRC, and the openbaar register then shows the entry as `INGESCHREVEN`.
|
||||||
|
|
||||||
|
**The path:** `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW
|
||||||
|
`/statussen`) → OpenZaak → NRC → Event Subscriber projects `INGESCHREVEN` → openbaar register.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Full stack up, then drive submit → public INGEDIEND → approve → public INGESCHREVEN:
|
||||||
|
make up
|
||||||
|
make verify-e2e
|
||||||
|
|
||||||
|
# 2. Or by hand: submit (as in S-09), note the reference, then approve it.
|
||||||
|
# The zaak is opened off the request path, so approve once GET shows a zaakUrl.
|
||||||
|
ref="<registration-reference-from-the-confirmation>"
|
||||||
|
curl -fsS http://localhost:8130/registrations/$ref | jq # domain (host port 8130): wait for .zaakUrl
|
||||||
|
curl -fsS -X POST http://localhost:8130/registrations/$ref/approve -i # → 204 No Content
|
||||||
|
|
||||||
|
# 3. The public register now shows the entry as approved.
|
||||||
|
curl -fsS http://localhost:8140/openbaar/register | jq
|
||||||
|
# → [ { "id": "<zaak-uuid>", "status": "INGESCHREVEN" } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
> **End of walking skeleton** (S-09 + S-09b): submit → process → projection → public visibility, from
|
||||||
|
> INGEDIEND through approval to INGESCHREVEN. The subscriber takes any post-creation status-set as the
|
||||||
|
> approval (ADR-0011) — a walking-skeleton assumption that tightens when more transitions arrive (S-12+).
|
||||||
|
|
||||||
|
## #78 — One reference across both portals (ADR-0012)
|
||||||
|
|
||||||
|
Before this change the self-service confirmation and the openbaar register showed **different**
|
||||||
|
identifiers, so a citizen could not look their registration back up. Now both show the same
|
||||||
|
**reference**: the domain `registrationId` is set as the zaak's `identificatie` by the ACL, and the
|
||||||
|
Event Subscriber enriches the projection with it by reading the zaak through the ACL (§8.1) — storing
|
||||||
|
it in the replay log so rebuild stays log-only (ADR-0008).
|
||||||
|
|
||||||
|
**The path:** domain passes `registrationId` → ACL sets it as `zaak.identificatie` → NRC →
|
||||||
|
Event Subscriber asks the ACL for the reference → projection row + replay log → openbaar register.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Submit as in S-09 and note the reference on the confirmation, then find it in the public register:
|
||||||
|
ref="<registration-reference-from-the-confirmation>"
|
||||||
|
curl -fsS "http://localhost:8140/openbaar/register?q=$ref" | jq
|
||||||
|
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND", "reference": "<same-ref-as-confirmation>" } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
> The openbaar register's "Referentie" column and its search now use this reference — the exact value
|
||||||
|
> the citizen saw on submit. Asserted end-to-end by the Playwright happy path.
|
||||||
|
|
||||||
|
## S-12 — Behandel portal: werkbak + beoordeling (#13, ADR-0013)
|
||||||
|
|
||||||
|
A behandelaar now works submitted registrations in a real portal instead of the temporary admin
|
||||||
|
endpoint. After a citizen submits (as above), the workflow parks the registration at the Flowable
|
||||||
|
`Beoordelen` user task, and it shows up in the **werkbak**. The behandelaar logs in against the
|
||||||
|
Keycloak `medewerker` realm and decides — **goedkeuren** (→ INGESCHREVEN via the ACL, per ADR-0011)
|
||||||
|
or **afwijzen** — which also completes the Beoordelen task so the process advances.
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 1. Open the behandel portal and log in as a behandelaar (medewerker realm):
|
||||||
|
# http://localhost:8142/ → merel-behandelaar / test123
|
||||||
|
#
|
||||||
|
# 2. The werkbak lists the registrations awaiting beoordeling (referentie / bsn / status).
|
||||||
|
# Find the reference from the submit confirmation and click "Goedkeuren" on that row.
|
||||||
|
#
|
||||||
|
# 3. The row drops off the werkbak (its Beoordelen task is completed) and the openbaar register
|
||||||
|
# (http://localhost:8141/) now shows that reference as INGESCHREVEN.
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** behandel portal → BFF `POST /behandel/registrations/{id}/decide` (behandelaar policy,
|
||||||
|
`medewerker` realm) → domain applies the decision + completes the Flowable `Beoordelen` task →
|
||||||
|
ACL → NRC → event-subscriber → projection → openbaar register shows INGESCHREVEN.
|
||||||
|
|
||||||
|
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
|
||||||
|
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
|
||||||
|
> drives the behandel portal in place of the old admin endpoint.
|
||||||
|
|
||||||
|
## S-11 — Withdrawal: "trek aanvraag in" (#12, ADR-0014)
|
||||||
|
|
||||||
|
A zorgprofessional can withdraw their own still-open registration from the self-service portal. The
|
||||||
|
withdrawal is owner-scoped (the BFF forwards the DigiD token's bsn; the domain only lets the owner
|
||||||
|
withdraw) and cancels the running workflow via a BPMN message event, so the case leaves the
|
||||||
|
behandelaar's werkbak.
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 1. Log in and submit at the self-service portal (http://localhost:8140/, jan-burger / test123),
|
||||||
|
# note the "Referentie" on the confirmation.
|
||||||
|
# 2. Click "Trek aanvraag in" → the page confirms the registration is ingetrokken.
|
||||||
|
# 3. In the behandel werkbak (http://localhost:8142/, merel-behandelaar) the registration no longer
|
||||||
|
# appears — its Beoordelen task was cancelled.
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** self-service → BFF `POST /self-service/registrations/{id}/withdraw` (DigiD, owner-scoped)
|
||||||
|
→ domain sets INGETROKKEN + correlates the `RegistratieIngetrokken` message to the process → the
|
||||||
|
interrupting boundary event ends it → the werkbak drops the case.
|
||||||
|
|
||||||
|
> DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path
|
||||||
|
> (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the
|
||||||
|
> `Een registratie intrekken` acceptance scenarios and the domain live check.
|
||||||
|
|
||||||
|
## S-14 — Beoordeling escalation: 14 days unclaimed → teamlead (#15, ADR-0015)
|
||||||
|
|
||||||
|
A beoordeling a behandelaar does not pick up within 14 days escalates to the teamlead. A
|
||||||
|
non-interrupting boundary timer on the `Beoordelen` task fires a `BeoordelingEscaleren` external task;
|
||||||
|
the domain's escalation worker reassigns the still-open task's candidate group from `behandelaar` to
|
||||||
|
`teamlead`, so it moves from the behandelaar werkbak into the teamlead's. The `Beoordelen` task keeps
|
||||||
|
its identity throughout — only who may claim it changes.
|
||||||
|
|
||||||
|
The timer is 14 days, so the demo fires it early through Flowable's management API (exactly what the
|
||||||
|
verify-domain check automates):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Submit at the self-service portal (http://localhost:8140/, jan-burger / test123). The case
|
||||||
|
# parks at Beoordelen, visible in the behandelaar werkbak (http://localhost:8142/, merel-behandelaar)
|
||||||
|
# but NOT claimed.
|
||||||
|
#
|
||||||
|
# 2. Find the parked instance and its Beoordelen task, then fire the boundary timer early:
|
||||||
|
FL=http://localhost:8090/flowable-rest/service
|
||||||
|
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
|
||||||
|
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||||
|
TJ=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||||
|
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$TJ" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"action":"move"}'
|
||||||
|
AJ=$(curl -s -u rest-admin:test "$FL/management/jobs?processInstanceId=$PID" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||||
|
curl -s -u rest-admin:test -X POST "$FL/management/jobs/$AJ" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"action":"execute"}'
|
||||||
|
#
|
||||||
|
# 3. Within a couple of poll cycles the task's candidate group flips to teamlead:
|
||||||
|
curl -s -u rest-admin:test "$FL/runtime/tasks/$TID/identitylinks" # → [{"group":"teamlead","type":"candidate"}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** BPMN non-interrupting `P14D` boundary timer on `Beoordelen` → `BeoordelingEscaleren`
|
||||||
|
external task → domain escalation worker (`BeoordelingEscalatiePump`) → Workflow Client swaps the task's
|
||||||
|
candidate group behandelaar → teamlead (§8.2).
|
||||||
|
|
||||||
|
> Both branches (escalate after 14 days; no-op when completed in time) are covered by the
|
||||||
|
> `Een beoordeling escaleren` acceptance scenarios and the Workflow Client unit tests; the timer firing
|
||||||
|
> and reassignment are asserted live by the verify-domain check.
|
||||||
|
|
||||||
|
## S-13 — Diploma-eligibility: foreign diplomas route through CBGV-advies (#14, ADR-0016)
|
||||||
|
|
||||||
|
A registration's diploma origin decides its route. A DMN service task in the registratie
|
||||||
|
process evaluates the `diploma-eligibility` decision on the `diplomaOrigin` start variable: a
|
||||||
|
**foreign** (Buitenlands) diploma is routed through an extra **CBGV-advies** user task before
|
||||||
|
beoordeling; a **domestic** (Binnenlands) one goes straight to beoordeling. The decision lives in the
|
||||||
|
DMN, not in code — a beheerder can read and adjust the decision table directly.
|
||||||
|
|
||||||
|
The self-service portal's eIDAS→foreign wiring is a later slice; for now the origin is submitted to
|
||||||
|
the domain directly, so the demo drives it through the domain endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Submit a foreign-diploma registration to the domain (note the returned Location/reference):
|
||||||
|
DOM=http://localhost:8080 # domain service
|
||||||
|
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' | grep -i '^location:'
|
||||||
|
#
|
||||||
|
# 2. Once the zaak is opened, the process first parks at WachtOpDocumenten (S-10a); complete that task
|
||||||
|
# (documents received) — then it parks at the CBGV-advies task (NOT Beoordelen). In Flowable:
|
||||||
|
FL=http://localhost:8090/flowable-rest/service
|
||||||
|
curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' | python3 -m json.tool
|
||||||
|
#
|
||||||
|
# 3. Complete the CBGV-advies task; the case then advances to the regular Beoordelen task:
|
||||||
|
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||||
|
curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"action":"complete"}'
|
||||||
|
# A domestic submission (default, or "Binnenlands") skips CBGV-advies and parks straight at Beoordelen.
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** domain sets the `diplomaOrigin` start variable → registratie process DMN
|
||||||
|
DMN service task sets `route` → exclusive gateway → foreign: `CBGVAdvies` user task → `Beoordelen`;
|
||||||
|
domestic: `Beoordelen` directly (§8.2, ADR-0016).
|
||||||
|
|
||||||
|
> The domestic/foreign paths are covered by the `Een diploma op herkomst routeren` acceptance
|
||||||
|
> scenarios and unit tests (the origin is carried into the process); the DMN decision and the
|
||||||
|
> foreign→CBGV routing are asserted live by the verify-domain check.
|
||||||
|
|
||||||
|
## S-10a — Document wait + 30-day timeout cancels the registration (#102, ADR-0017)
|
||||||
|
|
||||||
|
After the zaak is opened the registratie process parks at a **WachtOpDocumenten** user task, waiting
|
||||||
|
for the citizen's documents (their diploma). Two things can happen:
|
||||||
|
|
||||||
|
- **Documents arrive in time** → the task completes and the process continues to the diploma-eligibility
|
||||||
|
routing (S-13) → beoordeling.
|
||||||
|
- **30 days pass with no documents** → an interrupting `P30D` boundary timer cancels the wait, runs the
|
||||||
|
`RegistratieVerlopen` external task, and the domain expires the registration to the terminal status
|
||||||
|
**VERLOPEN** (the case is cancelled).
|
||||||
|
|
||||||
|
The "documents received" trigger is wired end-to-end in S-10a: the self-service page shows a
|
||||||
|
**"Documenten aanleveren"** button after submit (portal → BFF → domain → completes the wait). S-10b
|
||||||
|
turns that into a real file upload stored in the ZGW Documenten API via the ACL. The timeout branch is
|
||||||
|
demonstrated by firing the 30-day timer early via the management API.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOM=http://localhost:8080 # domain service
|
||||||
|
FL=http://localhost:8090/flowable-rest/service # flowable-rest
|
||||||
|
|
||||||
|
# 1. Submit a registration; once the zaak is opened it parks at WachtOpDocumenten:
|
||||||
|
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"bsn":"123456782"}' | grep -i '^location:' # note the /registrations/<id> reference
|
||||||
|
WQ='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten"}'
|
||||||
|
|
||||||
|
# 2a. Documents-in-time: complete the WachtOpDocumenten task → the process advances to beoordeling.
|
||||||
|
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||||
|
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||||
|
curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"action":"complete"}'
|
||||||
|
|
||||||
|
# 2b. Timeout: instead of completing it, fire the 30-day timer early via the management API. Find the
|
||||||
|
# instance's timer job, "move" it to executable; the async executor fires the interrupting event.
|
||||||
|
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||||
|
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
|
||||||
|
JID=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||||
|
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$JID" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"action":"move"}'
|
||||||
|
# The RegistratieVerlopen worker then expires the aggregate — read it back as VERLOPEN:
|
||||||
|
curl -s "$DOM/registrations/<id>" # → {"status":"Verlopen", ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** registratie process parks at `WachtOpDocumenten` → documents received completes it (→
|
||||||
|
routing → `Beoordelen`), OR the `P30D` interrupting timer fires → `RegistratieVerlopen` external task
|
||||||
|
→ domain worker expires the aggregate to `Verlopen` → `endVerlopen` (§8.2, ADR-0017).
|
||||||
|
|
||||||
|
> Both branches are covered by the `Een documenttermijn laten verlopen` acceptance scenarios (worker +
|
||||||
|
> aggregate) and unit tests; the wait completion and the 30-day timer firing are asserted live by the
|
||||||
|
> verify-domain check.
|
||||||
|
|
||||||
|
## S-10b — Diploma upload stored in the ZGW Documenten API (#103, ADR-0018)
|
||||||
|
|
||||||
|
The self-service "Documenten aanleveren" action (S-10a) is now a **real file upload**: after submitting,
|
||||||
|
the citizen picks a PDF and uploads it. The portal base64-encodes the file client-side and posts it to
|
||||||
|
the BFF; the BFF forwards it to the domain, which stores it via the **ACL** as a ZGW
|
||||||
|
`enkelvoudiginformatieobject` in the **Documenten (DRC) API** and relates it to the zaak — then completes
|
||||||
|
the `WachtOpDocumenten` wait so beoordeling can proceed. Per §8.1 only the ACL talks to ZGW.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make up
|
||||||
|
# 1. Log in as jan-burger / test123, submit, then — once the openbaar register shows the row —
|
||||||
|
# choose a PDF under "Documenten aanleveren" and upload it. The page confirms "aangeleverd".
|
||||||
|
open http://localhost:8140
|
||||||
|
#
|
||||||
|
# 2. Automated: the walking-skeleton e2e now uploads a real PDF before the behandelaar approves.
|
||||||
|
make verify-e2e
|
||||||
|
#
|
||||||
|
# 3. The ACL integration test proves the document is really created in the Documenten API and
|
||||||
|
# related to the zaak (against a live OpenZaak):
|
||||||
|
make verify-acl # → "Storing a diploma creates a real informatieobject related to the zaak"
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** portal (base64) → BFF `POST /self-service/registrations/{id}/documents` → domain
|
||||||
|
`ProvideDocuments` → ACL `POST /documenten` → ZGW `enkelvoudiginformatieobjecten` +
|
||||||
|
`zaakinformatieobjecten`; the wait is then completed and the case advances to Beoordelen (§8.1, ADR-0018).
|
||||||
|
|
||||||
|
## S-10c — the ZGW zaak is cancelled when the document term lapses (#106)
|
||||||
|
|
||||||
|
When the 30-day document term lapses (S-10a), the domain no longer only marks the aggregate `Verlopen` —
|
||||||
|
it now also cancels the **ZGW zaak** through the ACL, so OpenZaak and the register agree. The zaak is set
|
||||||
|
to a distinct, non-terminal **`Geannuleerd`** status with a **`Vervallen`** resultaat (as opposed to the
|
||||||
|
approval `Afgehandeld` + `Geregistreerd`), resolved by name in the ACL (§8.1, ADR-0019).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. The ACL integration test proves cancellation records the Geannuleerd status + a resultaat
|
||||||
|
# against a live OpenZaak:
|
||||||
|
make verify-acl # → "Cancelling a zaak records the geannuleerd status and a resultaat"
|
||||||
|
#
|
||||||
|
# 2. End-to-end: the domain check submits a registration, fires its 30-day timer early, and asserts
|
||||||
|
# the timeout worker both expires the registration (VERLOPEN) and cancels its zaak (Geannuleerd):
|
||||||
|
make verify-domain # → "the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak"
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** Flowable P30D timer → `RegistratieVerlopen` job → domain `ExpireRegistrationWorker` → ACL
|
||||||
|
`POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd); the aggregate then moves to
|
||||||
|
`Verlopen`. The ACL cancels the zaak **before** the aggregate is expired, so a failed ZGW call leaves the
|
||||||
|
job for redelivery rather than diverging the two (ADR-0019).
|
||||||
|
|||||||
@@ -72,3 +72,98 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
|
|||||||
- **Module boundaries:** replaced the demo eslint `depConstraints` (`scope:shop`/`scope:shared`, left
|
- **Module boundaries:** replaced the demo eslint `depConstraints` (`scope:shop`/`scope:shared`, left
|
||||||
over from the Nx angular template) with a permissive `*` default; scope/type tags can be
|
over from the Nx angular template) with a permissive `*` default; scope/type tags can be
|
||||||
introduced when the portal set grows.
|
introduced when the portal set grows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Serving + e2e (S-08d, #68)
|
||||||
|
|
||||||
|
- **Served by nginx, same-origin as the BFF.** The compose `self-service` image serves the built app
|
||||||
|
and **reverse-proxies** `/self-service/*` + `/openbaar/*` to the `bff` service. Because the
|
||||||
|
api-client uses **relative URLs**, the browser calls the app's own origin → nginx forwards to the
|
||||||
|
BFF: **no CORS**, and the DigiD token (same-origin) is attached by the interceptor. nginx resolves
|
||||||
|
the BFF at request time (a `resolver` + variable `proxy_pass`) so it starts before the BFF is up.
|
||||||
|
- **Runtime config.** The app fetches `/config.json` before bootstrap (`main.ts`); `appConfig` is a
|
||||||
|
factory. The dev default (`public/config.json`) points at `localhost:8180`; the Docker image bakes
|
||||||
|
the compose value (`keycloak:8080`). One build, per-environment OIDC authority.
|
||||||
|
- **e2e runs inside the compose network.** `infra/run-e2e-check.sh` runs Playwright in a container on
|
||||||
|
`cg`, so the browser reaches Keycloak as `keycloak:8080` — the **same issuer** the BFF validates
|
||||||
|
against (resolves the browser-vs-container mismatch, ADR-0010). It uses the official
|
||||||
|
`mcr.microsoft.com/playwright:<version>` image with browsers pre-baked, rather than downloading
|
||||||
|
~150 MB of Chromium on every run (issue #73) — the image tag is kept in lockstep with
|
||||||
|
`tests/e2e/package.json`'s `@playwright/test` version. The spec is copied in (`docker cp`), not
|
||||||
|
mounted, so it leaves nothing root-owned on the host. Wired as `verify-e2e` in the `verify-stack`
|
||||||
|
CI job.
|
||||||
|
- **e2e treats the portal origin as secure.** In-network the portal is served over plain HTTP on a
|
||||||
|
non-localhost origin (`http://self-service`), which is **not a secure context**, so Web Crypto
|
||||||
|
(`crypto.subtle`) is unavailable. angular-auth-oidc-client needs it for the PKCE code challenge, so
|
||||||
|
`authorize()` throws and the login redirect never fires. Production runs behind HTTPS where this is
|
||||||
|
a non-issue; rather than terminate TLS in the throwaway stack, the Playwright config passes
|
||||||
|
`--unsafely-treat-insecure-origin-as-secure` (honoured only by the full `channel: 'chromium'`
|
||||||
|
build, not the default headless-shell). This emulates the production HTTPS secure context without
|
||||||
|
touching the app or its production config.
|
||||||
|
- `tests/e2e` is a standalone Playwright project (its own `package.json`), not an Nx project — it's a
|
||||||
|
live-stack check like the other `verify-*` runners, not part of the `frontend` unit lane.
|
||||||
|
|
||||||
|
## Openbaar Register portal (S-09, #10)
|
||||||
|
|
||||||
|
- **Anonymous, no auth.** The openbaar register is a public read, so `apps/openbaar` has no
|
||||||
|
`angular-auth-oidc-client`, no interceptor, and no `config.json` — `main.ts` bootstraps `appConfig`
|
||||||
|
directly with just `provideHttpClient` + `provideRouter`. This is the deliberate contrast to
|
||||||
|
self-service and keeps the app trivially cacheable/CDN-able.
|
||||||
|
- **Same-origin via nginx, like self-service.** The compose `openbaar` image serves the built app and
|
||||||
|
reverse-proxies `/openbaar` to the BFF; the api-client's relative calls stay same-origin (no CORS).
|
||||||
|
Served on `:8141`, health-checked over IPv4 (`127.0.0.1`), no Keycloak dependency.
|
||||||
|
- **Public-safe by construction.** The portal only ever sees the BFF's `OpenbaarProjection.PublicView`
|
||||||
|
(id + status); `bsn`/`naam` never leave the BFF. The e2e asserts the bsn never renders.
|
||||||
|
- **Loads on open, filters on search.** `RegisterPage` fetches the full register on construction and
|
||||||
|
re-queries `/openbaar/register?q=` on search — no client-side filtering, the BFF owns the query.
|
||||||
|
|
||||||
|
## Behandel portal (S-12, #13)
|
||||||
|
|
||||||
|
The staff portal where a behandelaar works the **werkbak** (registrations awaiting beoordeling) and
|
||||||
|
decides each — goedkeuren or afwijzen. `apps/behandel` mirrors `apps/self-service`; the net-new
|
||||||
|
frontend work is the medewerker realm auth and the werkbak/decide page. Wiring rationale is in
|
||||||
|
**ADR-0013**; this entry records the frontend-specific choices.
|
||||||
|
|
||||||
|
- **Medewerker realm auth, reusing `libs/auth`.** Staff authenticate against the Keycloak
|
||||||
|
`medewerker` realm (public client `big-portal`), not `digid`. Rather than fork the auth lib, the
|
||||||
|
abstract `AuthService` grew a **`roles`/`hasRole` surface** (empty for realms without roles, e.g.
|
||||||
|
`digid`), and a parallel **`MedewerkerAuthService` + `provideMedewerkerAuth`** were added — same
|
||||||
|
auth-code + PKCE config, bound to the medewerker realm, reading the nested `realm_access.roles`
|
||||||
|
claim. The library's own `authInterceptor` attaches the token to the relative `/behandel/` calls
|
||||||
|
(secure route), exactly as self-service does for `/self-service/`.
|
||||||
|
- **Roles reach the frontend via a realm mapper.** Keycloak emits realm roles in the access token by
|
||||||
|
default but not the ID token/userinfo the SPA reads, so the medewerker `big-portal` client gets a
|
||||||
|
**realm-roles protocol mapper** (`realm_access.roles`, added to id + userinfo tokens). The
|
||||||
|
**BFF remains the security boundary** (`behandelaar` policy, 401/403 on `/behandel/*`, ADR-0013);
|
||||||
|
the frontend role signal is for display/UX, and the werkbak page surfaces a load failure (e.g. a
|
||||||
|
403 for a non-behandelaar) rather than swallowing it.
|
||||||
|
- **Same-origin via nginx, like the other portals.** The compose `behandel` image serves the built
|
||||||
|
app and reverse-proxies `/behandel` to the BFF (relative calls, no CORS). Served on `:8142`,
|
||||||
|
health-checked over IPv4 (`127.0.0.1`), depends on Keycloak for the medewerker realm.
|
||||||
|
- **Werkbak = decide-and-refresh.** `WerkbakPage` loads `GET /behandel/werkbak` on open and renders a
|
||||||
|
row per registration (referentie/bsn/status). Goedkeuren/afwijzen `POST /behandel/registrations/
|
||||||
|
{id}/decide` and then reload the werkbak, so the handled item drops off (its Flowable `Beoordelen`
|
||||||
|
task is completed). Per-row decide buttons carry an `aria-label` including the reference, so the
|
||||||
|
e2e (and screen readers) can target a specific registration in a shared werkbak.
|
||||||
|
- **Testing.** Component tests use `@testing-library/angular` with `BffApiV1Service`/`AuthService`
|
||||||
|
mocked and the axe WCAG 2.1 AA check; an `app.config.spec` drives the real interceptor + api-client
|
||||||
|
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
|
||||||
|
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
|
||||||
|
path.
|
||||||
|
|
||||||
|
## Self-service withdrawal: "trek aanvraag in" (S-11c, #12)
|
||||||
|
|
||||||
|
The submit confirmation grows a **"Trek aanvraag in"** action so a zorgprofessional can withdraw the
|
||||||
|
registration they just submitted (`apps/self-service`, on the existing `RegistrationPage`).
|
||||||
|
|
||||||
|
- **Keyed by the reference, owner-scoped at the BFF.** The button calls the generated
|
||||||
|
`postSelfServiceRegistrationsIdWithdraw(reference)` with the reference the submit returned. The
|
||||||
|
DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets
|
||||||
|
the owner withdraw (a mismatch is 404). No extra identity is entered in the UI.
|
||||||
|
- **Same confirm-and-surface pattern as submit.** A secondary-action button; on success the page
|
||||||
|
switches to an ingetrokken confirmation; a failure is surfaced (`role="alert"`) and the action
|
||||||
|
stays available to retry — mirroring how submit handles its failure rather than swallowing it.
|
||||||
|
- **Testing.** Component tests (`@testing-library/angular`, mocked BFF) cover the button appearing
|
||||||
|
after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the
|
||||||
|
browser round-trip is `tests/e2e/withdrawal.spec.ts`.
|
||||||
|
|||||||
@@ -17,7 +17,12 @@
|
|||||||
#
|
#
|
||||||
# Port map (host):
|
# Port map (host):
|
||||||
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
|
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
|
||||||
# 8100 ACL · 8180 Keycloak (all admin: admin / admin — dev only)
|
# 8100 ACL · 8130 Domain · 8180 Keycloak (all admin: admin / admin — dev only)
|
||||||
|
# 8140 self-service portal · 8141 openbaar register · 8142 behandel portal
|
||||||
|
#
|
||||||
|
# Portal OIDC on the HOST: browse the portals at their 8140/8141/8142 ports and log in via
|
||||||
|
# Keycloak on localhost:8180 (KC_HOSTNAME below pins the issuer there; the BFF still validates
|
||||||
|
# in-network via keycloak:8080). Test users are in docs/synthetic-data.md.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
@@ -205,6 +210,12 @@ services:
|
|||||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||||
KC_HEALTH_ENABLED: "true"
|
KC_HEALTH_ENABLED: "true"
|
||||||
KC_HTTP_ENABLED: "true"
|
KC_HTTP_ENABLED: "true"
|
||||||
|
# Pin the frontend/issuer URL to the host-published address so a browser on the host and the
|
||||||
|
# tokens it gets both use localhost:8180. KC_HOSTNAME_BACKCHANNEL_DYNAMIC lets in-network
|
||||||
|
# callers (the BFF via keycloak:8080) still resolve token/jwks endpoints to their request host,
|
||||||
|
# so the BFF validates the localhost:8180 issuer while fetching keys over the compose network.
|
||||||
|
KC_HOSTNAME: http://localhost:8180
|
||||||
|
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
|
||||||
ports:
|
ports:
|
||||||
- "8180:8080"
|
- "8180:8080"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -295,6 +306,14 @@ services:
|
|||||||
context: ../services/bff
|
context: ../services/bff
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: register-referentie/bff:dev
|
image: register-referentie/bff:dev
|
||||||
|
environment:
|
||||||
|
# Reach Keycloak over the compose network for metadata/keys; the discovered issuer is the
|
||||||
|
# host-pinned localhost:8180 (KC_HOSTNAME above), which is what browser tokens carry — so
|
||||||
|
# validation matches without the BFF ever needing to resolve localhost:8180 itself.
|
||||||
|
Keycloak__Authority: http://keycloak:8080/realms/digid
|
||||||
|
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
|
||||||
|
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||||
|
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -303,6 +322,39 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
domain:
|
||||||
|
condition: service_healthy
|
||||||
|
projection-api:
|
||||||
|
condition: service_healthy
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
|
# ── BIG Domain Service (S-05) ─────────────────────────────────────────────
|
||||||
|
domain:
|
||||||
|
build:
|
||||||
|
context: ../services/domain
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: register-referentie/domain:dev
|
||||||
|
environment:
|
||||||
|
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
|
||||||
|
Flowable__Username: rest-admin
|
||||||
|
Flowable__Password: test
|
||||||
|
Acl__BaseUrl: http://acl:8080/
|
||||||
|
ports:
|
||||||
|
- "8130:8080"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
acl:
|
||||||
|
condition: service_healthy
|
||||||
|
flowable-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
# ── Read projection (S-06) ────────────────────────────────────────────────
|
# ── Read projection (S-06) ────────────────────────────────────────────────
|
||||||
@@ -328,6 +380,10 @@ services:
|
|||||||
image: register-referentie/event-subscriber:dev
|
image: register-referentie/event-subscriber:dev
|
||||||
environment:
|
environment:
|
||||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||||
|
# The subscriber enriches the projection with each zaak's reference by asking the ACL — the only
|
||||||
|
# code allowed to read ZGW (§8.1, #78). Required: startup throws without it (parity with the
|
||||||
|
# canonical compose).
|
||||||
|
Acl__BaseUrl: http://acl:8080/
|
||||||
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||||
ports:
|
ports:
|
||||||
- "8110:8080"
|
- "8110:8080"
|
||||||
@@ -340,6 +396,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
projection-db:
|
projection-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
acl:
|
||||||
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
projection-api:
|
projection-api:
|
||||||
@@ -362,6 +420,73 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
|
# ── Portals (S-08/S-09/S-12) ──────────────────────────────────────────────
|
||||||
|
# nginx serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
|
||||||
|
# The images bake config.json with the compose authority (keycloak:8080), which a HOST browser
|
||||||
|
# can't resolve — so here we bind-mount a config.json pointing at the host-published localhost:8180
|
||||||
|
# (matching KC_HOSTNAME). openbaar is anonymous and needs no config.
|
||||||
|
self-service:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/self-service/Dockerfile
|
||||||
|
image: register-referentie/self-service:dev
|
||||||
|
ports:
|
||||||
|
- "8140:80"
|
||||||
|
volumes:
|
||||||
|
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
bff:
|
||||||
|
condition: service_healthy
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
|
openbaar:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/openbaar/Dockerfile
|
||||||
|
image: register-referentie/openbaar:dev
|
||||||
|
ports:
|
||||||
|
- "8141:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
bff:
|
||||||
|
condition: service_healthy
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
|
behandel:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/behandel/Dockerfile
|
||||||
|
image: register-referentie/behandel:dev
|
||||||
|
ports:
|
||||||
|
- "8142:80"
|
||||||
|
volumes:
|
||||||
|
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
bff:
|
||||||
|
condition: service_healthy
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
oz-db:
|
oz-db:
|
||||||
nrc-db:
|
nrc-db:
|
||||||
|
|||||||
@@ -259,19 +259,30 @@ services:
|
|||||||
flowable-init:
|
flowable-init:
|
||||||
image: docker.io/curlimages/curl:latest
|
image: docker.io/curlimages/curl:latest
|
||||||
restart: "no"
|
restart: "no"
|
||||||
# registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
|
# registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by
|
||||||
|
# infra/seed-config.sh.
|
||||||
volumes:
|
volumes:
|
||||||
- fl-bpmn:/work:ro
|
- fl-bpmn:/work:ro
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||||
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
|
||||||
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
|
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||||
echo "registratie already deployed; skip"
|
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments:
|
||||||
|
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
|
||||||
|
# must go via dmn-api. The process's DMN service task then resolves the decision across deployments
|
||||||
|
# by key (S-13, ADR-0016). Both steps are idempotent (skip if already deployed).
|
||||||
|
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
|
||||||
|
echo "diploma-eligibility DMN already deployed; skip"
|
||||||
else
|
else
|
||||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
|
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
|
||||||
|
fi
|
||||||
|
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
|
||||||
|
echo "registratie BPMN already deployed; skip"
|
||||||
|
else
|
||||||
|
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
|
||||||
fi
|
fi
|
||||||
depends_on:
|
depends_on:
|
||||||
flowable-rest:
|
flowable-rest:
|
||||||
@@ -295,6 +306,9 @@ services:
|
|||||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||||
# Override with the real zaaktype URL after running seed_catalogus.py.
|
# Override with the real zaaktype URL after running seed_catalogus.py.
|
||||||
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
|
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
|
||||||
|
# The informatieobjecttype a diploma is filed under (S-10b). Placeholder until seed_catalogus.py
|
||||||
|
# (OZ_PUBLISH=1) reports the real URL, which verify-domain injects like the zaaktype URL.
|
||||||
|
Acl__Defaults__InformatieobjecttypeUrl: ${ACL_INFORMATIEOBJECTTYPE_URL:-http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000}
|
||||||
ports:
|
ports:
|
||||||
- "8100:8080"
|
- "8100:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -349,6 +363,8 @@ services:
|
|||||||
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
|
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
|
||||||
# verify token request both use keycloak:8080 to keep the issuer consistent.
|
# verify token request both use keycloak:8080 to keep the issuer consistent.
|
||||||
Keycloak__Authority: http://keycloak:8080/realms/digid
|
Keycloak__Authority: http://keycloak:8080/realms/digid
|
||||||
|
# Behandelaars authenticate against the medewerker realm; the BFF validates it for /behandel/* (S-12c).
|
||||||
|
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
|
||||||
Downstream__Domain__BaseUrl: http://domain:8080/
|
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||||
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||||
ports:
|
ports:
|
||||||
@@ -396,6 +412,9 @@ services:
|
|||||||
image: register-referentie/event-subscriber:dev
|
image: register-referentie/event-subscriber:dev
|
||||||
environment:
|
environment:
|
||||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||||
|
# The subscriber enriches the projection with each zaak's reference (identificatie) by asking
|
||||||
|
# the ACL — the only code allowed to read ZGW (§8.1, #78).
|
||||||
|
Acl__BaseUrl: http://acl:8080/
|
||||||
# The bearer Open Notificaties must present on the abonnement callback. NRC's
|
# The bearer Open Notificaties must present on the abonnement callback. NRC's
|
||||||
# registration probe expects a 401 without it (ADR-0007). Dev-only token.
|
# registration probe expects a 401 without it (ADR-0007). Dev-only token.
|
||||||
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||||
@@ -410,6 +429,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
projection-db:
|
projection-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
acl:
|
||||||
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
# The read side of the projection. Shares Projection.ReadModel, so build context is root.
|
# The read side of the projection. Shares Projection.ReadModel, so build context is root.
|
||||||
@@ -433,6 +454,75 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
|
# ── Self-Service portal (S-08d) ────────────────────────────────────────────
|
||||||
|
# nginx serves the Angular app and reverse-proxies /self-service + /openbaar to the BFF
|
||||||
|
# (same-origin, no CORS). The Playwright e2e drives it inside this network so the DigiD
|
||||||
|
# token issuer (keycloak:8080) matches the BFF's authority (ADR-0010).
|
||||||
|
self-service:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/self-service/Dockerfile
|
||||||
|
image: register-referentie/self-service:dev
|
||||||
|
ports:
|
||||||
|
- "8140:80"
|
||||||
|
healthcheck:
|
||||||
|
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
bff:
|
||||||
|
condition: service_healthy
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
|
# The openbaar (public) register portal: nginx serves the Angular app and reverse-proxies
|
||||||
|
# /openbaar to the BFF. Anonymous — no DigiD, no Keycloak dependency (S-09).
|
||||||
|
openbaar:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/openbaar/Dockerfile
|
||||||
|
image: register-referentie/openbaar:dev
|
||||||
|
ports:
|
||||||
|
- "8141:80"
|
||||||
|
healthcheck:
|
||||||
|
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
bff:
|
||||||
|
condition: service_healthy
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
|
# The behandel portal: nginx serves the Angular app and reverse-proxies /behandel to the BFF.
|
||||||
|
# Behandelaars log in against the Keycloak medewerker realm (ADR-0013; S-12).
|
||||||
|
behandel:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/behandel/Dockerfile
|
||||||
|
image: register-referentie/behandel:dev
|
||||||
|
ports:
|
||||||
|
- "8142:80"
|
||||||
|
healthcheck:
|
||||||
|
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
bff:
|
||||||
|
condition: service_healthy
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
oz-db:
|
oz-db:
|
||||||
nrc-db:
|
nrc-db:
|
||||||
|
|||||||
@@ -35,24 +35,35 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
# Deploys workflows/registratie.bpmn via the REST API once flowable-rest is up.
|
# Deploys registratie.bpmn (process engine) and diploma-eligibility.dmn (DMN engine) via the REST
|
||||||
# Idempotent: skips if a deployment named "registratie" already exists.
|
# API once flowable-rest is up. Idempotent: skips each if already deployed.
|
||||||
flowable-init:
|
flowable-init:
|
||||||
image: docker.io/curlimages/curl:latest
|
image: docker.io/curlimages/curl:latest
|
||||||
restart: "no"
|
restart: "no"
|
||||||
# registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
|
# registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by
|
||||||
|
# infra/seed-config.sh.
|
||||||
volumes:
|
volumes:
|
||||||
- fl-bpmn:/work:ro
|
- fl-bpmn:/work:ro
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||||
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
|
||||||
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
|
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||||
echo "registratie already deployed; skip"
|
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments:
|
||||||
|
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
|
||||||
|
# must go via dmn-api. The process's DMN service task then resolves the decision across deployments
|
||||||
|
# by key (S-13, ADR-0016). Both steps are idempotent (skip if already deployed).
|
||||||
|
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
|
||||||
|
echo "diploma-eligibility DMN already deployed; skip"
|
||||||
else
|
else
|
||||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
|
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
|
||||||
|
fi
|
||||||
|
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
|
||||||
|
echo "registratie BPMN already deployed; skip"
|
||||||
|
else
|
||||||
|
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
|
||||||
fi
|
fi
|
||||||
depends_on:
|
depends_on:
|
||||||
flowable-rest:
|
flowable-rest:
|
||||||
|
|||||||
@@ -16,7 +16,22 @@
|
|||||||
"standardFlowEnabled": true,
|
"standardFlowEnabled": true,
|
||||||
"directAccessGrantsEnabled": true,
|
"directAccessGrantsEnabled": true,
|
||||||
"redirectUris": ["*"],
|
"redirectUris": ["*"],
|
||||||
"webOrigins": ["*"]
|
"webOrigins": ["*"],
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "realm roles",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-realm-role-mapper",
|
||||||
|
"config": {
|
||||||
|
"multivalued": "true",
|
||||||
|
"claim.name": "realm_access.roles",
|
||||||
|
"jsonType.label": "String",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"users": [
|
"users": [
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"authority": "http://localhost:8180/realms/medewerker"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"authority": "http://localhost:8180/realms/digid"
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ Creates (if absent):
|
|||||||
Auth uses the JWT client provisioned by setup_configuration (see ADR-0002).
|
Auth uses the JWT client provisioned by setup_configuration (see ADR-0002).
|
||||||
Stdlib only — no pip deps. Re-running is safe (matches existing by identifier).
|
Stdlib only — no pip deps. Re-running is safe (matches existing by identifier).
|
||||||
"""
|
"""
|
||||||
import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.request
|
import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
BASE = os.environ.get("OZ_BASE", "http://localhost:8000")
|
BASE = os.environ.get("OZ_BASE", "http://localhost:8000")
|
||||||
CLIENT_ID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
|
CLIENT_ID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
|
||||||
@@ -77,8 +77,12 @@ def publish_zaaktype(zt):
|
|||||||
Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's
|
Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's
|
||||||
`selectielijstProcestype`, plus a `resultaattypeomschrijving`.
|
`selectielijstProcestype`, plus a `resultaattypeomschrijving`.
|
||||||
"""
|
"""
|
||||||
|
# Ontvangen (begin) → Afgehandeld (eind, highest volgnummer). "Geannuleerd" (S-10c) sits between
|
||||||
|
# them: a non-terminal status the document-timeout branch sets, so it never displaces the Afgehandeld
|
||||||
|
# eindstatus the approval path resolves. Keyed by volgnummer on a fresh catalogus (CI reseeds); a
|
||||||
|
# stale local stack must reset its OpenZaak volumes for the renumbering to take effect.
|
||||||
have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")}
|
have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")}
|
||||||
for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Afgehandeld")]:
|
for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Geannuleerd"), (3, "Afgehandeld")]:
|
||||||
if volgnummer not in have_st:
|
if volgnummer not in have_st:
|
||||||
st, body = api("POST", "/statustypen", {
|
st, body = api("POST", "/statustypen", {
|
||||||
"omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer})
|
"omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer})
|
||||||
@@ -95,25 +99,42 @@ def publish_zaaktype(zt):
|
|||||||
sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}")
|
sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}")
|
||||||
print("create roltype Aanvrager")
|
print("create roltype Aanvrager")
|
||||||
|
|
||||||
if find(f"/resultaattypen?zaaktype={zt['url']}&status=alles"):
|
# Two resultaattypen, keyed by omschrijving so each is created independently (idempotent):
|
||||||
print("skip resultaattype Geregistreerd")
|
# "Geregistreerd" — the approval outcome (S-09b)
|
||||||
|
# "Vervallen" — the document-timeout cancellation outcome (S-10c)
|
||||||
|
# Both selectielijstklassen must share the zaaktype's selectielijstProcestype, so pick two
|
||||||
|
# Selectielijst resultaten from a single procestype and set that procestype on the zaaktype.
|
||||||
|
have_rt = {r.get("omschrijving") for r in find(f"/resultaattypen?zaaktype={zt['url']}&status=alles")}
|
||||||
|
wanted = [("Geregistreerd", "blijvend_bewaren"), ("Vervallen", "vernietigen")]
|
||||||
|
if all(naam in have_rt for naam, _ in wanted):
|
||||||
|
print("skip resultaattypen Geregistreerd + Vervallen")
|
||||||
else:
|
else:
|
||||||
resultaat = selectielijst("/resultaten?pageSize=1")["results"][0]
|
# Anchor on the procestype of an arbitrary resultaat, then fetch that procestype's resultaten so
|
||||||
|
# both klassen validate against the zaaktype's selectielijstProcestype.
|
||||||
|
procestype = selectielijst("/resultaten?pageSize=1")["results"][0]["procesType"]
|
||||||
|
resultaten = selectielijst(f"/resultaten?procesType={urllib.parse.quote(procestype, safe='')}")["results"]
|
||||||
|
if len(resultaten) < len(wanted):
|
||||||
|
sys.exit(f"selectielijst procestype has too few resultaten ({len(resultaten)}) for {len(wanted)} resultaattypen")
|
||||||
omschrijvingen = selectielijst("/resultaattypeomschrijvingen")
|
omschrijvingen = selectielijst("/resultaattypeomschrijvingen")
|
||||||
oms = (omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"])[0]["url"]
|
oms_list = omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"]
|
||||||
# The selectielijstklasse and the zaaktype must share a procestype.
|
|
||||||
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": resultaat["procesType"]})
|
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": procestype})
|
||||||
if st != 200:
|
if st != 200:
|
||||||
sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}")
|
sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}")
|
||||||
st, body = api("POST", "/resultaattypen", {
|
|
||||||
"zaaktype": zt["url"], "omschrijving": "Geregistreerd",
|
for i, (naam, archiefnominatie) in enumerate(wanted):
|
||||||
"resultaattypeomschrijving": oms, "selectielijstklasse": resultaat["url"],
|
if naam in have_rt:
|
||||||
"archiefnominatie": "blijvend_bewaren",
|
print(f"skip resultaattype {naam}")
|
||||||
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
|
continue
|
||||||
})
|
st, body = api("POST", "/resultaattypen", {
|
||||||
if st != 201:
|
"zaaktype": zt["url"], "omschrijving": naam,
|
||||||
sys.exit(f"create resultaattype -> {st}: {json.dumps(body, indent=2)}")
|
"resultaattypeomschrijving": oms_list[i]["url"], "selectielijstklasse": resultaten[i]["url"],
|
||||||
print("create resultaattype Geregistreerd")
|
"archiefnominatie": archiefnominatie,
|
||||||
|
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
|
||||||
|
})
|
||||||
|
if st != 201:
|
||||||
|
sys.exit(f"create resultaattype {naam} -> {st}: {json.dumps(body, indent=2)}")
|
||||||
|
print(f"create resultaattype {naam}")
|
||||||
|
|
||||||
if zt.get("concept", True):
|
if zt.get("concept", True):
|
||||||
st, body = api("POST", f"{zt['url']}/publish")
|
st, body = api("POST", f"{zt['url']}/publish")
|
||||||
@@ -124,6 +145,58 @@ def publish_zaaktype(zt):
|
|||||||
print("skip publish (already published)")
|
print("skip publish (already published)")
|
||||||
|
|
||||||
|
|
||||||
|
def seed_informatieobjecttype(cat, zt):
|
||||||
|
"""Create the "Diploma" informatieobjecttype and relate it to the zaaktype (both idempotent).
|
||||||
|
|
||||||
|
A diploma uploaded in S-10b is filed under this informatieobjecttype; OpenZaak only accepts a
|
||||||
|
document (and its zaak relation) once the informatieobjecttype is published AND allowed for the
|
||||||
|
zaak's zaaktype (a zaaktype-informatieobjecttype relation). Both the relation and this call must run
|
||||||
|
while the zaaktype is still a concept, so seed this *before* publishing the zaaktype. Returns the
|
||||||
|
informatieobjecttype dict.
|
||||||
|
"""
|
||||||
|
iots = [i for i in find(f"/informatieobjecttypen?catalogus={cat['url']}&status=alles")
|
||||||
|
if i.get("omschrijving") == "Diploma"]
|
||||||
|
if iots:
|
||||||
|
iot = iots[0]
|
||||||
|
print(f"skip informatieobjecttype Diploma ({iot['url']}) concept={iot.get('concept')}")
|
||||||
|
else:
|
||||||
|
st, iot = api("POST", "/informatieobjecttypen", {
|
||||||
|
"catalogus": cat["url"],
|
||||||
|
"omschrijving": "Diploma",
|
||||||
|
"vertrouwelijkheidaanduiding": "openbaar",
|
||||||
|
"informatieobjectcategorie": "diploma",
|
||||||
|
"beginGeldigheid": "2026-01-01",
|
||||||
|
})
|
||||||
|
if st != 201:
|
||||||
|
sys.exit(f"create informatieobjecttype -> {st}: {json.dumps(iot, indent=2)}")
|
||||||
|
print(f"create informatieobjecttype Diploma ({iot['url']})")
|
||||||
|
|
||||||
|
# Relate it to the zaaktype (must be done while both are concept).
|
||||||
|
relations = find(f"/zaaktype-informatieobjecttypen?zaaktype={zt['url']}&status=alles")
|
||||||
|
if any(r.get("informatieobjecttype") == iot["url"] for r in relations):
|
||||||
|
print("skip zaaktype-informatieobjecttype Diploma")
|
||||||
|
else:
|
||||||
|
st, body = api("POST", "/zaaktype-informatieobjecttypen", {
|
||||||
|
"zaaktype": zt["url"], "informatieobjecttype": iot["url"],
|
||||||
|
"volgnummer": 1, "richting": "inkomend"})
|
||||||
|
if st != 201:
|
||||||
|
sys.exit(f"relate zaaktype-informatieobjecttype -> {st}: {json.dumps(body, indent=2)}")
|
||||||
|
print("create zaaktype-informatieobjecttype Diploma")
|
||||||
|
|
||||||
|
return iot
|
||||||
|
|
||||||
|
|
||||||
|
def publish_informatieobjecttype(iot):
|
||||||
|
"""Publish the informatieobjecttype (idempotent) so documents may reference it."""
|
||||||
|
if iot.get("concept", True):
|
||||||
|
st, body = api("POST", f"{iot['url']}/publish")
|
||||||
|
if st != 200:
|
||||||
|
sys.exit(f"publish informatieobjecttype -> {st}: {json.dumps(body, indent=2)}")
|
||||||
|
print(f"publish informatieobjecttype Diploma ({iot['url']})")
|
||||||
|
else:
|
||||||
|
print("skip publish informatieobjecttype (already published)")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# 1. Catalogus
|
# 1. Catalogus
|
||||||
existing = [c for c in find(f"/catalogussen?domein=BIG") if c.get("domein") == "BIG"]
|
existing = [c for c in find(f"/catalogussen?domein=BIG") if c.get("domein") == "BIG"]
|
||||||
@@ -198,10 +271,16 @@ def main():
|
|||||||
# schema-mandatory" zaaktype S-01 asks for (ADR-0002). Set OZ_PUBLISH=1 to add
|
# schema-mandatory" zaaktype S-01 asks for (ADR-0002). Set OZ_PUBLISH=1 to add
|
||||||
# those relations and publish — needed so a real zaak POST is accepted, which
|
# those relations and publish — needed so a real zaak POST is accepted, which
|
||||||
# the ACL integration test (S-04a, #46) exercises. See ADR-0006.
|
# the ACL integration test (S-04a, #46) exercises. See ADR-0006.
|
||||||
|
iot = None
|
||||||
if PUBLISH:
|
if PUBLISH:
|
||||||
# Re-fetch: the bsn-eigenschap branch above may hold a stale concept flag.
|
# Re-fetch: the bsn-eigenschap branch above may hold a stale concept flag.
|
||||||
zt = next(z for z in find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
|
zt = next(z for z in find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
|
||||||
if z.get("identificatie") == "BIG-REGISTRATIE")
|
if z.get("identificatie") == "BIG-REGISTRATIE")
|
||||||
|
# Seed + relate the Diploma informatieobjecttype (S-10b) while the zaaktype is still concept,
|
||||||
|
# then publish both. Publish the informatieobjecttype before the zaaktype so the zaaktype's
|
||||||
|
# relations reference a published type.
|
||||||
|
iot = seed_informatieobjecttype(cat, zt)
|
||||||
|
publish_informatieobjecttype(iot)
|
||||||
publish_zaaktype(zt)
|
publish_zaaktype(zt)
|
||||||
|
|
||||||
# 5. Verify the JWT client can list the zaaktype (concepts included).
|
# 5. Verify the JWT client can list the zaaktype (concepts included).
|
||||||
@@ -214,6 +293,10 @@ def main():
|
|||||||
# zaaktype URL to configure the ACL's default-fill (ADR-0003/0009).
|
# zaaktype URL to configure the ACL's default-fill (ADR-0003/0009).
|
||||||
zt_url = next(z["url"] for z in zaaktypen if z.get("identificatie") == "BIG-REGISTRATIE")
|
zt_url = next(z["url"] for z in zaaktypen if z.get("identificatie") == "BIG-REGISTRATIE")
|
||||||
print(f"ZAAKTYPE_URL {zt_url}")
|
print(f"ZAAKTYPE_URL {zt_url}")
|
||||||
|
# Machine-readable informatieobjecttype URL (S-10b) so callers can configure the ACL's document
|
||||||
|
# default-fill. Only emitted when publishing — a concept informatieobjecttype can't back a document.
|
||||||
|
if iot is not None:
|
||||||
|
print(f"INFORMATIEOBJECTTYPE_URL {iot['url']}")
|
||||||
print(f"OK — BIG catalogus seeded (BIG-REGISTRATIE {state} + bsn eigenschap)")
|
print(f"OK — BIG catalogus seeded (BIG-REGISTRATIE {state} + bsn eigenschap)")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+342
-9
@@ -33,13 +33,18 @@ echo ">> openzaak=$oz_ip domain=$dom_ip network=$net"
|
|||||||
echo ">> seeding a published BIG zaaktype (idempotent) and capturing its URL"
|
echo ">> seeding a published BIG zaaktype (idempotent) and capturing its URL"
|
||||||
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 python:3-slim python /seed.py)"
|
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 python:3-slim python /seed.py)"
|
||||||
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
|
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
|
||||||
zt_url="$(docker start -a "$sid" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
|
seed_out="$(docker start -a "$sid")"
|
||||||
|
zt_url="$(printf '%s\n' "$seed_out" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
|
||||||
|
iot_url="$(printf '%s\n' "$seed_out" | sed -n 's/^INFORMATIEOBJECTTYPE_URL //p' | head -1)"
|
||||||
docker rm -f "$sid" >/dev/null
|
docker rm -f "$sid" >/dev/null
|
||||||
[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
|
[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
|
||||||
|
[ -n "$iot_url" ] || { echo "ERROR: seed did not report an INFORMATIEOBJECTTYPE_URL" >&2; exit 1; }
|
||||||
echo ">> zaaktype: $zt_url"
|
echo ">> zaaktype: $zt_url"
|
||||||
|
echo ">> informatieobjecttype: $iot_url"
|
||||||
|
|
||||||
echo ">> recreating the acl service pointed at the seeded zaaktype (host-consistent)"
|
echo ">> recreating the acl service pointed at the seeded zaaktype + informatieobjecttype (host-consistent)"
|
||||||
ACL_ZAAKTYPE_URL="$zt_url" ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl
|
ACL_ZAAKTYPE_URL="$zt_url" ACL_INFORMATIEOBJECTTYPE_URL="$iot_url" ACL_OPENZAAK_BASEURL="$oz_base/" \
|
||||||
|
docker compose -f "$compose" up -d acl
|
||||||
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
|
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
|
||||||
|
|
||||||
echo ">> submitting a registration to the domain"
|
echo ">> submitting a registration to the domain"
|
||||||
@@ -51,18 +56,346 @@ loc="$(docker run --rm --network "$net" curlimages/curl:latest \
|
|||||||
echo ">> registration accepted at $loc"
|
echo ">> registration accepted at $loc"
|
||||||
|
|
||||||
echo ">> polling the domain until the worker records the opened zaak"
|
echo ">> polling the domain until the worker records the opened zaak"
|
||||||
|
zaak_ok=""
|
||||||
for _ in $(seq 1 30); do
|
for _ in $(seq 1 30); do
|
||||||
body="$(docker run --rm --network "$net" curlimages/curl:latest \
|
body="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||||
-fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)"
|
-fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)"
|
||||||
if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then
|
if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then
|
||||||
echo "OK — the domain opened a zaak and recorded it on the registration:"
|
echo "OK — the domain opened a zaak and recorded it on the registration:"
|
||||||
echo "$body" | cut -c1-300
|
echo "$body" | cut -c1-300
|
||||||
exit 0
|
zaak_ok=1
|
||||||
|
break
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
echo "FAIL — the registration never received a zaak URL" >&2
|
if [ -z "$zaak_ok" ]; then
|
||||||
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
|
echo "FAIL — the registration never received a zaak URL" >&2
|
||||||
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
|
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
|
||||||
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
|
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
|
||||||
exit 1
|
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── S-12b: the process now parks at the Beoordelen user task. Exercise the exact Flowable REST
|
||||||
|
# contract the Workflow Client uses (query/claim/complete) against the live engine, reaching
|
||||||
|
# flowable-rest by container IP (same in-network constraint as above). ──────────────────────────
|
||||||
|
fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)"
|
||||||
|
[ -n "$fl" ] || { echo "ERROR: no running flowable-rest container" >&2; exit 1; }
|
||||||
|
fl_base="http://$(ip "$fl"):8080/flowable-rest/service"
|
||||||
|
reg_id="${loc##*/}"
|
||||||
|
|
||||||
|
# Extracts the Beoordelen task id for a given registration from a Flowable task-query response on
|
||||||
|
# stdin. Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing.
|
||||||
|
task_for_reg() { REG_ID="$1" python3 -c "import os,sys,json
|
||||||
|
try:
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
d={}
|
||||||
|
rid=os.environ['REG_ID']
|
||||||
|
# Flowable's task-query returns the included process variables under 'variables'.
|
||||||
|
print(next((t['id'] for t in (d.get('data') or [])
|
||||||
|
if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), ''))"; }
|
||||||
|
|
||||||
|
flcurl() { docker run --rm --network "$net" curlimages/curl:latest -fsS -u rest-admin:test "$@"; }
|
||||||
|
query='{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen","includeProcessVariables":true}'
|
||||||
|
wacht_query='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten","includeProcessVariables":true}'
|
||||||
|
|
||||||
|
# S-10a: every registration now parks at WachtOpDocumenten first (interrupting P30D timer). Completing
|
||||||
|
# that task stands in for the citizen's document upload (wired for real in S-10b), letting the process
|
||||||
|
# advance to the diploma routing / Beoordelen so the checks below still hold. The 30-day timeout branch
|
||||||
|
# is exercised separately at the end.
|
||||||
|
complete_wacht() { # reg_id
|
||||||
|
local rid="$1" wid="" r
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
r="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$wacht_query" 2>/dev/null || true)"
|
||||||
|
wid="$(printf '%s' "$r" | task_for_reg "$rid")"
|
||||||
|
[ -n "$wid" ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$wid" ] || { echo "FAIL — no WachtOpDocumenten task appeared for $rid" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
flcurl -X POST "$fl_base/runtime/tasks/$wid" -H 'Content-Type: application/json' -d '{"action":"complete"}' >/dev/null
|
||||||
|
echo ">> completed WachtOpDocumenten for $rid (documents received)"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ">> completing WachtOpDocumenten so the process advances (documents received)"
|
||||||
|
complete_wacht "$reg_id"
|
||||||
|
|
||||||
|
echo ">> polling Flowable for the Beoordelen user task (werkbak)"
|
||||||
|
task_id=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
|
||||||
|
task_id="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
|
||||||
|
[ -n "$task_id" ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$task_id" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo ">> Beoordelen task $task_id is waiting"
|
||||||
|
|
||||||
|
echo ">> claiming the task as merel-behandelaar"
|
||||||
|
flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"action":"claim","assignee":"merel-behandelaar"}' >/dev/null
|
||||||
|
|
||||||
|
echo ">> completing the beoordeling (goedkeuren)"
|
||||||
|
flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"action":"complete","variables":[{"name":"besluit","type":"string","value":"goedkeuren"}]}' >/dev/null
|
||||||
|
|
||||||
|
echo ">> asserting the process finished (no Beoordelen task remains for the registration)"
|
||||||
|
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
|
||||||
|
still="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
|
||||||
|
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
|
||||||
|
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
|
||||||
|
|
||||||
|
# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the
|
||||||
|
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
|
||||||
|
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
|
||||||
|
echo ">> submitting a second registration to withdraw"
|
||||||
|
loc2="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||||
|
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
|
||||||
|
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||||
|
[ -n "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; }
|
||||||
|
reg_id2="${loc2##*/}"
|
||||||
|
echo ">> second registration $reg_id2"
|
||||||
|
complete_wacht "$reg_id2"
|
||||||
|
|
||||||
|
echo ">> polling Flowable for its Beoordelen task"
|
||||||
|
task_id2=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
|
||||||
|
task_id2="$(printf '%s' "$resp" | task_for_reg "$reg_id2")"
|
||||||
|
[ -n "$task_id2" ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$task_id2" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id2" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo ">> Beoordelen task $task_id2 is waiting; withdrawing the registration via the domain"
|
||||||
|
|
||||||
|
# Owner-scoped: the withdraw carries the same bsn the registration was submitted with (S-11c).
|
||||||
|
docker run --rm --network "$net" curlimages/curl:latest \
|
||||||
|
-fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' >/dev/null
|
||||||
|
|
||||||
|
echo ">> asserting the process was cancelled (no Beoordelen task remains for the registration)"
|
||||||
|
gone=""
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
|
||||||
|
still2="$(printf '%s' "$resp" | task_for_reg "$reg_id2")"
|
||||||
|
[ -z "$still2" ] && { gone=1; break; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$gone" ] || { echo "FAIL — Beoordelen task for $reg_id2 still active after withdrawal" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo "OK — withdrawal cancelled the Beoordelen task; the registratie process ended (ingetrokken)"
|
||||||
|
|
||||||
|
# ── S-13: diploma-eligibility routing. A registration with a FOREIGN diploma must route through the
|
||||||
|
# extra CBGVAdvies user task before Beoordelen (the DMN service task sets route=CBGV_ADVIES and the
|
||||||
|
# gateway branches, ADR-0016). The domestic DIRECT path is already proven by the first registration
|
||||||
|
# above, which parked straight at Beoordelen. ──────────────────────────────────────────────────────
|
||||||
|
cbgv_query='{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies","includeProcessVariables":true}'
|
||||||
|
echo ">> submitting a registration with a foreign diploma"
|
||||||
|
locf="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||||
|
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' \
|
||||||
|
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||||
|
[ -n "$locf" ] || { echo "FAIL — foreign POST /registrations returned no Location" >&2; exit 1; }
|
||||||
|
reg_idf="${locf##*/}"
|
||||||
|
echo ">> foreign registration $reg_idf"
|
||||||
|
complete_wacht "$reg_idf"
|
||||||
|
|
||||||
|
echo ">> polling Flowable for its CBGV-advies task (foreign diplomas route here first)"
|
||||||
|
cbgv_task=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$cbgv_query" 2>/dev/null || true)"
|
||||||
|
cbgv_task="$(printf '%s' "$resp" | task_for_reg "$reg_idf")"
|
||||||
|
[ -n "$cbgv_task" ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$cbgv_task" ] || { echo "FAIL — no CBGVAdvies task appeared for the foreign registration $reg_idf" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo ">> CBGVAdvies task $cbgv_task is waiting"
|
||||||
|
|
||||||
|
echo ">> asserting it has NOT reached Beoordelen yet (still awaiting CBGV-advies)"
|
||||||
|
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
|
||||||
|
early="$(printf '%s' "$resp" | task_for_reg "$reg_idf")"
|
||||||
|
[ -z "$early" ] || { echo "FAIL — foreign registration reached Beoordelen ($early) before CBGV-advies" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo ">> completing the CBGV-advies task"
|
||||||
|
flcurl -X POST "$fl_base/runtime/tasks/$cbgv_task" -H 'Content-Type: application/json' -d '{"action":"complete"}' >/dev/null
|
||||||
|
|
||||||
|
echo ">> asserting it now advances to Beoordelen"
|
||||||
|
onward=""
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
|
||||||
|
[ -n "$(printf '%s' "$resp" | task_for_reg "$reg_idf")" ] && { onward=1; break; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$onward" ] || { echo "FAIL — foreign registration did not reach Beoordelen after CBGV-advies" >&2; exit 1; }
|
||||||
|
echo "OK — foreign diploma routed through CBGV-advies, then on to Beoordelen (DMN + gateway)"
|
||||||
|
|
||||||
|
# ── S-14: escalation. A third registration parks at Beoordelen. We fire its 14-day boundary timer
|
||||||
|
# early via Flowable's management API (the timer job is moved to executable and run), which routes a
|
||||||
|
# parallel token to the BeoordelingEscaleren external task. The domain's escalation worker acquires
|
||||||
|
# it and reassigns the still-open Beoordelen task from the behandelaar group to teamlead (ADR-0015). ─
|
||||||
|
echo ">> submitting a third registration to escalate"
|
||||||
|
loc3="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||||
|
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
|
||||||
|
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||||
|
[ -n "$loc3" ] || { echo "FAIL — third POST /registrations returned no Location" >&2; exit 1; }
|
||||||
|
reg_id3="${loc3##*/}"
|
||||||
|
echo ">> third registration $reg_id3"
|
||||||
|
|
||||||
|
# Extracts "<taskId> <processInstanceId>" for a registration from a task-query response on stdin.
|
||||||
|
task_and_pid_for_reg() { REG_ID="$1" python3 -c "import os,sys,json
|
||||||
|
try:
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
d={}
|
||||||
|
rid=os.environ['REG_ID']
|
||||||
|
t=next((t for t in (d.get('data') or [])
|
||||||
|
if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), None)
|
||||||
|
print(f\"{t['id']} {t['processInstanceId']}\" if t else '')"; }
|
||||||
|
|
||||||
|
# The candidate groups on a task (space-separated, sorted) from a runtime identitylinks response.
|
||||||
|
candidate_groups() { python3 -c "import sys,json
|
||||||
|
try:
|
||||||
|
links=json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
links=[]
|
||||||
|
print(' '.join(sorted(l.get('group') or '' for l in links if l.get('type')=='candidate' and l.get('group'))))"; }
|
||||||
|
|
||||||
|
# The first job id in a management jobs/timer-jobs response on stdin.
|
||||||
|
first_job_id() { python3 -c "import sys,json
|
||||||
|
try:
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
d={}
|
||||||
|
print(((d.get('data') or [{}])[0]).get('id',''))"; }
|
||||||
|
|
||||||
|
complete_wacht "$reg_id3"
|
||||||
|
|
||||||
|
echo ">> polling Flowable for its Beoordelen task"
|
||||||
|
task_id3=""; pid3=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
|
||||||
|
read -r task_id3 pid3 <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_id3")"
|
||||||
|
[ -n "$task_id3" ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$task_id3" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id3" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo ">> Beoordelen task $task_id3 (instance $pid3) is waiting for the behandelaar"
|
||||||
|
|
||||||
|
echo ">> asserting the task starts out claimable by the behandelaar group"
|
||||||
|
before="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" | candidate_groups)"
|
||||||
|
[ "$before" = "behandelaar" ] || { echo "FAIL — expected candidate group 'behandelaar', got '$before'" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo ">> firing the 14-day boundary timer early via the management API"
|
||||||
|
timer_id="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pid3" | first_job_id)"
|
||||||
|
[ -n "$timer_id" ] || { echo "FAIL — no timer job found for instance $pid3" >&2; exit 1; }
|
||||||
|
# Move the timer job to an executable async job. Flowable's async executor (running in flowable-rest)
|
||||||
|
# then picks it up and fires the non-interrupting boundary event. It may run the job before we can
|
||||||
|
# look, so executing it explicitly is a best-effort nudge — tolerate the job already being gone.
|
||||||
|
flcurl -X POST "$fl_base/management/timer-jobs/$timer_id" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
|
||||||
|
async_id="$(flcurl "$fl_base/management/jobs?processInstanceId=$pid3" 2>/dev/null | first_job_id || true)"
|
||||||
|
if [ -n "$async_id" ]; then
|
||||||
|
flcurl -X POST "$fl_base/management/jobs/$async_id" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
echo ">> timer fired; the BeoordelingEscaleren token is parked for the domain worker"
|
||||||
|
|
||||||
|
echo ">> polling until the escalation worker reassigns the beoordeling to the teamlead"
|
||||||
|
escalated=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
groups="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" 2>/dev/null | candidate_groups || true)"
|
||||||
|
[ "$groups" = "teamlead" ] && { escalated=1; break; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$escalated" ] || { echo "FAIL — Beoordelen task not reassigned to teamlead (candidate groups: '$groups')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo "OK — the 14-day timer escalated the still-open Beoordelen task to the teamlead"
|
||||||
|
|
||||||
|
# ── S-10a/S-10c: document timeout. A registration parks at WachtOpDocumenten and — unlike every block
|
||||||
|
# above — its documents never arrive. We fire its 30-day boundary timer early via the management API;
|
||||||
|
# the INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task.
|
||||||
|
# The domain's timeout worker acquires it, cancels the ZGW zaak via the ACL (S-10c), and expires the
|
||||||
|
# registration to VERLOPEN (ADR-0017). ─────────────────────────────────────────────────────────────
|
||||||
|
echo ">> submitting a registration to let its document term lapse"
|
||||||
|
locv="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||||
|
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
|
||||||
|
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||||
|
[ -n "$locv" ] || { echo "FAIL — timeout POST /registrations returned no Location" >&2; exit 1; }
|
||||||
|
reg_idv="${locv##*/}"
|
||||||
|
echo ">> timeout registration $reg_idv"
|
||||||
|
|
||||||
|
echo ">> polling Flowable for its WachtOpDocumenten task"
|
||||||
|
wacht_id=""; pidv=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$wacht_query" 2>/dev/null || true)"
|
||||||
|
read -r wacht_id pidv <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_idv")"
|
||||||
|
[ -n "$wacht_id" ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$wacht_id" ] || { echo "FAIL — no WachtOpDocumenten task appeared for $reg_idv" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo ">> WachtOpDocumenten task $wacht_id (instance $pidv) is waiting for documents"
|
||||||
|
|
||||||
|
echo ">> firing the 30-day document timer early via the management API"
|
||||||
|
timer_idv="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pidv" | first_job_id)"
|
||||||
|
[ -n "$timer_idv" ] || { echo "FAIL — no timer job found for instance $pidv" >&2; exit 1; }
|
||||||
|
# Move the timer job to an executable async job; the async executor fires the interrupting boundary
|
||||||
|
# event. It may run before we look, so executing it explicitly is a best-effort nudge (as for S-14).
|
||||||
|
flcurl -X POST "$fl_base/management/timer-jobs/$timer_idv" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
|
||||||
|
async_idv="$(flcurl "$fl_base/management/jobs?processInstanceId=$pidv" 2>/dev/null | first_job_id || true)"
|
||||||
|
if [ -n "$async_idv" ]; then
|
||||||
|
flcurl -X POST "$fl_base/management/jobs/$async_idv" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
echo ">> timer fired; the RegistratieVerlopen token is parked for the domain worker"
|
||||||
|
|
||||||
|
echo ">> polling the domain until the timeout worker expires the registration to VERLOPEN"
|
||||||
|
verlopen=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
body="$(docker run --rm --network "$net" curlimages/curl:latest -fsS "http://$dom_ip:8080$locv" 2>/dev/null || true)"
|
||||||
|
printf '%s' "$body" | grep -qi 'verlopen' && { verlopen=1; break; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$verlopen" ] || { echo "FAIL — registration $reg_idv not VERLOPEN after the document timer fired (body: $body)" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo "OK — the 30-day document timer expired the registration to VERLOPEN"
|
||||||
|
|
||||||
|
# S-10c: the worker cancels the ZGW zaak (ACL-first, before it expires the aggregate), so a VERLOPEN
|
||||||
|
# registration must carry a zaak whose current status is "Geannuleerd". Read it back from OpenZaak with
|
||||||
|
# a ZGW token minted like the seed's client (the same client OpenZaak trusts for this stack).
|
||||||
|
zaak_url_v="$(printf '%s' "$body" | grep -oiE 'http://[^"]*/zaken/api/v1/zaken/[a-f0-9-]+' | head -1)"
|
||||||
|
[ -n "$zaak_url_v" ] || { echo "FAIL — VERLOPEN registration $reg_idv exposes no zaak URL (body: $body)" >&2; exit 1; }
|
||||||
|
echo ">> confirming the zaak $zaak_url_v reached the Geannuleerd status in OpenZaak"
|
||||||
|
|
||||||
|
read_zaak_status() {
|
||||||
|
# -i so the heredoc reaches `python -` on the container's stdin (without it the script is empty).
|
||||||
|
docker run --rm -i --network "$net" \
|
||||||
|
-e OZ_CLIENT_ID="${OZ_CLIENT_ID:-big-reference-seed}" \
|
||||||
|
-e OZ_SECRET="${OZ_SECRET:-insecure-dev-secret-change-me}" \
|
||||||
|
python:3-slim python - "$1" <<'PY'
|
||||||
|
import base64, hashlib, hmac, json, os, sys, time, urllib.request
|
||||||
|
cid, sec = os.environ["OZ_CLIENT_ID"], os.environ["OZ_SECRET"]
|
||||||
|
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
|
||||||
|
def token():
|
||||||
|
hdr = {"alg": "HS256", "typ": "JWT"}
|
||||||
|
pl = {"iss": cid, "iat": int(time.time()), "client_id": cid, "user_id": "verify", "user_representation": "verify"}
|
||||||
|
seg = b64(json.dumps(hdr, separators=(",", ":")).encode()) + b"." + b64(json.dumps(pl, separators=(",", ":")).encode())
|
||||||
|
return (seg + b"." + b64(hmac.new(sec.encode(), seg, hashlib.sha256).digest())).decode()
|
||||||
|
def get(url):
|
||||||
|
req = urllib.request.Request(url, headers={
|
||||||
|
"Authorization": "Bearer " + token(), "Accept": "application/json", "Accept-Crs": "EPSG:4326"})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
zaak = get(sys.argv[1])
|
||||||
|
status_url = zaak.get("status")
|
||||||
|
if not status_url:
|
||||||
|
print(""); sys.exit(0)
|
||||||
|
print(get(get(status_url)["statustype"]).get("omschrijving", ""))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
geannuleerd=""
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
oms="$(read_zaak_status "$zaak_url_v" 2>/dev/null | tr -d '\r' || true)"
|
||||||
|
[ "$oms" = "Geannuleerd" ] && { geannuleerd=1; break; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$geannuleerd" ] || { echo "FAIL — zaak $zaak_url_v not Geannuleerd after timeout (current status omschrijving: '$oms')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo "OK — the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak"
|
||||||
|
exit 0
|
||||||
|
|||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Walking-skeleton e2e (S-08d) against an ALREADY-RUNNING full stack: drive the self-service portal
|
||||||
|
# in a real browser through mock-DigiD login → submit → confirmation (login → BFF → domain).
|
||||||
|
#
|
||||||
|
# Runs Playwright INSIDE the compose network (a container on `cg`), so the browser reaches
|
||||||
|
# Keycloak by service name (keycloak:8080) — the same authority the BFF validates against, so the
|
||||||
|
# token issuer matches (ADR-0010). The spec is copied into the container (docker cp), not mounted,
|
||||||
|
# so it leaves no root-owned files on the host. The caller owns stack bring-up + teardown.
|
||||||
|
#
|
||||||
|
# Uses the official Playwright image with browsers pre-baked, instead of downloading ~150 MB of
|
||||||
|
# Chromium on every run (issue #73). The image tag MUST match tests/e2e/package.json's
|
||||||
|
# @playwright/test version — bump both together.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
root="$(cd "$here/.." && pwd)"
|
||||||
|
|
||||||
|
ss="$(docker ps -q --filter 'name=self-service' | head -1)"
|
||||||
|
[ -n "$ss" ] || { echo "ERROR: no running self-service container — bring the stack up first" >&2; exit 1; }
|
||||||
|
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$ss" | head -1)"
|
||||||
|
echo ">> running Playwright e2e on network $net against http://self-service"
|
||||||
|
|
||||||
|
cid="$(docker create --network "$net" -w /e2e --ipc=host \
|
||||||
|
-e SELF_SERVICE_URL=http://self-service \
|
||||||
|
mcr.microsoft.com/playwright:v1.61.1-noble sh -c 'npm install --no-audit --no-fund && npx playwright test')"
|
||||||
|
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
|
||||||
|
docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
|
||||||
|
docker start -a "$cid"
|
||||||
+10
-1
@@ -35,12 +35,21 @@ populate() { # volume source(file or dir/.)
|
|||||||
|
|
||||||
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; }
|
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; }
|
||||||
|
|
||||||
|
# The registratie process (BPMN) and its diploma-eligibility DMN are deployed as SEPARATE Flowable
|
||||||
|
# deployments — the process engine and the DMN engine each own theirs (S-13, ADR-0016). flowable-rest
|
||||||
|
# does not cascade a .dmn bundled in a process .bar into the DMN engine, so we seed both raw files and
|
||||||
|
# let flowable-init deploy each via its own REST app. We stage them in a temp dir and copy its contents.
|
||||||
|
stage_flowable_workflows() {
|
||||||
|
local dir="$1"
|
||||||
|
cp "$here/../workflows/registratie.bpmn" "$here/../workflows/diploma-eligibility.dmn" "$dir/"
|
||||||
|
}
|
||||||
|
|
||||||
for key in "$@"; do
|
for key in "$@"; do
|
||||||
case "$key" in
|
case "$key" in
|
||||||
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
|
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
|
||||||
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
|
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
|
||||||
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
|
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
|
||||||
fl) populate rr-fl-bpmn "$here/../workflows/registratie.bpmn" ;;
|
fl) d="$(mktemp -d)"; stage_flowable_workflows "$d"; populate rr-fl-bpmn "$d/." ;;
|
||||||
*) echo "unknown seed key: $key" >&2; exit 2 ;;
|
*) echo "unknown seed key: $key" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -24,9 +24,23 @@ import {
|
|||||||
Observable
|
Observable
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
|
export interface DecideRequest {
|
||||||
|
besluit: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpenbaarEntry {
|
export interface OpenbaarEntry {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
/** @nullable */
|
||||||
|
reference: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProvideDocumentsRequest {
|
||||||
|
contentBase64: string;
|
||||||
|
/** @nullable */
|
||||||
|
fileName?: string | null;
|
||||||
|
/** @nullable */
|
||||||
|
contentType?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitAccepted {
|
export interface SubmitAccepted {
|
||||||
@@ -34,6 +48,12 @@ export interface SubmitAccepted {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WerkbakItem {
|
||||||
|
registrationId: string;
|
||||||
|
bsn: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type GetOpenbaarRegisterParams = {
|
export type GetOpenbaarRegisterParams = {
|
||||||
q?: string;
|
q?: string;
|
||||||
};
|
};
|
||||||
@@ -180,6 +200,78 @@ export class BffApiV1Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(
|
||||||
|
id: string, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||||
|
if (options?.observe === 'events') {
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/withdraw`,
|
||||||
|
undefined,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'events',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.observe === 'response') {
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/withdraw`,
|
||||||
|
undefined,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'response',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/withdraw`,
|
||||||
|
undefined,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'body',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
|
||||||
|
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
|
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
|
||||||
|
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
|
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
|
||||||
|
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
postSelfServiceRegistrationsIdDocuments<TData = void>(
|
||||||
|
id: string,
|
||||||
|
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||||
|
if (options?.observe === 'events') {
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/documents`,
|
||||||
|
provideDocumentsRequest,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'events',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.observe === 'response') {
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/documents`,
|
||||||
|
provideDocumentsRequest,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'response',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/documents`,
|
||||||
|
provideDocumentsRequest,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'body',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
|
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
@@ -213,4 +305,73 @@ export class BffApiV1Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
|
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
|
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
getBehandelWerkbak<TData = WerkbakItem[]>(
|
||||||
|
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||||
|
if (options?.observe === 'events') {
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/behandel/werkbak`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'events',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.observe === 'response') {
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/behandel/werkbak`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'response',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/behandel/werkbak`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'body',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
postBehandelRegistrationsIdDecide<TData = void>(id: string,
|
||||||
|
decideRequest: DecideRequest, options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
|
postBehandelRegistrationsIdDecide<TData = void>(id: string,
|
||||||
|
decideRequest: DecideRequest, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
|
postBehandelRegistrationsIdDecide<TData = void>(id: string,
|
||||||
|
decideRequest: DecideRequest, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
postBehandelRegistrationsIdDecide<TData = void>(
|
||||||
|
id: string,
|
||||||
|
decideRequest: DecideRequest, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||||
|
if (options?.observe === 'events') {
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/behandel/registrations/${id}/decide`,
|
||||||
|
decideRequest,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'events',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.observe === 'response') {
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/behandel/registrations/${id}/decide`,
|
||||||
|
decideRequest,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'response',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/behandel/registrations/${id}/decide`,
|
||||||
|
decideRequest,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'body',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export * from './lib/auth.service';
|
export * from './lib/auth.service';
|
||||||
export * from './lib/digid-auth.service';
|
export * from './lib/digid-auth.service';
|
||||||
export * from './lib/digid-auth.providers';
|
export * from './lib/digid-auth.providers';
|
||||||
|
export * from './lib/medewerker-auth.service';
|
||||||
|
export * from './lib/medewerker-auth.providers';
|
||||||
export * from './lib/authenticated.guard';
|
export * from './lib/authenticated.guard';
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { Signal } from '@angular/core';
|
import { signal, Signal } from '@angular/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The portal's view of the signed-in user. An abstraction over the OIDC library so components and
|
* The portal's view of the signed-in user. An abstraction over the OIDC library so components and
|
||||||
* guards depend on a small, mockable surface (the real implementation is DigiadAuthService).
|
* guards depend on a small, mockable surface (the real implementations are DigiadAuthService for
|
||||||
|
* citizens and MedewerkerAuthService for staff).
|
||||||
*/
|
*/
|
||||||
export abstract class AuthService {
|
export abstract class AuthService {
|
||||||
/** Whether a DigiD session is active. */
|
/** Whether a session is active. */
|
||||||
abstract readonly isAuthenticated: Signal<boolean>;
|
abstract readonly isAuthenticated: Signal<boolean>;
|
||||||
/** The citizen-service number from the DigiD token, once authenticated. */
|
/** The citizen-service number from the DigiD token, once authenticated (staff have none). */
|
||||||
abstract readonly bsn: Signal<string | undefined>;
|
abstract readonly bsn: Signal<string | undefined>;
|
||||||
/** Start the DigiD login (redirects to Keycloak). */
|
/**
|
||||||
|
* The realm roles carried in the token. Empty for realms that don't grant roles (e.g. `digid`);
|
||||||
|
* the `medewerker` realm carries `behandelaar`/`teamlead`.
|
||||||
|
*/
|
||||||
|
readonly roles: Signal<readonly string[]> = signal<readonly string[]>([]);
|
||||||
|
/** Start login (redirects to Keycloak). */
|
||||||
abstract login(): void;
|
abstract login(): void;
|
||||||
/** End the session. */
|
/** End the session. */
|
||||||
abstract logout(): void;
|
abstract logout(): void;
|
||||||
|
/** Whether the signed-in user holds the given realm role. */
|
||||||
|
hasRole(role: string): boolean {
|
||||||
|
return this.roles().includes(role);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ export interface DigiadAuthOptions {
|
|||||||
authority: string;
|
authority: string;
|
||||||
/** Where Keycloak redirects back to after login (usually the app origin). */
|
/** Where Keycloak redirects back to after login (usually the app origin). */
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
/** The BFF origin whose requests get the bearer token attached (secure route). */
|
/**
|
||||||
secureApiOrigin: string;
|
* Route prefixes whose requests get the bearer token attached. The api-client calls the BFF with
|
||||||
|
* **relative** URLs (same-origin via the nginx proxy), so these must be relative path prefixes
|
||||||
|
* (e.g. `/self-service/`) — angular-auth-oidc-client matches `req.url.startsWith(route)`, and a
|
||||||
|
* relative `req.url` never starts with an absolute origin.
|
||||||
|
*/
|
||||||
|
secureRoutes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +40,7 @@ export function provideDigiadAuth(options: DigiadAuthOptions): EnvironmentProvid
|
|||||||
responseType: 'code',
|
responseType: 'code',
|
||||||
silentRenew: true,
|
silentRenew: true,
|
||||||
useRefreshToken: true,
|
useRefreshToken: true,
|
||||||
secureRoutes: [options.secureApiOrigin],
|
secureRoutes: options.secureRoutes,
|
||||||
logLevel: LogLevel.Warn,
|
logLevel: LogLevel.Warn,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
|
||||||
|
import { LogLevel, provideAuth, withAppInitializerAuthCheck } from 'angular-auth-oidc-client';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { MedewerkerAuthService } from './medewerker-auth.service';
|
||||||
|
|
||||||
|
export interface MedewerkerAuthOptions {
|
||||||
|
/** The Keycloak `medewerker` realm issuer, as reachable from the browser. */
|
||||||
|
authority: string;
|
||||||
|
/** Where Keycloak redirects back to after login (usually the app origin). */
|
||||||
|
redirectUrl: string;
|
||||||
|
/**
|
||||||
|
* Route prefixes whose requests get the bearer token attached. The api-client calls the BFF with
|
||||||
|
* **relative** URLs (same-origin via the nginx proxy), so these must be relative path prefixes
|
||||||
|
* (e.g. `/behandel/`) — angular-auth-oidc-client matches `req.url.startsWith(route)`, and a
|
||||||
|
* relative `req.url` never starts with an absolute origin.
|
||||||
|
*/
|
||||||
|
secureRoutes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure medewerker login (Keycloak `medewerker` realm, public client `big-portal`, auth-code +
|
||||||
|
* PKCE) and bind {@link AuthService} to the medewerker-backed implementation. Register
|
||||||
|
* {@link authInterceptor} (re-exported from digid-auth.providers) in the app's HttpClient so BFF
|
||||||
|
* calls carry the token.
|
||||||
|
*/
|
||||||
|
export function provideMedewerkerAuth(options: MedewerkerAuthOptions): EnvironmentProviders {
|
||||||
|
return makeEnvironmentProviders([
|
||||||
|
provideAuth(
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
authority: options.authority,
|
||||||
|
redirectUrl: options.redirectUrl,
|
||||||
|
postLogoutRedirectUri: options.redirectUrl,
|
||||||
|
clientId: 'big-portal',
|
||||||
|
scope: 'openid profile',
|
||||||
|
responseType: 'code',
|
||||||
|
silentRenew: true,
|
||||||
|
useRefreshToken: true,
|
||||||
|
secureRoutes: options.secureRoutes,
|
||||||
|
logLevel: LogLevel.Warn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Run checkAuth() at startup so the login callback (?code=…) is processed before the router
|
||||||
|
// and guard run — without it the guard sees "not authenticated" and re-triggers login (loop).
|
||||||
|
withAppInitializerAuthCheck(),
|
||||||
|
),
|
||||||
|
{ provide: AuthService, useClass: MedewerkerAuthService },
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { OidcSecurityService } from 'angular-auth-oidc-client';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { MedewerkerAuthService } from './medewerker-auth.service';
|
||||||
|
|
||||||
|
function makeService(userData: unknown, authenticated = true) {
|
||||||
|
const oidc = {
|
||||||
|
isAuthenticated$: of({ isAuthenticated: authenticated }),
|
||||||
|
userData$: of({ userData }),
|
||||||
|
authorize: vi.fn(),
|
||||||
|
logoff: vi.fn(() => of(null)),
|
||||||
|
};
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [MedewerkerAuthService, { provide: OidcSecurityService, useValue: oidc }],
|
||||||
|
});
|
||||||
|
return { svc: TestBed.inject(MedewerkerAuthService), oidc };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MedewerkerAuthService', () => {
|
||||||
|
it('exposes the realm roles carried in the token', () => {
|
||||||
|
const { svc } = makeService({ realm_access: { roles: ['behandelaar', 'teamlead'] } });
|
||||||
|
expect(svc.roles()).toEqual(['behandelaar', 'teamlead']);
|
||||||
|
expect(svc.hasRole('behandelaar')).toBe(true);
|
||||||
|
expect(svc.hasRole('beheerder')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no roles when the token omits realm_access', () => {
|
||||||
|
const { svc } = makeService({ preferred_username: 'merel-behandelaar' });
|
||||||
|
expect(svc.roles()).toEqual([]);
|
||||||
|
expect(svc.hasRole('behandelaar')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects the OIDC authenticated state', () => {
|
||||||
|
const { svc } = makeService({}, true);
|
||||||
|
expect(svc.isAuthenticated()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts login by delegating to the OIDC library', () => {
|
||||||
|
const { svc, oidc } = makeService({});
|
||||||
|
svc.login();
|
||||||
|
expect(oidc.authorize).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { inject, Injectable, Signal } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { OidcSecurityService } from 'angular-auth-oidc-client';
|
||||||
|
import { map } from 'rxjs';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
/** The subset of the medewerker token the portal reads: Keycloak nests realm roles here. */
|
||||||
|
interface MedewerkerClaims {
|
||||||
|
realm_access?: { roles?: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Medewerker-backed AuthService over angular-auth-oidc-client (Keycloak `medewerker` realm). */
|
||||||
|
@Injectable()
|
||||||
|
export class MedewerkerAuthService extends AuthService {
|
||||||
|
private readonly oidc = inject(OidcSecurityService);
|
||||||
|
|
||||||
|
readonly isAuthenticated: Signal<boolean> = toSignal(
|
||||||
|
this.oidc.isAuthenticated$.pipe(map((result) => result.isAuthenticated)),
|
||||||
|
{ initialValue: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Staff have no BSN; the abstract surface keeps this present for the shared guard/interceptor.
|
||||||
|
readonly bsn: Signal<string | undefined> = toSignal(this.oidc.userData$.pipe(map(() => undefined)), {
|
||||||
|
initialValue: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
override readonly roles: Signal<readonly string[]> = toSignal(
|
||||||
|
this.oidc.userData$.pipe(
|
||||||
|
map((data) => (data.userData as MedewerkerClaims | null)?.realm_access?.roles ?? []),
|
||||||
|
),
|
||||||
|
{ initialValue: [] },
|
||||||
|
);
|
||||||
|
|
||||||
|
override login(): void {
|
||||||
|
this.oidc.authorize();
|
||||||
|
}
|
||||||
|
|
||||||
|
override logout(): void {
|
||||||
|
this.oidc.logoff().subscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,12 +20,53 @@ app.MapGet("/health", () => "Healthy");
|
|||||||
// The ACL's single operation, exposed as a service endpoint.
|
// The ACL's single operation, exposed as a service endpoint.
|
||||||
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
|
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn), ct);
|
var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn, body.Reference), ct);
|
||||||
return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
|
return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Approve a zaak: set it to its zaaktype's eindstatus (S-09b). The domain hands over only the zaak
|
||||||
|
// URL; the ACL owns the ZGW statustype resolution (§8.1).
|
||||||
|
app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
await acl.ApproveZaakAsync(new Uri(body.ZaakUrl), ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel a zaak on document-timeout expiry (S-10c): set it to its zaaktype's cancellation statustype
|
||||||
|
// + resultaat. The domain hands over only the zaak URL; the ACL owns the ZGW resolution (§8.1).
|
||||||
|
app.MapPost("/annuleringen", async (CancelZaakRequest body, AclService acl, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
await acl.CancelZaakAsync(new Uri(body.ZaakUrl), ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich
|
||||||
|
// the read projection without reading ZGW itself (§8.1, #78).
|
||||||
|
app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var reference = await acl.GetZaakReferenceAsync(new Uri(body.ZaakUrl), ct);
|
||||||
|
return Results.Ok(new { reference });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store an uploaded diploma against a zaak (S-10b): the domain sends the file as base64; the ACL
|
||||||
|
// creates the ZGW enkelvoudiginformatieobject and relates it to the zaak (§8.1). Returns its URL.
|
||||||
|
app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var url = await acl.StoreDiplomaAsync(
|
||||||
|
new Uri(body.ZaakUrl), Convert.FromBase64String(body.ContentBase64), body.FileName, body.ContentType, ct);
|
||||||
|
return Results.Ok(new { informatieobjectUrl = url.ToString() });
|
||||||
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
public sealed record OpenZaakRequest(string Bsn);
|
public sealed record OpenZaakRequest(string Bsn, string Reference);
|
||||||
|
|
||||||
|
public sealed record SetStatusRequest(string ZaakUrl);
|
||||||
|
|
||||||
|
public sealed record CancelZaakRequest(string ZaakUrl);
|
||||||
|
|
||||||
|
public sealed record ZaakReferenceRequest(string ZaakUrl);
|
||||||
|
|
||||||
|
public sealed record StoreDocumentRequest(string ZaakUrl, string ContentBase64, string FileName, string ContentType);
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -7,4 +7,8 @@ public sealed class AclDefaults
|
|||||||
public required string VerantwoordelijkeOrganisatie { get; init; }
|
public required string VerantwoordelijkeOrganisatie { get; init; }
|
||||||
public required string Vertrouwelijkheidaanduiding { get; init; }
|
public required string Vertrouwelijkheidaanduiding { get; init; }
|
||||||
public required Uri ZaaktypeUrl { get; init; }
|
public required Uri ZaaktypeUrl { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The informatieobjecttype an uploaded diploma is filed under (S-10b). Seeded in the
|
||||||
|
/// catalogus and injected like <see cref="ZaaktypeUrl"/>.</summary>
|
||||||
|
public required Uri InformatieobjecttypeUrl { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,69 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
|||||||
defaults.VerantwoordelijkeOrganisatie,
|
defaults.VerantwoordelijkeOrganisatie,
|
||||||
defaults.Vertrouwelijkheidaanduiding,
|
defaults.Vertrouwelijkheidaanduiding,
|
||||||
defaults.ZaaktypeUrl,
|
defaults.ZaaktypeUrl,
|
||||||
clock.Today);
|
clock.Today,
|
||||||
|
registration.Reference);
|
||||||
|
|
||||||
return gateway.OpenZaakAsync(request, ct);
|
return gateway.OpenZaakAsync(request, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The
|
||||||
|
/// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
|
||||||
|
/// </summary>
|
||||||
|
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
|
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the configured BIG zaaktype's
|
||||||
|
/// cancellation statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
|
||||||
|
/// statustype/resultaat means "cancelled" (§8.1).
|
||||||
|
/// </summary>
|
||||||
|
public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
|
return gateway.SetZaakToCancellationStatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
||||||
|
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
|
return gateway.GetZaakIdentificatieAsync(zaakUrl, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Store an uploaded diploma against the zaak (S-10b): default-fill the ZGW-mandatory document
|
||||||
|
/// fields (informatieobjecttype, bronorganisatie, vertrouwelijkheidaanduiding, taal, creatiedatum)
|
||||||
|
/// and hand the file to the gateway, which creates the informatieobject and relates it to the zaak.
|
||||||
|
/// The domain supplies only the zaak, the bytes, and the file's name/type (§8.1).
|
||||||
|
/// </summary>
|
||||||
|
public Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
ArgumentNullException.ThrowIfNull(content);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(contentType);
|
||||||
|
|
||||||
|
var request = new DocumentRequest(
|
||||||
|
defaults.Bronorganisatie,
|
||||||
|
defaults.InformatieobjecttypeUrl,
|
||||||
|
defaults.Vertrouwelijkheidaanduiding,
|
||||||
|
zaakUrl,
|
||||||
|
clock.Today,
|
||||||
|
Titel: "Diploma",
|
||||||
|
Auteur: "zorgprofessional",
|
||||||
|
Taal: "nld",
|
||||||
|
Bestandsnaam: fileName,
|
||||||
|
Formaat: contentType,
|
||||||
|
Inhoud: content);
|
||||||
|
|
||||||
|
return gateway.StoreDocumentAsync(request, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Acl.Application;
|
||||||
|
|
||||||
|
/// <summary>The fully default-filled diploma document the gateway will create in the ZGW Documenten
|
||||||
|
/// API and relate to the zaak (S-10b). <see cref="Inhoud"/> is the raw file content; the gateway
|
||||||
|
/// base64-encodes it into the ZGW <c>inhoud</c> field.</summary>
|
||||||
|
public sealed record DocumentRequest(
|
||||||
|
string Bronorganisatie,
|
||||||
|
Uri Informatieobjecttype,
|
||||||
|
string Vertrouwelijkheidaanduiding,
|
||||||
|
Uri Zaak,
|
||||||
|
DateOnly Creatiedatum,
|
||||||
|
string Titel,
|
||||||
|
string Auteur,
|
||||||
|
string Taal,
|
||||||
|
string Bestandsnaam,
|
||||||
|
string Formaat,
|
||||||
|
byte[] Inhoud);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
namespace Acl.Application;
|
namespace Acl.Application;
|
||||||
|
|
||||||
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here.</summary>
|
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here. <see cref="Reference"/>
|
||||||
public sealed record DomainRegistration(string Bsn);
|
/// is the registration's own id; the ACL records it as the zaak identificatie (adr-proposal #78).</summary>
|
||||||
|
public sealed record DomainRegistration(string Bsn, string Reference);
|
||||||
|
|||||||
@@ -5,4 +5,31 @@ namespace Acl.Application;
|
|||||||
public interface IZaakGateway
|
public interface IZaakGateway
|
||||||
{
|
{
|
||||||
Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default);
|
Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set the given zaak to the <em>eindstatus</em> (final statustype) of the supplied zaaktype —
|
||||||
|
/// the ZGW translation of "approve". The gateway resolves which statustype is the eindstatus from
|
||||||
|
/// the catalogus and POSTs a status against the zaak, dated <paramref name="datumStatusGezet"/>.
|
||||||
|
/// </summary>
|
||||||
|
Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set the given zaak to the <em>cancellation</em> statustype ("Geannuleerd") and record the
|
||||||
|
/// matching cancellation resultaat ("Vervallen") — the ZGW translation of "the 30-day document term
|
||||||
|
/// lapsed" (S-10c). Distinct from <see cref="SetZaakToEindstatusAsync"/> (approval): the gateway
|
||||||
|
/// resolves both the cancellation statustype and resultaattype from the catalogus by their
|
||||||
|
/// omschrijving, POSTs the resultaat then the status, dated <paramref name="datumStatusGezet"/>.
|
||||||
|
/// </summary>
|
||||||
|
Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Read the zaak's <c>identificatie</c> — the public-safe reference the register shows.
|
||||||
|
/// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78).</summary>
|
||||||
|
Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Store a diploma document (S-10b): create an <c>enkelvoudiginformatieobject</c> in the ZGW
|
||||||
|
/// Documenten API and relate it to the zaak via a <c>zaakinformatieobject</c>. Returns the URL of
|
||||||
|
/// the created informatieobject.
|
||||||
|
/// </summary>
|
||||||
|
Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ public sealed record ZaakRequest(
|
|||||||
string VerantwoordelijkeOrganisatie,
|
string VerantwoordelijkeOrganisatie,
|
||||||
string Vertrouwelijkheidaanduiding,
|
string Vertrouwelijkheidaanduiding,
|
||||||
Uri Zaaktype,
|
Uri Zaaktype,
|
||||||
DateOnly Startdatum);
|
DateOnly Startdatum,
|
||||||
|
string Identificatie);
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ namespace Acl.Infrastructure;
|
|||||||
/// <summary>The only code that talks to OpenZaak's Zaken API (ADR-0001).</summary>
|
/// <summary>The only code that talks to OpenZaak's Zaken API (ADR-0001).</summary>
|
||||||
public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway
|
public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway
|
||||||
{
|
{
|
||||||
|
// The ACL owns which ZGW statustype/resultaat carries each domain outcome (§8.1). These
|
||||||
|
// omschrijvingen match the seeded BIG catalogus (infra/openzaak/seed_catalogus.py).
|
||||||
|
private const string GeregistreerdResultaat = "Geregistreerd"; // approval outcome
|
||||||
|
private const string GeannuleerdStatus = "Geannuleerd"; // document-timeout cancellation status (S-10c)
|
||||||
|
private const string VervallenResultaat = "Vervallen"; // document-timeout cancellation outcome (S-10c)
|
||||||
|
|
||||||
public async Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
public async Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
@@ -20,7 +26,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
request.Zaaktype.ToString(),
|
request.Zaaktype.ToString(),
|
||||||
request.VerantwoordelijkeOrganisatie,
|
request.VerantwoordelijkeOrganisatie,
|
||||||
request.Startdatum.ToString("yyyy-MM-dd"),
|
request.Startdatum.ToString("yyyy-MM-dd"),
|
||||||
request.Vertrouwelijkheidaanduiding)),
|
request.Vertrouwelijkheidaanduiding,
|
||||||
|
request.Identificatie)),
|
||||||
};
|
};
|
||||||
message.Headers.Authorization =
|
message.Headers.Authorization =
|
||||||
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||||
@@ -41,13 +48,254 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
return new Uri(created.Url);
|
return new Uri(created.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
|
||||||
|
|
||||||
|
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
|
||||||
|
// Resolve the approval resultaat by name: once S-10c adds the Vervallen resultaattype, taking
|
||||||
|
// the first would be ambiguous (the Zaken API does not guarantee order).
|
||||||
|
var resultaattype = await ResolveResultaattypeByOmschrijvingAsync(zaaktypeUrl, GeregistreerdResultaat, ct);
|
||||||
|
|
||||||
|
// OpenZaak refuses to set a zaak's eindstatus unless the zaak has a resultaat
|
||||||
|
// ("resultaat-does-not-exist"), so record the resultaat first, then the status.
|
||||||
|
await PostAsync("/zaken/api/v1/resultaten",
|
||||||
|
new ResultaatDto(zaakUrl.ToString(), resultaattype.ToString()), "Setting the zaak resultaat", ct);
|
||||||
|
|
||||||
|
await PostAsync("/zaken/api/v1/statussen",
|
||||||
|
// datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC).
|
||||||
|
new StatusDto(zaakUrl.ToString(), eindstatus.ToString(),
|
||||||
|
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")),
|
||||||
|
"Setting the zaak status", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
|
||||||
|
|
||||||
|
// Distinct from approval: resolve the cancellation statustype + resultaat by name (Geannuleerd
|
||||||
|
// is a non-terminal statustype, so it is never the eindstatus the approval path resolves).
|
||||||
|
var cancellationStatus = await ResolveStatustypeByOmschrijvingAsync(zaaktypeUrl, GeannuleerdStatus, ct);
|
||||||
|
var cancellationResultaat = await ResolveResultaattypeByOmschrijvingAsync(zaaktypeUrl, VervallenResultaat, ct);
|
||||||
|
|
||||||
|
// As with approval, OpenZaak wants the resultaat recorded before the status.
|
||||||
|
await PostAsync("/zaken/api/v1/resultaten",
|
||||||
|
new ResultaatDto(zaakUrl.ToString(), cancellationResultaat.ToString()),
|
||||||
|
"Setting the zaak cancellation resultaat", ct);
|
||||||
|
|
||||||
|
await PostAsync("/zaken/api/v1/statussen",
|
||||||
|
new StatusDto(zaakUrl.ToString(), cancellationStatus.ToString(),
|
||||||
|
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")),
|
||||||
|
"Setting the zaak cancellation status", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
|
using var message = new HttpRequestMessage(HttpMethod.Get, zaakUrl);
|
||||||
|
message.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||||
|
// The zaak is a geo resource; the Zaken API requires the CRS header even on GET.
|
||||||
|
message.Headers.Add("Accept-Crs", "EPSG:4326");
|
||||||
|
|
||||||
|
using var response = await http.SendAsync(message, ct);
|
||||||
|
await EnsureSuccessAsync(response, "Reading the zaak", ct);
|
||||||
|
|
||||||
|
var zaak = await response.Content.ReadFromJsonAsync<ZaakReadDto>(ct)
|
||||||
|
?? throw new InvalidOperationException("OpenZaak returned an empty zaak response");
|
||||||
|
return zaak.Identificatie;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
// 1. Create the enkelvoudiginformatieobject in the Documenten API (not a geo API — no CRS).
|
||||||
|
var created = await PostForUrlAsync(
|
||||||
|
"/documenten/api/v1/enkelvoudiginformatieobjecten",
|
||||||
|
new EnkelvoudigInformatieobjectDto(
|
||||||
|
request.Bronorganisatie,
|
||||||
|
request.Creatiedatum.ToString("yyyy-MM-dd"),
|
||||||
|
request.Titel,
|
||||||
|
request.Auteur,
|
||||||
|
request.Taal,
|
||||||
|
request.Informatieobjecttype.ToString(),
|
||||||
|
Convert.ToBase64String(request.Inhoud),
|
||||||
|
request.Bestandsnaam,
|
||||||
|
request.Inhoud.Length,
|
||||||
|
request.Vertrouwelijkheidaanduiding,
|
||||||
|
request.Formaat,
|
||||||
|
"definitief",
|
||||||
|
// No usage-rights restrictions apply. Left null, OpenZaak rejects closing the related
|
||||||
|
// zaak with "indicatiegebruiksrecht-unset"; false records the deliberate "none" answer.
|
||||||
|
false),
|
||||||
|
"Creating the informatieobject", ct);
|
||||||
|
|
||||||
|
// 2. Relate it to the zaak (Zaken API — no CRS).
|
||||||
|
await PostAsync("/zaken/api/v1/zaakinformatieobjecten",
|
||||||
|
new ZaakInformatieobjectDto(request.Zaak.ToString(), created.ToString()),
|
||||||
|
"Relating the informatieobject to the zaak", ct);
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets
|
||||||
|
// a Content-Length instead of a chunked body (as with zaak-create).
|
||||||
|
private async Task PostAsync(string path, object dto, string action, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(dto),
|
||||||
|
};
|
||||||
|
message.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||||
|
await message.Content.LoadIntoBufferAsync(ct);
|
||||||
|
|
||||||
|
using var response = await http.SendAsync(message, ct);
|
||||||
|
await EnsureSuccessAsync(response, action, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POSTs a non-geo ZGW resource and returns the created resource's URL (as PostAsync, but reads back
|
||||||
|
// the `url` of the created object). Buffers the body so uwsgi gets a Content-Length.
|
||||||
|
private async Task<Uri> PostForUrlAsync(string path, object dto, string action, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(dto),
|
||||||
|
};
|
||||||
|
message.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||||
|
await message.Content.LoadIntoBufferAsync(ct);
|
||||||
|
|
||||||
|
using var response = await http.SendAsync(message, ct);
|
||||||
|
await EnsureSuccessAsync(response, action, ct);
|
||||||
|
|
||||||
|
var created = await response.Content.ReadFromJsonAsync<CreatedDto>(ct)
|
||||||
|
?? throw new InvalidOperationException($"OpenZaak returned an empty response for {action}");
|
||||||
|
return new Uri(created.Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureSuccessStatusCode discards the response body; ZGW returns a JSON problem detail on 400 that
|
||||||
|
// is essential for diagnosing a rejected request, so surface it in the exception.
|
||||||
|
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus.</summary>
|
||||||
|
private async Task<Uri> ResolveEindstatusAsync(Uri zaaktypeUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var page = await GetCatalogusAsync<StatustypePage>("statustypen", zaaktypeUrl, "statustypen", ct);
|
||||||
|
var results = page.Results ?? [];
|
||||||
|
|
||||||
|
// OpenZaak flags the terminal statustype (highest volgnummer) as isEindstatus; fall back to the
|
||||||
|
// highest volgnummer if the flag is absent.
|
||||||
|
var eindstatus = results.FirstOrDefault(s => s.IsEindstatus)
|
||||||
|
?? results.OrderByDescending(s => s.Volgnummer).FirstOrDefault()
|
||||||
|
?? throw new InvalidOperationException($"No statustypen found for zaaktype {zaaktypeUrl}");
|
||||||
|
return new Uri(eindstatus.Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolve a specific statustype from the catalogus by its omschrijving (e.g. "Geannuleerd").</summary>
|
||||||
|
private async Task<Uri> ResolveStatustypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var page = await GetCatalogusAsync<StatustypePage>("statustypen", zaaktypeUrl, "statustypen", ct);
|
||||||
|
var match = (page.Results ?? []).FirstOrDefault(s => s.Omschrijving == omschrijving)
|
||||||
|
?? throw new InvalidOperationException($"No '{omschrijving}' statustype found for zaaktype {zaaktypeUrl}");
|
||||||
|
return new Uri(match.Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolve a specific resultaattype from the catalogus by its omschrijving (the seed defines
|
||||||
|
/// "Geregistreerd" for approval and "Vervallen" for a document-timeout cancellation).</summary>
|
||||||
|
private async Task<Uri> ResolveResultaattypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var page = await GetCatalogusAsync<ResultaattypePage>("resultaattypen", zaaktypeUrl, "resultaattypen", ct);
|
||||||
|
var match = (page.Results ?? []).FirstOrDefault(r => r.Omschrijving == omschrijving)
|
||||||
|
?? throw new InvalidOperationException($"No '{omschrijving}' resultaattype found for zaaktype {zaaktypeUrl}");
|
||||||
|
return new Uri(match.Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published).
|
||||||
|
private async Task<T> GetCatalogusAsync<T>(string resource, Uri zaaktypeUrl, string label, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var query = new Uri(options.BaseUrl,
|
||||||
|
$"/catalogi/api/v1/{resource}?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
|
||||||
|
using var message = new HttpRequestMessage(HttpMethod.Get, query);
|
||||||
|
message.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||||
|
|
||||||
|
using var response = await http.SendAsync(message, ct);
|
||||||
|
await EnsureSuccessAsync(response, $"Querying {label}", ct);
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(ct)
|
||||||
|
?? throw new InvalidOperationException($"OpenZaak returned an empty {label} response");
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record ZaakDto(
|
private sealed record ZaakDto(
|
||||||
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
||||||
[property: JsonPropertyName("zaaktype")] string Zaaktype,
|
[property: JsonPropertyName("zaaktype")] string Zaaktype,
|
||||||
[property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
|
[property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
|
||||||
[property: JsonPropertyName("startdatum")] string Startdatum,
|
[property: JsonPropertyName("startdatum")] string Startdatum,
|
||||||
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding);
|
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding,
|
||||||
|
[property: JsonPropertyName("identificatie")] string Identificatie);
|
||||||
|
|
||||||
private sealed record ZaakCreatedDto(
|
private sealed record ZaakCreatedDto(
|
||||||
[property: JsonPropertyName("url")] string Url);
|
[property: JsonPropertyName("url")] string Url);
|
||||||
|
|
||||||
|
private sealed record ZaakReadDto(
|
||||||
|
[property: JsonPropertyName("identificatie")] string Identificatie);
|
||||||
|
|
||||||
|
private sealed record StatusDto(
|
||||||
|
[property: JsonPropertyName("zaak")] string Zaak,
|
||||||
|
[property: JsonPropertyName("statustype")] string Statustype,
|
||||||
|
[property: JsonPropertyName("datumStatusGezet")] string DatumStatusGezet);
|
||||||
|
|
||||||
|
private sealed record StatustypePage(
|
||||||
|
[property: JsonPropertyName("results")] IReadOnlyList<StatustypeDto>? Results);
|
||||||
|
|
||||||
|
private sealed record StatustypeDto(
|
||||||
|
[property: JsonPropertyName("url")] string Url,
|
||||||
|
[property: JsonPropertyName("volgnummer")] int Volgnummer,
|
||||||
|
[property: JsonPropertyName("isEindstatus")] bool IsEindstatus,
|
||||||
|
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
|
||||||
|
|
||||||
|
private sealed record ResultaatDto(
|
||||||
|
[property: JsonPropertyName("zaak")] string Zaak,
|
||||||
|
[property: JsonPropertyName("resultaattype")] string Resultaattype);
|
||||||
|
|
||||||
|
private sealed record ResultaattypePage(
|
||||||
|
[property: JsonPropertyName("results")] IReadOnlyList<ResultaattypeDto>? Results);
|
||||||
|
|
||||||
|
private sealed record ResultaattypeDto(
|
||||||
|
[property: JsonPropertyName("url")] string Url,
|
||||||
|
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
|
||||||
|
|
||||||
|
private sealed record CreatedDto(
|
||||||
|
[property: JsonPropertyName("url")] string Url);
|
||||||
|
|
||||||
|
private sealed record EnkelvoudigInformatieobjectDto(
|
||||||
|
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
||||||
|
[property: JsonPropertyName("creatiedatum")] string Creatiedatum,
|
||||||
|
[property: JsonPropertyName("titel")] string Titel,
|
||||||
|
[property: JsonPropertyName("auteur")] string Auteur,
|
||||||
|
[property: JsonPropertyName("taal")] string Taal,
|
||||||
|
[property: JsonPropertyName("informatieobjecttype")] string Informatieobjecttype,
|
||||||
|
[property: JsonPropertyName("inhoud")] string Inhoud,
|
||||||
|
[property: JsonPropertyName("bestandsnaam")] string Bestandsnaam,
|
||||||
|
[property: JsonPropertyName("bestandsomvang")] int Bestandsomvang,
|
||||||
|
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding,
|
||||||
|
[property: JsonPropertyName("formaat")] string Formaat,
|
||||||
|
[property: JsonPropertyName("status")] string Status,
|
||||||
|
[property: JsonPropertyName("indicatieGebruiksrecht")] bool IndicatieGebruiksrecht);
|
||||||
|
|
||||||
|
private sealed record ZaakInformatieobjectDto(
|
||||||
|
[property: JsonPropertyName("zaak")] string Zaak,
|
||||||
|
[property: JsonPropertyName("informatieobject")] string Informatieobject);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,68 @@ public sealed class OpenZaakFixture : IDisposable
|
|||||||
return JsonDocument.Parse(json).RootElement.Clone();
|
return JsonDocument.Parse(json).RootElement.Clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>GETs a non-geo ZGW resource (e.g. a status) by URL — no CRS headers.</summary>
|
||||||
|
public async Task<JsonElement> GetJsonAsync(Uri url, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var message = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MintToken());
|
||||||
|
|
||||||
|
using var response = await Http.SendAsync(message, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var json = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
return JsonDocument.Parse(json).RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The URL of the published "Diploma" informatieobjecttype (S-10b), or null when the
|
||||||
|
/// stack has not been seeded with OZ_PUBLISH=1. `status=definitief` returns published types only.</summary>
|
||||||
|
public async Task<Uri?> FindPublishedDiplomaInformatieobjecttypeAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var query = new Uri(BaseUrl, "/catalogi/api/v1/informatieobjecttypen?status=definitief");
|
||||||
|
var page = await GetJsonAsync(query, ct);
|
||||||
|
foreach (var iot in page.GetProperty("results").EnumerateArray())
|
||||||
|
if (iot.TryGetProperty("omschrijving", out var o) && o.GetString() == "Diploma")
|
||||||
|
return new Uri(iot.GetProperty("url").GetString()!);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The zaaktype's eindstatus (terminal statustype) URL — the one an approval sets.</summary>
|
||||||
|
public async Task<Uri> FindEindstatustypeAsync(Uri zaaktypeUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var query = new Uri(BaseUrl,
|
||||||
|
"/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
|
||||||
|
var page = await GetJsonAsync(query, ct);
|
||||||
|
var results = page.GetProperty("results");
|
||||||
|
|
||||||
|
Uri? fallback = null;
|
||||||
|
var highest = int.MinValue;
|
||||||
|
foreach (var st in results.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (st.TryGetProperty("isEindstatus", out var eind) && eind.GetBoolean())
|
||||||
|
return new Uri(st.GetProperty("url").GetString()!);
|
||||||
|
var volgnummer = st.GetProperty("volgnummer").GetInt32();
|
||||||
|
if (volgnummer > highest)
|
||||||
|
{
|
||||||
|
highest = volgnummer;
|
||||||
|
fallback = new Uri(st.GetProperty("url").GetString()!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback ?? throw new InvalidOperationException($"No statustypen for zaaktype {zaaktypeUrl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolve a statustype by its omschrijving (e.g. the S-10c "Geannuleerd" cancellation status).</summary>
|
||||||
|
public async Task<Uri> FindStatustypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var query = new Uri(BaseUrl,
|
||||||
|
"/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
|
||||||
|
var page = await GetJsonAsync(query, ct);
|
||||||
|
foreach (var st in page.GetProperty("results").EnumerateArray())
|
||||||
|
if (st.TryGetProperty("omschrijving", out var o) && o.GetString() == omschrijving)
|
||||||
|
return new Uri(st.GetProperty("url").GetString()!);
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"No '{omschrijving}' statustype for zaaktype {zaaktypeUrl}");
|
||||||
|
}
|
||||||
|
|
||||||
// A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here
|
// A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here
|
||||||
// rather than reusing Acl.Infrastructure's internal minter to keep that internal.
|
// rather than reusing Acl.Infrastructure's internal minter to keep that internal.
|
||||||
private string MintToken()
|
private string MintToken()
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
|
|||||||
"seed it with OZ_PUBLISH=1 (`make integration` does this).");
|
"seed it with OZ_PUBLISH=1 (`make integration` does this).");
|
||||||
|
|
||||||
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
||||||
|
var reference = Guid.NewGuid().ToString(); // zaak identificatie must be unique per bronorganisatie
|
||||||
var request = new ZaakRequest(
|
var request = new ZaakRequest(
|
||||||
Bronorganisatie: "517439943",
|
Bronorganisatie: "517439943",
|
||||||
VerantwoordelijkeOrganisatie: "517439943",
|
VerantwoordelijkeOrganisatie: "517439943",
|
||||||
Vertrouwelijkheidaanduiding: "openbaar",
|
Vertrouwelijkheidaanduiding: "openbaar",
|
||||||
Zaaktype: zaaktype!,
|
Zaaktype: zaaktype!,
|
||||||
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow));
|
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
|
||||||
|
Identificatie: reference);
|
||||||
|
|
||||||
var zaakUrl = await gateway.OpenZaakAsync(request);
|
var zaakUrl = await gateway.OpenZaakAsync(request);
|
||||||
|
|
||||||
@@ -36,10 +38,127 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
|
|||||||
new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(),
|
new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(),
|
||||||
zaakUrl.ToString());
|
zaakUrl.ToString());
|
||||||
|
|
||||||
// ...and that zaak is really persisted with the default-filled fields.
|
// ...and that zaak is really persisted with the default-filled fields + the reference identificatie.
|
||||||
var zaak = await stack.GetZaakAsync(zaakUrl);
|
var zaak = await stack.GetZaakAsync(zaakUrl);
|
||||||
Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString());
|
Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString());
|
||||||
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
|
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
|
||||||
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
|
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
|
||||||
|
Assert.Equal(reference, zaak.GetProperty("identificatie").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Setting_a_zaak_to_its_eindstatus_records_the_terminal_statustype()
|
||||||
|
{
|
||||||
|
var zaaktype = await stack.FindPublishedBigZaaktypeAsync();
|
||||||
|
Assert.True(zaaktype is not null,
|
||||||
|
"No published BIG-REGISTRATIE zaaktype found in OpenZaak — bring the stack up and " +
|
||||||
|
"seed it with OZ_PUBLISH=1 (`make integration` does this).");
|
||||||
|
|
||||||
|
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
||||||
|
var zaakUrl = await gateway.OpenZaakAsync(new ZaakRequest(
|
||||||
|
Bronorganisatie: "517439943",
|
||||||
|
VerantwoordelijkeOrganisatie: "517439943",
|
||||||
|
Vertrouwelijkheidaanduiding: "openbaar",
|
||||||
|
Zaaktype: zaaktype!,
|
||||||
|
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
|
||||||
|
Identificatie: Guid.NewGuid().ToString()));
|
||||||
|
|
||||||
|
await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
|
|
||||||
|
// The zaak now carries a current status, and it is the zaaktype's eindstatus.
|
||||||
|
var zaak = await stack.GetZaakAsync(zaakUrl);
|
||||||
|
var statusUrl = zaak.GetProperty("status").GetString();
|
||||||
|
Assert.False(string.IsNullOrEmpty(statusUrl), "the approved zaak has no current status");
|
||||||
|
|
||||||
|
var status = await stack.GetJsonAsync(new Uri(statusUrl!));
|
||||||
|
var eindstatustype = await stack.FindEindstatustypeAsync(zaaktype!);
|
||||||
|
Assert.Equal(eindstatustype.ToString(), status.GetProperty("statustype").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_a_zaak_records_the_geannuleerd_status_and_a_resultaat()
|
||||||
|
{
|
||||||
|
var zaaktype = await stack.FindPublishedBigZaaktypeAsync();
|
||||||
|
Assert.True(zaaktype is not null,
|
||||||
|
"No published BIG-REGISTRATIE zaaktype found in OpenZaak — bring the stack up and " +
|
||||||
|
"seed it with OZ_PUBLISH=1 (`make integration` does this).");
|
||||||
|
|
||||||
|
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
||||||
|
var zaakUrl = await gateway.OpenZaakAsync(new ZaakRequest(
|
||||||
|
Bronorganisatie: "517439943",
|
||||||
|
VerantwoordelijkeOrganisatie: "517439943",
|
||||||
|
Vertrouwelijkheidaanduiding: "openbaar",
|
||||||
|
Zaaktype: zaaktype!,
|
||||||
|
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
|
||||||
|
Identificatie: Guid.NewGuid().ToString()));
|
||||||
|
|
||||||
|
await gateway.SetZaakToCancellationStatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
|
|
||||||
|
// The zaak's current status is the Geannuleerd statustype — distinct from the approval eindstatus.
|
||||||
|
var zaak = await stack.GetZaakAsync(zaakUrl);
|
||||||
|
var statusUrl = zaak.GetProperty("status").GetString();
|
||||||
|
Assert.False(string.IsNullOrEmpty(statusUrl), "the cancelled zaak has no current status");
|
||||||
|
|
||||||
|
var status = await stack.GetJsonAsync(new Uri(statusUrl!));
|
||||||
|
var geannuleerd = await stack.FindStatustypeByOmschrijvingAsync(zaaktype!, "Geannuleerd");
|
||||||
|
Assert.Equal(geannuleerd.ToString(), status.GetProperty("statustype").GetString());
|
||||||
|
|
||||||
|
// ...and a resultaat is recorded (OpenZaak requires it before a closing/terminal status).
|
||||||
|
Assert.False(string.IsNullOrEmpty(zaak.GetProperty("resultaat").GetString()),
|
||||||
|
"the cancelled zaak has no resultaat");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Storing_a_diploma_creates_a_real_informatieobject_related_to_the_zaak()
|
||||||
|
{
|
||||||
|
var zaaktype = await stack.FindPublishedBigZaaktypeAsync();
|
||||||
|
Assert.True(zaaktype is not null,
|
||||||
|
"No published BIG-REGISTRATIE zaaktype found — seed the stack with OZ_PUBLISH=1.");
|
||||||
|
var informatieobjecttype = await stack.FindPublishedDiplomaInformatieobjecttypeAsync();
|
||||||
|
Assert.True(informatieobjecttype is not null,
|
||||||
|
"No published Diploma informatieobjecttype found — seed the stack with OZ_PUBLISH=1.");
|
||||||
|
|
||||||
|
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
||||||
|
var zaakUrl = await gateway.OpenZaakAsync(new ZaakRequest(
|
||||||
|
Bronorganisatie: "517439943",
|
||||||
|
VerantwoordelijkeOrganisatie: "517439943",
|
||||||
|
Vertrouwelijkheidaanduiding: "openbaar",
|
||||||
|
Zaaktype: zaaktype!,
|
||||||
|
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
|
||||||
|
Identificatie: Guid.NewGuid().ToString()));
|
||||||
|
|
||||||
|
var content = System.Text.Encoding.UTF8.GetBytes("%PDF-1.4 synthetic diploma\n");
|
||||||
|
var documentUrl = await gateway.StoreDocumentAsync(new DocumentRequest(
|
||||||
|
Bronorganisatie: "517439943",
|
||||||
|
Informatieobjecttype: informatieobjecttype!,
|
||||||
|
Vertrouwelijkheidaanduiding: "openbaar",
|
||||||
|
Zaak: zaakUrl,
|
||||||
|
Creatiedatum: DateOnly.FromDateTime(DateTime.UtcNow),
|
||||||
|
Titel: "Diploma",
|
||||||
|
Auteur: "zorgprofessional",
|
||||||
|
Taal: "nld",
|
||||||
|
Bestandsnaam: "diploma.pdf",
|
||||||
|
Formaat: "application/pdf",
|
||||||
|
Inhoud: content));
|
||||||
|
|
||||||
|
// The gateway returns the canonical informatieobject URL...
|
||||||
|
Assert.StartsWith(
|
||||||
|
new Uri(stack.BaseUrl, "/documenten/api/v1/enkelvoudiginformatieobjecten/").ToString(),
|
||||||
|
documentUrl.ToString());
|
||||||
|
|
||||||
|
// ...the document is really persisted with the default-filled fields...
|
||||||
|
var doc = await stack.GetJsonAsync(documentUrl);
|
||||||
|
Assert.Equal("diploma.pdf", doc.GetProperty("bestandsnaam").GetString());
|
||||||
|
Assert.Equal(informatieobjecttype.ToString(), doc.GetProperty("informatieobjecttype").GetString());
|
||||||
|
Assert.Equal(content.Length, doc.GetProperty("bestandsomvang").GetInt32());
|
||||||
|
// indicatieGebruiksrecht is recorded as "no restrictions"; left null, OpenZaak would refuse to
|
||||||
|
// close the zaak this document is related to (the S-10b regression that broke the e2e flow).
|
||||||
|
Assert.False(doc.GetProperty("indicatieGebruiksrecht").GetBoolean());
|
||||||
|
|
||||||
|
// ...and it is related to the zaak (a zaakinformatieobject links the two).
|
||||||
|
var relations = await stack.GetJsonAsync(new Uri(stack.BaseUrl,
|
||||||
|
"/zaken/api/v1/zaakinformatieobjecten?informatieobject=" + Uri.EscapeDataString(documentUrl.ToString())));
|
||||||
|
Assert.Contains(relations.EnumerateArray(),
|
||||||
|
r => r.GetProperty("zaak").GetString() == zaakUrl.ToString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,55 @@ public class AclServiceTests
|
|||||||
public ZaakRequest? Captured;
|
public ZaakRequest? Captured;
|
||||||
public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc");
|
public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc");
|
||||||
|
|
||||||
|
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved;
|
||||||
|
|
||||||
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Captured = request;
|
Captured = request;
|
||||||
return Task.FromResult(Result);
|
return Task.FromResult(Result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Cancelled;
|
||||||
|
|
||||||
|
public Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Cancelled = (zaakUrl, zaaktypeUrl, datumStatusGezet);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Uri? ReadReferenceFor;
|
||||||
|
|
||||||
|
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ReadReferenceFor = zaakUrl;
|
||||||
|
return Task.FromResult("REG-FROM-ZAAK");
|
||||||
|
}
|
||||||
|
|
||||||
|
public DocumentRequest? StoredDocument;
|
||||||
|
public Uri DocumentResult { get; } = new("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/doc-1");
|
||||||
|
|
||||||
|
public Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
StoredDocument = request;
|
||||||
|
return Task.FromResult(DocumentResult);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static AclDefaults Defaults() => new()
|
||||||
|
{
|
||||||
|
Bronorganisatie = "517439943",
|
||||||
|
VerantwoordelijkeOrganisatie = "517439943",
|
||||||
|
Vertrouwelijkheidaanduiding = "openbaar",
|
||||||
|
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
||||||
|
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
||||||
|
};
|
||||||
|
|
||||||
private sealed class FixedClock(DateOnly today) : IClock
|
private sealed class FixedClock(DateOnly today) : IClock
|
||||||
{
|
{
|
||||||
public DateOnly Today { get; } = today;
|
public DateOnly Today { get; } = today;
|
||||||
@@ -31,10 +73,11 @@ public class AclServiceTests
|
|||||||
VerantwoordelijkeOrganisatie = "517439943",
|
VerantwoordelijkeOrganisatie = "517439943",
|
||||||
Vertrouwelijkheidaanduiding = "openbaar",
|
Vertrouwelijkheidaanduiding = "openbaar",
|
||||||
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
||||||
|
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
||||||
};
|
};
|
||||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
var url = await service.OpenZaakAsync(new DomainRegistration("123456782"));
|
var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77"));
|
||||||
|
|
||||||
Assert.Equal(gateway.Result, url);
|
Assert.Equal(gateway.Result, url);
|
||||||
var req = Assert.IsType<ZaakRequest>(gateway.Captured);
|
var req = Assert.IsType<ZaakRequest>(gateway.Captured);
|
||||||
@@ -43,6 +86,8 @@ public class AclServiceTests
|
|||||||
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
|
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
|
||||||
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
|
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
|
||||||
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
|
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
|
||||||
|
// The registration reference becomes the zaak identificatie (#78).
|
||||||
|
Assert.Equal("reg-77", req.Identificatie);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -55,10 +100,123 @@ public class AclServiceTests
|
|||||||
VerantwoordelijkeOrganisatie = "517439943",
|
VerantwoordelijkeOrganisatie = "517439943",
|
||||||
Vertrouwelijkheidaanduiding = "openbaar",
|
Vertrouwelijkheidaanduiding = "openbaar",
|
||||||
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
||||||
|
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
||||||
};
|
};
|
||||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
|
||||||
Assert.Null(gateway.Captured);
|
Assert.Null(gateway.Captured);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_a_zaak_sets_it_to_its_zaaktypes_eindstatus_dated_today()
|
||||||
|
{
|
||||||
|
var gateway = new FakeGateway();
|
||||||
|
var defaults = Defaults();
|
||||||
|
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||||
|
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||||
|
|
||||||
|
await service.ApproveZaakAsync(zaak);
|
||||||
|
|
||||||
|
Assert.NotNull(gateway.Approved);
|
||||||
|
Assert.Equal(zaak, gateway.Approved!.Value.Zaak);
|
||||||
|
Assert.Equal(defaults.ZaaktypeUrl, gateway.Approved.Value.Zaaktype);
|
||||||
|
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Approved.Value.Datum);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
|
||||||
|
{
|
||||||
|
var gateway = new FakeGateway();
|
||||||
|
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
|
||||||
|
Assert.Null(gateway.Approved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_a_zaak_sets_it_to_the_cancellation_status_dated_today()
|
||||||
|
{
|
||||||
|
var gateway = new FakeGateway();
|
||||||
|
var defaults = Defaults();
|
||||||
|
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||||
|
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||||
|
|
||||||
|
await service.CancelZaakAsync(zaak);
|
||||||
|
|
||||||
|
Assert.NotNull(gateway.Cancelled);
|
||||||
|
Assert.Equal(zaak, gateway.Cancelled!.Value.Zaak);
|
||||||
|
Assert.Equal(defaults.ZaaktypeUrl, gateway.Cancelled.Value.Zaaktype);
|
||||||
|
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Cancelled.Value.Datum);
|
||||||
|
// Cancellation must not touch the approval path.
|
||||||
|
Assert.Null(gateway.Approved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_a_null_zaak_is_rejected_without_touching_the_gateway()
|
||||||
|
{
|
||||||
|
var gateway = new FakeGateway();
|
||||||
|
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.CancelZaakAsync(null!));
|
||||||
|
Assert.Null(gateway.Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Storing_a_diploma_default_fills_the_document_fields_and_returns_its_url()
|
||||||
|
{
|
||||||
|
var gateway = new FakeGateway();
|
||||||
|
var defaults = Defaults();
|
||||||
|
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||||
|
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||||
|
|
||||||
|
var url = await service.StoreDiplomaAsync(zaak, [1, 2, 3], "diploma.pdf", "application/pdf");
|
||||||
|
|
||||||
|
Assert.Equal(gateway.DocumentResult, url);
|
||||||
|
var req = gateway.StoredDocument!;
|
||||||
|
Assert.Equal(zaak, req.Zaak);
|
||||||
|
Assert.Equal(defaults.InformatieobjecttypeUrl, req.Informatieobjecttype);
|
||||||
|
Assert.Equal("517439943", req.Bronorganisatie);
|
||||||
|
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
|
||||||
|
Assert.Equal(new DateOnly(2026, 6, 4), req.Creatiedatum);
|
||||||
|
Assert.Equal("nld", req.Taal);
|
||||||
|
Assert.Equal("diploma.pdf", req.Bestandsnaam);
|
||||||
|
Assert.Equal("application/pdf", req.Formaat);
|
||||||
|
Assert.Equal(new byte[] { 1, 2, 3 }, req.Inhoud);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Storing_a_diploma_rejects_null_or_blank_arguments()
|
||||||
|
{
|
||||||
|
var service = new AclService(new FakeGateway(), Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||||
|
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.StoreDiplomaAsync(null!, [1], "d.pdf", "application/pdf"));
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.StoreDiplomaAsync(zaak, null!, "d.pdf", "application/pdf"));
|
||||||
|
await Assert.ThrowsAnyAsync<ArgumentException>(() => service.StoreDiplomaAsync(zaak, [1], " ", "application/pdf"));
|
||||||
|
await Assert.ThrowsAnyAsync<ArgumentException>(() => service.StoreDiplomaAsync(zaak, [1], "d.pdf", " "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie()
|
||||||
|
{
|
||||||
|
var gateway = new FakeGateway();
|
||||||
|
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||||
|
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||||
|
|
||||||
|
var reference = await service.GetZaakReferenceAsync(zaak);
|
||||||
|
|
||||||
|
Assert.Equal("REG-FROM-ZAAK", reference);
|
||||||
|
Assert.Equal(zaak, gateway.ReadReferenceFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reading_a_null_zaak_reference_is_rejected()
|
||||||
|
{
|
||||||
|
var gateway = new FakeGateway();
|
||||||
|
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
|
||||||
|
Assert.Null(gateway.ReadReferenceFor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class OpenZaakGatewayTests
|
|||||||
|
|
||||||
private static ZaakRequest SampleRequest() => new(
|
private static ZaakRequest SampleRequest() => new(
|
||||||
"517439943", "517439943", "openbaar",
|
"517439943", "517439943", "openbaar",
|
||||||
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4));
|
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4), "REG-REF-1");
|
||||||
|
|
||||||
private static StubHandler Created(out RequestCapture capture)
|
private static StubHandler Created(out RequestCapture capture)
|
||||||
{
|
{
|
||||||
@@ -67,6 +67,8 @@ public class OpenZaakGatewayTests
|
|||||||
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
|
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
|
||||||
Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
|
Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
|
||||||
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body);
|
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body);
|
||||||
|
// The registration reference is set as the zaak identificatie (#78).
|
||||||
|
Assert.Contains("\"identificatie\":\"REG-REF-1\"", capture.Body);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -131,8 +133,9 @@ public class OpenZaakGatewayTests
|
|||||||
Content = new StringContent("null", Encoding.UTF8, "application/json"),
|
Content = new StringContent("null", Encoding.UTF8, "application/json"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
|
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
|
||||||
|
Assert.Contains("empty zaak response", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -144,6 +147,417 @@ public class OpenZaakGatewayTests
|
|||||||
() => Gateway(handler).OpenZaakAsync(null!));
|
() => Gateway(handler).OpenZaakAsync(null!));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SetZaakToEindstatusAsync (approval / S-09b) ---
|
||||||
|
|
||||||
|
private const string ZaakUrl = "http://openzaak/zaken/api/v1/zaken/xyz";
|
||||||
|
private static readonly Uri Zaaktype = new("http://openzaak/catalogi/api/v1/zaaktypen/big");
|
||||||
|
|
||||||
|
private sealed class Recorder
|
||||||
|
{
|
||||||
|
public List<HttpRequestMessage> Requests { get; } = [];
|
||||||
|
public List<string?> Bodies { get; } = [];
|
||||||
|
public List<long?> ContentLengths { get; } = [];
|
||||||
|
|
||||||
|
public int IndexOf(string pathContains) =>
|
||||||
|
Requests.FindIndex(r => r.RequestUri!.ToString().Contains(pathContains));
|
||||||
|
|
||||||
|
// The (body, content-length, request) of the single request whose URL contains the segment.
|
||||||
|
public (string? Body, long? Length, HttpRequestMessage Request) Sent(string pathContains)
|
||||||
|
{
|
||||||
|
var i = IndexOf(pathContains);
|
||||||
|
return (Bodies[i], ContentLengths[i], Requests[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-route response config for the four calls the approval makes.
|
||||||
|
private sealed class OzRoutes
|
||||||
|
{
|
||||||
|
public string StatustypenJson { get; init; } = StatustypenPage(withEindstatusFlag: true);
|
||||||
|
public string ResultaattypenJson { get; init; } =
|
||||||
|
"""{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1","omschrijving":"Geregistreerd"}]}""";
|
||||||
|
public HttpStatusCode StatustypenStatus { get; init; } = HttpStatusCode.OK;
|
||||||
|
public HttpStatusCode ResultaattypenStatus { get; init; } = HttpStatusCode.OK;
|
||||||
|
public HttpStatusCode ResultaatPostStatus { get; init; } = HttpStatusCode.Created;
|
||||||
|
public HttpStatusCode StatusPostStatus { get; init; } = HttpStatusCode.Created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes the approval's four calls by URL: GET /statustypen, GET /resultaattypen (catalogus),
|
||||||
|
// then POST /resultaten and POST /statussen (zaken).
|
||||||
|
private static StubHandler ApprovalStub(Recorder rec, OzRoutes routes) => new(async req =>
|
||||||
|
{
|
||||||
|
rec.Requests.Add(req);
|
||||||
|
// Capture the length BEFORE reading the body (ReadAsStringAsync buffers as a side effect).
|
||||||
|
rec.ContentLengths.Add(req.Content?.Headers.ContentLength);
|
||||||
|
rec.Bodies.Add(req.Content is null ? null : await req.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
var url = req.RequestUri!.ToString();
|
||||||
|
if (req.Method == HttpMethod.Get && url.Contains("/statustypen"))
|
||||||
|
return Json(routes.StatustypenStatus, routes.StatustypenJson);
|
||||||
|
if (req.Method == HttpMethod.Get && url.Contains("/resultaattypen"))
|
||||||
|
return Json(routes.ResultaattypenStatus, routes.ResultaattypenJson);
|
||||||
|
if (url.Contains("/resultaten"))
|
||||||
|
return Json(routes.ResultaatPostStatus, """{"url":"http://openzaak/zaken/api/v1/resultaten/new"}""");
|
||||||
|
return Json(routes.StatusPostStatus, """{"url":"http://openzaak/zaken/api/v1/statussen/new"}""");
|
||||||
|
});
|
||||||
|
|
||||||
|
private static HttpResponseMessage Json(HttpStatusCode status, string body) =>
|
||||||
|
new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
||||||
|
|
||||||
|
// Two statustypen; the eindstatus is flagged on the *lower* volgnummer so the tests prove the
|
||||||
|
// isEindstatus flag is preferred over "highest volgnummer", not coincidentally equal to it.
|
||||||
|
private static string StatustypenPage(bool withEindstatusFlag) => JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
results = new object[]
|
||||||
|
{
|
||||||
|
new { url = "http://openzaak/catalogi/api/v1/statustypen/1", volgnummer = 1, isEindstatus = withEindstatusFlag },
|
||||||
|
new { url = "http://openzaak/catalogi/api/v1/statustypen/2", volgnummer = 2, isEindstatus = false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_sets_a_resultaat_then_posts_the_flagged_eindstatus_against_the_zaak()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
await Gateway(ApprovalStub(rec, new OzRoutes()))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
|
Assert.Equal(4, rec.Requests.Count);
|
||||||
|
|
||||||
|
// Both catalogus queries filter by the zaaktype and carry the bearer.
|
||||||
|
var statustypenGet = rec.Sent("/statustypen").Request;
|
||||||
|
Assert.Equal(HttpMethod.Get, statustypenGet.Method);
|
||||||
|
Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), statustypenGet.RequestUri!.ToString());
|
||||||
|
Assert.Equal("Bearer", statustypenGet.Headers.Authorization!.Scheme);
|
||||||
|
Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), rec.Sent("/resultaattypen").Request.RequestUri!.ToString());
|
||||||
|
|
||||||
|
// OpenZaak requires a resultaat before the eindstatus, so /resultaten precedes /statussen.
|
||||||
|
Assert.True(rec.IndexOf("/resultaten") < rec.IndexOf("/statussen"));
|
||||||
|
|
||||||
|
var resultaat = rec.Sent("/resultaten");
|
||||||
|
Assert.Equal("http://openzaak/zaken/api/v1/resultaten", resultaat.Request.RequestUri!.ToString());
|
||||||
|
Assert.Equal("Bearer", resultaat.Request.Headers.Authorization!.Scheme);
|
||||||
|
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", resultaat.Body);
|
||||||
|
Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/1\"", resultaat.Body);
|
||||||
|
Assert.True(resultaat.Length > 0);
|
||||||
|
|
||||||
|
var status = rec.Sent("/statussen");
|
||||||
|
Assert.Equal("http://openzaak/zaken/api/v1/statussen", status.Request.RequestUri!.ToString());
|
||||||
|
Assert.Equal("Bearer", status.Request.Headers.Authorization!.Scheme);
|
||||||
|
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", status.Body);
|
||||||
|
// The isEindstatus-flagged statustype (/1) is chosen — even though /2 has a higher volgnummer.
|
||||||
|
Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/1\"", status.Body);
|
||||||
|
Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", status.Body);
|
||||||
|
// Bodies are buffered (Content-Length set), so uwsgi doesn't get a chunked body.
|
||||||
|
Assert.True(status.Length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_selects_the_geregistreerd_resultaat_by_name_when_several_exist()
|
||||||
|
{
|
||||||
|
// Once S-10c adds a second resultaattype (Vervallen), picking the first is ambiguous — the
|
||||||
|
// Zaken API does not guarantee order. Approval must resolve its resultaat by omschrijving.
|
||||||
|
var rec = new Recorder();
|
||||||
|
var twoResultaattypen = """
|
||||||
|
{"results":[
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/resultaattypen/vervallen","omschrijving":"Vervallen"},
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd","omschrijving":"Geregistreerd"}
|
||||||
|
]}
|
||||||
|
""";
|
||||||
|
|
||||||
|
await Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = twoResultaattypen }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
|
Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd\"",
|
||||||
|
rec.Sent("/resultaten").Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SetZaakToCancellationStatusAsync (document-timeout cancellation / S-10c) ---
|
||||||
|
|
||||||
|
// A catalogus with the three statustypen S-10c seeds (Geannuleerd is non-terminal, below the
|
||||||
|
// Afgehandeld eindstatus) and both resultaattypen. Cancellation must resolve "Geannuleerd" and
|
||||||
|
// "Vervallen" by omschrijving, never the approval pair.
|
||||||
|
private const string CancellationStatustypenJson = """
|
||||||
|
{"results":[
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/statustypen/ontvangen","volgnummer":1,"omschrijving":"Ontvangen","isEindstatus":false},
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/statustypen/geannuleerd","volgnummer":2,"omschrijving":"Geannuleerd","isEindstatus":false},
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/statustypen/afgehandeld","volgnummer":3,"omschrijving":"Afgehandeld","isEindstatus":true}
|
||||||
|
]}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string CancellationResultaattypenJson = """
|
||||||
|
{"results":[
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd","omschrijving":"Geregistreerd"},
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/resultaattypen/vervallen","omschrijving":"Vervallen"}
|
||||||
|
]}
|
||||||
|
""";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_records_the_vervallen_resultaat_then_the_geannuleerd_status_against_the_zaak()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
await Gateway(ApprovalStub(rec, new OzRoutes
|
||||||
|
{
|
||||||
|
StatustypenJson = CancellationStatustypenJson,
|
||||||
|
ResultaattypenJson = CancellationResultaattypenJson,
|
||||||
|
})).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
|
// Resultaat precedes status (OpenZaak requires a resultaat before a closing/terminal status).
|
||||||
|
Assert.True(rec.IndexOf("/resultaten") < rec.IndexOf("/statussen"));
|
||||||
|
|
||||||
|
var resultaat = rec.Sent("/resultaten");
|
||||||
|
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", resultaat.Body);
|
||||||
|
// The cancellation resultaat (Vervallen) is chosen by name — not the approval one (Geregistreerd).
|
||||||
|
Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/vervallen\"", resultaat.Body);
|
||||||
|
|
||||||
|
var status = rec.Sent("/statussen");
|
||||||
|
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", status.Body);
|
||||||
|
// The Geannuleerd statustype is chosen by name — not the Afgehandeld eindstatus (approval).
|
||||||
|
Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/geannuleerd\"", status.Body);
|
||||||
|
Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", status.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_throws_when_the_zaaktype_has_no_geannuleerd_statustype()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes
|
||||||
|
{
|
||||||
|
// Only the approval statustypen — no "Geannuleerd".
|
||||||
|
StatustypenJson = StatustypenPage(withEindstatusFlag: true),
|
||||||
|
ResultaattypenJson = CancellationResultaattypenJson,
|
||||||
|
})).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("Geannuleerd", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_rejects_a_null_zaak_without_calling_openzaak()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||||
|
Gateway(handler).SetZaakToCancellationStatusAsync(null!, Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_rejects_a_null_zaaktype_without_calling_openzaak()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||||
|
Gateway(handler).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), null!, new DateOnly(2026, 6, 4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_surfaces_the_failure_when_recording_the_resultaat_is_rejected()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes
|
||||||
|
{
|
||||||
|
StatustypenJson = CancellationStatustypenJson,
|
||||||
|
ResultaattypenJson = CancellationResultaattypenJson,
|
||||||
|
ResultaatPostStatus = HttpStatusCode.BadRequest,
|
||||||
|
})).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("cancellation resultaat", ex.Message);
|
||||||
|
// It fails on the resultaat, before it ever posts the status.
|
||||||
|
Assert.Equal(-1, rec.IndexOf("/statussen"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_surfaces_the_failure_when_recording_the_status_is_rejected()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes
|
||||||
|
{
|
||||||
|
StatustypenJson = CancellationStatustypenJson,
|
||||||
|
ResultaattypenJson = CancellationResultaattypenJson,
|
||||||
|
StatusPostStatus = HttpStatusCode.BadRequest,
|
||||||
|
})).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("cancellation status", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_falls_back_to_the_highest_volgnummer_when_no_eindstatus_is_flagged()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
await Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = StatustypenPage(withEindstatusFlag: false) }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
|
// No isEindstatus flag → the highest volgnummer (/2) is chosen.
|
||||||
|
Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/2\"", rec.Sent("/statussen").Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_throws_when_the_zaaktype_has_no_statustypen()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
// A page with no `results` property (Results is null) — the eindstatus cannot be resolved.
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "{}" }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("No statustypen found", ex.Message);
|
||||||
|
// It never posts anything when it cannot resolve the eindstatus.
|
||||||
|
Assert.Single(rec.Requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_throws_when_the_statustypen_response_is_empty()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "null" }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("empty statustypen", ex.Message);
|
||||||
|
Assert.Single(rec.Requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_throws_when_the_statustypen_query_fails()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenStatus = HttpStatusCode.InternalServerError }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("Querying statustypen", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_throws_when_the_resultaattypen_query_fails()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenStatus = HttpStatusCode.InternalServerError }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("Querying resultaattypen", ex.Message);
|
||||||
|
Assert.Equal(-1, rec.IndexOf("/resultaten"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_throws_when_the_zaaktype_has_no_resultaattype()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = "{}" }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("'Geregistreerd' resultaattype", ex.Message);
|
||||||
|
// Resolved the eindstatus + queried resultaattypen, but posted nothing.
|
||||||
|
Assert.Equal(-1, rec.IndexOf("/resultaten"));
|
||||||
|
Assert.Equal(-1, rec.IndexOf("/statussen"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_throws_when_posting_the_resultaat_fails()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes { ResultaatPostStatus = HttpStatusCode.BadRequest }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("Setting the zaak resultaat", ex.Message);
|
||||||
|
// The status is never posted if the resultaat could not be recorded.
|
||||||
|
Assert.Equal(-1, rec.IndexOf("/statussen"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_throws_when_posting_the_status_fails()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes { StatusPostStatus = HttpStatusCode.BadRequest }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("Setting the zaak status", ex.Message);
|
||||||
|
// It got as far as the resultaat + the status POST (4 calls) before failing.
|
||||||
|
Assert.Equal(4, rec.Requests.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reading_a_zaak_returns_its_identificatie_with_bearer_and_crs()
|
||||||
|
{
|
||||||
|
RequestCapture? capture = null;
|
||||||
|
var handler = new StubHandler(req =>
|
||||||
|
{
|
||||||
|
capture = new RequestCapture { Seen = req };
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new { identificatie = "REG-XYZ", url = ZaakUrl }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var reference = await Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl));
|
||||||
|
|
||||||
|
Assert.Equal("REG-XYZ", reference);
|
||||||
|
Assert.Equal(HttpMethod.Get, capture!.Seen!.Method);
|
||||||
|
Assert.Equal(ZaakUrl, capture.Seen.RequestUri!.ToString());
|
||||||
|
Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme);
|
||||||
|
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen.Headers.GetValues("Accept-Crs")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reading_a_zaak_throws_when_openzaak_rejects_it()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ =>
|
||||||
|
Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("nope") }));
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(
|
||||||
|
() => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)));
|
||||||
|
Assert.Contains("Reading the zaak", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reading_a_zaak_throws_when_openzaak_returns_an_empty_body()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ =>
|
||||||
|
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)));
|
||||||
|
Assert.Contains("empty zaak response", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reading_a_null_zaak_is_rejected()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).GetZaakIdentificatieAsync(null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_rejects_a_null_zaak_or_zaaktype()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||||
|
Gateway(handler).SetZaakToEindstatusAsync(null!, Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||||
|
Gateway(handler).SetZaakToEindstatusAsync(new Uri(ZaakUrl), null!, new DateOnly(2026, 6, 4)));
|
||||||
|
}
|
||||||
|
|
||||||
// ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode.
|
// ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode.
|
||||||
private static string DecodeSegment(string segment)
|
private static string DecodeSegment(string segment)
|
||||||
{
|
{
|
||||||
@@ -151,4 +565,114 @@ public class OpenZaakGatewayTests
|
|||||||
b64 = (b64.Length % 4) switch { 2 => b64 + "==", 3 => b64 + "=", _ => b64 };
|
b64 = (b64.Length % 4) switch { 2 => b64 + "==", 3 => b64 + "=", _ => b64 };
|
||||||
return Encoding.UTF8.GetString(Convert.FromBase64String(b64));
|
return Encoding.UTF8.GetString(Convert.FromBase64String(b64));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- StoreDocumentAsync (diploma upload / S-10b) ---
|
||||||
|
|
||||||
|
private static readonly Uri Informatieobjecttype =
|
||||||
|
new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip");
|
||||||
|
|
||||||
|
private static DocumentRequest SampleDocument(byte[]? inhoud = null) => new(
|
||||||
|
Bronorganisatie: "517439943",
|
||||||
|
Informatieobjecttype: Informatieobjecttype,
|
||||||
|
Vertrouwelijkheidaanduiding: "openbaar",
|
||||||
|
Zaak: new Uri(ZaakUrl),
|
||||||
|
Creatiedatum: new DateOnly(2026, 6, 4),
|
||||||
|
Titel: "Diploma",
|
||||||
|
Auteur: "zorgprofessional",
|
||||||
|
Taal: "nld",
|
||||||
|
Bestandsnaam: "diploma.pdf",
|
||||||
|
Formaat: "application/pdf",
|
||||||
|
Inhoud: inhoud ?? [1, 2, 3, 4]);
|
||||||
|
|
||||||
|
// Routes the two document calls: POST /enkelvoudiginformatieobjecten (documenten) then
|
||||||
|
// POST /zaakinformatieobjecten (zaken).
|
||||||
|
private static StubHandler DocumentStub(Recorder rec) => new(async req =>
|
||||||
|
{
|
||||||
|
rec.Requests.Add(req);
|
||||||
|
rec.ContentLengths.Add(req.Content?.Headers.ContentLength);
|
||||||
|
rec.Bodies.Add(req.Content is null ? null : await req.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
return req.RequestUri!.ToString().Contains("/enkelvoudiginformatieobjecten")
|
||||||
|
? Json(HttpStatusCode.Created, """{"url":"http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/doc-1"}""")
|
||||||
|
: Json(HttpStatusCode.Created, """{"url":"http://openzaak/zaken/api/v1/zaakinformatieobjecten/rel-1"}""");
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Storing_a_document_creates_the_informatieobject_then_relates_it_to_the_zaak()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var url = await Gateway(DocumentStub(rec)).StoreDocumentAsync(SampleDocument([10, 20, 30]));
|
||||||
|
|
||||||
|
Assert.Equal("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/doc-1", url.ToString());
|
||||||
|
|
||||||
|
// 1. Create the enkelvoudiginformatieobject in the Documenten API.
|
||||||
|
var create = rec.Sent("/enkelvoudiginformatieobjecten");
|
||||||
|
Assert.Equal(HttpMethod.Post, create.Request.Method);
|
||||||
|
Assert.Equal("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten",
|
||||||
|
create.Request.RequestUri!.ToString());
|
||||||
|
Assert.Equal("Bearer", create.Request.Headers.Authorization!.Scheme);
|
||||||
|
Assert.Contains("\"bronorganisatie\":\"517439943\"", create.Body);
|
||||||
|
Assert.Contains("\"informatieobjecttype\":\"http://openzaak/catalogi/api/v1/informatieobjecttypen/dip\"", create.Body);
|
||||||
|
Assert.Contains("\"creatiedatum\":\"2026-06-04\"", create.Body);
|
||||||
|
Assert.Contains("\"titel\":\"Diploma\"", create.Body);
|
||||||
|
Assert.Contains("\"auteur\":\"zorgprofessional\"", create.Body);
|
||||||
|
Assert.Contains("\"taal\":\"nld\"", create.Body);
|
||||||
|
Assert.Contains("\"bestandsnaam\":\"diploma.pdf\"", create.Body);
|
||||||
|
Assert.Contains("\"formaat\":\"application/pdf\"", create.Body);
|
||||||
|
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", create.Body);
|
||||||
|
Assert.Contains("\"status\":\"definitief\"", create.Body);
|
||||||
|
// indicatieGebruiksrecht must be set explicitly (false = no usage restrictions); left null,
|
||||||
|
// OpenZaak refuses to close the zaak this document is related to ("indicatiegebruiksrecht-unset").
|
||||||
|
Assert.Contains("\"indicatieGebruiksrecht\":false", create.Body);
|
||||||
|
// The file content is base64-encoded into `inhoud`, with its byte length in `bestandsomvang`.
|
||||||
|
Assert.Contains($"\"inhoud\":\"{Convert.ToBase64String([10, 20, 30])}\"", create.Body);
|
||||||
|
Assert.Contains("\"bestandsomvang\":3", create.Body);
|
||||||
|
|
||||||
|
// 2. Relate that informatieobject to the zaak (Zaken API — no CRS).
|
||||||
|
var relate = rec.Sent("/zaakinformatieobjecten");
|
||||||
|
Assert.Equal(HttpMethod.Post, relate.Request.Method);
|
||||||
|
Assert.Equal("http://openzaak/zaken/api/v1/zaakinformatieobjecten",
|
||||||
|
relate.Request.RequestUri!.ToString());
|
||||||
|
Assert.Contains($"\"zaak\":\"{ZaakUrl}\"", relate.Body);
|
||||||
|
Assert.Contains("\"informatieobject\":\"http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/doc-1\"", relate.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Storing_a_document_buffers_the_body_and_sends_no_crs_headers()
|
||||||
|
{
|
||||||
|
// uwsgi rejects a chunked body (Content-Length must be present); the Documenten API is not a
|
||||||
|
// geo API, so no CRS headers (unlike the Zaken zaak-create).
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
await Gateway(DocumentStub(rec)).StoreDocumentAsync(SampleDocument());
|
||||||
|
|
||||||
|
var create = rec.Sent("/enkelvoudiginformatieobjecten");
|
||||||
|
Assert.NotNull(create.Length);
|
||||||
|
Assert.True(create.Length > 0);
|
||||||
|
Assert.False(create.Request.Headers.Contains("Accept-Crs"));
|
||||||
|
Assert.False(create.Request.Content!.Headers.Contains("Content-Crs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Storing_a_document_surfaces_an_openzaak_rejection()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ =>
|
||||||
|
Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||||
|
{
|
||||||
|
Content = new StringContent("""{"detail":"bad"}""", Encoding.UTF8, "application/json"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(
|
||||||
|
() => Gateway(handler).StoreDocumentAsync(SampleDocument()));
|
||||||
|
Assert.Contains("bad", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Storing_a_document_rejects_a_null_request()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).StoreDocumentAsync(null!));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,38 @@ namespace Bff.Api;
|
|||||||
public sealed record SubmitAccepted(string RegistrationId, string Status);
|
public sealed record SubmitAccepted(string RegistrationId, string Status);
|
||||||
|
|
||||||
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
|
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
|
||||||
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).</summary>
|
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).
|
||||||
public sealed record ProjectionEntry(string Id, string Status, string? Bsn, string? NaamPlaceholder);
|
/// <c>Reference</c> is the public-safe citizen reference (the zaak identificatie, #78).</summary>
|
||||||
|
public sealed record ProjectionEntry(string Id, string Status, string? Reference, string? Bsn, string? NaamPlaceholder);
|
||||||
|
|
||||||
/// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary>
|
/// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary>
|
||||||
public sealed record OpenbaarEntry(string Id, string Status);
|
public sealed record OpenbaarEntry(string Id, string Status, string? Reference);
|
||||||
|
|
||||||
|
/// <summary>A behandelaar's werkbak row: a registration awaiting beoordeling, with the bsn + status a
|
||||||
|
/// behandelaar sees (staff view — reached only behind medewerker/behandelaar authorization, S-12c).</summary>
|
||||||
|
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
|
||||||
|
|
||||||
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
|
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
|
||||||
public interface IDomainClient
|
public interface IDomainClient
|
||||||
{
|
{
|
||||||
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by
|
||||||
|
/// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is
|
||||||
|
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
|
||||||
|
Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Provide (upload) the diploma the caller's own registration is waiting for ("documenten
|
||||||
|
/// aanleveren"). The file is carried base64-encoded. Owner-scoped by <paramref name="bsn"/>. Returns
|
||||||
|
/// <c>false</c> when the domain reports the registration is unknown or not the caller's (404).</summary>
|
||||||
|
Task<bool> ProvideDocumentsAsync(
|
||||||
|
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
||||||
|
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Apply a behandelaar's decision (<c>goedkeuren</c>/<c>afwijzen</c>) to a registration.</summary>
|
||||||
|
Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Port to the read projection.</summary>
|
/// <summary>Port to the read projection.</summary>
|
||||||
@@ -36,6 +58,40 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
|||||||
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var response = await http.PostAsJsonAsync(
|
||||||
|
$"registrations/{registrationId}/withdraw", new { bsn }, ct);
|
||||||
|
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
return false;
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ProvideDocumentsAsync(
|
||||||
|
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var response = await http.PostAsJsonAsync(
|
||||||
|
$"registrations/{registrationId}/documents",
|
||||||
|
new { bsn, contentBase64, fileName, contentType }, ct);
|
||||||
|
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
return false;
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||||
|
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
||||||
|
|
||||||
|
public async Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var response = await http.PostAsJsonAsync(
|
||||||
|
$"registrations/{registrationId}/decide", new { besluit }, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
|
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ public static class OpenbaarProjection
|
|||||||
{
|
{
|
||||||
var filtered = string.IsNullOrWhiteSpace(q)
|
var filtered = string.IsNullOrWhiteSpace(q)
|
||||||
? entries
|
? entries
|
||||||
: entries.Where(e => e.Id.Contains(q, StringComparison.OrdinalIgnoreCase));
|
: entries.Where(e =>
|
||||||
|
e.Id.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(e.Reference?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||||
|
|
||||||
return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status))];
|
return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status, e.Reference))];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Bff.Api;
|
using Bff.Api;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
|
||||||
@@ -6,6 +8,10 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
|
|
||||||
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
|
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
|
||||||
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
|
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
|
||||||
|
// Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid),
|
||||||
|
// so the BFF validates a second issuer for the behandel endpoints (ADR-0013).
|
||||||
|
var medewerkerAuthority = builder.Configuration["Keycloak:MedewerkerAuthority"]
|
||||||
|
?? throw new InvalidOperationException("Missing configuration 'Keycloak:MedewerkerAuthority'");
|
||||||
var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
|
var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
|
||||||
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
|
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
|
||||||
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
|
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
|
||||||
@@ -19,8 +25,28 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
options.Authority = keycloakAuthority;
|
options.Authority = keycloakAuthority;
|
||||||
options.RequireHttpsMetadata = false;
|
options.RequireHttpsMetadata = false;
|
||||||
options.TokenValidationParameters.ValidateAudience = false;
|
options.TokenValidationParameters.ValidateAudience = false;
|
||||||
|
})
|
||||||
|
// The medewerker realm — behandel endpoints only. On validation we lift Keycloak's realm roles
|
||||||
|
// (the nested realm_access.roles claim) into role claims so authorization policies can require them.
|
||||||
|
.AddJwtBearer(BehandelAuth.Scheme, options =>
|
||||||
|
{
|
||||||
|
options.Authority = medewerkerAuthority;
|
||||||
|
options.RequireHttpsMetadata = false;
|
||||||
|
options.TokenValidationParameters.ValidateAudience = false;
|
||||||
|
options.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnTokenValidated = context =>
|
||||||
|
{
|
||||||
|
BehandelAuth.AddRealmRoles(context.Principal);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization(options =>
|
||||||
|
options.AddPolicy(BehandelAuth.Policy, policy => policy
|
||||||
|
.AddAuthenticationSchemes(BehandelAuth.Scheme)
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.RequireRole(BehandelAuth.BehandelaarRole)));
|
||||||
|
|
||||||
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
|
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
|
||||||
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
|
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
|
||||||
@@ -60,6 +86,46 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
|
|||||||
.Produces(StatusCodes.Status400BadRequest)
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
.Produces(StatusCodes.Status401Unauthorized);
|
.Produces(StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
|
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
|
||||||
|
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
|
||||||
|
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
|
||||||
|
app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var bsn = user.FindFirstValue("bsn");
|
||||||
|
if (string.IsNullOrWhiteSpace(bsn))
|
||||||
|
return Results.BadRequest("The token carries no bsn claim.");
|
||||||
|
|
||||||
|
var withdrawn = await domain.WithdrawRegistrationAsync(id, bsn, ct);
|
||||||
|
return withdrawn ? Results.NoContent() : Results.NotFound();
|
||||||
|
})
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
// Self-service provide-documents (S-10a): the signed-in zorgprofessional supplies the documents their
|
||||||
|
// registration is waiting for ("documenten aanleveren"). The bsn comes from the DigiD token and is
|
||||||
|
// forwarded to the domain, which owner-scopes the action and completes the WachtOpDocumenten task; a
|
||||||
|
// registration that is unknown or not the caller's comes back 404. The real file upload + ZGW storage
|
||||||
|
// is S-10b — this is the trigger that unblocks the process.
|
||||||
|
app.MapPost("/self-service/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var bsn = user.FindFirstValue("bsn");
|
||||||
|
if (string.IsNullOrWhiteSpace(bsn))
|
||||||
|
return Results.BadRequest("The token carries no bsn claim.");
|
||||||
|
if (string.IsNullOrWhiteSpace(body?.ContentBase64))
|
||||||
|
return Results.BadRequest("A document is required.");
|
||||||
|
|
||||||
|
var provided = await domain.ProvideDocumentsAsync(id, bsn, body.ContentBase64, body.FileName, body.ContentType, ct);
|
||||||
|
return provided ? Results.NoContent() : Results.NotFound();
|
||||||
|
})
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
|
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
|
||||||
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
@@ -68,7 +134,83 @@ app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection,
|
|||||||
})
|
})
|
||||||
.Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK);
|
.Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK);
|
||||||
|
|
||||||
|
// Behandelaar's werkbak: registrations awaiting beoordeling. Reached only with a medewerker-realm
|
||||||
|
// token carrying the behandelaar role; the BFF proxies the domain's werkbak (staff view, ADR-0013).
|
||||||
|
app.MapGet("/behandel/werkbak", async (IDomainClient domain, CancellationToken ct) =>
|
||||||
|
Results.Ok(await domain.GetWerkbakAsync(ct)))
|
||||||
|
.RequireAuthorization(BehandelAuth.Policy)
|
||||||
|
.Produces<IReadOnlyList<WerkbakItem>>(StatusCodes.Status200OK)
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
// A behandelaar's beoordeling on a registration (goedkeuren/afwijzen). Forwarded to the domain, which
|
||||||
|
// applies the decision and completes the workflow task (ADR-0013). Same medewerker/behandelaar gate.
|
||||||
|
app.MapPost("/behandel/registrations/{id}/decide",
|
||||||
|
async (string id, DecideRequest body, IDomainClient domain, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (!BehandelAuth.IsKnownBesluit(body.Besluit))
|
||||||
|
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
|
||||||
|
|
||||||
|
await domain.DecideAsync(id, body.Besluit, ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
})
|
||||||
|
.RequireAuthorization(BehandelAuth.Policy)
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
/// <summary>The behandelaar's decision on a registration.</summary>
|
||||||
|
public sealed record DecideRequest(string Besluit);
|
||||||
|
|
||||||
|
/// <summary>A diploma upload from the self-service portal — the file base64-encoded client-side, with
|
||||||
|
/// its name and MIME type. The bsn is taken from the DigiD token, not this body.</summary>
|
||||||
|
public sealed record ProvideDocumentsRequest(string ContentBase64, string? FileName = null, string? ContentType = null);
|
||||||
|
|
||||||
|
// Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013).
|
||||||
|
internal static class BehandelAuth
|
||||||
|
{
|
||||||
|
public const string Scheme = "medewerker";
|
||||||
|
public const string Policy = "behandelaar";
|
||||||
|
public const string BehandelaarRole = "behandelaar";
|
||||||
|
|
||||||
|
/// <summary>The beoordeling vocabulary the BFF accepts (case-insensitive); an unknown besluit is a
|
||||||
|
/// 400 without troubling the domain. Mirrors the domain's <c>BeoordelingsBesluit</c>.</summary>
|
||||||
|
public static bool IsKnownBesluit(string? besluit) =>
|
||||||
|
string.Equals(besluit, "goedkeuren", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(besluit, "afwijzen", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>Lift Keycloak's realm roles (the nested <c>realm_access.roles</c> claim) onto the
|
||||||
|
/// principal as role claims, so <c>RequireRole</c> can authorize on them.</summary>
|
||||||
|
public static void AddRealmRoles(ClaimsPrincipal? principal)
|
||||||
|
{
|
||||||
|
if (principal?.Identity is not ClaimsIdentity identity)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var realmAccess = principal.FindFirst("realm_access")?.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(realmAccess))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// A malformed realm_access claim must not fail authentication (a throw here becomes a 401);
|
||||||
|
// it simply yields no roles, so the authorization policy answers 403.
|
||||||
|
string[] roles;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
roles = JsonSerializer.Deserialize<RealmAccess>(realmAccess)?.Roles ?? [];
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var role in roles)
|
||||||
|
identity.AddClaim(new Claim(identity.RoleClaimType, role));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles);
|
||||||
|
}
|
||||||
|
|
||||||
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
|
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user