## What & why The behandel werkbak now **refreshes itself** while it is open, so a registration that reaches beoordeling after the behandelaar opened the page shows up on its own — no reload. `interval(WERKBAK_REFRESH_MS)` (5 s) re-reads the existing BFF endpoint, scoped to the page with `takeUntilDestroyed()`. A *background* read leaves the rows and states on screen alone until it has an answer, so a tick never flashes the loading state over rows being read and one failed poll never swaps the list for the error alert; a read that comes back also clears an earlier failure, so the view recovers on its own rather than needing the very reload this slice removes. No new endpoint, dependency or server-side state, and no service boundary moves — rxjs and `GET /behandel/werkbak` are both already here. **ADR-0032** records why polling rather than a pushed stream: nothing notifies the BFF either, so SSE/WebSockets would poll the domain *inside* the BFF for the same freshness, plus connection lifecycle, nginx buffering and a stateful BFF. Proposal: #163. Closes #162 ## Definition of Done - [x] Linked Gitea issue (above). - [x] Failing test committed before the implementation. - [x] Implementation makes the test pass; refactor commit if structure improved. - [x] Conventional Commits referencing the issue (`refs #162`). - [ ] CI green — all Gitea Actions jobs. - [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (unchanged; only the behandel bundle differs). - [x] Docs updated if behaviour, contracts, or operations changed. - [x] ADR added in `docs/architecture/` (ADR-0032). - [x] Demo note in `docs/demo-script.md` (user-visible). ## Notes for reviewers **The e2e is the real acceptance test, and it took two goes to make it one.** Simply dropping the `staff.reload()` from the happy path proved nothing: the werkbak was visited *after* the documents were supplied, so the row was already there at page load. The spec now logs the behandelaar in **first**, asserts the row is not there yet, and only then has the citizen supply the documents that route it to Beoordelen — so the row can only reach that already-open, never-reloaded page via the refresh. Verified both ways against a live stack: with the interval stubbed out it fails at `Goedkeuren <ref> … element(s) not found` after 30 s; with it, the behandel nginx logs the poll that delivers the row. The page is foregrounded before the assertion because Chromium throttles timers in a hidden tab. **Ceiling (named in the ADR):** a fixed 5 s interval, per open page, that keeps polling in a background tab; each tick costs one Flowable task query plus a store read per open task. Upgrade path: publish task events from the domain, then swap the `interval` for a stream — the endpoint contract and the rendering stay put. Gate on `document.visibilityState` first if request volume is the concern. **Two housekeeping notes, neither blocking:** - #162 is on **no milestone** (DoD item 1). It is portal UX, so it fits neither *Data Governance* nor *Production Posture* cleanly — your call where it lands. - The issue titles itself **S-26**, which already belongs to the self-service resume slice (#111, `BACKLOG.md`). Everything here references **#162**; worth renumbering the title if the S-ids are meant to stay unique. `BACKLOG.md` is untouched for the same reason (it mirrors the active milestone, and this slice is on none).Reviewed-on: #164
881 lines
48 KiB
Markdown
881 lines
48 KiB
Markdown
# Demo script
|
|
|
|
A running log of demoable outcomes, one section per slice. Each entry is a short,
|
|
copy-pasteable walkthrough against a local `make up` stack.
|
|
|
|
---
|
|
|
|
## S-26/#162 — the werkbak refreshes itself (ADR-0032)
|
|
|
|
**Outcome:** a registration that reaches beoordeling while a behandelaar already has the werkbak open
|
|
**appears on its own** — no reload. The page re-reads `GET /behandel/werkbak` every 5 seconds; a
|
|
background refresh swaps the rows in without flashing the loading state, and a transient failure no
|
|
longer strands the view on its error message until someone reloads.
|
|
|
|
```bash
|
|
# 1. Two windows. Left: the behandel werkbak, already open and idle.
|
|
python3 infra/keycloak/check_realms.py otp # a code, valid right now
|
|
open http://localhost:8142 # merel-behandelaar / test123 + that code
|
|
#
|
|
# 2. Right: submit a registration and supply its documents (this is what routes it to Beoordelen).
|
|
open http://localhost:8140 # jan-burger / test123 → indienen → upload a PDF
|
|
#
|
|
# 3. Watch the left window. Within ~5 seconds the new reference appears in the werkbak — the page was
|
|
# never reloaded and never left the werkbak.
|
|
#
|
|
# 4. Automated, end to end: the happy path now waits for the werkbak row WITHOUT reloading, so the
|
|
# absence of the reload IS the assertion.
|
|
make verify-e2e # → registration.spec: "… → behandelaar goedkeurt → public INGESCHREVEN"
|
|
#
|
|
# 5. Component level (background refresh, failure recovery, teardown):
|
|
pnpm nx test behandel # → "picks up a newly submitted registration without a reload" (+3 guards)
|
|
```
|
|
|
|
**The path:** unchanged — portal → BFF `GET /behandel/werkbak` → domain `Werkbak` → Flowable. Only the
|
|
page's cadence is new: `interval(WERKBAK_REFRESH_MS)` scoped to the page with `takeUntilDestroyed()`.
|
|
|
|
**Not push:** nothing notifies the BFF either, so SSE/WebSockets would poll the domain inside the BFF
|
|
for the same freshness plus connection state — see ADR-0032 for the trade-off and the upgrade path.
|
|
|
|
---
|
|
|
|
## S-19a — approval writes the register record to Objecten (#149, ADR-0028)
|
|
|
|
**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also
|
|
writes the canonical **register record** into the **Objecten** API. OpenZaak keeps the process,
|
|
Objecten holds the register. The write goes through the ACL (§8.1) and is **idempotent**: replaying an
|
|
approval updates the existing object instead of creating a second one.
|
|
|
|
```bash
|
|
# 1. Bring the stack up (Objecten, Objecttypen and the RegisterRecord objecttype come with it).
|
|
make up
|
|
#
|
|
# 2. End-to-end: the walking-skeleton e2e submits, approves via the behandel portal, and then
|
|
# asserts Objecten holds exactly one RegisterRecord for *that* registration:
|
|
make verify-e2e # → "DigiD submit → … → behandelaar goedkeurt → public INGESCHREVEN"
|
|
#
|
|
# 3. The ACL integration test proves the same writes against a live Objecten (upsert stays one object):
|
|
make verify-acl # → "Writes a register record and updates it in place on a second write"
|
|
#
|
|
# 4. See it for yourself — every register record currently in Objecten:
|
|
curl -s -H 'Authorization: Token 1234567890abcdef1234567890abcdef12345678' \
|
|
-H 'Accept-Crs: EPSG:4326' \
|
|
'http://localhost:8021/api/v2/objects' | python3 -m json.tool
|
|
```
|
|
|
|
Each object's `record.data` carries exactly `id`, `status`, `reference` — the schema forbids anything
|
|
else (ADR-0027), so no personal data can reach the world-readable register even by mistake.
|
|
|
|
**The path:** behandel portal → BFF → domain `BeoordeelRegistratie` → ACL `POST /statussen` → ZGW
|
|
`resultaten` + `statussen` (the process), **then** ACL → Objecten `POST`/`PATCH /api/v2/objects` (the
|
|
register). The objecttype URL is resolved by name from Objecttypen on first use, so nothing seed-time
|
|
is pinned in config (ADR-0028, same reasoning as ADR-0021).
|
|
|
|
**Not yet:** the public register still reads the NRC-derived projection — re-sourcing it from Objecten
|
|
is S-19b (#150).
|
|
|
|
---
|
|
|
|
## S-18c — RegisterRecord objecttype defined + registered (#141, ADR-0027)
|
|
|
|
**Outcome:** a **RegisterRecord** objecttype with a **published** JSON schema is registered in the
|
|
Objecttypen API at startup. The schema is public-safe by construction — `id`, `status`, `reference`
|
|
only, mirroring the BFF's `OpenbaarEntry` (no `bsn`/`naam`), `dataClassification: open`. This is the
|
|
schema S-19 writes register records against on approval. A `registerrecord-init` one-shot creates it
|
|
over the API once Objecttypen is healthy (the Objecttypen `setup_configuration` has no objecttype
|
|
step), idempotently.
|
|
|
|
```bash
|
|
make up
|
|
# The RegisterRecord objecttype exists with a published version:
|
|
curl -s -H "Authorization: Token 0123456789abcdef0123456789abcdef01234567" \
|
|
"http://localhost:8020/api/v2/objecttypes" | python3 -c \
|
|
'import sys,json; o=[x for x in json.load(sys.stdin)["results"] if x["name"]=="RegisterRecord"][0]; print(o["name"], o["dataClassification"], o["versions"])'
|
|
# → RegisterRecord open ['http://.../objecttypes/<uuid>/versions/1']
|
|
#
|
|
# Automated (a CI verify-stack step): asserts the objecttype exists, has a published version, and
|
|
# that version's schema carries id/status/reference.
|
|
make verify-registerrecord # → OK — RegisterRecord v1 published, fields=['id', 'reference', 'status']
|
|
```
|
|
|
|
**The path:** `infra/objecttypen-registerrecord/registerrecord.schema.json` (the reviewed public-safe
|
|
contract) + `register.py` are streamed into an external config volume by `infra/seed-config.sh
|
|
registerrecord` (bind-mounted locally); the `registerrecord-init` one-shot POSTs the objecttype + a
|
|
published version. Re-running is a no-op. S-19 (#20) writes records against this schema in Objecten.
|
|
|
|
---
|
|
|
|
## S-18b — Objecten API up in compose, wired to Objecttypen (#140)
|
|
|
|
**Outcome:** the upstream Maykin **Objecten API** runs in the stack — own **PostGIS** DB + redis,
|
|
config seeded like the other CG modules (`objecten-init` runs `setup_configuration` from the
|
|
`rr-objecten-config` volume: migrate + provision a dev **static API token** + register the
|
|
**Objecttypen API** (S-18a) as a trusted service), a health-checked `objecten` web on host `:8021`.
|
|
An object can now reference its objecttype; the ACL writes register records here on approval (S-19).
|
|
|
|
```bash
|
|
make up
|
|
# 1. The API is up; the seeded token authenticates (401 without, 200 with):
|
|
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8021/api/v2/objects # 401
|
|
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Token 1234567890abcdef1234567890abcdef12345678" \
|
|
http://localhost:8021/api/v2/objects # 200
|
|
#
|
|
# 2. It trusts Objecttypen — the seeded zgw_consumers service points at the Objecttypen API:
|
|
docker exec infra-objecten-1 python src/manage.py shell -c \
|
|
"from zgw_consumers.models import Service; print(*[(s.slug,s.api_root) for s in Service.objects.all()])"
|
|
# → ('objecttypen', 'http://objecttypen:8000/api/v2/')
|
|
#
|
|
# 3. Automated (a CI verify-stack step): asserts unauth 401 + token 200, against the running stack.
|
|
make verify-objecten # → OK — no-auth 401, token 200
|
|
```
|
|
|
|
**The path:** verbatim upstream image (`maykinmedia/objects-api`, pinned 3.4.0) + the same seed
|
|
pattern as S-18a — `infra/seed-config.sh objecten` streams `data.yaml` into an external config
|
|
volume, `objecten-init` (RUN_SETUP_CONFIG) applies it. Its `zgw_consumers` step registers Objecttypen
|
|
(`api_type: orc`, api-key auth with the S-18a dev token). The RegisterRecord objecttype (S-18c) and
|
|
the ACL write path (S-19) build on this.
|
|
|
|
---
|
|
|
|
## S-18a — Objecttypen API up in compose (#139)
|
|
|
|
**Outcome:** the upstream Maykin **Objecttypen API** runs in the stack — own Postgres + redis, config
|
|
seeded like the other CG modules (`objecttypen-init` runs `setup_configuration` from the
|
|
`rr-objecttypen-config` volume: migrate + provision a dev **static API token**), a health-checked
|
|
`objecttypen` web service on host `:8020`. This is the objecttype catalogue the register record
|
|
(S-18b/S-18c, S-19) will use.
|
|
|
|
```bash
|
|
make up
|
|
# 1. The API is up; the seeded token authenticates (401 without, 200 with):
|
|
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8020/api/v2/objecttypes # 401
|
|
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Token 0123456789abcdef0123456789abcdef01234567" \
|
|
http://localhost:8020/api/v2/objecttypes # 200
|
|
#
|
|
# 2. Automated (a CI verify-stack step): asserts both, against the running stack.
|
|
make verify-objecttypen # → OK — no-auth 401, token 200
|
|
```
|
|
|
|
**The path:** verbatim upstream image (`maykinmedia/objecttypes-api`, pinned) + the same seed pattern
|
|
as OpenZaak/NRC — `infra/seed-config.sh objecttypen` streams `data.yaml` into an external config
|
|
volume, `objecttypen-init` (RUN_SETUP_CONFIG) applies it. Objecten (S-18b) and the RegisterRecord
|
|
objecttype (S-18c) build on this.
|
|
|
|
---
|
|
|
|
## S-15b — Beheer-portal: default-fill configuration editor (#131, ADR-0026)
|
|
|
|
**Outcome:** a beheerder edits the ACL's ZGW **default-fill** values (bronorganisatie,
|
|
verantwoordelijke organisatie, vertrouwelijkheidaanduiding) from the beheer portal, and the next zaak
|
|
is stamped with the new values — no restart. Path: portal → BFF `GET/PUT /beheer/default-fill`
|
|
(beheerder role) → ACL `GET/PUT /default-fill` → a runtime-mutable in-memory store the ACL reads per
|
|
zaak (ADR-0026). The S-27 catalog-resolution keys stay static config (editing them would desync the
|
|
zaaktype cache). Store is in-memory: an edit reverts to the configured env on restart.
|
|
|
|
```bash
|
|
make up
|
|
# 1. Log in as bram-beheerder / test123 + OTP (`python3 infra/keycloak/check_realms.py otp`)
|
|
# → "Default-fill" tab → change a value → Opslaan.
|
|
open http://localhost:8143/default-fill
|
|
#
|
|
# 2. Automated: the ACL uses the current default-fill per zaak (unit) and the endpoints are behind the
|
|
# beheerder role (BFF unit):
|
|
# Acl.Tests → AclServiceTests.Opening_a_zaak_reflects_a_default_fill_update
|
|
# Bff.Tests → BeheerDefaultFillEndpointTests
|
|
```
|
|
|
|
---
|
|
|
|
## S-15a — Beheer-portal: read-only catalogus viewer (#130, ADR-0025)
|
|
|
|
**Outcome:** a new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus —
|
|
the published zaaktypen — **read-only**. A beheerder logs in and sees the seeded BIG-REGISTRATIE
|
|
zaaktype. The read path is portal → BFF `GET /beheer/catalogi/zaaktypen` (medewerker realm +
|
|
`beheerder` role) → ACL `GET /catalogi/zaaktypen` → ZGW Catalogi API. The BFF reaches the ACL
|
|
directly (ADR-0025); managing the default-fill config (S-15b) and MFA (S-15c) come next.
|
|
|
|
```bash
|
|
make up
|
|
# 1. Log in as bram-beheerder / test123 + OTP (`python3 infra/keycloak/check_realms.py otp`)
|
|
# → the catalogus lists the published zaaktypen.
|
|
open http://localhost:8143
|
|
#
|
|
# 2. Automated (a CI verify-stack e2e): a beheerder logs in and sees BIG-REGISTRATIE.
|
|
make verify-e2e # → catalogus.spec: "a beheerder sees the published zaaktypen in the catalogus"
|
|
#
|
|
# 3. The BFF endpoint is behind the beheerder role — a plain behandelaar gets 403 (BFF unit tests):
|
|
# Bff.Tests → BeheerEndpointTests.
|
|
```
|
|
|
|
**Auth:** the `beheerder` realm role + `bram-beheerder` user live in the medewerker realm
|
|
(`infra/keycloak/realms/medewerker-realm.json`); the BFF reuses the medewerker bearer scheme and its
|
|
realm-role lifting, requiring `beheerder` rather than `behandelaar`.
|
|
|
|
---
|
|
|
|
## S-16c — Prometheus metrics + golden-signal Grafana dashboard (#124, ADR-0023)
|
|
|
|
**Outcome:** the five .NET services now expose OpenTelemetry metrics in Prometheus format at `/metrics`
|
|
— ASP.NET Core + `HttpClient` instrumentation plus the built-in `System.Runtime` meter. Prometheus
|
|
scrapes each service (one job per service), and a **pre-built Grafana dashboard** — *Request path —
|
|
golden signals* — plots the four golden signals: **traffic** (req/s), **errors** (5xx/s), **latency**
|
|
(p95 request duration), and **saturation** (CPU cores in use), split by service. It populates under load.
|
|
|
|
```bash
|
|
# 1. Automated (a CI verify-stack step): generate BFF traffic and assert Prometheus scraped the
|
|
# golden-signal metric from every service.
|
|
make verify-metrics # → OK — targets up: [...]; request metric scraped from: [...]
|
|
|
|
# 2. By hand: drive the stack, generate some load, then open the dashboard.
|
|
make up
|
|
for i in $(seq 1 50); do curl -s localhost:8080/openbaar/register >/dev/null; done # BFF → projection-api
|
|
open http://localhost:3000 # Grafana → Dashboards → "Request path — golden signals"
|
|
open http://localhost:9090/targets # Prometheus → every service target UP
|
|
```
|
|
|
|
**The path:** each host adds `.WithMetrics(AddAspNetCoreInstrumentation + AddHttpClientInstrumentation +
|
|
AddMeter("System.Runtime") + AddPrometheusExporter)` and maps `/metrics`; Prometheus scrapes
|
|
`<service>:8080/metrics` (config in `infra/observability/prometheus/prometheus.yml`); Grafana ships the
|
|
dashboard via provisioning against the fixed `prometheus` datasource uid. No metrics are pushed over
|
|
OTLP — Prometheus pulls, so there is no collector hop (ADR-0023).
|
|
|
|
---
|
|
|
|
## S-16b — distributed traces across the .NET services (#123, ADR-0023)
|
|
|
|
**Outcome:** the five .NET services (BFF, Domain, ACL, projection-api, event-subscriber) now emit
|
|
OpenTelemetry traces — ASP.NET Core + `HttpClient` auto-instrumentation, exported over OTLP to Tempo.
|
|
Because every cross-service call goes through a typed `HttpClient`, the W3C `traceparent` propagates for
|
|
free, so a request is **one connected trace** across the services (bff → domain → acl → openzaak;
|
|
bff → projection-api). `/health` is filtered out. No browser-side instrumentation yet, so the trace
|
|
begins at the BFF; the async Flowable-poll boundary is a separate trace (ADR-0023).
|
|
|
|
```bash
|
|
# 1. Automated (a CI verify-stack step): generate BFF traffic and assert Tempo holds one trace
|
|
# spanning multiple services.
|
|
make verify-tracing # → OK — trace <id> spans ['bff', 'projection-api']
|
|
|
|
# 2. By hand: drive the stack, then explore traces in Grafana.
|
|
make up
|
|
curl -s localhost:8080/openbaar/register >/dev/null # BFF → projection-api
|
|
open http://localhost:3000 # Grafana → Explore → Tempo → Search → service.name = bff → open a trace
|
|
```
|
|
|
|
**The path:** each host wires `AddOpenTelemetry().WithTracing(AddAspNetCoreInstrumentation +
|
|
AddHttpClientInstrumentation + AddOtlpExporter)`; `OTEL_SERVICE_NAME` / `OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
come from compose; spans export to **tempo:4317** and render in Grafana against the provisioned Tempo
|
|
datasource.
|
|
|
|
---
|
|
|
|
## S-16a — observability backplane: Tempo + Prometheus + Grafana (#122, ADR-0023)
|
|
|
|
**Outcome:** the compose stack now includes a Grafana-native observability backplane — **Tempo** (OTLP
|
|
trace ingest on 4317/4318), **Prometheus**, and **Grafana** with both datasources auto-provisioned.
|
|
Nothing is instrumented yet (traces land in S-16b, metrics + dashboards in S-16c); this slice stands the
|
|
backplane up and proves Grafana can reach both datasources. Config is baked into small built images
|
|
(`infra/observability/`) — no collector, no config-volume seeding.
|
|
|
|
```bash
|
|
# 1. Bring the stack up, then assert the backplane is live (Grafana healthy + Tempo/Prometheus
|
|
# datasources reachable through Grafana). This is a CI verify-stack step.
|
|
make up
|
|
make verify-observability # → ✓ Grafana healthy ✓ Prometheus reachable ✓ Tempo reachable
|
|
|
|
# 2. Or just the backplane, no full stack needed (no external egress):
|
|
docker compose -f infra/docker-compose.yml up -d --build tempo prometheus grafana
|
|
open http://localhost:3000 # Grafana (admin/admin) → Connections → Data sources: Prometheus + Tempo
|
|
open http://localhost:9090 # Prometheus
|
|
```
|
|
|
|
**The path:** services will export OTLP → **Tempo:4317** and expose `/metrics` ← **Prometheus** scrapes;
|
|
**Grafana** (:3000) reads both via provisioned datasources with fixed uids `tempo` / `prometheus`.
|
|
|
|
---
|
|
|
|
## S-17 — herregistratie reminder sweep on a Quartz cron (#18, ADR-0022)
|
|
|
|
**Outcome:** an inscription (INGESCHREVEN) now carries the moment it was entered in the register, from
|
|
which its herregistratie deadline is derived (inscription + 5-year validity). A **Quartz.NET** cron job
|
|
in the Domain Service sweeps once a day (03:00, overridable via `Quartz__Cron`): every inscription
|
|
inside the 90-day window before its deadline is flagged `HerregistratieReminderVerstuurd` and logged.
|
|
The sweep is idempotent — a re-fire reminds no one twice — and is a deliberately different mechanism
|
|
from the queue-draining pumps (Quartz fires time-triggered sweeps; pumps drain Flowable queues,
|
|
ADR-0022). There is no outbound notification in v1: the reminder is the flag on the aggregate plus a
|
|
log line.
|
|
|
|
```bash
|
|
# 1. The domain unit tests prove the rule and the sweep end to end (rule → store query → sweep):
|
|
cd services/domain && dotnet test Big.Tests/Big.Tests.csproj \
|
|
--filter "FullyQualifiedName~Herregistratie|FullyQualifiedName~ReminderSweep"
|
|
# → the reminder is due once the 90-day window opens, not before; a reminded inscription is skipped
|
|
# on the next sweep; the sweep flags + persists every due inscription and returns their ids.
|
|
|
|
# 2. The read model surfaces the deadline once a registration is approved — the field the sweep acts on:
|
|
curl -s localhost:8000/registrations/<id> | jq '{status, herregistratieVoor, herregistratieReminderVerstuurd}'
|
|
# → after approval: herregistratieVoor is inscription + 5 years; the flag flips true once swept.
|
|
```
|
|
|
|
**The path:** `Registration.Approve(now)` stamps `IngeschrevenOp` → daily Quartz `HerregistratieReminderJob`
|
|
→ `HerregistratieReminderSweep` → `IRegistrationStore.FindDueForHerregistratieReminderAsync` (filtered by
|
|
the aggregate's own `HerregistratieReminderDue` rule) → `MarkHerregistratieReminderVerstuurd` + log.
|
|
|
|
---
|
|
|
|
## S-B04 — `make local` completes the whole flow with no manual seeding (#110, ADR-0020)
|
|
|
|
**Outcome:** the host-browser stack (`make local`) now self-seeds at bring-up — it publishes the BIG
|
|
zaaktype and wires the ACL to it, deploys the `diploma-eligibility` DMN, and registers the NRC
|
|
abonnement — so a fresh bring-up runs submit → werkbak → openbaar without the manual seeding the
|
|
`verify-*` scripts do for CI. (Previously the process stuck at `OpenZaakAanmaken`, the werkbak stayed
|
|
empty, and the openbaar register showed nothing.)
|
|
|
|
```bash
|
|
# 1. Fresh bring-up (self-seeding init containers: local-seed, nrc-subscribe; DMN in flowable-init).
|
|
make local
|
|
|
|
# 2. Assert the whole flow works with no manual seeding — submit opens a zaak, documents route it to
|
|
# the werkbak, and the reference appears in the openbaar register:
|
|
make verify-local # → "OK — a fresh local stack completed the flow with no manual seeding ..."
|
|
|
|
# 3. Or by hand in the browser: log in at http://localhost:8140 (jan-burger / test123), submit +
|
|
# upload a PDF, then approve it in the werkbak at http://localhost:8142 (merel-behandelaar /
|
|
# test123 + OTP, see S-15c); it shows as INGESCHREVEN in the openbaar register at
|
|
# http://localhost:8141.
|
|
```
|
|
|
|
> The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now
|
|
> carries only OpenZaak's IP base URL, which the ACL still needs because OpenZaak rejects a
|
|
> single-label host on zaak-create (ADR-0020 + ADR-0021).
|
|
|
|
---
|
|
|
|
## S-27 — ACL resolves its zaaktype by identificatie, not a pinned URL (#113, ADR-0021)
|
|
|
|
**Outcome:** the ACL discovers its BIG zaaktype (by `identificatie`) and diploma informatieobjecttype
|
|
(by `omschrijving`) from OpenZaak's Catalogi API, instead of being handed the server-assigned URLs.
|
|
No user-visible behaviour change — the flow runs exactly as before — but no stack captures/injects a
|
|
zaaktype URL any more, and a missing catalogus now fails with a clear message instead of an opaque 400.
|
|
|
|
```bash
|
|
# The live ACL↔OpenZaak integration test proves resolution against a real seeded OpenZaak:
|
|
make verify-acl # → "resolves the published BIG-REGISTRATIE zaaktype + Diploma informatieobjecttype by business key"
|
|
|
|
# End-to-end unchanged (the ACL self-discovers the zaaktype during the flow):
|
|
make verify-local # local stack — still green, now with no zaaktype-URL injection
|
|
make verify-domain # CI stack — recreates the ACL pointed only at OpenZaak's IP (no URL to inject)
|
|
```
|
|
|
|
> The ACL still needs its OpenZaak base URL at a URL-valid host (a container IP): OpenZaak's
|
|
> URLValidator rejects a single-label host like `openzaak:8000` on zaak-create. So ADR-0020's base-URL
|
|
> injection stays; only the zaaktype/informatieobjecttype **URL** injection is gone (ADR-0021).
|
|
|
|
---
|
|
|
|
## 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)
|
|
|
|
**Outcome:** a zorgprofessional logs in via mock DigiD and submits a BIG registration through the
|
|
self-service portal (NL Design System styling); the page confirms with the reference returned by the
|
|
BFF. The bsn comes from the DigiD token, so it's a confirm-and-submit flow (no bsn field).
|
|
|
|
```bash
|
|
# 1. Bring the backend + Keycloak up (BFF on :8080, Keycloak on :8180).
|
|
make up
|
|
|
|
# 2. Serve the portal (dev server); it redirects to Keycloak for DigiD login.
|
|
pnpm nx serve self-service # → http://localhost:4200
|
|
|
|
# 3. In the browser: log in as the mock DigiD user jan-burger / test123, then submit.
|
|
# The page shows the returned registration reference.
|
|
```
|
|
|
|
> First real UI. The full **login → submit → success** happy path is automated in **S-08d**
|
|
> (Playwright, against the compose-served app). Component tests + an axe WCAG 2.1 AA check on the
|
|
> submit page run headless in the `frontend` CI lane. See `docs/frontend-decisions.md`.
|
|
|
|
---
|
|
|
|
## S-08a — Nx workspace + self-service portal skeleton
|
|
|
|
**Outcome:** the frontend foundation — an Nx (pnpm) monorepo with the `self-service` Angular app
|
|
(standalone + signals), lint/test/build green in a CI Node lane. The login + submit form follow in
|
|
S-08c.
|
|
|
|
```bash
|
|
# From a fresh clone (Node 24 + pnpm 11):
|
|
pnpm install # native builds are pre-approved in pnpm-workspace.yaml
|
|
pnpm nx test self-service # Vitest component test
|
|
pnpm nx build self-service # production build
|
|
pnpm nx serve self-service # → http://localhost:4200 (placeholder page)
|
|
# Or the CI-equivalent one-shot:
|
|
make frontend # install + nx lint/test/build
|
|
```
|
|
|
|
> Nx manages only `apps/`+`libs/`; the .NET services stay on `dotnet`/the Makefile. NL Design System
|
|
> and the real form arrive in S-08c (#67); see `docs/frontend-decisions.md`.
|
|
|
|
---
|
|
|
|
## S-07 — BFF: the portals' single backend
|
|
|
|
**Outcome:** the BFF validates Keycloak `digid` tokens on the self-service submit (forwarding the
|
|
bsn to the domain) and serves the openbaar register anonymously with only public-safe fields — the
|
|
front door the portals (S-08/S-09) will talk to.
|
|
|
|
**The path:** portal → BFF `POST /self-service/registrations` (token-gated) → domain; and
|
|
BFF `GET /openbaar/register` (anonymous) → projection-api. See ADR-0010.
|
|
|
|
```bash
|
|
# 1. Bring the full stack up.
|
|
make up
|
|
|
|
# 2. Drive the BFF end-to-end (401 without a token, 202 with a real digid token, anonymous openbaar).
|
|
make verify-bff # → "OK — BFF: 401 without token, 202 with a digid token, anonymous ..."
|
|
|
|
# 3. Try it by hand (BFF on host port 8080).
|
|
# a) A digid access token for the mock user jan-burger (bsn 123456782):
|
|
tok=$(curl -s -X POST http://localhost:8180/realms/digid/protocol/openid-connect/token \
|
|
-d grant_type=password -d client_id=big-portal -d username=jan-burger -d password=test123 \
|
|
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
|
|
|
|
# b) Submit — without the token it is 401; with it, 202:
|
|
curl -s -o /dev/null -w "no token -> %{http_code}\n" -X POST http://localhost:8080/self-service/registrations
|
|
curl -s -o /dev/null -w "with token-> %{http_code}\n" -X POST http://localhost:8080/self-service/registrations \
|
|
-H "Authorization: Bearer $tok"
|
|
|
|
# c) The openbaar register is anonymous and exposes only id + status (never the bsn):
|
|
curl -fsS http://localhost:8080/openbaar/register | jq
|
|
```
|
|
|
|
> The self-service token is validated against Keycloak's `digid` realm; the openbaar lookup needs no
|
|
> token (S-09). The generated contract lives at `services/bff/openapi.json` — S-08's client is built
|
|
> from it.
|
|
|
|
---
|
|
|
|
## S-05 — BIG Domain Service: submit a registration
|
|
|
|
**Outcome:** submitting a registration starts a Flowable process; the external-task worker
|
|
opens a zaak via the ACL and records it on the aggregate — the upstream half of the skeleton
|
|
that produces the zaak S-06 then projects.
|
|
|
|
**The path:** domain `POST /registrations` → Flowable `registratie` process → `OpenZaakAanmaken`
|
|
worker → ACL → OpenZaak; `GET /registrations/{id}` shows the opened zaak (ADR-0009).
|
|
|
|
```bash
|
|
# 1. Bring the full stack up (seeds config, builds our services, waits for health).
|
|
make up
|
|
|
|
# 2. Drive the full path end-to-end. This also seeds a published BIG zaaktype and points the
|
|
# ACL at it (the zaak's zaaktype URL is server-assigned, so it isn't known at bring-up).
|
|
make verify-domain # → "OK — the domain opened a zaak and recorded it on the registration"
|
|
|
|
# 3. Submit one yourself (domain on host port 8130). Returns 202 + a Location to read back.
|
|
loc=$(curl -fsS -D - -o /dev/null -X POST http://localhost:8130/registrations \
|
|
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' | sed -n 's/\r$//; s/^[Ll]ocation: //p')
|
|
|
|
# 4. The worker opens the zaak off the request path (eventual consistency, ADR-0009); poll
|
|
# until zaakUrl is filled. (Step 2 must have run first, so the ACL knows the zaaktype.)
|
|
curl -fsS "http://localhost:8130$loc" | jq
|
|
# → { "registrationId": "...", "status": "Ingediend", "zaakUrl": "http://.../zaken/api/v1/zaken/<uuid>" }
|
|
```
|
|
|
|
> Registration state is in-memory for this slice (ADR-0009); the rebuildable read model is the
|
|
> projection (S-06), fed by the very zaak this flow opens.
|
|
|
|
---
|
|
|
|
## S-06 — Event Subscriber + read projection
|
|
|
|
**Outcome:** a zaak created in OpenZaak flows through NRC to the Event Subscriber, which
|
|
projects it into a rebuildable read projection the projection-api serves.
|
|
|
|
**The path:** OpenZaak → (notification) NRC → (abonnement callback) Event Subscriber →
|
|
`register_projection` → projection-api `GET /register`.
|
|
|
|
```bash
|
|
# 1. Bring the full stack up (seeds config, builds our services, waits for health).
|
|
make up
|
|
|
|
# 2. Register the Event Subscriber's abonnement and create a zaak, then read it back.
|
|
# (The verify-projection check does exactly this end-to-end and asserts the result.)
|
|
make verify-projection # → "OK — projection-api serves zaak <uuid> with status INGEDIEND"
|
|
|
|
# 3. Observe the projection directly via the read API (host port 8120).
|
|
curl -fsS http://localhost:8120/register | jq
|
|
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND", "bsn": null, "naamPlaceholder": null } ]
|
|
|
|
# 4. Idempotency + rebuild: replays don't duplicate; a rebuild repopulates from the
|
|
# notification log (no OpenZaak access needed — ADR-0008).
|
|
curl -fsS -X POST http://localhost:8110/admin/rebuild # Event Subscriber, host port 8110
|
|
curl -fsS http://localhost:8120/register | jq 'length' # → unchanged
|
|
```
|
|
|
|
> `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.
|
|
|
|
---
|
|
|
|
## 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 + OTP
|
|
#
|
|
# 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).
|
|
|
|
---
|
|
|
|
## S-15c — MFA on the medewerker realm (#132, ADR-0031)
|
|
|
|
**Outcome:** staff logins (behandel + beheer portals) need a **second factor**. The medewerker realm
|
|
seeds every medewerker with a TOTP credential, so Keycloak's conditional-OTP step challenges them in
|
|
both the browser flow and the direct grant; a password alone no longer yields a token. `CONFIGURE_TOTP`
|
|
is a default required action, so a medewerker added later must enrol first. Citizen realms (digid,
|
|
eherkenning, eidas) are unchanged — they mock brokers that carry their own assurance.
|
|
|
|
```bash
|
|
# 1. Manual: log in to the behandel portal. After username + password Keycloak asks for a code.
|
|
python3 infra/keycloak/check_realms.py otp # a valid code, right now
|
|
open http://localhost:8142 # merel-behandelaar / test123 + that code
|
|
#
|
|
# 2. Automated: the realm smoke check asserts the password alone is REFUSED, then that
|
|
# password + TOTP succeeds and still carries the behandelaar role:
|
|
make keycloak-smoke # → "medewerker merel-behandelaar password-only login refused [OK]"
|
|
#
|
|
# 3. End-to-end: every staff login in the e2e goes through the OTP prompt (loginMedewerker):
|
|
make verify-e2e # → registration.spec (behandelaar approves), catalogus.spec, default-fill.spec
|
|
```
|
|
|
|
**The path:** the seeded `otp` credential in `infra/keycloak/realms/medewerker-realm.json` activates
|
|
Keycloak's stock conditional-OTP subflow — no custom browser flow. The fixture secret is shared and
|
|
committed on purpose so the checks can compute codes; a real deployment enrols per-user authenticators
|
|
(ADR-0031).
|
|
|