Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0fb2b3e8c | ||
|
|
321ee50dcb | ||
|
|
94720f0fcb | ||
|
|
94742a261f | ||
|
|
2125fb0cfd | ||
|
|
0cd70ae8c3 | ||
|
|
d37d4c96c6 | ||
|
|
159f014c1e | ||
|
|
dd54688f86 | ||
|
|
0a97fa4bf7 | ||
|
|
23ea91de32 | ||
|
|
0494730223 | ||
|
|
fff88ca23d | ||
|
|
849bf4723b | ||
|
|
4698c869f3 | ||
|
|
d5dfbdc0b2 | ||
|
|
6771fccf47 | ||
|
|
88338396f6 | ||
|
|
4274fd30d1 | ||
|
|
4fe9915816 | ||
|
|
5f8ab4dbcd | ||
|
|
5de8c1e292 | ||
|
|
183d0bce31 | ||
|
|
d5e5fa254c | ||
|
|
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 |
+166
-5
@@ -9,6 +9,12 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Supersede stale runs: a new push to the same branch/PR cancels the previous run, so the runner's
|
||||
# concurrency slots aren't spent on commits nobody is waiting for (refs #127).
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Self-hosted runner — see docs/runbooks/ci.md for the runner setup.
|
||||
# `uses:` are absolute, tag-pinned URLs (CLAUDE.md §8.7 / §15).
|
||||
|
||||
@@ -23,6 +29,16 @@ jobs:
|
||||
- uses: https://github.com/actions/setup-dotnet@v4
|
||||
with:
|
||||
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
|
||||
|
||||
build:
|
||||
@@ -32,6 +48,12 @@ jobs:
|
||||
- uses: https://github.com/actions/setup-dotnet@v4
|
||||
with:
|
||||
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
|
||||
|
||||
unit:
|
||||
@@ -41,7 +63,19 @@ jobs:
|
||||
- uses: https://github.com/actions/setup-dotnet@v4
|
||||
with:
|
||||
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
|
||||
# Job summary (#136): a per-service pass/fail table from the TRX `make unit` wrote.
|
||||
- name: Unit test summary
|
||||
if: always()
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
||||
frontend:
|
||||
@@ -56,6 +90,12 @@ jobs:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
- run: make frontend
|
||||
# Job summary (#136): a per-frontend (app) pass/fail table from the vitest JSON each app wrote.
|
||||
- name: Frontend test summary
|
||||
if: always()
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
python3 infra/vitest-summary.py test-output >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
mutation:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -64,7 +104,36 @@ jobs:
|
||||
- uses: https://github.com/actions/setup-dotnet@v4
|
||||
with:
|
||||
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
|
||||
# Job summary (#136): render each service's Stryker Markdown report on the run page (Gitea
|
||||
# 1.27 $GITHUB_STEP_SUMMARY). `if: always()` so a ratchet break still reports — and because
|
||||
# `make mutation` stops at the first break, the summary also shows exactly where it stopped.
|
||||
# Guarded so it no-ops on a runner/server without summary support. Strips the report's UTF-8 BOM.
|
||||
- name: Mutation score summary
|
||||
if: always()
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
{
|
||||
echo "## 🧬 Mutation testing"
|
||||
echo
|
||||
for svc in acl event-subscriber domain bff; do
|
||||
echo "### $svc"
|
||||
echo
|
||||
report=$(ls services/"$svc"/StrykerOutput/*/reports/mutation-report.md 2>/dev/null | sort | tail -1)
|
||||
if [ -n "$report" ]; then
|
||||
sed '1s/^\xef\xbb\xbf//' "$report"
|
||||
else
|
||||
echo "_No report — \`make mutation\` stopped before \`$svc\` (earlier ratchet break)._"
|
||||
fi
|
||||
echo
|
||||
done
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
|
||||
# ratchet fails — that is exactly when you want to inspect the survivors.
|
||||
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
|
||||
@@ -101,35 +170,127 @@ jobs:
|
||||
path: services/bff/StrykerOutput/**/reports/mutation-report.html
|
||||
if-no-files-found: warn
|
||||
|
||||
# One stage for every check that needs the live stack. On the single self-hosted
|
||||
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
|
||||
# is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs in a built
|
||||
# image and everything reaches services by container IP. Needs Docker + egress
|
||||
# One stage for every check that needs the live stack. Booting OpenZaak once (instead
|
||||
# of once per job) is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs
|
||||
# in a built image and everything reaches services by container IP. Needs Docker + egress
|
||||
# (base images, nuget, selectielijst.openzaak.nl).
|
||||
#
|
||||
# `needs: [mutation]` is NOT a data dependency — it serialises the two memory-heavy jobs so
|
||||
# they never co-schedule now the runner has capacity >1. A concurrent Stryker run + full-stack
|
||||
# bring-up + Playwright browser on one host is what OOMs the e2e (commit d5e5fa2, #126). The
|
||||
# light .NET/frontend jobs have no `needs`, so they still parallelise up to runner capacity.
|
||||
#
|
||||
# No `if: ${{ !cancelled() }}` here (removed in #134): on Gitea 1.27 + act_runner 2.0.0, a job
|
||||
# gated by a status-function `if` (always()/cancelled()) on top of `needs` routes through the new
|
||||
# transitional "Cancelling" state + capability negotiation and never leaves `waiting` — it's never
|
||||
# dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a
|
||||
# failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push
|
||||
# re-run exercises verify-stack, so we still get the signal.
|
||||
verify-stack:
|
||||
needs: [mutation]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
# Bring the full stack up + wait for health — this also is the DoD "compose up
|
||||
# reaches green health" smoke (it replaces the old compose-smoke job).
|
||||
# Each check carries an `id` so the summary step below can report its per-check outcome (#136).
|
||||
# A failed check skips the rest (no step `if:`), so the table shows exactly where it stopped.
|
||||
- name: Bring up the full stack & wait for health
|
||||
id: up
|
||||
run: make verify-up
|
||||
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
|
||||
id: obs
|
||||
run: OBS_TIMEOUT=180 make verify-observability
|
||||
- name: Objecttypen API up + token authenticates
|
||||
id: objecttypen
|
||||
run: OBJECTTYPEN_TIMEOUT=120 make verify-objecttypen
|
||||
- name: Objecten API up + token authenticates + trusts Objecttypen
|
||||
id: objecten
|
||||
run: OBJECTEN_TIMEOUT=120 make verify-objecten
|
||||
- name: RegisterRecord objecttype registered + published
|
||||
id: registerrecord
|
||||
run: REGISTERRECORD_TIMEOUT=120 make verify-registerrecord
|
||||
- name: ACL ↔ OpenZaak integration tests
|
||||
id: acl
|
||||
run: make verify-acl
|
||||
- name: OpenZaak → NRC notification delivery
|
||||
id: nrc
|
||||
run: make verify-nrc
|
||||
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
||||
id: projection
|
||||
run: make verify-projection
|
||||
- name: Objecten → NRC notification delivery
|
||||
id: objecten_nrc
|
||||
run: make verify-objecten-notifications
|
||||
- name: Domain → Flowable → ACL → OpenZaak
|
||||
id: domain
|
||||
run: make verify-domain
|
||||
- name: BFF → Keycloak + domain + projection
|
||||
id: bff
|
||||
run: make verify-bff
|
||||
- name: Distributed traces reach Tempo (one connected trace across services)
|
||||
id: tracing
|
||||
run: TRACING_TIMEOUT=120 make verify-tracing
|
||||
- name: Golden-signal metrics scraped by Prometheus (/metrics on every service)
|
||||
id: metrics
|
||||
run: METRICS_TIMEOUT=120 make verify-metrics
|
||||
- name: Self-service e2e (Playwright, login → submit → success)
|
||||
id: e2e
|
||||
run: make verify-e2e
|
||||
# Job summary (#136): a pass/fail table of every live-stack check, so a red verify-stack shows
|
||||
# which check failed at a glance. `if: always()` (step-level — safe on runner 2.0.0, unlike the
|
||||
# job-level status-function `if` of #134) so it renders even after a check fails.
|
||||
- name: verify-stack check summary
|
||||
if: always()
|
||||
env:
|
||||
UP: ${{ steps.up.outcome }}
|
||||
OBS: ${{ steps.obs.outcome }}
|
||||
OBJECTTYPEN: ${{ steps.objecttypen.outcome }}
|
||||
OBJECTEN: ${{ steps.objecten.outcome }}
|
||||
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
|
||||
OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }}
|
||||
ACL: ${{ steps.acl.outcome }}
|
||||
NRC: ${{ steps.nrc.outcome }}
|
||||
PROJECTION: ${{ steps.projection.outcome }}
|
||||
DOMAIN: ${{ steps.domain.outcome }}
|
||||
BFF: ${{ steps.bff.outcome }}
|
||||
TRACING: ${{ steps.tracing.outcome }}
|
||||
METRICS: ${{ steps.metrics.outcome }}
|
||||
E2E: ${{ steps.e2e.outcome }}
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
icon() { case "$1" in success) echo "✅";; failure) echo "❌";; skipped) echo "⏭️";; cancelled) echo "🚫";; *) echo "❔ ${1:-—}";; esac; }
|
||||
{
|
||||
echo "## 🔌 verify-stack checks"
|
||||
echo
|
||||
echo "| Check | Result |"
|
||||
echo "| ----- | :----: |"
|
||||
echo "| Bring up + health | $(icon "$UP") |"
|
||||
echo "| Observability backplane | $(icon "$OBS") |"
|
||||
echo "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |"
|
||||
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
|
||||
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
|
||||
echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |"
|
||||
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
|
||||
echo "| OpenZaak → NRC | $(icon "$NRC") |"
|
||||
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
|
||||
echo "| Domain → Flowable → ACL → OpenZaak | $(icon "$DOMAIN") |"
|
||||
echo "| BFF → Keycloak + domain + projection | $(icon "$BFF") |"
|
||||
echo "| Distributed traces (Tempo) | $(icon "$TRACING") |"
|
||||
echo "| Golden-signal metrics (Prometheus) | $(icon "$METRICS") |"
|
||||
echo "| Self-service e2e (Playwright) | $(icon "$E2E") |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
# Job summary (#136): per-spec Playwright results, from the JSON report run-e2e-check.sh copied
|
||||
# out of the e2e container. Turns a red e2e into a one-glance "which spec" instead of a log dive.
|
||||
- name: e2e spec summary
|
||||
if: always()
|
||||
run: |
|
||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||
python3 infra/playwright-summary.py tests/e2e/playwright-report.json >> "$GITHUB_STEP_SUMMARY"
|
||||
# Log dump must precede teardown (which removes the containers).
|
||||
- name: Dump container logs on 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 self-service 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 beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init tempo prometheus grafana 2>&1 || true
|
||||
- name: Tear down
|
||||
if: always()
|
||||
run: make down
|
||||
|
||||
@@ -57,3 +57,7 @@ vitest.config.*.timestamp*
|
||||
tests/e2e/node_modules/
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/playwright-report/
|
||||
__pycache__/
|
||||
TestResults/
|
||||
test-output/
|
||||
tests/e2e/playwright-report.json
|
||||
|
||||
+78
-15
@@ -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.
|
||||
|
||||
### 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:**
|
||||
|
||||
- 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.
|
||||
- Public-safe field whitelist enforced and tested.
|
||||
- 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 (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)
|
||||
|
||||
**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)
|
||||
|
||||
@@ -207,36 +239,67 @@ 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.
|
||||
|
||||
### 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`)*
|
||||
|
||||
### S-15 · Beheer-portal — catalogus & default-fill rules
|
||||
### S-15 · Beheer-portal — catalogus & default-fill rules *(split — #16 closed)*
|
||||
|
||||
**Outcome:** Beheer portal lets an admin view ZTC catalogi (read-only first), and manage the ACL's default-fill configuration via a CRUD UI. MFA on the medewerker realm enforced.
|
||||
|
||||
### S-16 · OpenTelemetry traces + Grafana dashboard
|
||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
|
||||
- **S-15a** (#130) · Beheer portal skeleton + read-only catalogi viewer — new beheer Angular app (medewerker-realm login) showing ZTC catalogi/zaaktypen read-only, via a BFF `/beheer/*` read endpoint proxying a read-only ACL Catalogi endpoint (§8.1, reuses the ADR-0021 Catalogi client).
|
||||
- **S-15b** (#131) · ACL default-fill configuration CRUD — the `Acl__Defaults__*` config (ADR-0003) becomes a managed store with CRUD via the BFF + a portal UI. Depends on S-15a.
|
||||
- **S-15c** (#132) · Enforce MFA (OTP) on the Keycloak medewerker realm.
|
||||
|
||||
### S-16 · OpenTelemetry traces + Grafana dashboard *(split — #17 closed)*
|
||||
|
||||
**Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals.
|
||||
|
||||
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep
|
||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
|
||||
**Outcome:** Nightly job that finds entries within 90 days of expiry and emits a domain event. (No outbound notification in v1 — logged.)
|
||||
- **S-16a** (#122) · Observability backplane — Grafana Tempo + Prometheus + Grafana in compose, datasources auto-provisioned (ADR-0023). No collector; config baked into built images.
|
||||
- **S-16b** (#123) · Distributed traces across the five .NET services (OTLP → Tempo; traceparent propagates via the typed HttpClients). Depends on S-16a. ✅
|
||||
- **S-16c** (#124) · Prometheus metrics + golden-signal Grafana dashboards. Depends on S-16a. ✅
|
||||
|
||||
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep ✅
|
||||
|
||||
**Outcome:** Daily Quartz.NET cron job finds inscriptions within 90 days of their herregistratie deadline and reminds each (flag on the aggregate + log). No outbound notification and no domain event in v1 — the reminder is the persisted flag, surfaced on the read model (ADR-0022, #120). Quartz fires time-triggered sweeps; the existing pumps stay as queue-drainers.
|
||||
|
||||
---
|
||||
|
||||
## Iteration 4 — Objecten and the authoritative register *(milestone: `Iteration 4 — Objecten`)*
|
||||
|
||||
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined
|
||||
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined *(split — #19 closed)*
|
||||
|
||||
**Outcome:** Objecten and Objecttypen running. A `RegisterRecord` objecttype defined with the public-safe schema.
|
||||
|
||||
### S-19 · ACL extension: write register-record to Objecten on approval
|
||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
|
||||
- **S-18a** (#139, ✅) · Objecttypen API up in compose (own DB + seeded config + health + static token).
|
||||
- **S-18b** (#140, ✅) · Objecten API up in compose, wired to Objecttypen. Depends on S-18a.
|
||||
- **S-18c** (#141, ✅) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b.
|
||||
|
||||
### S-19 · ACL extension: write register-record to Objecten on approval *(split — #20 closed)*
|
||||
|
||||
**Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events.
|
||||
|
||||
**ADR required:** "Why Objecten holds the register, OpenZaak holds the process."
|
||||
|
||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
|
||||
- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028).
|
||||
- **S-19b** (#150, ✅) · Read projection sourced from Objecten instead of NRC zaak events. *(split — #150 closed)*
|
||||
- **S-19b-1** (#152, ✅) · Objecten publishes to NRC — broker, celery worker, `objecten` kanaal, notifications config. Turns back on what ADR-0028 deliberately disabled.
|
||||
- **S-19b-2** (#153, ✅) · Projection derived from `RegisterRecord` objects, rebuildable from the Objecten-derived log. The ACL also writes an INGEDIEND record on submit, so the register holds the whole lifecycle. Carries ADR-0030.
|
||||
|
||||
---
|
||||
|
||||
## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)*
|
||||
|
||||
+112
-1
@@ -2,19 +2,130 @@
|
||||
|
||||
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
|
||||
- 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
|
||||
- Add idempotent Gitea backlog seeder
|
||||
- Remove bootstrap scripts from main (#35)
|
||||
- Contributor workflow — templates, git-cliff, gitea-workflow doc (closes #31) (#38)
|
||||
|
||||
### Documentation
|
||||
- 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
|
||||
- Placeholder BFF + /health endpoint (closes #28) (#34)
|
||||
- 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
|
||||
# (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.
|
||||
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service
|
||||
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten
|
||||
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
||||
# 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
|
||||
@@ -18,7 +18,7 @@ WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api se
|
||||
# volumes are `external`, so compose won't remove them — CFG_VOLS lists them for
|
||||
# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md.
|
||||
SEED := bash infra/seed-config.sh
|
||||
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn
|
||||
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn rr-objecttypen-config rr-objecten-config rr-registerrecord-config
|
||||
# Local-only stack: same services but config is bind-mounted (no seed step), so a
|
||||
# plain `docker compose -f infra/docker-compose.local.yml up` works on any local
|
||||
# engine. This is the no-make / Windows-friendly path. See that file's header.
|
||||
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
|
||||
endif
|
||||
endif
|
||||
|
||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-notifications smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||
|
||||
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
||||
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
||||
@@ -70,8 +70,9 @@ build:
|
||||
dotnet build $(SLN) -c Release
|
||||
|
||||
## unit: run unit tests (excludes the container-backed Integration lane)
|
||||
# TRX per test project (→ TestResults/) feeds the CI per-service summary (#136); harmless locally.
|
||||
unit:
|
||||
dotnet test $(SLN) -c Release --filter "Category!=Integration"
|
||||
dotnet test $(SLN) -c Release --filter "Category!=Integration" --logger trx --results-directory TestResults
|
||||
|
||||
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
||||
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
||||
@@ -93,14 +94,14 @@ mutation:
|
||||
# podman-compose, and needing no `--wait` flag or host port access. The one-shots
|
||||
# (oz-init, flowable-init) aren't polled; they just need to have run.
|
||||
smoke:
|
||||
$(SEED) oz nrc kc fl
|
||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
bash -c 'WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc'
|
||||
|
||||
## up: seed config volumes and start the full stack (use instead of bare
|
||||
## `docker compose up`, which can't self-seed the external config volumes)
|
||||
up:
|
||||
$(SEED) oz nrc kc fl
|
||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
|
||||
## down: stop and remove the local stack (incl. the external config volumes)
|
||||
@@ -114,6 +115,11 @@ local:
|
||||
docker compose -f $(LOCAL_COMPOSE) up -d --build
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||
|
||||
## verify-local: acceptance check for the local stack (S-B04) — a fresh `make local` completes the
|
||||
## whole flow (zaaktype seeded + DMN deployed + NRC abonnement) with NO manual seeding.
|
||||
verify-local:
|
||||
bash infra/run-local-flow-check.sh
|
||||
|
||||
## local-down: stop and remove the bind-mount stack
|
||||
local-down:
|
||||
docker compose -f $(LOCAL_COMPOSE) down --volumes
|
||||
@@ -133,7 +139,7 @@ changelog:
|
||||
## verify-up: bring the FULL stack up and wait for health (CI verify-stack step 1;
|
||||
## subsumes the old compose-smoke health gate — the DoD "up reaches green" check).
|
||||
verify-up:
|
||||
$(SEED) oz nrc kc fl
|
||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||
|
||||
@@ -165,17 +171,53 @@ verify-bff:
|
||||
verify-e2e:
|
||||
bash infra/run-e2e-check.sh
|
||||
|
||||
## verify-observability: assert the observability backplane (Grafana + provisioned Tempo &
|
||||
## Prometheus datasources) is live, against the already-running stack (S-16a).
|
||||
verify-observability:
|
||||
bash infra/run-observability-check.sh
|
||||
|
||||
## verify-tracing: assert one connected distributed trace spans the .NET services in Tempo
|
||||
## (S-16b), against the already-running stack.
|
||||
verify-tracing:
|
||||
bash infra/run-tracing-check.sh
|
||||
|
||||
## verify-metrics: assert the services expose /metrics and Prometheus scrapes the golden
|
||||
## signals (S-16c), against the already-running stack.
|
||||
verify-metrics:
|
||||
bash infra/run-metrics-check.sh
|
||||
|
||||
## verify-objecttypen: assert the Objecttypen API is up + its static token authenticates
|
||||
## (S-18a), against the already-running stack.
|
||||
verify-objecttypen:
|
||||
bash infra/run-objecttypen-check.sh
|
||||
|
||||
## verify-objecten: assert the Objecten API is up + its static token authenticates and it
|
||||
## trusts the Objecttypen API (S-18b), against the already-running stack.
|
||||
verify-objecten:
|
||||
bash infra/run-objecten-check.sh
|
||||
|
||||
## verify-registerrecord: assert the RegisterRecord objecttype is registered + published in the
|
||||
## Objecttypen API (S-18c), against the already-running stack.
|
||||
verify-registerrecord:
|
||||
bash infra/run-registerrecord-check.sh
|
||||
|
||||
## verify-objecten-notifications: assert a RegisterRecord write in Objecten is DELIVERED as an
|
||||
## `objecten` notification via NRC (S-19b-1), against the already-running stack.
|
||||
verify-objecten-notifications:
|
||||
bash infra/run-objecten-notifications-check.sh
|
||||
|
||||
## 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`
|
||||
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
||||
verify:
|
||||
$(SEED) oz nrc kc fl
|
||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
@bash -c 'set -e; rc=0; \
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
|
||||
&& bash infra/run-acl-integration.sh \
|
||||
&& bash infra/run-notification-check.sh \
|
||||
&& bash infra/run-projection-check.sh \
|
||||
&& bash infra/run-objecten-notifications-check.sh \
|
||||
&& bash infra/run-domain-check.sh \
|
||||
&& bash infra/run-bff-check.sh \
|
||||
&& bash infra/run-e2e-check.sh || 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,82 @@
|
||||
{
|
||||
"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,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
}
|
||||
},
|
||||
"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,27 @@
|
||||
# Multi-stage build for the beheer 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/beheer apps/beheer
|
||||
COPY libs libs
|
||||
RUN pnpm nx build beheer
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY apps/beheer/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /src/dist/apps/beheer/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 beheer 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 /beheer/ {
|
||||
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,82 @@
|
||||
{
|
||||
"name": "beheer",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"prefix": "app",
|
||||
"sourceRoot": "apps/beheer/src",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@angular/build:application",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": "production",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/beheer",
|
||||
"browser": "apps/beheer/src/main.ts",
|
||||
"tsConfig": "apps/beheer/tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "apps/beheer/public"
|
||||
}
|
||||
],
|
||||
"styles": ["apps/beheer/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": "beheer:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "beheer:build:development"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
},
|
||||
"test": {
|
||||
"executor": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"watch": false,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
"continuous": true,
|
||||
"executor": "@nx/web:file-server",
|
||||
"options": {
|
||||
"buildTarget": "beheer:build",
|
||||
"staticFilePath": "dist/apps/beheer/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,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 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 beheer 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('beheer 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 catalogus call', () => {
|
||||
bff.getBeheerCatalogiZaaktypen().subscribe();
|
||||
|
||||
const req = http.expectOne('/beheer/catalogi/zaaktypen');
|
||||
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
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 `/beheer/` is secured; the app calls no other endpoint group.
|
||||
*/
|
||||
export const SECURE_API_ROUTES = ['/beheer/'];
|
||||
|
||||
/**
|
||||
* 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,5 @@
|
||||
<nav aria-label="Beheer" class="utrecht-theme">
|
||||
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Catalogus</a>
|
||||
<a routerLink="/default-fill" routerLinkActive="active">Default-fill</a>
|
||||
</nav>
|
||||
<router-outlet></router-outlet>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { authenticatedGuard } from 'auth';
|
||||
import { CatalogusPage } from './catalogus/catalogus-page';
|
||||
import { DefaultFillPage } from './default-fill/default-fill-page';
|
||||
|
||||
export const appRoutes: Route[] = [
|
||||
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
|
||||
{ path: 'default-fill', component: DefaultFillPage, 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 CatalogusPage 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 = 'beheer';
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<main utrecht-document class="utrecht-theme">
|
||||
<utrecht-article>
|
||||
<utrecht-heading-1>Catalogus</utrecht-heading-1>
|
||||
<p utrecht-paragraph>
|
||||
De gepubliceerde zaaktypen uit de ZTC-catalogus. Alleen-lezen — beheer van de default-fill volgt
|
||||
in een latere slice.
|
||||
</p>
|
||||
|
||||
@if (loading()) {
|
||||
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
||||
} @else if (failed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Kon de catalogus niet laden. Controleer of je als beheerder bent ingelogd en probeer het
|
||||
opnieuw.
|
||||
</p>
|
||||
} @else if (loaded() && items().length === 0) {
|
||||
<p utrecht-paragraph role="status">De catalogus bevat geen gepubliceerde zaaktypen.</p>
|
||||
} @else if (items().length > 0) {
|
||||
<table utrecht-table>
|
||||
<caption>
|
||||
Gepubliceerde zaaktypen
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Identificatie</th>
|
||||
<th scope="col">Omschrijving</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (zaaktype of items(); track zaaktype.identificatie) {
|
||||
<tr>
|
||||
<td>{{ zaaktype.identificatie }}</td>
|
||||
<td>{{ zaaktype.omschrijving }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</utrecht-article>
|
||||
</main>
|
||||
@@ -0,0 +1,75 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { render, screen } from '@testing-library/angular';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { BeheerZaaktype, BffApiV1Service } from 'api-client';
|
||||
import { AuthService } from 'auth';
|
||||
import { axe } from 'vitest-axe';
|
||||
import { CatalogusPage } from './catalogus-page';
|
||||
|
||||
const sample: BeheerZaaktype[] = [
|
||||
{ identificatie: 'BIG-REGISTRATIE', omschrijving: 'BIG-registratie' },
|
||||
{ identificatie: 'BIG-HERREGISTRATIE', omschrijving: 'BIG-herregistratie' },
|
||||
];
|
||||
|
||||
class FakeAuth extends AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly bsn = signal<string | undefined>(undefined);
|
||||
override readonly roles = signal<readonly string[]>(['beheerder']);
|
||||
login(): void {
|
||||
/* not exercised here */
|
||||
}
|
||||
logout(): void {
|
||||
/* not exercised here */
|
||||
}
|
||||
}
|
||||
|
||||
function setup(overrides: { getBeheerCatalogiZaaktypen?: ReturnType<typeof vi.fn> } = {}) {
|
||||
const getBeheerCatalogiZaaktypen =
|
||||
overrides.getBeheerCatalogiZaaktypen ?? vi.fn().mockReturnValue(of(sample));
|
||||
return {
|
||||
getBeheerCatalogiZaaktypen,
|
||||
providers: [
|
||||
{ provide: BffApiV1Service, useValue: { getBeheerCatalogiZaaktypen } },
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('CatalogusPage', () => {
|
||||
it('lists the published zaaktypen on open', async () => {
|
||||
const { getBeheerCatalogiZaaktypen, providers } = setup();
|
||||
await render(CatalogusPage, { providers });
|
||||
|
||||
expect(getBeheerCatalogiZaaktypen).toHaveBeenCalled();
|
||||
expect(await screen.findByText('BIG-REGISTRATIE')).toBeTruthy();
|
||||
expect(screen.getByText('BIG-registratie')).toBeTruthy();
|
||||
expect(screen.getByText('BIG-HERREGISTRATIE')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows an empty state when the catalogus has no published zaaktypen', async () => {
|
||||
const { providers } = setup({ getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(of([])) });
|
||||
await render(CatalogusPage, { providers });
|
||||
|
||||
expect(await screen.findByText(/geen gepubliceerde zaaktypen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a load failure instead of swallowing it', async () => {
|
||||
const { providers } = setup({
|
||||
getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
||||
});
|
||||
await render(CatalogusPage, { providers });
|
||||
|
||||
expect(await screen.findByText(/kon de catalogus niet laden/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has no WCAG 2.1 AA violations', async () => {
|
||||
document.documentElement.lang = 'nl';
|
||||
const { container } = await render(CatalogusPage, { providers: setup().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 { BeheerZaaktype, BffApiV1Service } from 'api-client';
|
||||
import { UtrechtComponentsModule } from 'ui';
|
||||
|
||||
/**
|
||||
* The beheer catalogus viewer (S-15a): a signed-in beheerder sees the published ZTC zaaktypen,
|
||||
* read-only. The list is served by the BFF (`GET /beheer/catalogi/zaaktypen`), which proxies the ACL —
|
||||
* the only code allowed to read the ZGW Catalogi API (§8.1, ADR-0025). Managing default-fill is S-15b.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-catalogus-page',
|
||||
imports: [UtrechtComponentsModule],
|
||||
templateUrl: './catalogus-page.html',
|
||||
})
|
||||
export class CatalogusPage {
|
||||
private readonly bff = inject(BffApiV1Service);
|
||||
|
||||
protected readonly items = signal<BeheerZaaktype[]>([]);
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly loaded = signal(false);
|
||||
protected readonly failed = signal(false);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.failed.set(false);
|
||||
this.bff.getBeheerCatalogiZaaktypen().subscribe({
|
||||
next: (rows: BeheerZaaktype[]) => {
|
||||
this.items.set(rows);
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
},
|
||||
// Surface the failure (e.g. 403 for a non-beheerder) instead of swallowing it.
|
||||
error: () => {
|
||||
this.items.set([]);
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<main utrecht-document class="utrecht-theme">
|
||||
<utrecht-article>
|
||||
<utrecht-heading-1>Default-fill</utrecht-heading-1>
|
||||
<p utrecht-paragraph>
|
||||
De ZGW-standaardwaarden die de ACL op elke nieuwe zaak invult (ADR-0003). Een wijziging geldt
|
||||
voor de eerstvolgende zaak.
|
||||
</p>
|
||||
|
||||
@if (loading()) {
|
||||
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
||||
} @else if (loaded()) {
|
||||
<form (submit)="save(); $event.preventDefault()">
|
||||
<p>
|
||||
<label for="bronorganisatie">Bronorganisatie</label><br />
|
||||
<input
|
||||
id="bronorganisatie"
|
||||
name="bronorganisatie"
|
||||
[value]="bronorganisatie()"
|
||||
(input)="bronorganisatie.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<label for="verantwoordelijkeOrganisatie">Verantwoordelijke organisatie</label><br />
|
||||
<input
|
||||
id="verantwoordelijkeOrganisatie"
|
||||
name="verantwoordelijkeOrganisatie"
|
||||
[value]="verantwoordelijkeOrganisatie()"
|
||||
(input)="verantwoordelijkeOrganisatie.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<label for="vertrouwelijkheidaanduiding">Vertrouwelijkheidaanduiding</label><br />
|
||||
<input
|
||||
id="vertrouwelijkheidaanduiding"
|
||||
name="vertrouwelijkheidaanduiding"
|
||||
[value]="vertrouwelijkheidaanduiding()"
|
||||
(input)="vertrouwelijkheidaanduiding.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<button utrecht-button appearance="primary-action-button" type="submit" [disabled]="saving()">
|
||||
Opslaan
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if (saved()) {
|
||||
<p utrecht-paragraph role="status">De standaardwaarden zijn opgeslagen.</p>
|
||||
}
|
||||
@if (failed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Opslaan is niet gelukt. Controleer of je als beheerder bent ingelogd en probeer het opnieuw.
|
||||
</p>
|
||||
}
|
||||
} @else if (failed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Kon de standaardwaarden niet laden. Controleer of je als beheerder bent ingelogd en probeer
|
||||
het opnieuw.
|
||||
</p>
|
||||
}
|
||||
</utrecht-article>
|
||||
</main>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { fireEvent, render, screen } from '@testing-library/angular';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
|
||||
import { AuthService } from 'auth';
|
||||
import { axe } from 'vitest-axe';
|
||||
import { DefaultFillPage } from './default-fill-page';
|
||||
|
||||
const current: BeheerDefaultFill = {
|
||||
bronorganisatie: '517439943',
|
||||
verantwoordelijkeOrganisatie: '517439943',
|
||||
vertrouwelijkheidaanduiding: 'openbaar',
|
||||
};
|
||||
|
||||
class FakeAuth extends AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly bsn = signal<string | undefined>(undefined);
|
||||
override readonly roles = signal<readonly string[]>(['beheerder']);
|
||||
login(): void {
|
||||
/* not exercised */
|
||||
}
|
||||
logout(): void {
|
||||
/* not exercised */
|
||||
}
|
||||
}
|
||||
|
||||
function setup(
|
||||
overrides: {
|
||||
getBeheerDefaultFill?: ReturnType<typeof vi.fn>;
|
||||
putBeheerDefaultFill?: ReturnType<typeof vi.fn>;
|
||||
} = {},
|
||||
) {
|
||||
const getBeheerDefaultFill = overrides.getBeheerDefaultFill ?? vi.fn().mockReturnValue(of(current));
|
||||
const putBeheerDefaultFill = overrides.putBeheerDefaultFill ?? vi.fn().mockReturnValue(of(undefined));
|
||||
return {
|
||||
getBeheerDefaultFill,
|
||||
putBeheerDefaultFill,
|
||||
providers: [
|
||||
{ provide: BffApiV1Service, useValue: { getBeheerDefaultFill, putBeheerDefaultFill } },
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('DefaultFillPage', () => {
|
||||
it('loads the current default-fill into the form on open', async () => {
|
||||
const { getBeheerDefaultFill, providers } = setup();
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
expect(getBeheerDefaultFill).toHaveBeenCalled();
|
||||
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
|
||||
expect(bron.value).toBe('517439943');
|
||||
});
|
||||
|
||||
it('saves the edited values via the BFF', async () => {
|
||||
const { putBeheerDefaultFill, providers } = setup();
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
|
||||
fireEvent.input(bron, { target: { value: '999999999' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }));
|
||||
|
||||
expect(putBeheerDefaultFill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ bronorganisatie: '999999999', vertrouwelijkheidaanduiding: 'openbaar' }),
|
||||
);
|
||||
expect(await screen.findByText(/standaardwaarden zijn opgeslagen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a save failure instead of swallowing it', async () => {
|
||||
const { providers } = setup({
|
||||
putBeheerDefaultFill: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
||||
});
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /opslaan/i }));
|
||||
|
||||
expect(await screen.findByText(/opslaan is niet gelukt/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has no WCAG 2.1 AA violations', async () => {
|
||||
document.documentElement.lang = 'nl';
|
||||
const { container } = await render(DefaultFillPage, { providers: setup().providers });
|
||||
|
||||
const results = await axe(container, {
|
||||
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
||||
});
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
|
||||
import { UtrechtComponentsModule } from 'ui';
|
||||
|
||||
/**
|
||||
* The beheer default-fill editor (S-15b): a beheerder reads and edits the ZGW default-fill values the
|
||||
* ACL stamps on every zaak (ADR-0003). Load and save go through the BFF (`/beheer/default-fill`),
|
||||
* which proxies the ACL (ADR-0025). A save takes effect on the next zaak (the ACL reads it per zaak).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-default-fill-page',
|
||||
imports: [UtrechtComponentsModule],
|
||||
templateUrl: './default-fill-page.html',
|
||||
})
|
||||
export class DefaultFillPage {
|
||||
private readonly bff = inject(BffApiV1Service);
|
||||
|
||||
protected readonly bronorganisatie = signal('');
|
||||
protected readonly verantwoordelijkeOrganisatie = signal('');
|
||||
protected readonly vertrouwelijkheidaanduiding = signal('');
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly loaded = signal(false);
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly failed = signal(false);
|
||||
protected readonly saved = signal(false);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.failed.set(false);
|
||||
this.saved.set(false);
|
||||
this.bff.getBeheerDefaultFill().subscribe({
|
||||
next: (d: BeheerDefaultFill) => {
|
||||
this.bronorganisatie.set(d.bronorganisatie);
|
||||
this.verantwoordelijkeOrganisatie.set(d.verantwoordelijkeOrganisatie);
|
||||
this.vertrouwelijkheidaanduiding.set(d.vertrouwelijkheidaanduiding);
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
save(): void {
|
||||
this.saving.set(true);
|
||||
this.failed.set(false);
|
||||
this.saved.set(false);
|
||||
this.bff
|
||||
.putBeheerDefaultFill({
|
||||
bronorganisatie: this.bronorganisatie(),
|
||||
verantwoordelijkeOrganisatie: this.verantwoordelijkeOrganisatie(),
|
||||
vertrouwelijkheidaanduiding: this.vertrouwelijkheidaanduiding(),
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.saved.set(true);
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Beheerportaal 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,82 @@
|
||||
{
|
||||
"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,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
}
|
||||
},
|
||||
"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
|
||||
@@ -19,5 +19,9 @@ 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
|
||||
|
||||
@@ -64,7 +64,9 @@
|
||||
"test": {
|
||||
"executor": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"watch": false
|
||||
"watch": false,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
|
||||
@@ -3,9 +3,56 @@
|
||||
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
||||
|
||||
@if (submitted()) {
|
||||
<p utrecht-paragraph role="status">
|
||||
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
||||
</p>
|
||||
@if (withdrawn()) {
|
||||
<p utrecht-paragraph role="status">
|
||||
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 {
|
||||
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
||||
@if (failed()) {
|
||||
|
||||
@@ -17,12 +17,29 @@ 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)),
|
||||
// Resume lookup (S-26): default to 204/empty — no in-flight registration, so the submit form shows.
|
||||
getCurrent = vi.fn().mockReturnValue(of(undefined)),
|
||||
) {
|
||||
return {
|
||||
post,
|
||||
withdraw,
|
||||
provideDocuments,
|
||||
getCurrent,
|
||||
providers: [
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
{ provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
|
||||
{
|
||||
provide: BffApiV1Service,
|
||||
useValue: {
|
||||
getSelfServiceRegistrations: getCurrent,
|
||||
postSelfServiceRegistrations: post,
|
||||
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||
postSelfServiceRegistrationsIdDocuments: provideDocuments,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -43,6 +60,21 @@ describe('RegistrationPage', () => {
|
||||
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resumes an existing registration on load, without submitting again (S-26)', async () => {
|
||||
const { post, providers: p } = providers(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
vi.fn().mockReturnValue(of({ registrationId: 'reg-77', status: 'Ingediend' })),
|
||||
);
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
// The confirmation view is restored from the in-flight registration — no submit click.
|
||||
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
||||
expect(screen.getByText(/reg-77/)).toBeTruthy();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -56,6 +88,76 @@ describe('RegistrationPage', () => {
|
||||
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 () => {
|
||||
// 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.
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { BffApiV1Service, type SubmitAccepted } from 'api-client';
|
||||
import { Component, inject, type OnInit, signal } from '@angular/core';
|
||||
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
|
||||
import { AuthService } from 'auth';
|
||||
import { UtrechtComponentsModule } from 'ui';
|
||||
|
||||
/**
|
||||
* 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-
|
||||
* 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).
|
||||
*
|
||||
* On load it asks the BFF for the caller's current open registration and restores the submitted view
|
||||
* if there is one, so a page refresh no longer strands an in-flight registration (S-26).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-registration-page',
|
||||
imports: [UtrechtComponentsModule],
|
||||
templateUrl: './registration-page.html',
|
||||
})
|
||||
export class RegistrationPage {
|
||||
export class RegistrationPage implements OnInit {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly bff = inject(BffApiV1Service);
|
||||
|
||||
@@ -22,6 +26,30 @@ export class RegistrationPage {
|
||||
protected readonly reference = signal<string | undefined>(undefined);
|
||||
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);
|
||||
|
||||
/** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's
|
||||
* current open registration, or 204 (empty body) when there is none — in which case we show the
|
||||
* submit form as before. Failures are non-fatal for the same reason. */
|
||||
ngOnInit(): void {
|
||||
this.bff.getSelfServiceRegistrations().subscribe({
|
||||
next: (current: CurrentRegistration | void) => {
|
||||
if (current && current.registrationId) {
|
||||
this.reference.set(current.registrationId);
|
||||
this.submitted.set(true);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// No resumable registration (or the lookup failed) — fall back to the submit form.
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
this.submitting.set(true);
|
||||
@@ -39,4 +67,74 @@ export class RegistrationPage {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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
-1
@@ -207,7 +207,7 @@ A slice is done when:
|
||||
## 15. Out of scope for v1
|
||||
|
||||
- OpenMetadata data governance module (v3 slice).
|
||||
- Objecten as the authoritative register record store (v2 slice — v1 uses OpenZaak zaak-eigenschappen as a placeholder).
|
||||
- ~~Objecten as the authoritative register record store~~ — **delivered** in S-19a (#149, ADR-0028); the approval path writes a `RegisterRecord` object to Objecten rather than the planned zaak-eigenschappen placeholder.
|
||||
- Production-grade Helm chart (sketch only).
|
||||
- Multi-tenancy.
|
||||
- Real outbound notifications (email/SMS) — logged to console in v1.
|
||||
|
||||
@@ -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".
|
||||
@@ -0,0 +1,92 @@
|
||||
# ADR-0020: The local stack self-seeds the zaaktype, DMN, and NRC abonnement at bring-up
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-22
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-B04 (#110). Local-stack twin of the seeding the verify-* scripts do for CI
|
||||
(`infra/run-domain-check.sh`, `infra/verify-notification-driver.py`). Superseded in part by S-27
|
||||
(#113), which would let the ACL resolve its zaaktype by identificatie and remove the URL injection.
|
||||
|
||||
## Context
|
||||
|
||||
`infra/docker-compose.local.yml` is the host-browser-friendly stack (`make local`) — the one a
|
||||
developer clicks through the portals with. It had drifted behind three slices, so a fresh bring-up
|
||||
could not complete the flow:
|
||||
|
||||
1. The ACL pointed at a placeholder zaaktype (`…/00000000-…`), so zaak creation failed with OpenZaak
|
||||
`400` and the registratie process stuck at `OpenZaakAanmaken` (S-05).
|
||||
2. `flowable-init` deployed only `registratie.bpmn`, not `diploma-eligibility.dmn`, so completing
|
||||
`WachtOpDocumenten` 404'd on the missing decision and never reached `Beoordelen` (S-10a/S-13).
|
||||
3. No NRC abonnement was registered, so notifications reached NRC and went nowhere — the projection
|
||||
and the openbaar register stayed empty (S-06).
|
||||
|
||||
The CI stack (`infra/docker-compose.yml`) does not hit this because its `verify-*` scripts seed the
|
||||
zaaktype, deploy the DMN, and register the abonnement at *test* time. The local stack has no such
|
||||
harness — a developer just runs `make local` and browses. The non-obvious wrinkle is (1): the
|
||||
zaaktype **UUID is assigned by OpenZaak at creation**, so the ACL's zaaktype URL is not knowable when
|
||||
the compose file is written and cannot be a static value.
|
||||
|
||||
## Decision
|
||||
|
||||
**Make the local stack self-seed at bring-up via one-shot init containers, and hand the ACL its
|
||||
server-assigned zaaktype URL through a shared-volume env file it sources on startup.**
|
||||
|
||||
- **DMN (gap 2).** `flowable-init` now deploys `diploma-eligibility.dmn` to the DMN engine
|
||||
(`/flowable-rest/dmn-api/dmn-repository/deployments`) as a separate deployment alongside the BPMN —
|
||||
identical to the CI `flowable-init`. Idempotent.
|
||||
- **Zaaktype + ACL wiring (gap 1).** A `local-seed` one-shot runs the existing
|
||||
`infra/openzaak/seed_catalogus.py` (`OZ_PUBLISH=1`) against OpenZaak and writes the resulting
|
||||
`Acl__Defaults__ZaaktypeUrl` / `…InformatieobjecttypeUrl` / `Acl__OpenZaak__BaseUrl` into
|
||||
`seed-env:/out/acl.env`. The ACL mounts that volume read-only and overrides its entrypoint to
|
||||
`sh -c 'set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll'`, so the real values override the
|
||||
compose placeholders before the app reads config. The ACL `depends_on: local-seed
|
||||
(service_completed_successfully)`.
|
||||
- **Abonnement (gap 3).** A `nrc-subscribe` one-shot registers an abonnement on the `zaken` kanaal
|
||||
pointing at the event-subscriber's `/notifications` callback (`infra/local/register-abonnement.py`).
|
||||
It is a leaf — nothing depends on it — so it can wait for the event-subscriber without forming a
|
||||
cycle with the ACL bootstrap.
|
||||
- **Reach OpenZaak/NRC by container IP, not service name.** Both the seed's ZTC calls and the
|
||||
abonnement's `callbackUrl` are validated by Django's URLValidator, which rejects a single-label host
|
||||
like `openzaak` / `event-subscriber`. The scripts resolve the target's container IP at runtime (as
|
||||
`infra/run-domain-check.sh` does), keeping the seeded URLs valid **and** host-consistent — the ACL's
|
||||
base URL is set to the same OpenZaak IP that owns the zaaktype URL.
|
||||
- **Acceptance.** `make verify-local` (`infra/run-local-flow-check.sh`) submits against a fresh stack
|
||||
and asserts the zaak opens, the case reaches the werkbak after documents, and the reference appears
|
||||
in the openbaar register — the red-to-green test for all three gaps.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- A fresh `make local` completes the full demo (submit → werkbak → openbaar) with no manual seeding —
|
||||
the slice's stated outcome.
|
||||
- Reuses the proven CI mechanisms (`seed_catalogus.py`, the DMN deploy, the abonnement driver) rather
|
||||
than inventing new ones; the only genuinely new piece is the entrypoint-sourced env file.
|
||||
- No service code changes — the fix is entirely in `infra/` (compose + two small scripts), so the ACL
|
||||
image and the CI stack are untouched.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The two compose files diverge further: the CI stack seeds at test time, the local stack at bring-up.
|
||||
Mitigated by reusing the same underlying scripts and cross-referencing them.
|
||||
- The ACL entrypoint override couples the local ACL to the seed-written file path (`/seed/acl.env`);
|
||||
if the seed fails, the ACL fails to start (loud, healthcheck-visible — preferred over silently
|
||||
running with a placeholder).
|
||||
- Container-IP-based URLs are re-derived on each bring-up; a keep-volumes restart with a changed
|
||||
OpenZaak IP relies on OpenZaak rebuilding hyperlinked URLs from the request host (it does) so the
|
||||
idempotent re-seed reports current-IP URLs.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **ACL resolves its zaaktype by identificatie (`BIG-REGISTRATIE`) at startup.** The cleaner,
|
||||
less-brittle design — no server-assigned URL to capture — and it would help the CI stack too. But it
|
||||
changes a service's runtime behaviour and its config contract, needs new ACL tests + mutation
|
||||
coverage, and still needs a seed step to *create* the zaaktype. Deliberately split out as its own
|
||||
slice with its own ADR (S-27 / #113) rather than folded into this infra-only fix.
|
||||
- **A documented `make local-seed` step run after `make local`.** Smallest change, but it fails the
|
||||
slice's "no manual seeding" outcome — the local stack is exactly the one meant to just work in a
|
||||
browser. Rejected.
|
||||
- **Fixed zaaktype UUID via OpenZaak `setup_configuration`/fixtures.** OpenZaak assigns UUIDs on POST;
|
||||
declaratively creating a fully *published* zaaktype (statustypen + resultaattypen validated against
|
||||
the Selectielijst + roltypen + iot relations) is not something `setup_configuration` supports
|
||||
cleanly in 1.28.2. Rejected as more fragile than reusing `seed_catalogus.py`.
|
||||
@@ -0,0 +1,67 @@
|
||||
# ADR-0021: The ACL resolves its zaaktype by identificatie, not a pinned URL
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-22
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-27 (#113), proposed in #117. The cleaner design deliberately split out of S-B04
|
||||
(#110, ADR-0020), which fixed the local stack with an infra-only bootstrap.
|
||||
|
||||
## Context
|
||||
|
||||
The ACL was handed a **pinned zaaktype URL** (`Acl__Defaults__ZaaktypeUrl`) and diploma
|
||||
informatieobjecttype URL. OpenZaak assigns those UUIDs at creation, so the URL is not knowable when
|
||||
the compose file is written — every stack had to seed the catalogus and then capture + inject the
|
||||
resulting URLs out of band: `run-domain-check.sh` for CI, and the `local-seed` → `acl.env` bootstrap
|
||||
(ADR-0020) for `make local`. Brittle, and a stale/placeholder URL failed opaquely (OpenZaak 400).
|
||||
|
||||
## Decision
|
||||
|
||||
**The ACL resolves its zaaktype (by `identificatie`) and diploma informatieobjecttype (by
|
||||
`omschrijving`) from OpenZaak's Catalogi API, instead of being handed the URLs.**
|
||||
|
||||
- **Config:** `AclDefaults.ZaaktypeUrl`/`InformatieobjecttypeUrl` → `ZaaktypeIdentificatie`
|
||||
(`BIG-REGISTRATIE`) / `InformatieobjecttypeOmschrijving` (`Diploma`).
|
||||
- **Lookup (gateway, §8.1):** `GET /catalogi/api/v1/zaaktypen?status=definitief&identificatie=…` →
|
||||
the published zaaktype URL; `GET /catalogi/api/v1/informatieobjecttypen?status=definitief` matched
|
||||
on `omschrijving`. Reuses the gateway's existing catalogus-query machinery.
|
||||
- **Timing = lazy + cached (`CachedZaaktypeCatalog`).** Resolve on first use (first zaak open /
|
||||
document store) and cache for the process lifetime. Lazy avoids a startup ordering coupling — the
|
||||
ACL never crash-loops when it boots before the catalogus is published. A **failed** resolution is
|
||||
not cached, so it is retried on the next call (e.g. once the zaaktype is published); a restart
|
||||
re-resolves.
|
||||
- **Failure mode:** no published match → a clear "No published zaaktype with identificatie '…' found
|
||||
in OpenZaak — is the BIG catalogus seeded and published?" error, replacing the opaque placeholder
|
||||
400.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- No stack captures or injects a server-assigned URL any more: `run-domain-check.sh` drops the
|
||||
`ACL_ZAAKTYPE_URL`/`ACL_INFORMATIEOBJECTTYPE_URL` capture+inject, `docker-compose.yml`/`.local.yml`
|
||||
drop the placeholder URL env, and `local-seed`/`acl.env` shrink to a single line. The ACL
|
||||
self-configures from the catalogus it already talks to.
|
||||
- The failure mode is legible (a named error instead of a 400 on a zeros-UUID).
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The ACL still needs its OpenZaak **BaseUrl** pointed at a **URL-valid host (a container IP)**, so
|
||||
the base-URL injection from ADR-0020 stays (the local `acl.env` now carries only that; CI keeps
|
||||
`ACL_OPENZAAK_BASEURL`). This is **not** something S-27 can remove: OpenZaak validates the
|
||||
`zaaktype` field on zaak-create with Django's URLValidator and **rejects a single-label host**
|
||||
(`http://openzaak:8000/…` → `zaaktype: bad-url, "Voer een geldige URL in."`, confirmed empirically).
|
||||
So ADR-0020's `seed-env` volume + ACL entrypoint shim are **simplified, not deleted**.
|
||||
- New branching in the gateway/resolver → unit + integration test surface; the mutation ratchet
|
||||
covers it (§5).
|
||||
- A seed step still **creates + publishes** the zaaktype (this ADR changes only discovery). Reaching
|
||||
OpenZaak's Catalogi API to *seed* likewise needs the IP host (its query params hit the same
|
||||
URLValidator) — unchanged from before.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Resolve at startup** (eager). Simpler cache, but reintroduces the ordering coupling (crash-loop
|
||||
if the catalogus isn't published yet). Rejected in favour of lazy.
|
||||
- **Per-request resolution** (no cache). No stale-cache risk, but a Catalogi lookup on every ACL
|
||||
operation. Rejected; a process-lifetime cache with restart-to-refresh is enough here.
|
||||
- **Keep the pinned URL** (status quo / ADR-0020 only). Rejected — the brittleness this ADR removes is
|
||||
exactly what S-27 was carved out to fix.
|
||||
@@ -0,0 +1,79 @@
|
||||
# ADR-0022: Quartz.NET for time-triggered fleet sweeps
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-23
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-17 (#18) · **Proposal issue:** #120
|
||||
|
||||
## Context
|
||||
|
||||
A BIG inscription is valid for a fixed term; before it lapses the zorgprofessional
|
||||
must herregistreren. S-17 adds a **herregistratie reminder sweep**: once a day,
|
||||
scan the register for inscriptions whose deadline is within the reminder window and
|
||||
remind each one.
|
||||
|
||||
The Domain Service already runs periodic background work — `OpenZaakJobPump`,
|
||||
`BeoordelingEscalatiePump`, `RegistratieVerlopenPump`. Those are **continuous job
|
||||
pollers**: they drain Flowable's external-task/job queues at-least-once, picking up
|
||||
work as soon as it is parked, on a short poll interval. The reminder sweep is a
|
||||
different shape of work: **time-triggered**, once a day, over our own store — there
|
||||
is no queue to drain and no "as soon as possible" requirement.
|
||||
|
||||
The PRD already names the scheduler component: "Scheduler (Quartz.NET): fleet-wide
|
||||
sweeps (expiry, reminders)" (§39, §94). Adding Quartz.NET is nonetheless a new
|
||||
dependency, so this decision is recorded before the code lands (CLAUDE.md §14).
|
||||
|
||||
## Decision
|
||||
|
||||
**Use Quartz.NET for time-triggered fleet sweeps, starting with the herregistratie
|
||||
reminder sweep. Leave the existing pumps as `BackgroundService` job pollers.**
|
||||
|
||||
- `HerregistratieReminderJob` (a Quartz `IJob`) is fired by a cron trigger — daily
|
||||
at 03:00 by default, overridable with `Quartz__Cron`. It is a thin shell: it
|
||||
resolves the pure `HerregistratieReminderSweep` (application layer) and logs how
|
||||
many reminders went out.
|
||||
- The sweep's rule lives in the domain: `Registration.HerregistratieReminderDue(asOf)`,
|
||||
which the store query and the sweep both build on. The sweep marks each reminded
|
||||
inscription (`HerregistratieReminderVerstuurd`), so a re-fire reminds no one twice
|
||||
(§8.6).
|
||||
|
||||
Two options were rejected:
|
||||
|
||||
1. **A `BackgroundService` with a 24h `Task.Delay`.** No new dependency, but it
|
||||
drifts to process-start time, has no cron/misfire semantics, and contradicts the
|
||||
PRD's named component. A daily "run at 03:00" is exactly what cron scheduling is
|
||||
for.
|
||||
2. **Migrating the three pumps onto Quartz too, for one mechanism.** Rejected: the
|
||||
pumps are not schedulers. Forcing a "run at time T" tool onto "drain this queue
|
||||
continuously" work is churn and a boundary change for negative benefit. The
|
||||
teachable distinction is worth keeping: **pumps drain queues; Quartz fires
|
||||
sweeps.**
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Cron scheduling with restart-stable timing and misfire handling, for free.
|
||||
- The reminder rule is one domain method, reused by the store query and the sweep;
|
||||
the scheduler owns none of the policy.
|
||||
- The reference app now demonstrates the intended Scheduler component.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- One new dependency (`Quartz`, `Quartz.Extensions.Hosting`) in the Domain Service.
|
||||
- Two periodic-work mechanisms coexist (pumps + Quartz). Deliberate — they model
|
||||
two genuinely different concerns, documented here.
|
||||
|
||||
**Follow-up**
|
||||
|
||||
- The validity term (5 years) and reminder lead time (16 weeks) are domain
|
||||
calibration knobs; promote them to beheer config (S-15) if a demo needs them
|
||||
per-catalogus.
|
||||
- The Quartz job stores its schedule in RAM (`RAMJobStore`); a persistent/clustered
|
||||
store is a later concern if the Domain Service is scaled out.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None. Quartz is internal to the Domain Service and drives an application use case
|
||||
over the store port. No ZGW or Flowable coupling is added; the sweep talks to no
|
||||
peer module.
|
||||
@@ -0,0 +1,82 @@
|
||||
# ADR-0023: Grafana-native observability stack (Tempo + Prometheus + Grafana)
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-23
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-16a (#122), first of the S-16 (#17) split
|
||||
|
||||
## Context
|
||||
|
||||
The PRD calls for "OpenTelemetry traces, Prometheus metrics; a local Grafana with
|
||||
pre-built dashboards" (§80). S-16 was split (CLAUDE.md §13) into a backplane slice
|
||||
(this one), distributed tracing (#123), and metrics + dashboards (#124). The
|
||||
backplane must stand up first: a local, CI-friendly place for traces and metrics to
|
||||
land, viewable in one UI, reaching green health within the 3-minute compose budget.
|
||||
|
||||
Two shape decisions are non-obvious enough to record.
|
||||
|
||||
## Decision
|
||||
|
||||
**Run a Grafana-native stack — Grafana Tempo (traces) + Prometheus (metrics) +
|
||||
Grafana (UI) — with the services exporting OTLP straight to Tempo (no collector),
|
||||
and ship the config baked into small built images.**
|
||||
|
||||
### Trace backend: Tempo (not Jaeger)
|
||||
|
||||
Tempo keeps everything under one Grafana pane alongside metrics (and later logs),
|
||||
which is exactly the "local Grafana with dashboards" the PRD asks for. Jaeger would
|
||||
add a second UI and a second mental model for no benefit at this scale.
|
||||
|
||||
### No OTLP collector
|
||||
|
||||
Tempo ingests OTLP directly (gRPC 4317 / HTTP 4318) and Prometheus scrapes each
|
||||
service's `/metrics`, so a collector would be a hop that processes nothing. Skipped.
|
||||
If we later need fan-out, tail sampling, or log processing, a collector is an
|
||||
additive change — the services already speak OTLP.
|
||||
|
||||
### Config baked into built images, not config volumes
|
||||
|
||||
The upstream Common Ground modules (OpenZaak, NRC, Keycloak, Flowable) run as
|
||||
**verbatim** images and get their config streamed into external named volumes by
|
||||
`infra/seed-config.sh`, because bind mounts don't reach sibling containers on the
|
||||
CI runner (see `docs/runbooks/gitea-actions-gotchas.md`). The observability tools
|
||||
are **not** peer modules we must run verbatim, so we take the simpler path: a
|
||||
three-line `Dockerfile` per tool that `COPY`s its config in. This reaches sibling
|
||||
containers everywhere (docker, podman, CI) with no seed step, no `CFG_VOLS` entry,
|
||||
and no Makefile sprawl.
|
||||
|
||||
### Verified, not assumed
|
||||
|
||||
`infra/run-observability-check.sh` (the `verify-observability` step, run early in CI
|
||||
`verify-stack`) asks Grafana to reach both datasources — Prometheus via its health
|
||||
method, Tempo via the datasource proxy (Tempo's Grafana plugin implements no health
|
||||
method) — so the check proves the datasources are actually wired, not merely that
|
||||
containers started. The containers are not in `WAIT_SVCS`; the check polls Grafana
|
||||
itself, so no in-image healthcheck tool is required.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- One UI for traces + metrics + (future) logs. Config is versioned in
|
||||
`infra/observability/` and self-contained in the images.
|
||||
- Backplane is independent of app instrumentation — #123 and #124 build on it.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Three more images built each CI run (kept small; not on the health-gate list).
|
||||
- Storage is ephemeral container fs — a demo backplane, not a retention target.
|
||||
Object storage for Tempo / remote-write for Prometheus is a later concern.
|
||||
- Tempo runs **single-binary**, so its distributor and ingester are one process and
|
||||
some of its distributed-mode machinery is not just redundant but harmful. Its
|
||||
ingester-pool health check is disabled (`ingester_client.pool_config`) because with
|
||||
a single in-process ingester the check can never route around a failure — a 1s
|
||||
loopback-gRPC deadline missed under CI load only evicted the one ingester and made
|
||||
Tempo drop spans, which is how `verify-tracing` flaked (#156). Expect the same
|
||||
shape from other distributed-mode knobs if we tune them; the fix is to switch to
|
||||
real multi-ingester Tempo, not to re-enable them here.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None. The stack is passive infrastructure: services *push* OTLP and *expose*
|
||||
`/metrics`; nothing in the stack calls into a service or a peer module.
|
||||
@@ -0,0 +1,53 @@
|
||||
# ADR-0024: Expose OTel metrics with the (prerelease) Prometheus AspNetCore exporter
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-24
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-16c (#124), last of the S-16 (#17) split
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0023 already fixed the shape of metrics collection: **Prometheus scrapes each
|
||||
service's `/metrics`** (pull, no collector). S-16c implements it. That needs a package
|
||||
that turns the OpenTelemetry `MeterProvider` into a Prometheus scrape endpoint inside
|
||||
ASP.NET Core. The canonical one is `OpenTelemetry.Exporter.Prometheus.AspNetCore`
|
||||
(`AddPrometheusExporter()` + `app.MapPrometheusScrapingEndpoint()`).
|
||||
|
||||
The catch: that exporter has **never had a stable release** — the whole OTel .NET
|
||||
Prometheus exporter line is versioned `-beta` (we pin `1.17.0-beta.1`, matched to the
|
||||
`1.17.0` core we already use). Adding it is a new dependency (CLAUDE.md §14), and taking
|
||||
a prerelease package into all five services is the decision worth recording.
|
||||
|
||||
## Decision
|
||||
|
||||
**Add `OpenTelemetry.Exporter.Prometheus.AspNetCore` `1.17.0-beta.1` to the five .NET
|
||||
services and expose `/metrics` with it.**
|
||||
|
||||
- What it gives us: the OTel-native pull endpoint, so the meters we already register for
|
||||
tracing-adjacent instrumentation surface as Prometheus text with zero extra plumbing.
|
||||
- What we'd write to replace it: a hand-rolled `IMetricsListener`/`MeterListener` that
|
||||
formats Prometheus exposition text — real work, and a reimplementation of a widely-used
|
||||
library for no gain.
|
||||
- Risk it adds: a prerelease API that can shift between betas. Contained: it is only
|
||||
wired in `Program.cs` (two calls per service, excluded from mutation), the version is
|
||||
pinned, and `verify-metrics` proves the endpoint + scrape actually work each CI run.
|
||||
|
||||
The alternative — pushing metrics over OTLP to a collector that re-exposes them — was
|
||||
already rejected in ADR-0023 (no collector hop). Not revisited here.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Golden-signal metrics on `/metrics` with the standard OTel names
|
||||
(`http_server_request_duration_seconds`, `dotnet_*`), scraped straight by Prometheus.
|
||||
- No collector, no bespoke exposition code.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- A `-beta` package in production services. Mitigated by the pin + the `verify-metrics`
|
||||
CI gate; upgrading tracks the OTel core version bumps.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None. Metrics are passive: Prometheus pulls; no service calls into the stack.
|
||||
@@ -0,0 +1,58 @@
|
||||
# ADR-0025: The BFF reads the catalogus directly from the ACL
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-24
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-15a (#130), first of the S-15 (#16) split
|
||||
|
||||
## Context
|
||||
|
||||
The beheer portal shows a read-only view of the ZTC catalogus (the published
|
||||
zaaktypen). Two coupling rules constrain where that data can come from:
|
||||
|
||||
- **§8.1** — only the ACL may talk to the ZGW APIs (Catalogi included). So the
|
||||
catalogus read *must* originate in the ACL.
|
||||
- **§8.3** — portals talk only to the BFF. So the portal reaches the ACL only
|
||||
through the BFF.
|
||||
|
||||
That leaves the question of *how the BFF gets the data*. Until now the BFF fanned
|
||||
out to exactly two backends — the Domain Service and the read projection. The
|
||||
catalogus is neither: it is not a registration (domain) nor a projected read model.
|
||||
|
||||
## Decision
|
||||
|
||||
**The BFF calls the ACL directly for the beheer catalogus read** — a new typed
|
||||
`IAclClient` (`GET /catalogi/zaaktypen`), configured by `Downstream:Acl:BaseUrl`,
|
||||
mirroring the existing `IDomainClient` / `IProjectionClient` pattern.
|
||||
|
||||
Rejected alternative — **route it through the Domain Service** (BFF → domain →
|
||||
ACL): the catalogus is not a domain concern, so the domain would gain a
|
||||
pass-through endpoint that owns no aggregate and no invariant, blurring the
|
||||
domain's responsibility purely to avoid a new edge. That is worse coupling, not
|
||||
better.
|
||||
|
||||
This adds one service-to-service edge (BFF → ACL) — an architecturally
|
||||
significant boundary change (§14), hence this ADR. It does **not** bend §8: the
|
||||
ACL stays the only code that reads ZGW, and the portal still talks only to the
|
||||
BFF. The ACL endpoint is a plain read that trusts its callers (§8.3); the
|
||||
beheerder authorization lives at the BFF (medewerker realm + `beheerder` role).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The catalogus read follows the shortest honest path; the domain stays about
|
||||
registrations.
|
||||
- Symmetric with the other downstream clients — nothing new to learn.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The BFF now depends on three backends instead of two. The ACL must be reachable
|
||||
for the beheer portal to load (it already is — the BFF is on the same network).
|
||||
- A second consumer of the ACL (alongside the domain and event-subscriber), so
|
||||
ACL read endpoints are now part of more than one caller's contract.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
A new BFF → ACL edge. §8.1 and §8.3 remain intact; §14 (boundary change) is the
|
||||
reason this ADR exists.
|
||||
@@ -0,0 +1,61 @@
|
||||
# ADR-0026: Runtime-mutable ACL default-fill (in-memory store, seeded from config)
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-24
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-15b (#131), second of the S-15 (#16) split
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0003 made the ACL *default-fill* the ZGW-mandatory fields it stamps on every
|
||||
zaak, supplied as static configuration (`Acl:Defaults`, read once at startup as an
|
||||
immutable singleton). S-15b lets a beheerder **edit** those values from the portal
|
||||
and have the next zaak reflect them — so the defaults must become mutable at runtime.
|
||||
|
||||
Two questions: **what** is editable, and **where** the mutable state lives.
|
||||
|
||||
## Decision
|
||||
|
||||
**Make the three ZGW default-fill fields a runtime-mutable, in-memory store
|
||||
(`IDefaultFillStore`), seeded from `Acl:Defaults` at startup. The ACL reads it per
|
||||
zaak; the beheer `PUT /default-fill` replaces it.**
|
||||
|
||||
### Only the three ZGW fill fields are editable
|
||||
|
||||
`Acl:Defaults` also carries the S-27 catalog-resolution keys (`ZaaktypeIdentificatie`,
|
||||
`InformatieobjecttypeOmschrijving`). Those feed the resolved-URL cache
|
||||
(`CachedZaaktypeCatalog`, ADR-0021); editing them at runtime would leave a stale cache
|
||||
and is catalogus *wiring*, not "default fill". So they **stay static config** and are
|
||||
out of scope for the CRUD. The editable set is exactly `Bronorganisatie`,
|
||||
`VerantwoordelijkeOrganisatie`, `Vertrouwelijkheidaanduiding` (`DefaultFillSettings`).
|
||||
|
||||
### In-memory, not persisted
|
||||
|
||||
The store is a thread-safe in-memory singleton. **An edit is lost on restart**, when it
|
||||
reverts to the configured env. That is acceptable for this reference app: the slice
|
||||
demonstrates the *pattern* (beheer edits config that the ACL honours), not durable
|
||||
config management. The ACL stays stateless — no DB, no EF, no migration, no extra
|
||||
compose service.
|
||||
|
||||
- ponytail ceiling: no persistence, no audit trail, no optimistic concurrency.
|
||||
- Upgrade path: back `IDefaultFillStore` with a DB (or an Objecten record) if durable,
|
||||
audited, multi-instance config is needed — the port stays the same.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Demoable end to end (edit in portal → next zaak reflects it) with minimal moving parts.
|
||||
- The read path is per-zaak, so no restart and no cache concerns for the ZGW fields.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Edits don't survive a restart and aren't shared across replicas (single-instance
|
||||
assumption). Documented ceiling above.
|
||||
- Two sources of default config now (static keys on `AclDefaults`, mutable fields in the
|
||||
store) — a deliberate split by editability.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None new. The BFF→ACL edge already exists (ADR-0025); this adds a read/write pair on it.
|
||||
The ACL remains the owner of the ZGW-facing config.
|
||||
@@ -0,0 +1,81 @@
|
||||
# ADR-0027: The RegisterRecord objecttype is public-safe by construction
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-27
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-18c (#141), third of the S-18 (#19) split
|
||||
|
||||
## Context
|
||||
|
||||
S-18 stands up Objecttypen (S-18a) and Objecten (S-18b) as the authoritative
|
||||
register-record store (PRD §"Objecten as the authoritative register record store").
|
||||
S-19 (#20) will, on approval, write the canonical register record to the Objecten API
|
||||
instead of OpenZaak zaak-eigenschappen, and the openbaar (public) register will read it.
|
||||
|
||||
Objecten validates every object against a **objecttype version's JSON schema**. So the
|
||||
schema is a contract: it fixes which fields a register record may carry. The register is
|
||||
read **anonymously** by the openbaar portal (ADR-0010), so the schema is also a
|
||||
disclosure boundary — anything the schema allows can end up public.
|
||||
|
||||
Two questions: **which fields** the schema defines, and **how** the objecttype gets into
|
||||
the Objecttypen API (which has no declarative objecttype step).
|
||||
|
||||
## Decision
|
||||
|
||||
**Define a `RegisterRecord` objecttype whose published schema carries exactly the
|
||||
public-safe fields — `id`, `status`, `reference` — and register it over the API at
|
||||
startup with a one-shot, idempotently.**
|
||||
|
||||
### The schema mirrors the BFF's public projection, not the internal one
|
||||
|
||||
The public-safe field set already exists: the BFF's `OpenbaarEntry`
|
||||
(`services/bff/Bff.Api/DownstreamClients.cs`) — `id`, `status`, `reference` — is what
|
||||
`OpenbaarProjection.PublicView` narrows every row down to, dropping `bsn` and
|
||||
`naamPlaceholder` at the boundary (S-09). The RegisterRecord schema mirrors that record,
|
||||
**not** the internal `RegisterEntry` / `RegisterEntryRow` (which carry bsn/naam):
|
||||
|
||||
| field | type | notes |
|
||||
|-------|------|-------|
|
||||
| `id` | string (required) | zaak id — the entry's stable key |
|
||||
| `status` | string (required) | enum `INGEDIEND` \| `INGESCHREVEN` (`RegistrationStatus`) |
|
||||
| `reference` | string \| null | citizen-facing zaak identificatie (ADR-0012) |
|
||||
|
||||
`additionalProperties: false` so a record can't smuggle a field the schema didn't
|
||||
sanction, and `dataClassification: "open"` records the intent that this objecttype is
|
||||
public. **`bsn` and `naamPlaceholder` are deliberately absent** — public-safe by
|
||||
construction, so S-19 cannot write a personal-data field into the public register even by
|
||||
mistake.
|
||||
|
||||
### Registered over the API by a one-shot, not setup_configuration
|
||||
|
||||
The Objecttypen API's `setup_configuration` (3.4.2) provisions only tokens — it has no
|
||||
declarative step to create an objecttype with a schema. So a `registerrecord-init`
|
||||
compose one-shot (stdlib Python, on the stack network) creates the objecttype + a
|
||||
**published** version over the API once Objecttypen is healthy, following the ADR-0020
|
||||
self-seed pattern. It is **idempotent**: if a `RegisterRecord` with a version already
|
||||
exists it is a no-op, so it is safe on every `up`.
|
||||
|
||||
- ponytail ceiling: no schema-migration/versioning story — a schema change means editing
|
||||
`registerrecord.schema.json` and bumping the version by hand; the one-shot only ever
|
||||
adds v1 if none exists.
|
||||
- Upgrade path: if the schema evolves, have the one-shot diff the published schema and
|
||||
POST a new version, or move to a declarative step once the upstream supports one.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The public register's disclosure surface is fixed in one reviewed artifact
|
||||
(`registerrecord.schema.json`) and enforced by Objecten's own validation.
|
||||
- Self-seeds on a fresh `make up` / bare local compose; no manual step, no built image.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The public-safe field set now lives in two places — the BFF's `OpenbaarEntry` and this
|
||||
schema — that must be kept in sync by hand (a drift check is a candidate for later).
|
||||
- Hand-managed schema version (ceiling above).
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None new. Registration talks to the Objecttypen API over its documented API. S-19 will
|
||||
write records via the ACL (§8.1) — this ADR only fixes the schema they conform to.
|
||||
@@ -0,0 +1,175 @@
|
||||
# ADR-0028: Objecten holds the register, OpenZaak holds the process
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-14
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-19a (#149), first of the S-19 (#20) split
|
||||
|
||||
## Context
|
||||
|
||||
Until this slice the register existed only as a **derived** thing: the read projection
|
||||
rows the Event Subscriber builds from NRC zaak notifications (ADR-0008). There is no
|
||||
system anywhere that holds "who is registered" as a first-class record — drop the
|
||||
projection database and the only way back is to replay ZGW history and re-derive it.
|
||||
|
||||
That is the wrong shape for a register. A BIG registration is a **fact about a person**
|
||||
that outlives the case that produced it: it is looked up, corrected, superseded, and
|
||||
retained on its own schedule. The zaak that produced it is a **process record** — it
|
||||
opens, moves through statussen, and closes. Storing the fact inside the process record
|
||||
(as zaak `eigenschappen`, the v1 placeholder PRD §"Registration" mentions) welds the two
|
||||
lifecycles together: the register can then never be read, retained, or corrected without
|
||||
going through the case system that happened to create it.
|
||||
|
||||
S-18 stood up Objecten + Objecttypen and registered the public-safe `RegisterRecord`
|
||||
objecttype (ADR-0027). The open question this ADR closes: **where the authoritative
|
||||
register record lives, and who writes it.**
|
||||
|
||||
## Decision
|
||||
|
||||
**The register record lives in the Objecten API as a `RegisterRecord` object. OpenZaak
|
||||
keeps only the process. On approval the ACL writes both: the ZGW eindstatus, then the
|
||||
register record.**
|
||||
|
||||
### Not zaak eigenschappen
|
||||
|
||||
Eigenschappen are per-zaaktype, untyped strings, and readable only by walking the zaak.
|
||||
They inherit the zaak's lifecycle and its archiving regime, and they give the public
|
||||
register no queryable surface of its own. Objecten gives a JSON-schema-validated record
|
||||
(ADR-0027 makes that schema the disclosure boundary), a queryable collection, and a
|
||||
lifecycle the zaak cannot drag around with it.
|
||||
|
||||
### The ACL writes it, not the domain or the Event Subscriber
|
||||
|
||||
CLAUDE.md §8.1 keeps upstream Common Ground modules behind the ACL. Objecten is such a
|
||||
module, so the same rule applies: `ObjectenGateway` is the only code that talks to it,
|
||||
and the domain keeps handing the ACL nothing but a zaak URL. The alternative — having the
|
||||
Event Subscriber write the record when it sees the status notification — would make the
|
||||
register a *second* derived artefact of ZGW, which is exactly the coupling this ADR
|
||||
removes.
|
||||
|
||||
### Two writes, converging rather than transactional
|
||||
|
||||
Approval is now two writes across two modules, so it cannot be atomic. Both are made
|
||||
idempotent instead:
|
||||
|
||||
- a ZGW status is an append-only log entry, so re-setting the eindstatus is harmless;
|
||||
- the register write is an **upsert keyed on the zaak id** — search Objecten for an
|
||||
existing object with that `id`, then PATCH it or POST a new one.
|
||||
|
||||
A caller that retries a half-failed approval therefore converges. This is the same
|
||||
eventual-consistency posture as everywhere else in the system (CLAUDE.md §2.2, §8.6),
|
||||
not an exception carved out for this path.
|
||||
|
||||
### The objecttype is resolved by name, lazily
|
||||
|
||||
The objecttype URL and version number are assigned by Objecttypen at seed time, so they
|
||||
cannot be pinned in config — the ACL resolves them by the configured name
|
||||
(`Acl__Objecten__ObjecttypeName`), taking the highest **published** version. This is the
|
||||
same reasoning as ADR-0021 for zaaktypen.
|
||||
|
||||
Resolution happens on the first approval, not at startup, so the ACL needs no `depends_on`
|
||||
on Objecten and will not crash-loop when it boots ahead of the seed. A failed resolution
|
||||
is not cached, so it is retried on the next approval.
|
||||
|
||||
- ponytail ceiling: the resolution is memoised per gateway instance, and the gateway is a
|
||||
transient typed `HttpClient` — in practice one extra GET per approval against a
|
||||
neighbouring container.
|
||||
- Upgrade path: lift it into a singleton cache (as `CachedZaaktypeCatalog` does for ZGW)
|
||||
if approvals ever get hot enough for that GET to matter.
|
||||
|
||||
### The objecttype's UUID is pinned, not server-assigned
|
||||
|
||||
Objecten refuses to store an object whose objecttype it has not been configured with
|
||||
(`ObjectType with url=… is not configured`), and its configuration identifies an
|
||||
objecttype **by UUID** — supplied through a static `setup_configuration` file applied
|
||||
when the container starts, before the `registerrecord-init` one-shot has run.
|
||||
|
||||
Rather than thread a seed-time UUID from one container into another's config, the UUID is
|
||||
**pinned**: `infra/objecttypen-registerrecord/register.py` creates the objecttype with a
|
||||
fixed UUID (the Objecttypen API accepts a client-supplied one), and
|
||||
`infra/objecten/setup_configuration/data.yaml` declares that same UUID. Both sides are
|
||||
declared up front, both stay idempotent, and neither has to wait for the other.
|
||||
|
||||
The cost is a constant duplicated across two files that must be kept in step; each carries
|
||||
a comment pointing at the other.
|
||||
|
||||
### The ACL must reach Objecttypen at the URL Objecten knows it by
|
||||
|
||||
Objecttypen builds the `url` it returns from the request's own Host header, and Objecten
|
||||
matches an incoming object's `type` against the `api_root` it was configured with. So an
|
||||
ACL that reads Objecttypen at `http://localhost:8020` gets back a `localhost` objecttype
|
||||
URL that Objecten then rejects as "not one of the available choices" — even though it is
|
||||
the same objecttype.
|
||||
|
||||
`Acl__Objecten__ObjecttypenBaseUrl` must therefore match Objecten's configured
|
||||
`api_root` (`http://objecttypen:8000/api/v2/`). This is the same class of constraint as
|
||||
ADR-0006's "point the ACL at OpenZaak's container IP", and it is why the Objecten
|
||||
integration tests only pass from inside the compose network.
|
||||
|
||||
### Objecten's notifications are off for this slice
|
||||
|
||||
Objecten publishes to a Notificaties API on every write, and `notifications_api_common`
|
||||
**raises** rather than skipping when that configuration is absent — so with no NRC wiring,
|
||||
every `POST /api/v2/objects` returns 500 after creating and rolling back the object.
|
||||
|
||||
Objecten → NRC is not wired: there is no broker, no Celery worker, no `objecten` kanaal and
|
||||
no abonnement for it. Configuring only the client side would make writes succeed while
|
||||
every message was dropped on the floor — a delivery path that looks wired and isn't. So
|
||||
`NOTIFICATIONS_DISABLED` is set for Objecten in both compose files instead.
|
||||
|
||||
- ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a
|
||||
register write yet.
|
||||
- **Lifted by ADR-0029** (S-19b-1, #152): broker, worker, `objecten` kanaal and
|
||||
notifications config now exist, and `NOTIFICATIONS_DISABLED` is `false`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The register is a first-class record with its own schema, lifecycle and query surface,
|
||||
independent of the case that produced it.
|
||||
- The disclosure boundary is enforced by Objecten's schema validation (ADR-0027), not by
|
||||
discipline in projection code.
|
||||
- The read projection can become a cache of Objecten rather than a re-derivation of ZGW —
|
||||
done in S-19b-2 (#153), ADR-0030.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Approval writes to two modules and is eventually consistent; a failure between them
|
||||
leaves a zaak in eindstatus without a register record until the approval is retried.
|
||||
Nothing repairs that automatically yet.
|
||||
- One more upstream module on the approval path, and one more dev credential
|
||||
(`Acl__Objecten__Token`) in compose.
|
||||
- Two new hand-kept constants: the pinned objecttype UUID (two files) and the objecttype
|
||||
name (compose + `register.py`).
|
||||
- ~~Until S-19b lands, the public register is still read from the NRC-derived projection, so
|
||||
the register record is written but not yet read — the two must agree.~~ Closed by ADR-0030:
|
||||
the projection is now derived from the register, so there is only one source to agree with.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None bent. §8.1 is extended in spirit — the ACL is the only code that talks to Objecten,
|
||||
exactly as it is the only code that talks to ZGW. The domain still passes only a zaak URL,
|
||||
and no service reaches Objecten's database.
|
||||
|
||||
## Verification
|
||||
|
||||
The end-to-end assertion lives in the Playwright happy path
|
||||
(`tests/e2e/registration.spec.ts`, run by `verify-e2e`): after the behandelaar approves and
|
||||
the openbaar register shows `INGESCHREVEN`, it asserts Objecten holds exactly one
|
||||
`RegisterRecord` for *that* reference, with status `INGESCHREVEN` and no field outside the
|
||||
public-safe schema.
|
||||
|
||||
It belongs there and not in `verify-domain`, which looks like the obvious home: that check
|
||||
completes the Beoordelen task straight through Flowable REST (deliberately — it exists to
|
||||
exercise the Workflow Client's REST contract), which bypasses the domain `decide` path that
|
||||
calls the ACL. The e2e is the only check that drives a real approval.
|
||||
|
||||
`ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl`
|
||||
inside the compose network) drives the real gateway against a live Objecten + Objecttypen
|
||||
pair: two writes for the same id leave exactly one object, carrying the second write's
|
||||
status and nothing outside the public-safe schema.
|
||||
|
||||
All three findings above — the pinned UUID, the notifications block, and the base-URL
|
||||
constraint — came out of running the gateway against those live modules while writing the
|
||||
slice, not out of CI.
|
||||
@@ -0,0 +1,122 @@
|
||||
# ADR-0029: Objecten publishes register events to NRC
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-14
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-19b-1 (#152), first of the S-19b (#150) split
|
||||
- **Supersedes in part:** ADR-0028's "Objecten's notifications are off for this slice"
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0028 put the authoritative register record in the Objecten API and had the ACL write
|
||||
it on approval. It also switched Objecten's notifications **off** — deliberately, with a
|
||||
stated ceiling: there was no broker, no worker, no `objecten` kanaal and no abonnement, so
|
||||
turning the client side on alone would have produced a delivery path that looks wired and
|
||||
drops every message.
|
||||
|
||||
S-19b-2 (#153) wants the read projection sourced from register writes rather than
|
||||
re-derived from ZGW zaak events. That needs the notifications to actually arrive. This ADR
|
||||
builds the four missing pieces and lifts the ceiling.
|
||||
|
||||
## Decision
|
||||
|
||||
**Objecten publishes to the same NRC OpenZaak already publishes to, on the `objecten`
|
||||
kanaal, delivered by its own Celery worker — provisioned declaratively on both sides,
|
||||
exactly as ADR-0007 did for OpenZaak.**
|
||||
|
||||
- **Objecten** (`infra/objecten/setup_configuration/data.yaml`): a `zgw_consumers` service
|
||||
`nrc` (api_type `nrc`) plus a `notifications_config` step naming it, and
|
||||
`NOTIFICATIONS_DISABLED: "false"` in both compose files.
|
||||
- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): an `objecten` kanaal
|
||||
alongside `zaken`.
|
||||
- **`objecten-celery`**: a worker container on the Objecten image (`/celery_worker.sh`),
|
||||
mirroring `oz-celery`, with `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND` on
|
||||
`objecten-redis` db 1 (db 0 is already the cache).
|
||||
|
||||
### One NRC, one credential, one kanaal per publisher
|
||||
|
||||
Objecten reuses the `big-reference-seed` client OpenZaak publishes with. NRC verifies its
|
||||
JWT and authorizes it against OpenZaak's Autorisaties API (ADR-0007), which grants that
|
||||
client `heeft_alle_autorisaties` — so no second credential and no publisher-specific
|
||||
authorization is needed. A second NRC, or a second credential, would buy isolation this
|
||||
reference application has no use for.
|
||||
|
||||
The kanaal name is **not ours to choose**: the Objects API sends
|
||||
`NOTIFICATIONS_KANAAL = "objecten"`. NRC rejects a publish to an unregistered kanaal
|
||||
(`"Kanaal met deze naam bestaat niet"`), which is precisely what the failing check for this
|
||||
slice reported first. Its filter set (`object_type`) matches the kenmerken the Objects API
|
||||
sends, so an abonnement can narrow to one objecttype instead of receiving every write.
|
||||
|
||||
### Writers address Objecten as `objecten.local` — NRC rejects single-label hosts
|
||||
|
||||
NRC types a notification's `hoofdObject` and `resourceUrl` as DRF `URLField`s, so Django's
|
||||
`URLValidator` runs on them — and it refuses a **single-label** host. Objecten fills both
|
||||
from the object `url` that DRF built with `request.build_absolute_uri`, i.e. **the Host the
|
||||
caller used**. Write to `http://objecten:8000` and NRC answers every publish with
|
||||
|
||||
```
|
||||
{"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]}
|
||||
```
|
||||
|
||||
which `objecten-celery` then retries with exponential backoff, forever, in the background —
|
||||
the write itself having returned 201.
|
||||
|
||||
`SITE_DOMAIN` does **not** fix this; it is not what builds those URLs. The fix is on the
|
||||
caller side: the `objecten` service carries an `objecten.local` network alias, and every
|
||||
component whose writes must be notified — the ACL (`Acl__Objecten__BaseUrl`), the gateway
|
||||
integration tests, this slice's verify check — addresses it there. An alias rather than a
|
||||
plain dotted `SITE_DOMAIN` so the host still **resolves in-network**: a subscriber that
|
||||
follows `resourceUrl` reaches the record it points at, which S-19b-2 will do. Readers are
|
||||
unaffected and keep using the plain service name.
|
||||
|
||||
This is the same class of constraint as ADR-0028's "the ACL's Objecttypen base URL must
|
||||
match Objecten's configured `api_root`": these modules put request-derived hosts into data
|
||||
another module then validates or dereferences.
|
||||
|
||||
- ponytail ceiling: nothing *enforces* that a new writer uses the alias — it would get a 201
|
||||
and silently no notification.
|
||||
- Upgrade path: if a second writer ever appears, rename the compose service to `objecten.local`
|
||||
so the plain name stops working, rather than adding a lint.
|
||||
|
||||
### A worker, not a synchronous send
|
||||
|
||||
`notifications_api_common` only schedules the send on transaction commit. Without a worker
|
||||
the task sits in redis forever and every register write is silently undelivered — the exact
|
||||
half-wired state ADR-0028 refused to ship. No `beat` for Objecten: it is a publisher, not a
|
||||
subscriber, and `nrc-beat` already drains NRC's delivery queue.
|
||||
|
||||
## Verification
|
||||
|
||||
`make verify-objecten-notifications` (`infra/run-objecten-notifications-check.sh`, in the
|
||||
CI `verify-stack` job) registers an abonnement on the `objecten` kanaal pointing at a
|
||||
throwaway webhook sink, writes a `RegisterRecord` exactly as the ACL does on approval, and
|
||||
asserts the notification reaches the sink. That is the whole chain in one assertion:
|
||||
Objecten → `objecten-celery` → NRC → `nrc-beat` → the callback. Any missing piece — broker,
|
||||
worker, kanaal, notifications config — shows up as a non-delivery rather than as a green
|
||||
config.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- A register write is now observable by anything that subscribes, which is what S-19b-2
|
||||
(#153) needs to make the projection a cache of Objecten rather than a re-derivation of ZGW.
|
||||
- ADR-0028's ceiling is lifted: the delivery path is proven end to end, not merely configured.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- One more long-running container (`objecten-celery`) on an already memory-tight CI runner.
|
||||
- A second publisher on the shared `big-reference-seed` credential — a credential rotation
|
||||
now touches two modules.
|
||||
- Objecten now has two in-network names, and which one a caller uses silently decides
|
||||
whether its writes are notified (ceiling above).
|
||||
- ponytail ceiling: notification delivery has no dead-letter or alerting — a failed publish
|
||||
is visible only in the worker log.
|
||||
- Upgrade path: if undelivered register events start mattering, subscribe an audit sink or
|
||||
read NRC's own delivery admin rather than building a retry layer here.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None bent. This is infrastructure between two upstream modules, over their documented
|
||||
APIs; no service reaches another's database. §8.6 (idempotency at every event boundary)
|
||||
applies to whatever consumes the new kanaal — S-19b-2's problem, not this slice's.
|
||||
@@ -0,0 +1,141 @@
|
||||
# ADR-0030: The read projection is sourced from the register, not from ZGW
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-28
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-19b-2 (#153), second of the S-19b (#150) split
|
||||
- **Builds on:** ADR-0008 (read projection store), ADR-0028 (Objecten holds the register), ADR-0029 (Objecten publishes to NRC)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0028 moved the authoritative register record into the Objecten API, and said what should
|
||||
follow: "the read projection can become a cache of Objecten rather than a re-derivation of
|
||||
ZGW." Until this slice it was still the latter — the Event Subscriber listened on the `zaken`
|
||||
kanaal and inferred register state from case events:
|
||||
|
||||
- a `zaak`/`create` meant INGEDIEND;
|
||||
- any `status`/`create` was taken to be the approval, so meant INGESCHREVEN — the subscriber
|
||||
may not read OpenZaak (§8.1), so it could not tell one statustype from another;
|
||||
- the citizen-facing reference was not in the notification at all, so every projection had a
|
||||
second hop: ask the ACL for the zaak's identificatie (#78).
|
||||
|
||||
So the register — a fact about a person — was reconstructed by guessing at the lifecycle of the
|
||||
case that happened to produce it. ADR-0029 made the register itself publish. This ADR switches
|
||||
the projection over to it.
|
||||
|
||||
## Decision
|
||||
|
||||
**The Event Subscriber listens on the `objecten` kanaal and projects the `RegisterRecord` the
|
||||
notification points at. The projection is a cache of the register; ZGW is no longer a source.**
|
||||
|
||||
- The subscriber's abonnement moves from `zaken` to `objecten` (`register-abonnement.py`, and
|
||||
the CI projection check).
|
||||
- An Objecten notification carries **no record data** — only the object URL and the objecttype
|
||||
as a kenmerk — so the record is read back through the ACL (`POST /register-records/read`).
|
||||
§8.1 applies to Objecten exactly as ADR-0028 established: the ACL is the only code that talks
|
||||
to it.
|
||||
- The accepted acties are `create`, `update` and `partial_update`. The last one is not
|
||||
defensive breadth: the ACL upserts with PATCH, and DRF routes a PATCH through the notifying
|
||||
`update()` while naming the action `partial_update` — which is what Objecten publishes. So
|
||||
every approval arrives as `partial_update`, and accepting only `create`/`update` drops the
|
||||
one state change this slice exists to project. `destroy` is deliberately not accepted:
|
||||
removing a registration from the public register is its own decision.
|
||||
- The record already carries `id`, `status` and `reference`, so the row is the record. The
|
||||
zaak-shaped surface goes: `IsZaakCreated`, `IsZaakStatusSet`, `ZaakUrl`, `ZaakId`, and
|
||||
`ToEntry`'s `Resource == "status"` inference are replaced by `IsRegisterRecordWritten` +
|
||||
`ObjectUrl`, and the ACL enrichment hop disappears.
|
||||
|
||||
### The ACL writes an INGEDIEND record on submit
|
||||
|
||||
Before this slice only approval wrote a record, so re-sourcing alone would have silently
|
||||
dropped every INGEDIEND row from the public register. `OpenZaakAsync` therefore upserts a
|
||||
record with status INGEDIEND after opening the zaak, keyed on the same zaak id that approval
|
||||
later upserts to INGESCHREVEN.
|
||||
|
||||
This is the same two-writes-converging posture ADR-0028 already accepted for approval, now on
|
||||
the submit path too: both writes are idempotent, so a retried submit updates the record rather
|
||||
than adding a second one (§8.6). The reference comes from the registration itself, so unlike
|
||||
approval this path needs no ZGW read-back.
|
||||
|
||||
The alternative — a register holding only INGESCHREVEN — is arguably the more correct reading
|
||||
of "public register", but it narrows what the openbaar portal shows and reads against PRD §68
|
||||
("~50 register entries with diverse statuses"). Rejected as a behaviour change this slice was
|
||||
not asked to make.
|
||||
|
||||
### The dedup key is the projected row, not the notification
|
||||
|
||||
NRC carries no notification id and may redeliver, so the idempotency key is derived from
|
||||
content (as before). The obvious candidates both break here:
|
||||
|
||||
- **the object URL alone** — the ACL upserts *one object per registration*, so submit and
|
||||
approval notify about the same URL, and the approval would be swallowed as a duplicate;
|
||||
- **object URL + actie** — a retried approval is a second `update`, so it would be dropped
|
||||
while genuinely being the same state (harmless), but a *third* distinct state would collide
|
||||
with it (not harmless).
|
||||
|
||||
The key is therefore the object plus the state that write puts in the projection —
|
||||
`objecten:object:{url}:{status}:{reference}`. A redelivery collapses; a genuine state change
|
||||
does not. That is exactly the property §8.6 asks for, and it needs no version field from
|
||||
Objecten's internals.
|
||||
|
||||
### The notification log holds the row, not the event
|
||||
|
||||
`processed_notifications` stops describing ZGW events (`actie`, `zaak_id`, `resource`) and
|
||||
holds the projected row itself (`register_id`, `status`, `reference`). A rebuild becomes a
|
||||
replay with no mapping rules and no upstream reads at all — §8.4 held before via the ACL hop;
|
||||
now it holds outright.
|
||||
|
||||
The migration **drops** the old columns rather than renaming them. EF scaffolded renames
|
||||
(`resource` → `register_id`, `zaak_id` → `status`) that would have carried ZGW values into
|
||||
columns meaning something else entirely, and a rebuild would then have projected that garbage.
|
||||
|
||||
- ponytail ceiling: the migration empties both tables. A pre-slice row describes a zaak event
|
||||
the new projector cannot reproject, and the registrations behind those rows have no
|
||||
RegisterRecord in Objecten (only approvals wrote one), so they are not re-derivable from the
|
||||
new source either.
|
||||
- Upgrade path: fine while stacks are ephemeral. If a long-lived environment ever needs to keep
|
||||
them, backfill by walking Objecten's objects rather than replaying the log.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The register is read from the register. The projection is a derived cache of a first-class
|
||||
record, not an inference over someone else's lifecycle.
|
||||
- The "any status-create is the approval" guess is gone — a real source of wrongness the moment
|
||||
the zaaktype grows a second statustype.
|
||||
- One hop fewer per notification: the record carries its own reference, so the ACL enrichment
|
||||
call disappears.
|
||||
- A rebuild needs nothing but its own log (§8.4).
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Submission is now two writes across two modules and eventually consistent. A failure between
|
||||
them leaves a zaak with no register record until the submit is retried; nothing repairs that
|
||||
automatically yet — the same gap ADR-0028 recorded for approval, now on a second path.
|
||||
- The projection lags the register by a notification round trip, where it used to lag the zaak
|
||||
by one. In practice the same order of magnitude.
|
||||
- Projecting now depends on the ACL being reachable, where the reference enrichment used to be
|
||||
the only ACL dependency. A failed read means the notification is not logged and not
|
||||
projected — NRC retries, so it converges, but the failure mode is now on the main path.
|
||||
- OpenZaak still publishes to `zaken` and nothing in the product listens. Kept because the
|
||||
`verify-nrc` check asserts that path, and turning off a working publisher to save nothing
|
||||
would be its own risk.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None bent. §8.1 holds — the subscriber reaches Objecten only through the ACL. §8.4 is
|
||||
strengthened: the projection is rebuildable from its own log, with no upstream reads at all.
|
||||
§8.6 is what the dedup-key discussion above is about.
|
||||
|
||||
## Verification
|
||||
|
||||
`make verify-projection` (`infra/run-projection-check.sh`, in CI's `verify-stack`) opens a zaak
|
||||
**through the ACL** and asserts projection-api serves a row for it with status INGEDIEND — the
|
||||
whole new chain in one assertion: ACL → Objecten → `objecten-celery` → NRC → `nrc-beat` →
|
||||
Event Subscriber → projection → projection-api. A zaak created behind the ACL's back produces
|
||||
no row, which is the re-source working rather than a gap.
|
||||
|
||||
`RegisterProjectieBijwerken.feature` covers the use case in business language, including the
|
||||
approval case — the same row moving INGEDIEND → INGESCHREVEN, which is now one registration's
|
||||
record being updated rather than two unrelated ZGW events.
|
||||
@@ -0,0 +1,49 @@
|
||||
# ADR-0031 — MFA on the medewerker realm, with a fixture TOTP secret
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-09-03
|
||||
- **Slice:** S-15c (Gitea #132)
|
||||
|
||||
## Context
|
||||
|
||||
Staff (behandelaar, teamlead, beheerder) act on citizens' registrations and on the ACL's
|
||||
default-fill: the highest-privilege logins in the platform. The medewerker realm protected
|
||||
them with a password alone, while the citizen realms (digid, eherkenning, eidas) mock
|
||||
brokers that carry their own assurance levels. A reference application that demonstrates a
|
||||
government architecture should show MFA on the staff realm.
|
||||
|
||||
Two things had to be decided: **how** to enforce OTP in a realm export, and **how the
|
||||
automated checks and a human demo obtain a code** — the e2e drives a real browser login and
|
||||
`make keycloak-smoke` drives a real password grant, so neither can scan a QR.
|
||||
|
||||
## Decision
|
||||
|
||||
**Enforce OTP by giving every seeded medewerker a TOTP credential**, rather than replacing
|
||||
Keycloak's browser flow with a copy whose OTP execution is `REQUIRED`.
|
||||
|
||||
Keycloak's stock `browser` and `direct grant` flows both contain a *conditional OTP*
|
||||
subflow that fires when the user has an OTP credential. Seeding the credential therefore
|
||||
turns the challenge on for every seeded user, in both flows, without duplicating ~40 lines
|
||||
of flow JSON into the export. `CONFIGURE_TOTP` is additionally set as a **default required
|
||||
action**, so a medewerker created later must enrol before their first login.
|
||||
|
||||
**The seeded secret is a fixed, committed fixture** (`BIGMEDEWERKEROTPSEED`) shared by all
|
||||
medewerkers. Codes are then computable: `infra/keycloak/check_realms.py` (Python, stdlib
|
||||
`hmac`) and `tests/e2e/medewerker-login.ts` (Node `crypto`) each implement RFC 6238 in
|
||||
about six lines — no OTP dependency on either side, and no enrolment step in the tests.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A password alone no longer yields a token on the medewerker realm; `check_realms.py`
|
||||
asserts that refusal, so the enforcement cannot silently regress.
|
||||
- Every medewerker login in the e2e goes through `loginMedewerker()`, which submits the OTP
|
||||
form. New staff specs must use it.
|
||||
- **The secret is public.** It is a demo fixture and worthless outside this synthetic
|
||||
stack, in the same class as the committed `test123` passwords and the mock DigiD broker.
|
||||
A real deployment enrols per-user authenticators (or federates to DigiD Machtigen /
|
||||
eHerkenning at the required assurance level) and seeds no credentials at all.
|
||||
- Enforcement is *effectively* realm-wide but *technically* per-user: the conditional
|
||||
subflow is what fires. A medewerker whose OTP credential were removed would fall back to
|
||||
the required action at next login (enrol, then challenge) rather than skipping MFA — an
|
||||
acceptable equivalence for this purpose, and the reason the required action is set.
|
||||
- Reversal is a one-file edit: drop the `otp` credentials and the `requiredActions` block.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user