Compare commits
142
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25b593ec3e | ||
|
|
c25ec24c73 | ||
|
|
24927b1e80 | ||
|
|
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 | ||
|
|
986e36bc7d | ||
|
|
7e152e4432 | ||
|
|
5bf25f094d | ||
|
|
0e6c7d2066 | ||
|
|
39923e0e68 | ||
|
|
2e00ad38ba | ||
|
|
be016f920c | ||
|
|
d3f23a4da3 | ||
|
|
490e7347b0 | ||
|
|
4f311c9b5a | ||
|
|
a55ba1160d | ||
|
|
4416d1f4ed | ||
|
|
074101e836 | ||
|
|
5089c2aea6 | ||
|
|
29f3dcc6cf | ||
|
|
2c196245c2 | ||
|
|
72c2bdfae7 | ||
|
|
311aab0aba | ||
|
|
fcdb117768 | ||
|
|
c3f0710a18 | ||
|
|
7c363099ff | ||
|
|
a069ab07a2 | ||
|
|
34969659f7 | ||
|
|
fd90c4abe2 | ||
|
|
3824f85af6 | ||
|
|
ef877ebc80 | ||
|
|
9c961f9a13 | ||
|
|
0b82841b14 | ||
|
|
5a4331a416 | ||
|
|
96d447832f | ||
|
|
a07d8277d6 | ||
|
|
69d6e80378 | ||
|
|
5d32d4f15e | ||
|
|
d767430ad7 | ||
|
|
751ca006a7 | ||
|
|
fea806848b | ||
|
|
2f5d656b54 | ||
|
|
72efab3ae0 | ||
|
|
1edd34e2db | ||
|
|
f885e0a3be | ||
|
|
ac874bf746 | ||
|
|
67f0ffb88d | ||
|
|
5a3f28ac6d | ||
|
|
e9a873c152 | ||
|
|
79dcd8f14b | ||
|
|
22ab38f328 | ||
|
|
0d34d60797 | ||
|
|
6d4adaf957 | ||
|
|
39b2388a9d | ||
|
|
8d176c2603 | ||
|
|
53751fd1bc | ||
|
|
cc9e7852e1 | ||
|
|
c9edf27a48 | ||
|
|
c3ccffe417 | ||
|
|
0d0778036e | ||
|
|
fa8382fc02 | ||
|
|
06d8d13e19 | ||
|
|
a111e5cc20 | ||
|
|
7ef63c7ae9 | ||
|
|
017cd5e66b | ||
|
|
c70840e5b7 | ||
|
|
32c98f00db | ||
|
|
d49443353e | ||
|
|
a256db1a23 | ||
|
|
4d07285dcd | ||
|
|
f3e9db7147 | ||
|
|
86cc65f4d9 | ||
|
|
4474585606 | ||
|
|
3829cb0b68 | ||
|
|
855a5565fe | ||
|
|
09de500fb8 | ||
|
|
4322c607cb | ||
|
|
d0582cef65 | ||
|
|
f2e575b427 | ||
|
|
fd5fa5ac3c | ||
|
|
5f3dd31925 | ||
|
|
347713766e | ||
|
|
7ecc184111 | ||
|
|
e8510bf9c3 | ||
|
|
6ac2fca384 | ||
|
|
10816f5303 | ||
|
|
89b097d015 | ||
|
|
5a83216395 | ||
|
|
f9e123dfcb | ||
|
|
e87113da24 | ||
|
|
dda4c58e1c | ||
|
|
b349dff496 | ||
|
|
6d8e1d0830 | ||
|
|
a0aa22c80b | ||
|
|
12049a0f35 | ||
|
|
9ff7937055 | ||
|
|
88de47d1bb | ||
|
|
8528664660 | ||
|
|
f32fc4e8c0 | ||
|
|
eaca611842 | ||
|
|
28041228bd | ||
|
|
4b2af5c635 | ||
|
|
71b76a0ef9 | ||
|
|
c904c64597 | ||
|
|
195a76aaf2 | ||
|
|
0409eb42c5 | ||
|
|
8c5bbe05a9 | ||
|
|
e85774d482 | ||
|
|
ada2e807a3 | ||
|
|
d4a89e6e62 | ||
|
|
dfd6224fea | ||
|
|
7d67ecbde1 | ||
|
|
dfbaf7640a | ||
|
|
364d2eceb2 | ||
|
|
c746648e5c |
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"dotnet-stryker": {
|
||||||
|
"version": "4.15.0",
|
||||||
|
"commands": [
|
||||||
|
"dotnet-stryker"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
},
|
||||||
|
"dotnet-ef": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"commands": [
|
||||||
|
"dotnet-ef"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Editor configuration, see http://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# .NET sources use 4-space indent (dotnet format enforces this). The 2-space default
|
||||||
|
# above is for the frontend (TS/HTML/CSS/JSON); C# keeps the .NET convention.
|
||||||
|
[*.cs]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: ADR proposal
|
||||||
|
about: Propose a decision that needs recording before coding (CLAUDE.md §14)
|
||||||
|
title: "ADR: "
|
||||||
|
labels:
|
||||||
|
- type:adr-proposal
|
||||||
|
---
|
||||||
|
|
||||||
|
**Decision to be made:**
|
||||||
|
|
||||||
|
**Context / forces:** <!-- what makes this non-obvious; constraints, trade-offs -->
|
||||||
|
|
||||||
|
**Options considered:**
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
|
||||||
|
**Proposed option + why:**
|
||||||
|
|
||||||
|
**Consequences:** <!-- what becomes easier/harder; what we commit to -->
|
||||||
|
|
||||||
|
**Coupling rules touched (CLAUDE.md §8):** <!-- none, or which and why -->
|
||||||
|
|
||||||
|
> On acceptance, the ADR file (`docs/architecture/adr-NNNN-title.md`, Nygard
|
||||||
|
> template) lands in the PR that implements the decision.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: Bug
|
||||||
|
about: Something behaves incorrectly
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- type:bug
|
||||||
|
---
|
||||||
|
|
||||||
|
**What happened:**
|
||||||
|
|
||||||
|
**What you expected:**
|
||||||
|
|
||||||
|
**Steps to reproduce:**
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
|
||||||
|
**Environment:** <!-- branch/commit, OS, container engine, anything relevant -->
|
||||||
|
|
||||||
|
**Logs / evidence:**
|
||||||
|
|
||||||
|
**Suspected area:** <!-- e.g. area:bff, area:acl — add the matching area label -->
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: Slice (user story)
|
||||||
|
about: A backlog slice — independently demoable, encodes the Definition of Done
|
||||||
|
title: "S-NN · "
|
||||||
|
labels:
|
||||||
|
- type:slice
|
||||||
|
---
|
||||||
|
|
||||||
|
**Outcome:** <!-- one sentence; user-visible if possible -->
|
||||||
|
|
||||||
|
**Acceptance:**
|
||||||
|
<!-- Gherkin scenarios or testable assertions -->
|
||||||
|
-
|
||||||
|
|
||||||
|
**Touches:** <!-- services and folders -->
|
||||||
|
|
||||||
|
**Out of scope:** <!-- explicit non-goals -->
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- [ ] This linked Gitea issue exists and is on the right milestone.
|
||||||
|
- [ ] Failing test written and committed first (`test(scope): … (refs #NN)`).
|
||||||
|
- [ ] Implementation makes the test pass (`feat(scope): … (refs #NN)`).
|
||||||
|
- [ ] Refactor commit follows if structure improved.
|
||||||
|
- [ ] Conventional Commit messages referencing this issue.
|
||||||
|
- [ ] All Gitea Actions CI jobs green (or `make ci` green while no runner exists).
|
||||||
|
- [ ] `docker compose up` from a fresh clone reaches green health checks within 3 minutes.
|
||||||
|
- [ ] Docs touched if behaviour, contracts, or operations changed.
|
||||||
|
- [ ] ADR added in `docs/architecture/` if a non-obvious decision was made.
|
||||||
|
- [ ] Demo note appended to `docs/demo-script.md` if the slice is user-visible.
|
||||||
|
- [ ] This issue closed by the merging PR (`closes #NN`).
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!-- Title: Conventional Commit style, e.g. feat(bff): … (closes #NN) -->
|
||||||
|
|
||||||
|
## What & why
|
||||||
|
|
||||||
|
<!-- Summary of the change and the slice/bug it addresses. -->
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- [ ] Linked Gitea issue (above).
|
||||||
|
- [ ] Failing test committed before the implementation.
|
||||||
|
- [ ] Implementation makes the test pass; refactor commit if structure improved.
|
||||||
|
- [ ] Conventional Commits referencing the issue (`refs #NN`).
|
||||||
|
- [ ] CI green — all Gitea Actions jobs (or `make ci` green while no runner exists).
|
||||||
|
- [ ] `docker compose up` from a fresh clone reaches green health checks within 3 minutes.
|
||||||
|
- [ ] Docs updated if behaviour, contracts, or operations changed.
|
||||||
|
- [ ] ADR added in `docs/architecture/` if a non-obvious decision was made.
|
||||||
|
- [ ] Demo note in `docs/demo-script.md` if user-visible.
|
||||||
|
|
||||||
|
## Notes for reviewers
|
||||||
|
|
||||||
|
<!-- Anything that helps review: trade-offs, follow-ups, known gaps. -->
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Self-hosted runner — see docs/runbooks/ci.md for the runner setup.
|
||||||
|
# `uses:` are absolute, tag-pinned URLs (CLAUDE.md §8.7 / §15).
|
||||||
|
|
||||||
|
# Each job calls a `make` target — the same one developers run locally
|
||||||
|
# (`make ci`). The Makefile is the single source of truth; see docs/runbooks/ci.md.
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
- 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:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
- 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:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
- 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
|
||||||
|
|
||||||
|
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 11
|
||||||
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: make frontend
|
||||||
|
|
||||||
|
mutation:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
- 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
|
||||||
|
# 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
|
||||||
|
# ratchet (make mutation's exit code), not the report, so a Gitea artifact-backend
|
||||||
|
# 500 must not fail the job (gitea-actions-gotchas.md §4). Glob handles Stryker's
|
||||||
|
# non-deterministic StrykerOutput/<timestamp>/ dir. Pinned @v3: @v4's bundled
|
||||||
|
# @actions/artifact hard-aborts on non-github.com (GHES guard) — see the runbook.
|
||||||
|
- uses: https://github.com/actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
name: acl-mutation-report
|
||||||
|
path: services/acl/StrykerOutput/**/reports/mutation-report.html
|
||||||
|
if-no-files-found: warn
|
||||||
|
- uses: https://github.com/actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
name: event-subscriber-mutation-report
|
||||||
|
path: services/event-subscriber/StrykerOutput/**/reports/mutation-report.html
|
||||||
|
if-no-files-found: warn
|
||||||
|
- uses: https://github.com/actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
name: domain-mutation-report
|
||||||
|
path: services/domain/StrykerOutput/**/reports/mutation-report.html
|
||||||
|
if-no-files-found: warn
|
||||||
|
- uses: https://github.com/actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
name: bff-mutation-report
|
||||||
|
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
|
||||||
|
# (base images, nuget, selectielijst.openzaak.nl).
|
||||||
|
verify-stack:
|
||||||
|
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).
|
||||||
|
- name: Bring up the full stack & wait for health
|
||||||
|
run: make verify-up
|
||||||
|
- name: ACL ↔ OpenZaak integration tests
|
||||||
|
run: make verify-acl
|
||||||
|
- name: OpenZaak → NRC notification delivery
|
||||||
|
run: make verify-nrc
|
||||||
|
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
||||||
|
run: make verify-projection
|
||||||
|
- name: Domain → Flowable → ACL → OpenZaak
|
||||||
|
run: make verify-domain
|
||||||
|
- name: BFF → Keycloak + domain + projection
|
||||||
|
run: make verify-bff
|
||||||
|
- name: Self-service e2e (Playwright, login → submit → success)
|
||||||
|
run: make verify-e2e
|
||||||
|
# Log dump must precede teardown (which removes the containers).
|
||||||
|
- 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 openbaar behandel 2>&1 || true
|
||||||
|
- name: Tear down
|
||||||
|
if: always()
|
||||||
|
run: make down
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
# .NET build output
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
[Dd]ebug/
|
||||||
|
[Rr]elease/
|
||||||
|
*.user
|
||||||
|
|
||||||
|
# Reqnroll-generated test code (regenerated from *.feature on build)
|
||||||
|
*.feature.cs
|
||||||
|
|
||||||
|
# Test results / coverage
|
||||||
|
[Tt]est[Rr]esults/
|
||||||
|
*.trx
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
*.coverage
|
||||||
|
|
||||||
|
# Stryker.NET mutation-testing reports (regenerated by `make mutation`)
|
||||||
|
StrykerOutput/
|
||||||
|
|
||||||
|
# Rider / VS / VS Code
|
||||||
|
.idea/
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Node / Angular (added as the frontend lands)
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.angular/
|
||||||
|
|
||||||
|
# Python / MkDocs
|
||||||
|
.venv/
|
||||||
|
site/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# ── Frontend (Nx / Angular / pnpm) ──
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
tmp/
|
||||||
|
out-tsc/
|
||||||
|
/coverage
|
||||||
|
.angular/
|
||||||
|
.nx/cache
|
||||||
|
.nx/workspace-data
|
||||||
|
.nx/self-healing
|
||||||
|
.nx/migrate-runs
|
||||||
|
.nx/polygraph
|
||||||
|
vite.config.*.timestamp*
|
||||||
|
vitest.config.*.timestamp*
|
||||||
|
|
||||||
|
.angular
|
||||||
|
|
||||||
|
# Playwright e2e (installed/generated in-container or on local runs)
|
||||||
|
tests/e2e/node_modules/
|
||||||
|
tests/e2e/test-results/
|
||||||
|
tests/e2e/playwright-report/
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Add files here to ignore them from prettier formatting
|
||||||
|
/dist
|
||||||
|
/coverage
|
||||||
|
/.nx/cache
|
||||||
|
/.nx/workspace-data
|
||||||
|
.angular
|
||||||
|
|
||||||
|
.nx/self-healing
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
+52
-15
@@ -151,32 +151,47 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
|
|||||||
|
|
||||||
### S-08 · Self-Service portal (Angular, NL DS) — submit a registration
|
### S-08 · Self-Service portal (Angular, NL DS) — submit a registration
|
||||||
|
|
||||||
**Outcome:** The self-service Angular app, in the Nx monorepo, lets a zorgprofessional log in via mock DigiD and submit a registration. NL Design System styling. Generated API client.
|
> **S-08 was split** (CLAUDE.md §13; issue #9 closed) into the sub-slices below — it bundled the
|
||||||
|
> Nx bootstrap, the generated client, the NL DS + DigiD form, and a full-stack Playwright e2e, well
|
||||||
|
> past 1–2 days. Each sub-slice is independently demoable and CI-green.
|
||||||
|
|
||||||
|
- **S-08a (#65)** · Nx monorepo + Angular tooling + CI Node lane. Placeholder `self-service` app; `nx lint/test/build` green in a new CI Node lane.
|
||||||
|
- **S-08b (#66)** · Generated api-client lib from `services/bff/openapi.json` (never hand-written, §10) + a mocked-BFF unit test.
|
||||||
|
- **S-08c (#67)** · Self-service submit form — NL Design System `libs/ui`, DigiD OIDC `libs/auth`, component tests (Angular Testing Library), axe WCAG 2.1 AA on the submit page.
|
||||||
|
- **S-08d (#68)** · Playwright happy-path e2e (login → submit → success) against the full stack + compose serving + CI e2e lane.
|
||||||
|
|
||||||
|
**Out of scope (whole of S-08):** document upload, status tracking page.
|
||||||
|
|
||||||
|
### 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. Shows the public-visibility half of the walking skeleton.
|
||||||
|
|
||||||
|
_Split from the original S-09 — scoped to the portal only; the approval flow is **S-09b (#75)**._
|
||||||
|
|
||||||
**Acceptance:**
|
**Acceptance:**
|
||||||
|
|
||||||
- E2E test (Playwright): full happy path, login → submit → success page.
|
- E2E test: after a zorgprofessional registers via self-service (S-08), the openbaar register shows the entry (as `INGEDIEND`).
|
||||||
- Component tests (Testing Library) for the form.
|
- Public-safe field whitelist enforced and tested (already in the BFF; add a portal component test + a11y check).
|
||||||
- Accessibility audit (axe-core) passes WCAG 2.1 AA on the submit page.
|
|
||||||
|
|
||||||
**Touches:** `apps/self-service/`, `libs/ui/`, `libs/auth/`, `libs/api-client/`, tests.
|
**Touches:** `apps/openbaar/`, compose serving, e2e, docs.
|
||||||
|
|
||||||
**Out of scope:** document upload, status tracking page.
|
**Out of scope:** approval/status transition (S-09b), advanced search filters, sorting.
|
||||||
|
|
||||||
### S-09 · Openbaar Register portal — public lookup
|
### S-09b · Approval flow — temp admin endpoint + status transition to projection *(#75)*
|
||||||
|
|
||||||
**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:** 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:**
|
**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.
|
- A new terminal/approved status (e.g. `INGESCHREVEN`) exists and is projected.
|
||||||
- Public-safe field whitelist enforced and tested.
|
- 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:** `apps/openbaar/`, projection-api hardening, tests.
|
**Touches:** `services/domain`, `services/acl`, `services/event-subscriber`, `services/projection-api`, e2e.
|
||||||
|
|
||||||
**Out of scope:** advanced search filters, sorting.
|
**Out of scope:** behandel-portal UI (S-12), assessment logic (S-13), escalation (S-15).
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -184,9 +199,25 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
|
|||||||
|
|
||||||
### S-10 · Document upload + boundary timer for document timeout (Flow 2)
|
### S-10 · Document upload + boundary timer for document timeout (Flow 2)
|
||||||
|
|
||||||
**Outcome:** BPMN extended with a "wacht op documenten" user task with a 30-day boundary timer. Self-service portal supports diploma upload. On timeout the case is cancelled.
|
Split (issue #11 closed) into two independently-demoable slices per §13 — the original spanned six net-new surfaces including a new ZGW boundary:
|
||||||
|
|
||||||
**Acceptance:** BDD scenarios for both branches; integration tests for the timer firing.
|
#### S-10a · Document-wait task + 30-day timeout cancellation + provision trigger — #102
|
||||||
|
|
||||||
|
**Outcome:** BPMN gains a `WachtOpDocumenten` user task with a 30-day (P30D) interrupting boundary timer. On timeout the case is cancelled — the timer runs to a dedicated cancel end-event and the domain aggregate moves to a new terminal status `Verlopen` via an external-worker (mirrors S-14 escalation / S-11 withdrawal). "Documents received" is wired end-to-end (domain endpoint + BFF + a "Documenten aanleveren" button on the self-service page) so the walking-skeleton e2e stays green — but the document is **not yet stored** in ZGW; that is S-10b.
|
||||||
|
|
||||||
|
**Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom; the registration e2e provides documents before the behandelaar step.
|
||||||
|
|
||||||
|
#### S-10b · Real diploma upload stored via the ACL Documenten API — #103
|
||||||
|
|
||||||
|
**Outcome:** the self-service "Documenten aanleveren" action becomes a real file upload; the file (base64-encoded end-to-end) is stored in the ZGW Documenten (DRC) API as an `enkelvoudiginformatieobject` and related to the zaak, with all document calls routed through the ACL (§8.1, ADR-0018). Builds on the S-10a trigger/wait. Depends on #102.
|
||||||
|
|
||||||
|
**Acceptance:** ACL Documenten gateway integration test (real OpenZaak); Playwright e2e uploads a real PDF.
|
||||||
|
|
||||||
|
#### S-10c · Close the ZGW zaak on document-timeout expiry — #106
|
||||||
|
|
||||||
|
**Outcome:** when the 30-day term lapses (S-10a `RegistratieVerlopen`), the ZGW zaak is set to a distinct non-terminal `Geannuleerd` status + `Vervallen` resultaat (not just the domain aggregate → `Verlopen`), resolved by name in the ACL. Adds the cancellation statustype/resultaattype to the seed + an ACL `CancelZaakAsync`/`POST /annuleringen` + expiry-worker wiring. Carved from S-10b (ADR-0017/0018/0019). Depends on #103.
|
||||||
|
|
||||||
|
**Acceptance:** ACL↔OpenZaak integration test (cancellation records `Geannuleerd` + a resultaat, live); the domain verify script fires the P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD asserts the zaak is cancelled on timeout but untouched when documents arrive in time.
|
||||||
|
|
||||||
### S-11 · Withdrawal (Flow 3)
|
### S-11 · Withdrawal (Flow 3)
|
||||||
|
|
||||||
@@ -208,6 +239,12 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
|
|||||||
|
|
||||||
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
|
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
|
||||||
|
|
||||||
|
### S-26 · Self-service — resume an existing registration after refresh — #111
|
||||||
|
|
||||||
|
**Outcome:** a signed-in zorgprofessional who reloads the self-service portal (or returns later) gets back to their in-flight registration and its actions (Documenten aanleveren, Trek aanvraag in), instead of a blank submit form with the reference lost. Today all post-submit state lives in in-memory signals, the reference is not in the URL, and there is no self-service read endpoint — so a reload strands the registration. Adds an owner-scoped (DigiD bsn) `GET /self-service/registrations` on the BFF/domain and a load-on-init/route restore in the portal.
|
||||||
|
|
||||||
|
**Acceptance:** BDD — resume after refresh shows the existing registration; lookup is owner-scoped (never another citizen's); a user with no in-flight registration still sees the submit form. Playwright e2e reloads mid-flow and asserts the actions remain reachable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
|
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
|
||||||
|
|||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project. Generated from Conventional Commits by git-cliff.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# Developer + CI entrypoints.
|
||||||
|
#
|
||||||
|
# These targets are the single source of truth for the checks. The Gitea
|
||||||
|
# Actions workflow (.gitea/workflows/ci.yaml) invokes the SAME targets, so
|
||||||
|
# `make ci` locally runs exactly what the pipeline runs — no drift. Until a
|
||||||
|
# self-hosted runner is registered, `make ci` is the gate (see docs/runbooks/ci.md).
|
||||||
|
|
||||||
|
SLN := register-referentie.slnx
|
||||||
|
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 openbaar behandel
|
||||||
|
# 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
|
||||||
|
# containerized CI runner. SEED populates them; run it before every `up`. The
|
||||||
|
# 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
|
||||||
|
# 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.
|
||||||
|
LOCAL_COMPOSE := infra/docker-compose.local.yml
|
||||||
|
OZ_COMPOSE := infra/openzaak/docker-compose.yml
|
||||||
|
OZ_BASE := http://localhost:8000
|
||||||
|
NRC_COMPOSE := infra/opennotificaties/docker-compose.yml
|
||||||
|
NRC_BASE := http://localhost:8001
|
||||||
|
KC_COMPOSE := infra/keycloak/docker-compose.yml
|
||||||
|
KC_BASE := http://localhost:8180
|
||||||
|
FL_COMPOSE := infra/flowable/docker-compose.yml
|
||||||
|
FL_BASE := http://localhost:8090/flowable-rest/service
|
||||||
|
STACK_FILES := -f $(OZ_COMPOSE) -f $(NRC_COMPOSE)
|
||||||
|
|
||||||
|
# On a rootless Podman dev box, point Docker CLI/Compose at the Podman socket —
|
||||||
|
# but only if that socket exists and DOCKER_HOST isn't already set, so real
|
||||||
|
# Docker hosts and CI runners are left untouched.
|
||||||
|
PODMAN_SOCK := /run/user/$(shell id -u)/podman/podman.sock
|
||||||
|
ifeq ($(wildcard $(PODMAN_SOCK)),$(PODMAN_SOCK))
|
||||||
|
ifeq ($(origin DOCKER_HOST),undefined)
|
||||||
|
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 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).
|
||||||
|
ci: lint build unit mutation frontend verify
|
||||||
|
|
||||||
|
## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required)
|
||||||
|
# Tests run in their own phase, ahead of the build. The @angular/build:unit-test
|
||||||
|
# (Vitest) runner spawns a worker with a hard-coded 60s/90s startup timeout that is
|
||||||
|
# not configurable. When the ~5min production build shares the run-many pool, it
|
||||||
|
# starves that worker of CPU on constrained CI runners and Vitest fails with
|
||||||
|
# "Timeout waiting for worker to respond". Splitting the phases keeps tests off the
|
||||||
|
# heavy build's back so the worker starts well inside its window.
|
||||||
|
frontend:
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm nx run-many -t lint test
|
||||||
|
pnpm nx run-many -t build
|
||||||
|
|
||||||
|
## lint: verify formatting (no changes)
|
||||||
|
lint:
|
||||||
|
dotnet format $(SLN) --verify-no-changes
|
||||||
|
|
||||||
|
## build: release build
|
||||||
|
build:
|
||||||
|
dotnet build $(SLN) -c Release
|
||||||
|
|
||||||
|
## unit: run unit tests (excludes the container-backed Integration lane)
|
||||||
|
unit:
|
||||||
|
dotnet test $(SLN) -c Release --filter "Category!=Integration"
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
# makes `make mutation` work from a fresh clone. Each service owns its config + break
|
||||||
|
# threshold (the ratchet, CLAUDE.md §5): each services/<svc>/stryker-config.json.
|
||||||
|
# Scores never regress below baseline.
|
||||||
|
mutation:
|
||||||
|
dotnet tool restore
|
||||||
|
cd services/acl && dotnet stryker
|
||||||
|
cd services/event-subscriber && dotnet stryker
|
||||||
|
cd services/domain && dotnet stryker
|
||||||
|
cd services/bff && dotnet stryker
|
||||||
|
|
||||||
|
## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down
|
||||||
|
# SEED populates the external config volumes first (upstream images used verbatim;
|
||||||
|
# only our acl/bff are built). `up -d --build` starts EVERYTHING. Readiness is
|
||||||
|
# checked by infra/wait-healthy.sh polling the durable, health-checked services
|
||||||
|
# ($(WAIT_SVCS)) via `docker inspect` — portable across docker compose and
|
||||||
|
# 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
|
||||||
|
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
|
||||||
|
docker compose -f $(COMPOSE) up -d --build
|
||||||
|
|
||||||
|
## down: stop and remove the local stack (incl. the external config volumes)
|
||||||
|
down:
|
||||||
|
docker compose -f $(COMPOSE) down --volumes
|
||||||
|
-docker volume rm -f $(CFG_VOLS)
|
||||||
|
|
||||||
|
## local: bring up the bind-mount stack (no seed step) and wait for health
|
||||||
|
## (Windows / no-make users: run `docker compose -f infra/docker-compose.local.yml up -d --build` directly)
|
||||||
|
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
|
||||||
|
|
||||||
|
## changelog: regenerate CHANGELOG.md from Conventional Commits (git-cliff)
|
||||||
|
changelog:
|
||||||
|
git-cliff --output CHANGELOG.md
|
||||||
|
|
||||||
|
# ── ZGW verification ───────────────────────────────────────────────────────
|
||||||
|
# On the single runner CI jobs run sequentially, so the OpenZaak-dependent checks
|
||||||
|
# share ONE full-stack bring-up: the `verify-stack` CI job runs `verify-up` then
|
||||||
|
# `verify-acl` + `verify-nrc` as steps against the same stack (issue #58). The
|
||||||
|
# check logic lives in stack-agnostic runners that reach services by container IP
|
||||||
|
# (gitea-actions-gotchas.md §5/§6); `integration` / `verify-notifications` are local
|
||||||
|
# convenience wrappers that bring up a lighter stack and call the same runners.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
docker compose -f $(COMPOSE) up -d --build
|
||||||
|
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||||
|
|
||||||
|
## verify-acl: ACL ↔ OpenZaak integration tests against the already-running stack.
|
||||||
|
verify-acl:
|
||||||
|
bash infra/run-acl-integration.sh
|
||||||
|
|
||||||
|
## verify-nrc: OpenZaak → NRC notification delivery against the already-running stack.
|
||||||
|
verify-nrc:
|
||||||
|
bash infra/run-notification-check.sh
|
||||||
|
|
||||||
|
## verify-projection: OpenZaak → NRC → Event Subscriber → projection-api end-to-end (S-06),
|
||||||
|
## against the already-running stack.
|
||||||
|
verify-projection:
|
||||||
|
bash infra/run-projection-check.sh
|
||||||
|
|
||||||
|
## verify-domain: domain → Flowable → ACL → OpenZaak end-to-end (S-05), against the
|
||||||
|
## already-running stack. Recreates the acl service to inject the seeded zaaktype URL.
|
||||||
|
verify-domain:
|
||||||
|
bash infra/run-domain-check.sh
|
||||||
|
|
||||||
|
## verify-bff: BFF end-to-end (S-07) against the up stack — token validation on self-service
|
||||||
|
## + anonymous public-safe openbaar register (ADR-0010).
|
||||||
|
verify-bff:
|
||||||
|
bash infra/run-bff-check.sh
|
||||||
|
|
||||||
|
## verify-e2e: walking-skeleton Playwright e2e (S-08d) against the up stack — DigiD login →
|
||||||
|
## submit → confirmation, driven inside the compose network.
|
||||||
|
verify-e2e:
|
||||||
|
bash infra/run-e2e-check.sh
|
||||||
|
|
||||||
|
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
||||||
|
## 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
|
||||||
|
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-domain-check.sh \
|
||||||
|
&& bash infra/run-bff-check.sh \
|
||||||
|
&& bash infra/run-e2e-check.sh || rc=$$?; \
|
||||||
|
docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
|
||||||
|
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
|
||||||
|
exit $$rc'
|
||||||
|
|
||||||
|
## integration: local convenience — ACL integration test against a throwaway
|
||||||
|
## OpenZaak-only stack (fast iteration). CI uses verify-acl on the shared stack.
|
||||||
|
integration:
|
||||||
|
bash infra/run-integration.sh
|
||||||
|
|
||||||
|
## openzaak-up: start the OpenZaak stack (migrations run on first start)
|
||||||
|
openzaak-up:
|
||||||
|
$(SEED) oz
|
||||||
|
docker compose -f $(OZ_COMPOSE) up -d
|
||||||
|
|
||||||
|
## openzaak-smoke: start OpenZaak, then assert it is up with auth enforced
|
||||||
|
openzaak-smoke: openzaak-up
|
||||||
|
@bash -c 'set -e; \
|
||||||
|
echo "waiting for OpenZaak to respond..."; \
|
||||||
|
for i in $$(seq 1 60); do \
|
||||||
|
code=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/zaken/api/v1/zaken || true); \
|
||||||
|
[ -n "$$code" ] && [ "$$code" != "000" ] && break; sleep 3; \
|
||||||
|
done; \
|
||||||
|
echo "GET /zaken/api/v1/zaken (unauth) -> $$code (expect 403, auth enforced)"; test "$$code" = "403"; \
|
||||||
|
admin=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/admin/); \
|
||||||
|
echo "GET /admin/ -> $$admin (expect 302)"; test "$$admin" = "302"; \
|
||||||
|
root=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/zaken/api/v1/); \
|
||||||
|
echo "GET /zaken/api/v1/ -> $$root (expect 200)"; test "$$root" = "200"; \
|
||||||
|
echo "OpenZaak smoke OK"'
|
||||||
|
|
||||||
|
## openzaak-seed: bring OpenZaak up and seed the BIG catalogus (idempotent)
|
||||||
|
openzaak-seed: openzaak-up
|
||||||
|
@bash -c 'for i in $$(seq 1 50); do \
|
||||||
|
c=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/catalogi/api/v1/ || true); \
|
||||||
|
[ "$$c" = "200" ] && break; sleep 3; done; echo "OpenZaak ready ($$c)"'
|
||||||
|
python3 infra/openzaak/seed_catalogus.py
|
||||||
|
|
||||||
|
## openzaak-down: stop and remove the OpenZaak stack (wipes data)
|
||||||
|
openzaak-down:
|
||||||
|
docker compose -f $(OZ_COMPOSE) down --volumes
|
||||||
|
-docker volume rm -f rr-oz-config
|
||||||
|
|
||||||
|
## verify-notifications: local convenience — OpenZaak → NRC notification delivery
|
||||||
|
## against a throwaway oz+nrc stack (S-01-c). CI uses verify-nrc on the shared stack.
|
||||||
|
verify-notifications:
|
||||||
|
bash infra/verify-notifications.sh
|
||||||
|
|
||||||
|
## stack-up: start OpenZaak + Open Notificaties together (shared network), with
|
||||||
|
## OpenZaak publishing notifications to NRC (S-01-c).
|
||||||
|
stack-up:
|
||||||
|
$(SEED) oz nrc
|
||||||
|
OZ_NOTIFICATIONS_DISABLED=false docker compose $(STACK_FILES) up -d
|
||||||
|
|
||||||
|
## stack-smoke: start both, assert OpenZaak (403/302/200) and NRC (302) are reachable
|
||||||
|
stack-smoke: stack-up
|
||||||
|
@bash -c 'set -e; \
|
||||||
|
echo "waiting for OpenZaak + Open Notificaties..."; \
|
||||||
|
for i in $$(seq 1 60); do \
|
||||||
|
oz=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/admin/ || true); \
|
||||||
|
nrc=$$(curl -s -o /dev/null -w "%{http_code}" $(NRC_BASE)/admin/ || true); \
|
||||||
|
[ "$$oz" = "302" ] && [ "$$nrc" = "302" ] && break; sleep 3; done; \
|
||||||
|
z=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/zaken/api/v1/zaken); \
|
||||||
|
echo "OpenZaak /zaken (unauth) -> $$z (expect 403)"; test "$$z" = "403"; \
|
||||||
|
echo "OpenZaak /admin/ -> $$oz (expect 302)"; test "$$oz" = "302"; \
|
||||||
|
echo "Open Notificaties /admin/-> $$nrc (expect 302)"; test "$$nrc" = "302"; \
|
||||||
|
echo "stack smoke OK"'
|
||||||
|
|
||||||
|
## stack-down: stop and remove both stacks (wipes data)
|
||||||
|
stack-down:
|
||||||
|
docker compose $(STACK_FILES) down --volumes
|
||||||
|
-docker volume rm -f rr-oz-config rr-nrc-config
|
||||||
|
|
||||||
|
## keycloak-up: start Keycloak with the four imported realms
|
||||||
|
keycloak-up:
|
||||||
|
$(SEED) kc
|
||||||
|
docker compose -f $(KC_COMPOSE) up -d
|
||||||
|
|
||||||
|
## keycloak-smoke: start Keycloak, then verify each realm logs in + returns its claim
|
||||||
|
keycloak-smoke: keycloak-up
|
||||||
|
@bash -c 'for i in $$(seq 1 60); do \
|
||||||
|
c=$$(curl -s -o /dev/null -w "%{http_code}" $(KC_BASE)/realms/digid/.well-known/openid-configuration || true); \
|
||||||
|
[ "$$c" = "200" ] && break; sleep 3; done; echo "Keycloak ready ($$c)"'
|
||||||
|
python3 infra/keycloak/check_realms.py
|
||||||
|
|
||||||
|
## keycloak-down: stop and remove Keycloak
|
||||||
|
keycloak-down:
|
||||||
|
docker compose -f $(KC_COMPOSE) down --volumes
|
||||||
|
-docker volume rm -f rr-kc-realms
|
||||||
|
|
||||||
|
## flowable-up: start Flowable (deploys registratie.bpmn on boot)
|
||||||
|
flowable-up:
|
||||||
|
$(SEED) fl
|
||||||
|
docker compose -f $(FL_COMPOSE) up -d
|
||||||
|
|
||||||
|
## flowable-smoke: start Flowable, then verify a started instance waits on the external task
|
||||||
|
flowable-smoke: flowable-up
|
||||||
|
@bash -c 'for i in $$(seq 1 80); do \
|
||||||
|
c=$$(curl -s -o /dev/null -w "%{http_code}" -u rest-admin:test $(FL_BASE)/repository/process-definitions?key=registratie || true); \
|
||||||
|
[ "$$c" = "200" ] && break; sleep 3; done; echo "Flowable ready ($$c)"'
|
||||||
|
python3 infra/flowable/verify.py
|
||||||
|
|
||||||
|
## flowable-down: stop and remove Flowable
|
||||||
|
flowable-down:
|
||||||
|
docker compose -f $(FL_COMPOSE) down --volumes
|
||||||
|
-docker volume rm -f rr-fl-bpmn
|
||||||
|
|
||||||
|
## help: list available targets
|
||||||
|
help:
|
||||||
|
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## //'
|
||||||
@@ -41,22 +41,32 @@ For the architecture rationale, see [docs/PRD.md §3](docs/PRD.md) and [docs/arc
|
|||||||
|
|
||||||
**Prerequisites**
|
**Prerequisites**
|
||||||
|
|
||||||
- Docker Engine (or Docker Desktop) with Compose v2
|
- .NET 10 SDK (for `make lint/build/unit`)
|
||||||
- ~8 GB free RAM, ~10 GB free disk
|
- A container engine with Compose v2 — Docker, or rootless Podman (see [docs/runbooks/ci.md](docs/runbooks/ci.md) for the Podman + Compose-provider setup)
|
||||||
- Bash or PowerShell
|
- `make`, `curl`, `git`
|
||||||
|
- ~4 GB free RAM, ~5 GB free disk (grows as services land)
|
||||||
|
|
||||||
**Bring the stack up**
|
**Clone**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.respellion.local/respellion/register-reference.git
|
git clone git@git.labs.respellion.tech:eho/register-referentie.git
|
||||||
cd register-reference
|
cd register-referentie
|
||||||
cp .env.example .env # edit if you change ports
|
|
||||||
docker compose -f infra/docker-compose.yml up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Health checks should be green within ~3 minutes on a developer machine. If something fails, see [docs/runbooks/local-startup.md](docs/runbooks/local-startup.md).
|
**Wired today (Iteration 0):** only the placeholder BFF exists so far. Get to green in under 10 minutes — run the full check gate, or just the running service:
|
||||||
|
|
||||||
**Default URLs**
|
```bash
|
||||||
|
make ci # lint + build + unit + container smoke — the CI gate
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f infra/docker-compose.yml up -d --build --wait
|
||||||
|
curl http://localhost:8080/health # -> Healthy
|
||||||
|
```
|
||||||
|
|
||||||
|
`--wait` exits non-zero unless the container reports healthy, so it doubles as the compose-up smoke test. The remaining services and the URLs below land in later slices.
|
||||||
|
|
||||||
|
**Target service URLs** *(most land in later slices)*
|
||||||
|
|
||||||
| Service | URL |
|
| Service | URL |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -64,7 +74,7 @@ Health checks should be green within ~3 minutes on a developer machine. If somet
|
|||||||
| Openbaar register | http://localhost:4201 |
|
| Openbaar register | http://localhost:4201 |
|
||||||
| Behandel-portal | http://localhost:4202 |
|
| Behandel-portal | http://localhost:4202 |
|
||||||
| Beheer-portal | http://localhost:4203 |
|
| Beheer-portal | http://localhost:4203 |
|
||||||
| BFF | http://localhost:5000 |
|
| BFF | http://localhost:8080 |
|
||||||
| OpenZaak | http://localhost:8000 |
|
| OpenZaak | http://localhost:8000 |
|
||||||
| Open Notificaties | http://localhost:8001 |
|
| Open Notificaties | http://localhost:8001 |
|
||||||
| Flowable | http://localhost:8080 |
|
| Flowable | http://localhost:8080 |
|
||||||
@@ -73,10 +83,12 @@ Health checks should be green within ~3 minutes on a developer machine. If somet
|
|||||||
|
|
||||||
Test credentials, BSNs, and personas: see [docs/synthetic-data.md](docs/synthetic-data.md).
|
Test credentials, BSNs, and personas: see [docs/synthetic-data.md](docs/synthetic-data.md).
|
||||||
|
|
||||||
**Re-seed synthetic data**
|
**Build the docs site**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./tools/seed.sh # or pwsh ./tools/seed.ps1
|
python3 -m venv .venv && .venv/bin/pip install mkdocs-material
|
||||||
|
.venv/bin/mkdocs serve # live preview at http://localhost:8000
|
||||||
|
.venv/bin/mkdocs build # static site in ./site
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Multi-stage build for the behandel portal (Angular → nginx).
|
||||||
|
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
|
||||||
|
FROM node:24-slim AS build
|
||||||
|
WORKDIR /src
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
|
||||||
|
|
||||||
|
# Restore first (cached unless the manifests change).
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Sources (only what the app + its libs need).
|
||||||
|
COPY apps/behandel apps/behandel
|
||||||
|
COPY libs libs
|
||||||
|
RUN pnpm nx build behandel
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY apps/behandel/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /src/dist/apps/behandel/browser /usr/share/nginx/html
|
||||||
|
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
||||||
|
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
|
||||||
|
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import nx from '@nx/eslint-plugin';
|
||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...nx.configs['flat/angular'],
|
||||||
|
...nx.configs['flat/angular-template'],
|
||||||
|
...baseConfig,
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@angular-eslint/directive-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'attribute',
|
||||||
|
prefix: 'app',
|
||||||
|
style: 'camelCase',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
prefix: 'app',
|
||||||
|
style: 'kebab-case',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.html'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
|
||||||
|
# even before the BFF is up and picks up restarts — instead of failing to load the config.
|
||||||
|
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||||
|
|
||||||
|
# Same-origin API: proxy the behandel endpoint group to the bff service. The api-client uses
|
||||||
|
# relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the
|
||||||
|
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
|
||||||
|
location /behandel/ {
|
||||||
|
set $bff http://bff:8080;
|
||||||
|
proxy_pass $bff;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — Angular client-side routing.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"name": "behandel",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"projectType": "application",
|
||||||
|
"prefix": "app",
|
||||||
|
"sourceRoot": "apps/behandel/src",
|
||||||
|
"tags": [],
|
||||||
|
"targets": {
|
||||||
|
"build": {
|
||||||
|
"executor": "@angular/build:application",
|
||||||
|
"outputs": ["{options.outputPath}"],
|
||||||
|
"defaultConfiguration": "production",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/apps/behandel",
|
||||||
|
"browser": "apps/behandel/src/main.ts",
|
||||||
|
"tsConfig": "apps/behandel/tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "apps/behandel/public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["apps/behandel/src/styles.css"]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1mb",
|
||||||
|
"maximumError": "2mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kb",
|
||||||
|
"maximumError": "8kb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"continuous": true,
|
||||||
|
"executor": "@angular/build:dev-server",
|
||||||
|
"defaultConfiguration": "development",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "behandel:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "behandel:build:development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"executor": "@angular/build:unit-test",
|
||||||
|
"options": {
|
||||||
|
"watch": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve-static": {
|
||||||
|
"continuous": true,
|
||||||
|
"executor": "@nx/web:file-server",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "behandel:build",
|
||||||
|
"staticFilePath": "dist/apps/behandel/browser",
|
||||||
|
"spa": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"authority": "http://localhost:8180/realms/medewerker"
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,73 @@
|
|||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { BffApiV1Service } from 'api-client';
|
||||||
|
import { authInterceptor } from 'auth';
|
||||||
|
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
|
||||||
|
import { SECURE_API_ROUTES } from './app.config';
|
||||||
|
|
||||||
|
// Guards the medewerker token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and
|
||||||
|
// the angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a
|
||||||
|
// configured secureRoute. A regression to an absolute origin makes the relative URL never match, so
|
||||||
|
// the behandel calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor
|
||||||
|
// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config
|
||||||
|
// source and token storage are faked, so the assertion turns on the actual route-matching.
|
||||||
|
describe('behandel medewerker token wiring', () => {
|
||||||
|
let http: HttpTestingController;
|
||||||
|
let bff: BffApiV1Service;
|
||||||
|
const token = 'medewerker-access-token';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{
|
||||||
|
provide: ConfigurationService,
|
||||||
|
useValue: {
|
||||||
|
hasAtLeastOneConfig: () => true,
|
||||||
|
getAllConfigurations: () => [{ configId: 'medewerker', secureRoutes: SECURE_API_ROUTES }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A signed-in session: the storage the interceptor's token lookup reads from.
|
||||||
|
provide: AbstractSecurityStorage,
|
||||||
|
useValue: {
|
||||||
|
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
|
||||||
|
write: () => undefined,
|
||||||
|
remove: () => undefined,
|
||||||
|
clear: () => undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
http = TestBed.inject(HttpTestingController);
|
||||||
|
bff = TestBed.inject(BffApiV1Service);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
it('attaches the bearer token to the relative werkbak call', () => {
|
||||||
|
bff.getBehandelWerkbak().subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne('/behandel/werkbak');
|
||||||
|
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches the bearer token to the relative decide call', () => {
|
||||||
|
bff.postBehandelRegistrationsIdDecide('reg-1', { besluit: 'goedkeuren' }).subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne('/behandel/registrations/reg-1/decide');
|
||||||
|
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||||
|
req.flush(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the anonymous openbaar register call unauthenticated', () => {
|
||||||
|
bff.getOpenbaarRegister().subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne((r) => r.url === '/openbaar/register');
|
||||||
|
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { authInterceptor, provideMedewerkerAuth } from 'auth';
|
||||||
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
|
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
|
||||||
|
export interface RuntimeConfig {
|
||||||
|
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
|
||||||
|
authority: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
|
||||||
|
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
|
||||||
|
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
|
||||||
|
* unattached. Only `/behandel/` is secured; the app calls no other endpoint group.
|
||||||
|
*/
|
||||||
|
export const SECURE_API_ROUTES = ['/behandel/'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
|
||||||
|
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
|
||||||
|
*/
|
||||||
|
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
|
||||||
|
return {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(appRoutes),
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||||
|
provideMedewerkerAuth({
|
||||||
|
authority: runtime.authority,
|
||||||
|
redirectUrl: origin,
|
||||||
|
secureRoutes: SECURE_API_ROUTES,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { authenticatedGuard } from 'auth';
|
||||||
|
import { WerkbakPage } from './werkbak/werkbak-page';
|
||||||
|
|
||||||
|
export const appRoutes: Route[] = [
|
||||||
|
{ path: '', component: WerkbakPage, canActivate: [authenticatedGuard] },
|
||||||
|
];
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { render, screen } from '@testing-library/angular';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('renders the router outlet shell', async () => {
|
||||||
|
const { container } = await render(App, {
|
||||||
|
providers: [provideRouter([])],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shell is a thin host for routed pages (the WerkbakPage owns the heading).
|
||||||
|
expect(container.querySelector('router-outlet')).toBeTruthy();
|
||||||
|
expect(screen).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [RouterModule],
|
||||||
|
selector: 'app-root',
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.css',
|
||||||
|
})
|
||||||
|
export class App {
|
||||||
|
protected title = 'behandel';
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<main utrecht-document class="utrecht-theme">
|
||||||
|
<utrecht-article>
|
||||||
|
<utrecht-heading-1>Werkbak</utrecht-heading-1>
|
||||||
|
<p utrecht-paragraph>
|
||||||
|
Registraties die wachten op beoordeling. Keur elke registratie goed of wijs deze af.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
||||||
|
} @else if (failed()) {
|
||||||
|
<p utrecht-paragraph role="alert">
|
||||||
|
Kon de werkbak niet laden. Controleer of je als behandelaar bent ingelogd en probeer het
|
||||||
|
opnieuw.
|
||||||
|
</p>
|
||||||
|
} @else if (loaded() && items().length === 0) {
|
||||||
|
<p utrecht-paragraph role="status">De werkbak is leeg.</p>
|
||||||
|
} @else if (items().length > 0) {
|
||||||
|
<table utrecht-table>
|
||||||
|
<caption>
|
||||||
|
Registraties in behandeling
|
||||||
|
</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Referentie</th>
|
||||||
|
<th scope="col">BSN</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Actie</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (item of items(); track item.registrationId) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.registrationId }}</td>
|
||||||
|
<td>{{ item.bsn }}</td>
|
||||||
|
<td>{{ item.status }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
utrecht-button
|
||||||
|
appearance="primary-action-button"
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="'Goedkeuren ' + item.registrationId"
|
||||||
|
[disabled]="deciding() === item.registrationId"
|
||||||
|
(click)="decide(item.registrationId, 'goedkeuren')"
|
||||||
|
>
|
||||||
|
Goedkeuren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
utrecht-button
|
||||||
|
appearance="secondary-action-button"
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="'Afwijzen ' + item.registrationId"
|
||||||
|
[disabled]="deciding() === item.registrationId"
|
||||||
|
(click)="decide(item.registrationId, 'afwijzen')"
|
||||||
|
>
|
||||||
|
Afwijzen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</utrecht-article>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/angular';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
||||||
|
import { AuthService } from 'auth';
|
||||||
|
import { axe } from 'vitest-axe';
|
||||||
|
import { WerkbakPage } from './werkbak-page';
|
||||||
|
|
||||||
|
const sample: WerkbakItem[] = [
|
||||||
|
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
|
||||||
|
{ registrationId: 'reg-2', bsn: '111222333', status: 'InBehandeling' },
|
||||||
|
];
|
||||||
|
|
||||||
|
class FakeAuth extends AuthService {
|
||||||
|
readonly isAuthenticated = signal(true);
|
||||||
|
readonly bsn = signal<string | undefined>(undefined);
|
||||||
|
override readonly roles = signal<readonly string[]>(['behandelaar']);
|
||||||
|
login(): void {
|
||||||
|
/* not exercised here */
|
||||||
|
}
|
||||||
|
logout(): void {
|
||||||
|
/* spied in tests */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
overrides: {
|
||||||
|
getBehandelWerkbak?: ReturnType<typeof vi.fn>;
|
||||||
|
postBehandelRegistrationsIdDecide?: ReturnType<typeof vi.fn>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const getBehandelWerkbak =
|
||||||
|
overrides.getBehandelWerkbak ?? vi.fn().mockReturnValue(of(sample));
|
||||||
|
const postBehandelRegistrationsIdDecide =
|
||||||
|
overrides.postBehandelRegistrationsIdDecide ?? vi.fn().mockReturnValue(of(undefined));
|
||||||
|
return {
|
||||||
|
getBehandelWerkbak,
|
||||||
|
postBehandelRegistrationsIdDecide,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: BffApiV1Service,
|
||||||
|
useValue: { getBehandelWerkbak, postBehandelRegistrationsIdDecide },
|
||||||
|
},
|
||||||
|
{ provide: AuthService, useClass: FakeAuth },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WerkbakPage', () => {
|
||||||
|
it('lists the registrations awaiting beoordeling on open', async () => {
|
||||||
|
const { getBehandelWerkbak, providers } = setup();
|
||||||
|
await render(WerkbakPage, { providers });
|
||||||
|
|
||||||
|
expect(getBehandelWerkbak).toHaveBeenCalled();
|
||||||
|
expect(await screen.findByText('reg-1')).toBeTruthy();
|
||||||
|
expect(screen.getByText('123456782')).toBeTruthy();
|
||||||
|
expect(screen.getByText('reg-2')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('approves a registration (goedkeuren) and refreshes the werkbak', async () => {
|
||||||
|
const { getBehandelWerkbak, postBehandelRegistrationsIdDecide, providers } = setup();
|
||||||
|
await render(WerkbakPage, { providers });
|
||||||
|
|
||||||
|
fireEvent.click((await screen.findAllByRole('button', { name: /goedkeuren/i }))[0]);
|
||||||
|
|
||||||
|
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
|
||||||
|
besluit: 'goedkeuren',
|
||||||
|
});
|
||||||
|
// Reloaded after the decision: once on open, once after deciding.
|
||||||
|
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a registration (afwijzen) via the decide endpoint', async () => {
|
||||||
|
const { postBehandelRegistrationsIdDecide, providers } = setup();
|
||||||
|
await render(WerkbakPage, { providers });
|
||||||
|
|
||||||
|
fireEvent.click((await screen.findAllByRole('button', { name: /afwijzen/i }))[0]);
|
||||||
|
|
||||||
|
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
|
||||||
|
besluit: 'afwijzen',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an empty state when the werkbak has no items', async () => {
|
||||||
|
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
|
||||||
|
await render(WerkbakPage, { providers });
|
||||||
|
|
||||||
|
expect(await screen.findByText(/werkbak is leeg/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a load failure instead of swallowing it', async () => {
|
||||||
|
const { providers } = setup({
|
||||||
|
getBehandelWerkbak: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
||||||
|
});
|
||||||
|
await render(WerkbakPage, { providers });
|
||||||
|
|
||||||
|
expect(await screen.findByText(/kon de werkbak niet laden/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no WCAG 2.1 AA violations', async () => {
|
||||||
|
document.documentElement.lang = 'nl';
|
||||||
|
const { container } = await render(WerkbakPage, { providers: setup().providers });
|
||||||
|
|
||||||
|
const results = await axe(container, {
|
||||||
|
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
||||||
|
import { UtrechtComponentsModule } from 'ui';
|
||||||
|
|
||||||
|
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
|
||||||
|
type Besluit = 'goedkeuren' | 'afwijzen';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The behandel werkbak: a signed-in behandelaar sees the registrations awaiting beoordeling (the open
|
||||||
|
* Flowable `Beoordelen` tasks, read through the domain) and decides each — goedkeuren or afwijzen. A
|
||||||
|
* decision posts to the BFF, which applies the domain transition and completes the workflow task
|
||||||
|
* (ADR-0013; S-12). After a decision the werkbak refreshes so the handled item drops off the list.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-werkbak-page',
|
||||||
|
imports: [UtrechtComponentsModule],
|
||||||
|
templateUrl: './werkbak-page.html',
|
||||||
|
})
|
||||||
|
export class WerkbakPage {
|
||||||
|
private readonly bff = inject(BffApiV1Service);
|
||||||
|
|
||||||
|
protected readonly items = signal<WerkbakItem[]>([]);
|
||||||
|
protected readonly loading = signal(false);
|
||||||
|
protected readonly loaded = signal(false);
|
||||||
|
protected readonly failed = signal(false);
|
||||||
|
protected readonly deciding = signal<string | undefined>(undefined);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.failed.set(false);
|
||||||
|
this.bff.getBehandelWerkbak().subscribe({
|
||||||
|
next: (rows: WerkbakItem[]) => {
|
||||||
|
this.items.set(rows);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.loaded.set(true);
|
||||||
|
},
|
||||||
|
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
|
||||||
|
error: () => {
|
||||||
|
this.items.set([]);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.loaded.set(true);
|
||||||
|
this.failed.set(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
decide(registrationId: string, besluit: Besluit): void {
|
||||||
|
this.deciding.set(registrationId);
|
||||||
|
this.bff.postBehandelRegistrationsIdDecide(registrationId, { besluit }).subscribe({
|
||||||
|
// Refresh so the decided registration drops off the werkbak (its task is now completed).
|
||||||
|
next: () => {
|
||||||
|
this.deciding.set(undefined);
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.deciding.set(undefined);
|
||||||
|
this.failed.set(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Behandelportaal BIG-register</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { App } from './app/app';
|
||||||
|
import { appConfig, type RuntimeConfig } from './app/app.config';
|
||||||
|
|
||||||
|
// Load environment config before bootstrap so the OIDC authority is set per environment
|
||||||
|
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
|
||||||
|
fetch('config.json')
|
||||||
|
.then((response) => response.json() as Promise<RuntimeConfig>)
|
||||||
|
.then((config) => bootstrapApplication(App, appConfig(config)))
|
||||||
|
.catch((err) => console.error(err));
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
|
||||||
|
@import '@utrecht/design-tokens/dist/index.css';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"emitDecoratorMetadata": false,
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Multi-stage build for the openbaar portal (Angular → nginx).
|
||||||
|
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
|
||||||
|
FROM node:24-slim AS build
|
||||||
|
WORKDIR /src
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
|
||||||
|
|
||||||
|
# Restore first (cached unless the manifests change).
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Sources (only what the app + its libs need).
|
||||||
|
COPY apps/openbaar apps/openbaar
|
||||||
|
COPY libs libs
|
||||||
|
RUN pnpm nx build openbaar
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
|
||||||
|
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import nx from '@nx/eslint-plugin';
|
||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...nx.configs['flat/angular'],
|
||||||
|
...nx.configs['flat/angular-template'],
|
||||||
|
...baseConfig,
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@angular-eslint/directive-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'attribute',
|
||||||
|
prefix: 'app',
|
||||||
|
style: 'camelCase',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
prefix: 'app',
|
||||||
|
style: 'kebab-case',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.html'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
|
||||||
|
# even before the BFF is up and picks up restarts — instead of failing to load the config.
|
||||||
|
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||||
|
|
||||||
|
# Same-origin API: proxy the anonymous openbaar endpoint group to the bff service. The api-client
|
||||||
|
# uses relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS.
|
||||||
|
location /openbaar/ {
|
||||||
|
set $bff http://bff:8080;
|
||||||
|
proxy_pass $bff;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — Angular client-side routing.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"name": "openbaar",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"projectType": "application",
|
||||||
|
"prefix": "app",
|
||||||
|
"sourceRoot": "apps/openbaar/src",
|
||||||
|
"tags": [],
|
||||||
|
"targets": {
|
||||||
|
"build": {
|
||||||
|
"executor": "@angular/build:application",
|
||||||
|
"outputs": ["{options.outputPath}"],
|
||||||
|
"defaultConfiguration": "production",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/apps/openbaar",
|
||||||
|
"browser": "apps/openbaar/src/main.ts",
|
||||||
|
"tsConfig": "apps/openbaar/tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "apps/openbaar/public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["apps/openbaar/src/styles.css"]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1mb",
|
||||||
|
"maximumError": "2mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kb",
|
||||||
|
"maximumError": "8kb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"continuous": true,
|
||||||
|
"executor": "@angular/build:dev-server",
|
||||||
|
"defaultConfiguration": "development",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "openbaar:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "openbaar:build:development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"executor": "@angular/build:unit-test",
|
||||||
|
"options": {
|
||||||
|
"watch": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve-static": {
|
||||||
|
"continuous": true,
|
||||||
|
"executor": "@nx/web:file-server",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "openbaar:build",
|
||||||
|
"staticFilePath": "dist/apps/openbaar/browser",
|
||||||
|
"spa": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
ApplicationConfig,
|
||||||
|
provideBrowserGlobalErrorListeners,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The openbaar register is a public, anonymous read: no DigiD, no auth interceptor. The app is served
|
||||||
|
* same-origin as the BFF (nginx proxies /openbaar), so the api-client's relative calls stay same-origin.
|
||||||
|
*/
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(appRoutes),
|
||||||
|
provideHttpClient(),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { RegisterPage } from './register/register-page';
|
||||||
|
|
||||||
|
export const appRoutes: Route[] = [{ path: '', component: RegisterPage }];
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { render } from '@testing-library/angular';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('renders the router outlet shell', async () => {
|
||||||
|
const { container } = await render(App, {
|
||||||
|
providers: [provideRouter([])],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shell is a thin host for routed pages (the RegisterPage owns the heading).
|
||||||
|
expect(container.querySelector('router-outlet')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [RouterModule],
|
||||||
|
selector: 'app-root',
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.css',
|
||||||
|
})
|
||||||
|
export class App {
|
||||||
|
protected title = 'openbaar';
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<main utrecht-document class="utrecht-theme">
|
||||||
|
<utrecht-article>
|
||||||
|
<utrecht-heading-1>Openbaar BIG-register</utrecht-heading-1>
|
||||||
|
<p utrecht-paragraph>
|
||||||
|
Zoek in het openbare register van BIG-registraties. Alleen publieke gegevens worden getoond.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div role="search">
|
||||||
|
<label for="register-search" utrecht-form-label>Zoek op referentie</label>
|
||||||
|
<input
|
||||||
|
id="register-search"
|
||||||
|
type="search"
|
||||||
|
utrecht-textbox
|
||||||
|
[ngModel]="query()"
|
||||||
|
(ngModelChange)="query.set($event)"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
(keyup.enter)="search()"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
utrecht-button
|
||||||
|
appearance="primary-action-button"
|
||||||
|
type="button"
|
||||||
|
[disabled]="loading()"
|
||||||
|
(click)="search()"
|
||||||
|
>
|
||||||
|
Zoeken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
||||||
|
} @else if (searched() && entries().length === 0) {
|
||||||
|
<p utrecht-paragraph role="status">Geen inschrijvingen gevonden.</p>
|
||||||
|
} @else if (entries().length > 0) {
|
||||||
|
<table utrecht-table>
|
||||||
|
<caption>Inschrijvingen in het openbaar register</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Referentie</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (entry of entries(); track entry.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.reference }}</td>
|
||||||
|
<td>{{ entry.status }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</utrecht-article>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/angular';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
|
||||||
|
import { axe } from 'vitest-axe';
|
||||||
|
import { RegisterPage } from './register-page';
|
||||||
|
|
||||||
|
const sample: OpenbaarEntry[] = [
|
||||||
|
{ id: 'zaak-abc', status: 'INGEDIEND', reference: 'REG-abc' },
|
||||||
|
{ id: 'zaak-def', status: 'INGESCHREVEN', reference: 'REG-def' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function providers(get = vi.fn().mockReturnValue(of(sample))) {
|
||||||
|
return {
|
||||||
|
get,
|
||||||
|
providers: [{ provide: BffApiV1Service, useValue: { getOpenbaarRegister: get } }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RegisterPage', () => {
|
||||||
|
it('lists the public register entries from the BFF on open', async () => {
|
||||||
|
const { get } = providers();
|
||||||
|
await render(RegisterPage, { providers: providers(get).providers });
|
||||||
|
|
||||||
|
expect(get).toHaveBeenCalled();
|
||||||
|
// The Referentie column shows the citizen's reference (matches the submit confirmation, #78),
|
||||||
|
// not the internal zaak id.
|
||||||
|
expect(await screen.findByText(/REG-abc/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/INGEDIEND/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/REG-def/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches by the entered term', async () => {
|
||||||
|
const get = vi.fn().mockReturnValue(of(sample));
|
||||||
|
await render(RegisterPage, { providers: providers(get).providers });
|
||||||
|
|
||||||
|
fireEvent.input(screen.getByRole('searchbox'), { target: { value: 'zaak-abc' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /zoek/i }));
|
||||||
|
|
||||||
|
expect(get).toHaveBeenLastCalledWith({ q: 'zaak-abc' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an empty-state message when the register has no matches', async () => {
|
||||||
|
const get = vi.fn().mockReturnValue(of([] as OpenbaarEntry[]));
|
||||||
|
await render(RegisterPage, { providers: providers(get).providers });
|
||||||
|
|
||||||
|
expect(await screen.findByText(/geen inschrijvingen gevonden/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no WCAG 2.1 AA violations', async () => {
|
||||||
|
document.documentElement.lang = 'nl';
|
||||||
|
const { container } = await render(RegisterPage, { providers: providers().providers });
|
||||||
|
|
||||||
|
const results = await axe(container, {
|
||||||
|
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
|
||||||
|
import { UtrechtComponentsModule } from 'ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The openbaar (public) BIG-register: an anonymous search over the read projection's public-safe
|
||||||
|
* view (id + status only — bsn/naam never leave the BFF; ADR-0010). Loads the full register on open
|
||||||
|
* and filters by the search term via the BFF's `/openbaar/register?q=` endpoint (S-09).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-register-page',
|
||||||
|
imports: [FormsModule, UtrechtComponentsModule],
|
||||||
|
templateUrl: './register-page.html',
|
||||||
|
})
|
||||||
|
export class RegisterPage {
|
||||||
|
private readonly bff = inject(BffApiV1Service);
|
||||||
|
|
||||||
|
protected readonly query = signal('');
|
||||||
|
protected readonly entries = signal<OpenbaarEntry[]>([]);
|
||||||
|
protected readonly loading = signal(false);
|
||||||
|
protected readonly searched = signal(false);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Show the full register on open; the search box narrows it.
|
||||||
|
this.search();
|
||||||
|
}
|
||||||
|
|
||||||
|
search(): void {
|
||||||
|
const q = this.query().trim();
|
||||||
|
this.loading.set(true);
|
||||||
|
this.bff.getOpenbaarRegister(q ? { q } : {}).subscribe({
|
||||||
|
next: (rows: OpenbaarEntry[]) => {
|
||||||
|
this.entries.set(rows);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.searched.set(true);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.entries.set([]);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.searched.set(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Openbaar BIG-register</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { App } from './app/app';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
|
||||||
|
// The openbaar register is anonymous (no DigiD, no runtime config) — bootstrap directly.
|
||||||
|
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
|
||||||
|
@import '@utrecht/design-tokens/dist/index.css';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"emitDecoratorMetadata": false,
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Point nginx's reverse-proxy `resolver` at THIS container's real DNS server.
|
||||||
|
#
|
||||||
|
# The portal nginx configs use a variable proxy_pass, which needs a `resolver` so the BFF hostname is
|
||||||
|
# resolved at request time (nginx can start before the BFF is up). The config hardcodes Docker's
|
||||||
|
# embedded DNS (127.0.0.11) — correct on Docker/Docker Desktop, but rootless podman uses a
|
||||||
|
# network-specific address (aardvark, e.g. 10.89.0.1), so proxied calls 502 there. Read the actual
|
||||||
|
# nameserver from /etc/resolv.conf and substitute it, so the reverse proxy works on any engine.
|
||||||
|
#
|
||||||
|
# Runs from the nginx image's /docker-entrypoint.d/ before nginx starts. On Docker the nameserver IS
|
||||||
|
# 127.0.0.11, so the substitution is a no-op. Guarded (no `set -e`) so it's safe whether the nginx
|
||||||
|
# entrypoint executes or sources it.
|
||||||
|
ns="$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null)"
|
||||||
|
if [ -n "$ns" ] && [ "$ns" != "127.0.0.11" ]; then
|
||||||
|
sed -i "s/resolver 127\.0\.0\.11/resolver $ns/" /etc/nginx/conf.d/default.conf 2>/dev/null || true
|
||||||
|
echo "portal-nginx-resolver: set resolver to $ns"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Multi-stage build for the self-service portal (Angular → nginx).
|
||||||
|
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
|
||||||
|
FROM node:24-slim AS build
|
||||||
|
WORKDIR /src
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
|
||||||
|
|
||||||
|
# Restore first (cached unless the manifests change).
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Sources (only what the app + its libs need).
|
||||||
|
COPY apps/self-service apps/self-service
|
||||||
|
COPY libs libs
|
||||||
|
RUN pnpm nx build self-service
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY apps/self-service/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
|
||||||
|
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
||||||
|
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
|
||||||
|
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,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,29 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
|
||||||
|
# even before the BFF is up and picks up restarts — instead of failing to load the config.
|
||||||
|
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||||
|
|
||||||
|
# Same-origin API: proxy the BFF endpoint groups to the bff service. The api-client uses relative
|
||||||
|
# URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the DigiD
|
||||||
|
# token (same-origin) is attached by the app's interceptor (S-08d/ADR-0010).
|
||||||
|
location /self-service/ {
|
||||||
|
set $bff http://bff:8080;
|
||||||
|
proxy_pass $bff;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
location /openbaar/ {
|
||||||
|
set $bff http://bff:8080;
|
||||||
|
proxy_pass $bff;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — Angular client-side routing.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"name": "self-service",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"projectType": "application",
|
||||||
|
"prefix": "app",
|
||||||
|
"sourceRoot": "apps/self-service/src",
|
||||||
|
"tags": [],
|
||||||
|
"targets": {
|
||||||
|
"build": {
|
||||||
|
"executor": "@angular/build:application",
|
||||||
|
"outputs": ["{options.outputPath}"],
|
||||||
|
"defaultConfiguration": "production",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/apps/self-service",
|
||||||
|
"browser": "apps/self-service/src/main.ts",
|
||||||
|
"tsConfig": "apps/self-service/tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "apps/self-service/public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["apps/self-service/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": "self-service:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "self-service:build:development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"executor": "@angular/build:unit-test",
|
||||||
|
"options": {
|
||||||
|
"watch": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve-static": {
|
||||||
|
"continuous": true,
|
||||||
|
"executor": "@nx/web:file-server",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "self-service:build",
|
||||||
|
"staticFilePath": "dist/apps/self-service/browser",
|
||||||
|
"spa": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"authority": "http://localhost:8180/realms/digid"
|
||||||
|
}
|
||||||
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 DigiD token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and the
|
||||||
|
// angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a configured
|
||||||
|
// secureRoute. A regression to an absolute origin (as once shipped) makes the relative URL never match,
|
||||||
|
// so the submit goes out unauthenticated and fails silently. This drives the REAL interceptor and the
|
||||||
|
// REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config source
|
||||||
|
// and the token storage are faked, so the assertion turns on the actual route-matching.
|
||||||
|
describe('self-service DigiD token wiring', () => {
|
||||||
|
let http: HttpTestingController;
|
||||||
|
let bff: BffApiV1Service;
|
||||||
|
const token = 'digid-access-token';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{
|
||||||
|
provide: ConfigurationService,
|
||||||
|
useValue: {
|
||||||
|
hasAtLeastOneConfig: () => true,
|
||||||
|
getAllConfigurations: () => [{ configId: 'digid', secureRoutes: SECURE_API_ROUTES }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A signed-in session: the storage the interceptor's token lookup reads from.
|
||||||
|
provide: AbstractSecurityStorage,
|
||||||
|
useValue: {
|
||||||
|
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
|
||||||
|
write: () => undefined,
|
||||||
|
remove: () => undefined,
|
||||||
|
clear: () => undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
http = TestBed.inject(HttpTestingController);
|
||||||
|
bff = TestBed.inject(BffApiV1Service);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
it('attaches the bearer token to the relative self-service BFF call', () => {
|
||||||
|
bff.postSelfServiceRegistrations().subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne('/self-service/registrations');
|
||||||
|
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||||
|
req.flush({ registrationId: 'reg-1', status: 'Ingediend' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the anonymous openbaar register call unauthenticated', () => {
|
||||||
|
bff.getOpenbaarRegister().subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne((r) => r.url === '/openbaar/register');
|
||||||
|
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
ApplicationConfig,
|
||||||
|
provideBrowserGlobalErrorListeners,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { authInterceptor, provideDigiadAuth } from 'auth';
|
||||||
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
|
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
|
||||||
|
export interface RuntimeConfig {
|
||||||
|
/** The Keycloak `digid` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
|
||||||
|
authority: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route prefixes whose requests carry the DigiD 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.
|
||||||
|
* `/openbaar/` is deliberately excluded: it is the anonymous public register.
|
||||||
|
*/
|
||||||
|
export const SECURE_API_ROUTES = ['/self-service/'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
|
||||||
|
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
|
||||||
|
*/
|
||||||
|
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
|
||||||
|
return {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(appRoutes),
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||||
|
provideDigiadAuth({
|
||||||
|
authority: runtime.authority,
|
||||||
|
redirectUrl: origin,
|
||||||
|
secureRoutes: SECURE_API_ROUTES,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { authenticatedGuard } from 'auth';
|
||||||
|
import { RegistrationPage } from './registration/registration-page';
|
||||||
|
|
||||||
|
export const appRoutes: Route[] = [
|
||||||
|
{ path: '', component: RegistrationPage, 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 RegistrationPage 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 = 'self-service';
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<main utrecht-document class="utrecht-theme">
|
||||||
|
<utrecht-article>
|
||||||
|
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
||||||
|
|
||||||
|
@if (submitted()) {
|
||||||
|
@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()) {
|
||||||
|
<p utrecht-paragraph role="alert">
|
||||||
|
Er ging iets mis bij het indienen van uw registratie. Probeer het opnieuw.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
utrecht-button
|
||||||
|
appearance="primary-action-button"
|
||||||
|
type="button"
|
||||||
|
[disabled]="submitting()"
|
||||||
|
(click)="submit()"
|
||||||
|
>
|
||||||
|
Registratie indienen
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</utrecht-article>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/angular';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { AuthService } from 'auth';
|
||||||
|
import { BffApiV1Service } from 'api-client';
|
||||||
|
import { axe } from 'vitest-axe';
|
||||||
|
import { RegistrationPage } from './registration-page';
|
||||||
|
|
||||||
|
class FakeAuth extends AuthService {
|
||||||
|
readonly isAuthenticated = signal(true);
|
||||||
|
readonly bsn = signal<string | undefined>('123456782');
|
||||||
|
login(): void {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
logout(): void {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: {
|
||||||
|
getSelfServiceRegistrations: getCurrent,
|
||||||
|
postSelfServiceRegistrations: post,
|
||||||
|
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||||
|
postSelfServiceRegistrationsIdDocuments: provideDocuments,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RegistrationPage', () => {
|
||||||
|
it('shows the signed-in BSN', async () => {
|
||||||
|
await render(RegistrationPage, { providers: providers().providers });
|
||||||
|
expect(screen.getByText(/123456782/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits the registration and confirms', async () => {
|
||||||
|
const { post, providers: p } = providers();
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
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 });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
// The failure is surfaced (not swallowed), the confirmation is not shown, and the user can retry.
|
||||||
|
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/ontvangen/i)).toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers to withdraw after submitting, and withdrawing confirms', async () => {
|
||||||
|
const { withdraw, providers: p } = providers();
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||||
|
|
||||||
|
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
|
||||||
|
expect(withdraw).toHaveBeenCalledWith('reg-9');
|
||||||
|
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A small PDF file the citizen "uploads"; the component base64-encodes it client-side.
|
||||||
|
const diploma = () => new File([new Uint8Array([1, 2, 3])], 'diploma.pdf', { type: 'application/pdf' });
|
||||||
|
|
||||||
|
it('uploads a chosen diploma after submitting, and doing so confirms', async () => {
|
||||||
|
const { provideDocuments, providers: p } = providers();
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
|
||||||
|
// Choose the file, then upload it.
|
||||||
|
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
|
||||||
|
|
||||||
|
// The upload is keyed by the reference and carries the base64 file + its name; the page confirms.
|
||||||
|
expect(await screen.findByText(/documenten.*aangeleverd/i)).toBeTruthy();
|
||||||
|
expect(provideDocuments).toHaveBeenCalledWith(
|
||||||
|
'reg-9',
|
||||||
|
expect.objectContaining({ fileName: 'diploma.pdf', contentType: 'application/pdf', contentBase64: expect.any(String) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a diploma-upload failure and keeps the action available', async () => {
|
||||||
|
const { providers: p } = providers(
|
||||||
|
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
vi.fn().mockReturnValue(throwError(() => new Error('documents rejected'))),
|
||||||
|
);
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
fireEvent.change(screen.getByLabelText(/diploma/i), { target: { files: [diploma()] } });
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/aangeleverd/i)).toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a withdraw failure and keeps the action available', async () => {
|
||||||
|
const { providers: p } = providers(
|
||||||
|
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
|
||||||
|
);
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
||||||
|
// 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.
|
||||||
|
document.documentElement.lang = 'nl';
|
||||||
|
const { container } = await render(RegistrationPage, { providers: providers().providers });
|
||||||
|
|
||||||
|
const results = await axe(container, {
|
||||||
|
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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). 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 implements OnInit {
|
||||||
|
private readonly auth = inject(AuthService);
|
||||||
|
private readonly bff = inject(BffApiV1Service);
|
||||||
|
|
||||||
|
protected readonly bsn = this.auth.bsn;
|
||||||
|
protected readonly submitting = signal(false);
|
||||||
|
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);
|
||||||
|
this.failed.set(false);
|
||||||
|
this.bff.postSelfServiceRegistrations().subscribe({
|
||||||
|
next: (accepted: SubmitAccepted) => {
|
||||||
|
this.reference.set(accepted.registrationId);
|
||||||
|
this.submitted.set(true);
|
||||||
|
this.submitting.set(false);
|
||||||
|
},
|
||||||
|
// Surface the failure instead of swallowing it: re-enable the button so the user can retry.
|
||||||
|
error: () => {
|
||||||
|
this.failed.set(true);
|
||||||
|
this.submitting.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
this.selectedFile.set(input.files?.[0] ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async provideDocuments(): Promise<void> {
|
||||||
|
const reference = this.reference();
|
||||||
|
const file = this.selectedFile();
|
||||||
|
if (!reference || !file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.providingDocuments.set(true);
|
||||||
|
this.provideDocumentsFailed.set(false);
|
||||||
|
let contentBase64: string;
|
||||||
|
try {
|
||||||
|
contentBase64 = await readAsBase64(file);
|
||||||
|
} catch {
|
||||||
|
this.provideDocumentsFailed.set(true);
|
||||||
|
this.providingDocuments.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.bff
|
||||||
|
.postSelfServiceRegistrationsIdDocuments(reference, {
|
||||||
|
contentBase64,
|
||||||
|
fileName: file.name,
|
||||||
|
contentType: file.type || 'application/pdf',
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.documentsProvided.set(true);
|
||||||
|
this.providingDocuments.set(false);
|
||||||
|
},
|
||||||
|
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||||
|
error: () => {
|
||||||
|
this.provideDocumentsFailed.set(true);
|
||||||
|
this.providingDocuments.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
withdraw(): void {
|
||||||
|
const reference = this.reference();
|
||||||
|
if (!reference) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.withdrawing.set(true);
|
||||||
|
this.withdrawFailed.set(false);
|
||||||
|
this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.withdrawn.set(true);
|
||||||
|
this.withdrawing.set(false);
|
||||||
|
},
|
||||||
|
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||||
|
error: () => {
|
||||||
|
this.withdrawFailed.set(true);
|
||||||
|
this.withdrawing.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a file's bytes as a base64 string (without the `data:...;base64,` prefix). */
|
||||||
|
function readAsBase64(file: File): Promise<string> {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(((reader.result as string) ?? '').split(',', 2)[1] ?? '');
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error('Could not read the file.'));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>self-service</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"]
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# git-cliff configuration — generates CHANGELOG.md from Conventional Commits.
|
||||||
|
# Run via `make changelog`. See https://git-cliff.org.
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
header = """
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project. Generated from Conventional Commits by git-cliff.\n
|
||||||
|
"""
|
||||||
|
body = """
|
||||||
|
{% if version %}\
|
||||||
|
## {{ version }} — {{ timestamp | date(format="%Y-%m-%d") }}
|
||||||
|
{% else %}\
|
||||||
|
## Unreleased
|
||||||
|
{% endif %}\
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group | upper_first }}
|
||||||
|
{% for commit in commits %}\
|
||||||
|
- {{ commit.message | upper_first }}{% if commit.breaking %} **[BREAKING]**{% endif %}
|
||||||
|
{% endfor %}\
|
||||||
|
{% endfor %}\n
|
||||||
|
"""
|
||||||
|
trim = true
|
||||||
|
|
||||||
|
[git]
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = true
|
||||||
|
split_commits = false
|
||||||
|
protect_breaking_commits = true
|
||||||
|
tag_pattern = "v[0-9]*"
|
||||||
|
# CalVer tags: YYYY.MM.PATCH
|
||||||
|
filter_commits = false
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Features" },
|
||||||
|
{ message = "^fix", group = "Bug Fixes" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^refactor", group = "Refactor" },
|
||||||
|
{ message = "^docs", group = "Documentation" },
|
||||||
|
{ message = "^test", group = "Tests" },
|
||||||
|
{ message = "^ci", group = "CI" },
|
||||||
|
{ message = "^build", group = "Build" },
|
||||||
|
{ message = "^arch", group = "Architecture" },
|
||||||
|
{ message = "^chore", group = "Chores" },
|
||||||
|
{ message = ".*", group = "Other" },
|
||||||
|
]
|
||||||
+1
-1
@@ -85,7 +85,7 @@ The five flows form the BDD acceptance backbone (Gherkin scenarios in `tests/acc
|
|||||||
|
|
||||||
- **Source control & collaboration:** **Gitea** (Respellion self-hosted) — repository, issues, milestones, labels, projects, releases, container registry, wiki, packages.
|
- **Source control & collaboration:** **Gitea** (Respellion self-hosted) — repository, issues, milestones, labels, projects, releases, container registry, wiki, packages.
|
||||||
- **CI/CD:** **Gitea Actions** running on Respellion-hosted `act_runner` instances. Workflow files live in `.gitea/workflows/`. Marketplace actions are referenced via absolute URLs (`uses: https://github.com/actions/checkout@v4` or Gitea-hosted equivalents where available) for reproducibility.
|
- **CI/CD:** **Gitea Actions** running on Respellion-hosted `act_runner` instances. Workflow files live in `.gitea/workflows/`. Marketplace actions are referenced via absolute URLs (`uses: https://github.com/actions/checkout@v4` or Gitea-hosted equivalents where available) for reproducibility.
|
||||||
- **Backend:** .NET 9 (LTS at iteration time), C#, minimal APIs for BFF, MediatR for in-process messaging within Domain Service, EF Core for the projection store and domain DB.
|
- **Backend:** .NET 10 (LTS at iteration time), C#, minimal APIs for BFF, MediatR for in-process messaging within Domain Service, EF Core for the projection store and domain DB.
|
||||||
- **Frontend:** Angular (latest LTS) + TypeScript, standalone components + signals, Nx monorepo, NL Design System component library, Angular Testing Library + Playwright.
|
- **Frontend:** Angular (latest LTS) + TypeScript, standalone components + signals, Nx monorepo, NL Design System component library, Angular Testing Library + Playwright.
|
||||||
- **Workflow:** Flowable (BPMN + DMN) via Docker image; Postgres for engine store.
|
- **Workflow:** Flowable (BPMN + DMN) via Docker image; Postgres for engine store.
|
||||||
- **Identity:** Keycloak with pre-seeded realms.
|
- **Identity:** Keycloak with pre-seeded realms.
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# ADR-0001: Loose coupling to upstream Common Ground modules
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-06-03
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Template note:** This is the first ADR and doubles as the worked example of
|
||||||
|
the Nygard template. Copy its shape for new ADRs (`adr-NNNN-title.md`).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This reference application orchestrates several upstream Common Ground modules —
|
||||||
|
OpenZaak (ZGW APIs), Open Notificaties (NRC), Objecten/Objecttypen, Flowable,
|
||||||
|
Keycloak. Each is an independently developed, independently deployed peer. The
|
||||||
|
temptation in a demo is to reach straight into a peer's database or couple to its
|
||||||
|
internal schema to move faster. That coupling is exactly what makes Common Ground
|
||||||
|
landscapes brittle and un-upgradeable in practice.
|
||||||
|
|
||||||
|
We need a stance, recorded up front, on how our services may talk to these peers.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**We integrate with upstream modules only through their documented public APIs, and
|
||||||
|
we isolate that integration behind explicit anti-corruption boundaries.**
|
||||||
|
|
||||||
|
Concretely (mirrors CLAUDE.md §8):
|
||||||
|
|
||||||
|
1. The **ACL** is the only code that talks to ZGW APIs; no other service constructs
|
||||||
|
ZGW URLs.
|
||||||
|
2. The **Workflow Client** is the only code that talks to Flowable; BPMN models hold
|
||||||
|
no OpenZaak knowledge.
|
||||||
|
3. **Portals talk only to the BFF** — never directly to a backend or a peer module.
|
||||||
|
4. **No direct database access across services or to any peer.** Each service owns
|
||||||
|
its schema; the Read Projection is a rebuildable derived artefact.
|
||||||
|
5. **Idempotency at every event boundary** (the Event Subscriber tolerates duplicate
|
||||||
|
and out-of-order NRC events).
|
||||||
|
|
||||||
|
Bending any of these is an ADR-worthy moment (CLAUDE.md §14): stop and open an
|
||||||
|
`adr-proposal` issue first.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- Upstream modules can be upgraded or swapped behind their APIs without rippling
|
||||||
|
through our services.
|
||||||
|
- Coupling is visible and minimal — anti-corruption code lives in one named place.
|
||||||
|
- The architecture teaches the Common Ground pattern by enforcing it.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- More indirection: a translation layer (ACL, Workflow Client) instead of direct
|
||||||
|
calls. Accepted — it's the point.
|
||||||
|
- Eventual consistency across aggregates must be designed for, not assumed away.
|
||||||
|
|
||||||
|
**Follow-up**
|
||||||
|
|
||||||
|
- Each integration slice that touches a boundary references this ADR; new boundary
|
||||||
|
decisions get their own ADR.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# ADR-0002: BIG catalogus design and OpenZaak seeding
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-06-03
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-01 (#2)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-01 needs a reproducible `BIG` catalogus in OpenZaak with a **lean** `BIG-registratie`
|
||||||
|
zaaktype (only schema-mandatory fields) plus a `bsn` eigenschap, and a JWT client that
|
||||||
|
can list zaaktypen. We had to decide *how* to provision this idempotently at startup.
|
||||||
|
|
||||||
|
Findings from the OpenZaak image (`openzaak/open-zaak:latest`):
|
||||||
|
- `setup_configuration` (run by the init container) is declarative and idempotent, with
|
||||||
|
steps for **JWT secrets** and **applicaties** (`vng_api_common_credentials`,
|
||||||
|
`vng_api_common_applicaties`) — but **no step for catalogi/zaaktypen**.
|
||||||
|
- Catalogus/zaaktype/eigenschap can only be created through the **ZTC REST API**.
|
||||||
|
- Publishing a zaaktype requires ≥1 roltype, ≥1 resultaattype and ≥2 statustypen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. **Provision the JWT client declaratively** via `infra/openzaak/setup_configuration/data.yaml`:
|
||||||
|
a `JWTSecret` (`big-reference-seed` / dev secret) and an `Applicatie` with
|
||||||
|
`heeft_alle_autorisaties: true`. Idempotent, runs in the init container.
|
||||||
|
2. **Seed the catalogus/zaaktype/eigenschap via the ZTC API** with an idempotent,
|
||||||
|
stdlib-only script (`infra/openzaak/seed_catalogus.py`, `make openzaak-seed`). It mints
|
||||||
|
a ZGW JWT from the provisioned client and matches existing objects (by `domein` /
|
||||||
|
`identificatie` / `naam`, querying `status=alles` so concepts are seen) before creating.
|
||||||
|
3. **Keep the zaaktype a CONCEPT (not published).** Publishing pulls in roltypen,
|
||||||
|
statustypen and resultaattypen, which go beyond "schema-mandatory"; those arrive with
|
||||||
|
the workflow/zaak slices that actually need a published type. Listing uses `status=alles`.
|
||||||
|
4. **Disable outbound notifications** (`NOTIFICATIONS_DISABLED=true`) until Open Notificaties
|
||||||
|
(NRC) lands in S-01-c — otherwise every ZTC write 500s trying to notify.
|
||||||
|
5. **Fixed dev values:** RSIN `517439943` (elfproef-valid test value); the JWT secret is
|
||||||
|
dev-only and documented as such.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Reproducible & version-robust:** the API-driven seed doesn't depend on fixture PKs or
|
||||||
|
a catalogi `setup_configuration` step that may change between versions.
|
||||||
|
- **Teaches the pattern:** the seed talks to OpenZaak exactly the way the ACL will later —
|
||||||
|
through the documented ZGW API, with a JWT (ADR-0001).
|
||||||
|
- The seed is a script, but a **data loader is explicitly anticipated** (PRD §8); it lives
|
||||||
|
under `infra/openzaak/`, not as ad-hoc tooling.
|
||||||
|
- **Follow-ups:** re-enable notifications when NRC is up (S-01-c); publish the zaaktype (add
|
||||||
|
the related types) when a slice needs to create real zaken; pin the OpenZaak image tag.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Fully declarative in `data.yaml`** — rejected: no catalogi/zaaktype step exists.
|
||||||
|
- **Django `loaddata` fixture** — rejected: brittle, tied to model PKs and the exact image
|
||||||
|
version; bypasses the API the rest of the system uses.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# ADR-0003: ACL default-fill strategy
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-06-04
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-04 (#5); builds on ADR-0001 (loose coupling)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The ACL is the only code that talks to ZGW APIs (ADR-0001 / CLAUDE.md §8.1). When the
|
||||||
|
domain asks it to "open a zaak", the domain payload is intentionally free of ZGW
|
||||||
|
specifics — it carries domain facts (e.g. the registrant's BSN), not OpenZaak fields. But
|
||||||
|
OpenZaak's `POST /zaken` requires ZGW-mandatory fields: `bronorganisatie`,
|
||||||
|
`verantwoordelijkeOrganisatie`, `startdatum`, `vertrouwelijkheidaanduiding`, and a
|
||||||
|
`zaaktype` URL. Something has to supply those, and it must not leak into the domain.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The ACL default-fills the ZGW-mandatory zaak fields; the domain never sees them.**
|
||||||
|
|
||||||
|
- `bronorganisatie`, `verantwoordelijkeOrganisatie`, `vertrouwelijkheidaanduiding`, and the
|
||||||
|
`zaaktype` URL come from **ACL configuration** (`AclDefaults` options) — not hardcoded,
|
||||||
|
not from the domain. This keeps them operationally manageable (the beheer portal will
|
||||||
|
edit them in S-15) and environment-specific (the seeded BIG zaaktype URL differs per env).
|
||||||
|
- `startdatum` is derived from an injected **clock** (today's date), so it is
|
||||||
|
deterministic in tests.
|
||||||
|
- The mapping from domain payload → ZGW `ZaakRequest` lives entirely inside the ACL
|
||||||
|
(`Application` builds the request from payload + defaults; `Infrastructure` serialises and
|
||||||
|
POSTs it). No other service constructs ZGW payloads or URLs.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Positive:** the domain stays ZGW-agnostic; ZGW knowledge is in one named place; defaults
|
||||||
|
are config (testable, env-specific, later editable via the beheer portal).
|
||||||
|
- **Cost:** the ACL must be configured per environment (the seeded zaaktype URL, the
|
||||||
|
organisation RSINs). Missing/invalid config fails fast at the ACL boundary.
|
||||||
|
- **Follow-ups:** mapping the BSN onto the zaak (eigenschap/rol), status transitions, and
|
||||||
|
documents are explicitly out of scope for S-04 and get their own slices.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Defaults in the domain payload** — rejected: leaks ZGW concerns into the domain,
|
||||||
|
violating ADR-0001.
|
||||||
|
- **Hardcoded defaults in code** — rejected: not env-specific, not operationally editable.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# ADR-0004: Reqnroll as the BDD acceptance framework
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-06-04
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-04 (#5); supports CLAUDE.md §3 (BDD at the use-case level) and §11 (tests pyramid)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
CLAUDE.md §11 mandates that each user-visible flow is driven by a Gherkin acceptance
|
||||||
|
scenario living in `tests/acceptance/`, and §3 names "BDD at the use-case level" as a core
|
||||||
|
engineering principle. The foundational slices (S-00…S-03) added no acceptance layer; S-04
|
||||||
|
is the first slice with real domain behaviour to drive, so it is where the BDD framework is
|
||||||
|
introduced. We need a .NET tool that:
|
||||||
|
|
||||||
|
- parses Gherkin `.feature` files and binds steps to C#,
|
||||||
|
- integrates with the existing xUnit test runner (the repo standardises on xUnit), so
|
||||||
|
acceptance tests run under the same `dotnet test` / `make ci` gate as everything else,
|
||||||
|
- is actively maintained on modern .NET (we target net10.0).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Use [Reqnroll](https://reqnroll.net/) (`Reqnroll.xUnit`) for acceptance tests.**
|
||||||
|
|
||||||
|
- Reqnroll is the actively-maintained, open-source successor to SpecFlow (which is no longer
|
||||||
|
maintained). It keeps the same Gherkin + `[Binding]` model, so the knowledge transfers.
|
||||||
|
- `Reqnroll.xUnit` generates one xUnit test per scenario, so acceptance tests are discovered
|
||||||
|
and run by the same runner as the unit tests — no second test framework, no extra CI step.
|
||||||
|
- Acceptance projects live under `tests/acceptance/` per the PRD §9 layout. Generated
|
||||||
|
`*.feature.cs` files are build artefacts and are git-ignored.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Positive:** one assertion/runner stack (xUnit) across unit and acceptance tests; scenarios
|
||||||
|
are written in business language (Dutch domain terms inline) and reviewed as the slice's
|
||||||
|
contract; maintained tooling on net10.0.
|
||||||
|
- **Cost:** a new dependency (`Reqnroll.xUnit`) and its xUnit v2 transitive graph. Reqnroll
|
||||||
|
pulls `xunit.core` but not the assertion library, so the `xunit` metapackage is referenced
|
||||||
|
explicitly to get `Assert`.
|
||||||
|
- **Replaceable by:** hand-written xUnit "scenario" tests with a Given/When/Then helper, at
|
||||||
|
the cost of losing Gherkin as the shared, readable contract — which is the whole point of §3.
|
||||||
|
- **Follow-ups:** the real-OpenZaak integration test (Testcontainers) and the Stryker mutation
|
||||||
|
baseline for S-04 are tracked as their own issues split off #5.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **SpecFlow** — rejected: unmaintained and without an official net10.0 story; Reqnroll is its
|
||||||
|
drop-in successor.
|
||||||
|
- **Plain xUnit Given/When/Then helpers** — rejected for user-visible flows: loses the
|
||||||
|
business-readable Gherkin contract that §3/§11 require. Still fine for unit-level tests.
|
||||||
|
- **Xunit.Gherkin.Quick** — rejected: lighter but less featureful (no hooks/scoped contexts,
|
||||||
|
smaller community) than Reqnroll.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR-0005: Stryker.NET for mutation testing, baseline on the ACL
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-06-25
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-04b (#47); proposed in #51; supports CLAUDE.md §5 (mutation ratchet) and §3 (Definition of Done)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
CLAUDE.md §5 mandates Stryker on every PR with a **ratchet**: CI fails on a regression
|
||||||
|
below the established baseline, and the baseline only ever moves up. §3 lists "mutation
|
||||||
|
(ratchet)" as a Definition-of-Done gate for **every** slice. Yet no baseline existed — so,
|
||||||
|
strictly, no slice could satisfy that gate. S-04b establishes it.
|
||||||
|
|
||||||
|
The ACL is the natural place to set the first baseline: it is the first service with real
|
||||||
|
branching logic — `OpenZaakGateway` (HTTP contract, geo CRS headers, error handling),
|
||||||
|
`ZgwToken` (HS256 JWT minting), and the `AclService` default-fill mapping. We need a tool
|
||||||
|
that:
|
||||||
|
|
||||||
|
- mutates C# and runs the existing xUnit suite per mutant,
|
||||||
|
- is reproducible (same version locally and in CI, no global install),
|
||||||
|
- understands this repo's `.slnx` solution format (used repo-wide),
|
||||||
|
- emits a break threshold CI can gate on.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Use [Stryker.NET](https://stryker-mutator.io/docs/stryker-net/) (`dotnet-stryker`),
|
||||||
|
pinned as a local dotnet tool**, configured in solution mode against `Acl.slnx`.
|
||||||
|
|
||||||
|
- Pinned in `.config/dotnet-tools.json` (v4.15.0); `dotnet tool restore` makes
|
||||||
|
`make mutation` reproducible from a fresh clone, locally and in CI — no global install.
|
||||||
|
- **Solution mode** (`stryker-config.json` → `solution: Acl.slnx`) mutates the two projects
|
||||||
|
under test (`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` is untested and skipped.
|
||||||
|
Stryker 4.15 reads `.slnx` directly, so no throwaway `.sln` shim is needed.
|
||||||
|
- A `mutation` make target runs it; it is wired into `make ci` and a parallel Gitea Actions
|
||||||
|
`mutation` job, keeping `make ci` an exact mirror of the pipeline.
|
||||||
|
|
||||||
|
**Baseline:** writing S-04b's tests surfaced that the ACL suite was thin — the initial
|
||||||
|
score was **35%** (survivors: unasserted CRS headers, null guards, error paths, and JWT
|
||||||
|
claims). Those tests were strengthened (killing the mutants honestly rather than lowering
|
||||||
|
the bar), raising the score to **95%**. The enforced `break` threshold is set to **90%** —
|
||||||
|
one-mutant headroom over the ~20-mutant surface, since a single mutant is ≈5%.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Positive:** test *strength* is gated, not just coverage; the ratchet protects the ACL's
|
||||||
|
ZGW contract logic; the baseline is repo-wide and ratchets upward per §5.
|
||||||
|
- **Cost:** a new dependency (`dotnet-stryker`) and a slower CI job than unit tests (~25 s on
|
||||||
|
the small ACL). Pinned + tool-restored, so reproducible.
|
||||||
|
- **One accepted survivor:** a mutation of the empty-response *exception message string*.
|
||||||
|
Asserting exception message text is brittle and the behaviour (type + control flow) is
|
||||||
|
unchanged — treated as an equivalent mutant, not a test gap.
|
||||||
|
- **Commitment:** later slices ratchet the threshold up deliberately, never down (§5). New
|
||||||
|
services add their own mutation run as they gain branching logic (BFF, Domain, …).
|
||||||
|
- **Replaceable by:** no realistic .NET alternative — Stryker.NET is the tool §5 already
|
||||||
|
names; the fallback is no mutation testing, which §5 forbids.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Global `dotnet tool install -g`** — rejected: not reproducible/pinned per clone; the
|
||||||
|
local manifest gives every checkout and the CI runner the same version.
|
||||||
|
- **Mutate the whole `register-referentie.slnx`** — rejected for this slice: scopes the
|
||||||
|
baseline to services with no logic yet (BFF skeleton), diluting the signal. Each service
|
||||||
|
opts in as it gains logic.
|
||||||
|
- **Application-only scope** — rejected: would leave `Acl.Infrastructure`'s HTTP/JWT logic —
|
||||||
|
the riskiest code — unguarded by the ratchet.
|
||||||
|
- **Coverage gate instead of mutation** — rejected: line coverage does not measure whether
|
||||||
|
tests would *catch* a regression; that is the whole point of §5.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# ADR-0006: Provision the ACL integration test against the compose stack
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-06-29
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-04a (#46); proposed in #53; builds on ADR-0001 (loose coupling), ADR-0002 (catalogus design), ADR-0003 (default-fill); supports CLAUDE.md §11 (integration tests via real containers)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-04 delivered the ACL's one operation — `OpenZaakGateway.OpenZaakAsync` — with unit
|
||||||
|
tests against a stubbed `HttpMessageHandler` and a Reqnroll scenario over an in-memory
|
||||||
|
stand-in. The deferred S-04 acceptance criterion (S-04a) is the one a stub cannot meet:
|
||||||
|
|
||||||
|
> Integration test using Testcontainers against real OpenZaak passes.
|
||||||
|
|
||||||
|
The test must drive the gateway against a **real** OpenZaak — real ZGW JWT auth, the real
|
||||||
|
`POST /zaken/api/v1/zaken` contract, real CRS handling — and assert a zaak comes back.
|
||||||
|
|
||||||
|
Two ways to stand OpenZaak up were considered (the issue's open question): (a) a full
|
||||||
|
**Testcontainers** graph started by the test, or (b) target the **running compose stack**
|
||||||
|
the repo already defines (`infra/openzaak/docker-compose.yml`, `make openzaak-up`).
|
||||||
|
|
||||||
|
Investigation reversed the initially-favoured Testcontainers option:
|
||||||
|
|
||||||
|
1. **Testcontainers .NET has no docker-compose support.** OpenZaak needs PostGIS + Redis +
|
||||||
|
a `setup_configuration` one-shot (the JWT client) + the API. Honouring "full graph" would
|
||||||
|
mean re-implementing that five-service stack — init ordering, the config volume, health
|
||||||
|
gating — by hand in C#, duplicating the maintained compose file and rotting with it. That
|
||||||
|
rubs against CLAUDE.md §13 ("if a test is hard to write, the design is wrong").
|
||||||
|
2. **The test cannot be hermetic anyway.** OpenZaak's Zaken API rejects a zaak against a
|
||||||
|
*concept* zaaktype (`not-published`), and a *published* zaaktype requires ≥1 resultaattype,
|
||||||
|
which OpenZaak validates by fetching the external **Selectielijst** reference API
|
||||||
|
(`selectielijst.openzaak.nl`). So a real zaak POST already depends on outbound internet
|
||||||
|
from the OpenZaak container — the self-containment that motivated Testcontainers is lost
|
||||||
|
regardless of how the containers are started.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The ACL integration test targets the running compose stack; it does not start containers
|
||||||
|
itself. No new test dependency is added.**
|
||||||
|
|
||||||
|
- A gated test project `Acl.IntegrationTests` (`[Trait("Category","Integration")]`) talks to
|
||||||
|
OpenZaak with a plain `HttpClient`, reusing the same endpoint + JWT-client config the seed
|
||||||
|
uses (`OZ_BASE` / `OZ_CLIENT_ID` / `OZ_SECRET`, defaulting to the local stack). It locates
|
||||||
|
the published `BIG-REGISTRATIE` zaaktype via the Catalogi API and exercises the real
|
||||||
|
`OpenZaakGateway` against it.
|
||||||
|
- **The lane is kept out of the fast checks.** `make unit` runs with
|
||||||
|
`--filter "Category!=Integration"`; Stryker is pinned to `Acl.Tests` (`test-projects`), so
|
||||||
|
neither the unit nor the mutation lane needs a live stack. A `make integration` target
|
||||||
|
(`infra/run-integration.sh`) brings up a throwaway OpenZaak and runs the lane locally.
|
||||||
|
In CI the check runs as the `verify-acl` step of the consolidated `verify-stack` job
|
||||||
|
(issue #58) — one shared full-stack bring-up. This matches `make` being the single
|
||||||
|
source of truth (ADR-0005).
|
||||||
|
- **Publishing is opt-in in the seed.** `infra/openzaak/seed_catalogus.py` gains an
|
||||||
|
`OZ_PUBLISH=1` path that adds the relations OpenZaak's publish requires — two statustypen
|
||||||
|
(begin/eind), a roltype, and a resultaattype whose Selectielijst procestype is matched onto
|
||||||
|
the zaaktype — then publishes. The default seed (S-01 / ADR-0002) still leaves the zaaktype
|
||||||
|
a concept; only `make integration` flips the switch.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Positive:** a small, honest test over the real ZGW contract with no bespoke orchestration
|
||||||
|
to maintain; the compose stack is exercised exactly as operators run it; no new dependency.
|
||||||
|
- **It caught a real bug.** The gateway sent the zaak body via `JsonContent` without a
|
||||||
|
`Content-Length`, so .NET framed it as `Transfer-Encoding: chunked`, which OpenZaak's uwsgi
|
||||||
|
rejects with 400. A stubbed handler accepts either framing, so only a real OpenZaak surfaced
|
||||||
|
it. Fixed by buffering the body (`LoadIntoBufferAsync`); guarded in the fast lane by a unit
|
||||||
|
test asserting a `Content-Length` is set. This is the concrete justification for §11's
|
||||||
|
integration tier.
|
||||||
|
- **External dependency:** the integration job needs the OpenZaak container to reach
|
||||||
|
`selectielijst.openzaak.nl`. It is a stable public reference API (the same one OpenZaak uses
|
||||||
|
in production) but it is a network touchpoint, and a CI environment without egress would need
|
||||||
|
a local Selectielijst service or a recorded fixture. `OZ_SELECTIELIJST` overrides the base URL.
|
||||||
|
- **Cost:** the lane needs the stack up first, so it is separate from the fast lanes.
|
||||||
|
- **Runs on the hosted runner.** A process *on* the runner can't reach the stack's published
|
||||||
|
ports (Compose starts sibling containers via the host daemon — gitea-actions-gotchas.md §5,
|
||||||
|
same split as §1), so `infra/run-integration.sh` runs both the seed and the test as containers
|
||||||
|
*joined to the OpenZaak network*, reaching it by **container IP** (a single-label host like
|
||||||
|
`openzaak` isn't URL-valid for OpenZaak's own `URLValidator`; an IPv4 literal is). Code is
|
||||||
|
delivered by image build / `docker cp`, never bind mounts. The CI job therefore needs only
|
||||||
|
Docker — no `setup-dotnet`. (This closed the follow-up that was originally split out as #55.)
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Full Testcontainers graph** — rejected: re-implements the compose stack in C# (brittle,
|
||||||
|
duplicative) for no hermeticity gain, since the Selectielijst dependency remains.
|
||||||
|
- **Single OpenZaak container (sqlite/locmem)** — rejected: diverges from the real
|
||||||
|
PostGIS-backed, Redis-cached deployment; the Zaken API is a geo API and the divergence would
|
||||||
|
undermine the contract the test exists to verify.
|
||||||
|
- **Mock OpenZaak / record-replay** — rejected: that is what the existing stubbed-handler unit
|
||||||
|
tests already do; it cannot exercise the real contract, and would not have caught the chunked
|
||||||
|
body bug.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# ADR-0007: Wiring OpenZaak → Open Notificaties (NRC) for notifications
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-06-29
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-01-c (#56); completes S-01 (#2); unblocks the Event Subscriber (#7); builds on ADR-0002 (catalogus/seed) and ADR-0006 (runner-safe container harnesses)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-01 brought OpenZaak + Open Notificaties (NRC) up in compose but **deferred the
|
||||||
|
notification wiring**: OpenZaak ran with `NOTIFICATIONS_DISABLED=true` and NRC's
|
||||||
|
`setup_configuration` was empty. The walking skeleton (PRD §12) needs the upstream
|
||||||
|
event path — a zaak created in OpenZaak must publish a notification NRC fans out to
|
||||||
|
subscribers — before the Event Subscriber (#7) can consume it.
|
||||||
|
|
||||||
|
The OpenZaak↔NRC handshake is intricate and several details are non-obvious; they
|
||||||
|
were nailed down by iterating `setup_configuration` against the running stack.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Provision both sides declaratively via `setup_configuration`, authenticate with the
|
||||||
|
existing `big-reference-seed` client, and run NRC's celery-beat so deliveries happen.**
|
||||||
|
|
||||||
|
- **OpenZaak** (`infra/openzaak/setup_configuration/data.yaml`): a `zgw_consumers`
|
||||||
|
service `nrc` (api_type `nrc`, the NRC API root) plus `notifications_config` naming
|
||||||
|
it. `NOTIFICATIONS_DISABLED` is flipped to `false` **only when NRC is present** —
|
||||||
|
the full stack and the local twin set it; OpenZaak-only bring-ups (`openzaak-up`,
|
||||||
|
the ACL integration test) default it back to `true` via `OZ_NOTIFICATIONS_DISABLED`
|
||||||
|
so they don't 500 publishing to an absent NRC.
|
||||||
|
- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): the
|
||||||
|
`big-reference-seed` JWT credential (to verify OpenZaak's token), a `zgw_consumers`
|
||||||
|
`ac` service pointing at **OpenZaak's Autorisaties API**, the `autorisaties_api`
|
||||||
|
step delegating authorization to that AC, and the `zaken` kanaal. NRC's init
|
||||||
|
container switches from `migrate` to `/setup_configuration.sh`; its data.yaml is
|
||||||
|
delivered through the `rr-nrc-config` external volume by `infra/seed-config.sh`
|
||||||
|
(the same `docker cp` pattern as OpenZaak — bind mounts don't reach the CI runner's
|
||||||
|
daemon).
|
||||||
|
- **celery-beat is required.** NRC accepts a notification and writes a
|
||||||
|
`ScheduledNotification`; a periodic `execute_notifications` task (celery-beat,
|
||||||
|
every `NOTIFICATION_SEC_INTERVAL`s) drains it to the worker for delivery. The lean
|
||||||
|
S-01 stack dropped beat — so notifications were accepted but never delivered. An
|
||||||
|
`nrc-beat` service is added to every compose; the interval is lowered to 5s.
|
||||||
|
|
||||||
|
Verification is a runner-safe smoke (`infra/run-notification-check.sh`): it seeds a
|
||||||
|
published BIG zaaktype, registers an abonnement to a webhook sink, creates a zaak, and
|
||||||
|
asserts the sink receives the `zaken`/`create` notification — all from containers
|
||||||
|
**inside** the compose network (ADR-0006). Locally it runs via `make verify-notifications`
|
||||||
|
(a throwaway oz+nrc stack); in CI it runs as the `verify-nrc` step of the consolidated
|
||||||
|
`verify-stack` job (one shared full-stack bring-up — issue #58).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Positive:** the walking-skeleton event path works end to end; #7 can consume real
|
||||||
|
notifications; the wiring is declarative and reproducible from a fresh `make`.
|
||||||
|
- **Gotchas captured (see gitea-actions-gotchas.md):**
|
||||||
|
- **Single-label hosts aren't URL-valid.** OpenZaak/NRC reject `http://openzaak…`
|
||||||
|
/`http://nrc-web…` in URLs they validate (Django `URLValidator`); the verify
|
||||||
|
harness reaches services and registers the sink callback **by container IP**.
|
||||||
|
- **Abonnement callbacks must enforce auth.** NRC probes the callback during
|
||||||
|
registration and refuses it (`no-auth-on-callback-url`) unless it returns 401
|
||||||
|
without the configured `Authorization`; the sink enforces a bearer token.
|
||||||
|
- **Cost:** an extra long-running service (`nrc-beat`) per stack, and the verify job
|
||||||
|
needs egress (base images + `selectielijst.openzaak.nl`, since the published
|
||||||
|
zaaktype the check creates a zaak against depends on it — ADR-0006).
|
||||||
|
- **Dev-only credentials** reused (`big-reference-seed` / its secret) across publish,
|
||||||
|
AC lookup, and seeding — acceptable for the reference app, not production.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **NRC with its own (non-AC) authorization** — rejected: delegating to OpenZaak's
|
||||||
|
Autorisaties API is the upstream-intended model and reuses the applicatie that
|
||||||
|
already grants `heeft_alle_autorisaties`.
|
||||||
|
- **Keep beat out, deliver synchronously** — not an option: Open Notificaties 1.16
|
||||||
|
delivers via scheduled notifications drained by beat; there is no sync path.
|
||||||
|
- **A persistent abonnement in `setup_configuration`** instead of registering one in
|
||||||
|
the verify harness — deferred: the real subscriber is #7; the harness's sink
|
||||||
|
abonnement is throwaway and IP-specific.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# ADR-0008: The read projection — a shared, rebuildable store with a writer and a reader
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-06-30
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-06 (#7); builds on ADR-0001 (loose coupling), ADR-0007 (#56, OZ→NRC wiring); first EF Core usage in the repo
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-06 (#7) adds the upstream event path's destination: an **Event Subscriber** that consumes
|
||||||
|
NRC notifications and a **read projection** the openbaar register reads. The walking-skeleton
|
||||||
|
projection (PRD §8.4) holds one row per zaak — `id`, `bsn`, `naam_placeholder`, `status` —
|
||||||
|
and must be **idempotent** (NRC redelivers and reorders, CLAUDE.md §8.6) and **rebuildable**
|
||||||
|
(a derived artefact, never a write-only source of truth).
|
||||||
|
|
||||||
|
Two design questions had no obvious answer:
|
||||||
|
|
||||||
|
1. **Where does `bsn` come from?** The NRC `zaken`/`zaak`/`create` notification carries only the
|
||||||
|
zaak URL plus the fixed `kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`).
|
||||||
|
It does **not** carry the bsn. Reading it means calling a ZGW API — which **only the ACL** may
|
||||||
|
do (CLAUDE.md §8.1). The issue's "Touches" lists only `event-subscriber` + `projection-api`,
|
||||||
|
not the ACL.
|
||||||
|
2. **Who owns the projection schema?** The subscriber writes the projection; the projection-api
|
||||||
|
reads it. CLAUDE.md §8.5 says "no direct DB access across services; each service owns its
|
||||||
|
schema." Two deployables on one table looks like a violation.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**One Postgres database is the read projection. The Event Subscriber writes it (projector) and
|
||||||
|
the projection-api reads it (query); both are processes of the single "Read Projection" bounded
|
||||||
|
context and share one schema, defined in a shared `Projection.ReadModel` library. `bsn` is
|
||||||
|
deferred.**
|
||||||
|
|
||||||
|
- **Schema ownership.** The read model — `register_projection` plus the subscriber's
|
||||||
|
`processed_notifications` log — lives in `services/projection-api/Projection.ReadModel`
|
||||||
|
(EF Core + Npgsql). Both services reference it. This is the textbook CQRS read-model split
|
||||||
|
(one writer, one reader over one derived store), **not** the cross-*domain* DB reach §8.5
|
||||||
|
forbids: no domain owns write-state here; the projection is rebuildable (§8.4). §8.5 still
|
||||||
|
holds for every domain database.
|
||||||
|
- **Idempotency** is the primary key on `processed_notifications.key` (a deterministic key
|
||||||
|
derived from the immutable notification content). A duplicate insert raises a unique violation,
|
||||||
|
caught and reported as "already recorded", so the duplicate never reaches the projection. The
|
||||||
|
projection upsert is itself idempotent on the zaak id, a second line of defence.
|
||||||
|
- **Rebuild replays the log, not OpenZaak.** `POST /admin/rebuild` clears `register_projection`
|
||||||
|
and reprojects every row in `processed_notifications`. So "rebuildable" needs **no** ZGW access
|
||||||
|
(§8.1) and no ACL dependency — keeping S-06 within its stated scope.
|
||||||
|
- **`bsn` and `naam_placeholder` are deferred.** They are columns (nullable) but the minimal slice
|
||||||
|
populates only `id` + `status` (`INGEDIEND`) from the notification. Populating personal data
|
||||||
|
requires reading the zaak **through the ACL** (§8.1) and is its own follow-up; the column shape
|
||||||
|
is in place so that change is additive.
|
||||||
|
- **New dependency: EF Core 10 + `Npgsql.EntityFrameworkCore.PostgreSQL`.** What it gives us: a
|
||||||
|
migrated relational schema, LINQ queries, and a clean port implementation. What we'd write
|
||||||
|
instead: hand-rolled SQL + a migration runner. Risk: ORM complexity and an extra dependency
|
||||||
|
graph — bounded here to a tiny two-table read model. `dotnet-ef` is pinned as a local tool for
|
||||||
|
migrations; `NuGetAuditMode=direct` keeps EF's design-time-only tooling transitive out of the
|
||||||
|
audited, shipped graph.
|
||||||
|
|
||||||
|
The end-to-end path is verified by a runner-safe live-stack smoke (`infra/run-projection-check.sh`,
|
||||||
|
the `verify-projection` step of the `verify-stack` job, #58): register an abonnement at the real
|
||||||
|
Event Subscriber's callback, create a zaak, assert projection-api serves an `INGEDIEND` row — all
|
||||||
|
in-network, reaching services by container IP (ADR-0006/0007).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Positive:** the upstream event path reaches a queryable projection; idempotent and rebuildable
|
||||||
|
without OpenZaak; S-06 stays inside its stated touch-set (no ACL change); the projection-api is
|
||||||
|
ready for S-09 to tighten public-safe field filtering.
|
||||||
|
- **Negative / deferred:**
|
||||||
|
- `bsn`/`naam_placeholder` stay empty until a follow-up wires zaak reads via the ACL.
|
||||||
|
- The abonnement is registered by the verify harness (by container IP), not provisioned
|
||||||
|
persistently — ADR-0007 already deferred a persistent abonnement, and a single-label service
|
||||||
|
host is not URL-valid for NRC, so persistent registration needs a dotted network alias. Tracked
|
||||||
|
as a follow-up; a plain `make up` therefore needs the abonnement registered before the event
|
||||||
|
path flows.
|
||||||
|
- Two services share one database. Acceptable for a derived read model; revisit if the read and
|
||||||
|
write sides ever need independent scaling or storage.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Subscriber reads OpenZaak directly to fill `bsn`** — rejected: breaks §8.1 (only the ACL talks
|
||||||
|
to ZGW) and would need its own ADR to bend the rule.
|
||||||
|
- **Extend the ACL with a zaak-read operation, consumed as a library** — viable and §8.1-clean, but
|
||||||
|
it grows S-06 beyond its stated scope (touches the ACL) and pulls personal-data handling forward;
|
||||||
|
deferred to a follow-up.
|
||||||
|
- **projection-api owns the DB and exposes an internal write endpoint the subscriber calls** —
|
||||||
|
rejected for the walking skeleton: adds an HTTP hop and a write surface on a read service for no
|
||||||
|
current benefit over a shared, rebuildable read model.
|
||||||
|
- **Separate databases for the log and the projection** — rejected as premature: both are the read
|
||||||
|
side's private, rebuildable state; one DB is simpler and still honours §8.5's intent.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# ADR-0009: The Domain Service drives Flowable as an external-task job worker
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-06-30
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-05 (#6); proposal #60; builds on ADR-0001 (loose coupling, §8.1/§8.2), S-03 (#4, the `registratie` BPMN), S-04 (#5, the ACL `OpenZaak` operation)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-05 (#6) adds the **BIG Domain Service**. Submitting a registration must: create a
|
||||||
|
`Registration` aggregate, **start the Flowable `registratie` process** (S-03), have the
|
||||||
|
`OpenZaakAanmaken` task **open a zaak via the ACL** (S-04), and store the resulting zaak URL
|
||||||
|
back on the aggregate.
|
||||||
|
|
||||||
|
`OpenZaakAanmaken` is a Flowable **external-worker** service task (`flowable:type="external-worker"`,
|
||||||
|
topic `OpenZaakAanmaken`). Flowable does not push it anywhere — it parks the job and waits for a
|
||||||
|
worker to **acquire and lock** it, do the work, and **complete** it. Two coupling rules constrain
|
||||||
|
who may do what:
|
||||||
|
|
||||||
|
- **§8.2 — the Workflow Client is the only code that talks to Flowable.** BPMN models never embed
|
||||||
|
OpenZaak knowledge; they ask the Workflow Client to execute external tasks.
|
||||||
|
- **§8.1 — the ACL is the only code that talks to ZGW.** The worker opens the zaak *through the ACL*,
|
||||||
|
never by constructing ZGW URLs itself.
|
||||||
|
|
||||||
|
This is an ADR-worthy moment (§14): a service boundary is defined and both coupling rules are
|
||||||
|
exercised. The open question is *how* the external task is driven.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The Domain Service drives the `OpenZaakAanmaken` task as a hosted external-task job worker
|
||||||
|
(PRD §36). Orchestration is eventually consistent, not request-synchronous.**
|
||||||
|
|
||||||
|
- **`POST /registrations` is fast and side-effecting only on the domain side.** It creates the
|
||||||
|
`Registration` aggregate in state `INGEDIEND`, persists it, and asks the Workflow Client to start
|
||||||
|
one `registratie` process instance, recording the process-instance id on the aggregate. It returns
|
||||||
|
immediately; it does **not** wait for the zaak to be opened.
|
||||||
|
- **A hosted worker polls Flowable for `OpenZaakAanmaken` jobs.** It acquires and locks a job, calls
|
||||||
|
the ACL `OpenZaak` operation (§8.1), attaches the returned zaak URL to the matching aggregate
|
||||||
|
(`Registration.AttachZaak`), and completes the job in Flowable. The process then runs to its end
|
||||||
|
event.
|
||||||
|
- **The Workflow Client is the only Flowable client (§8.2).** It lives in the Domain Service's
|
||||||
|
`Infrastructure` layer and speaks Flowable's REST API (start process-instance; acquire/lock/complete
|
||||||
|
external-worker jobs). No other code — not the Application layer, not the BPMN — knows Flowable
|
||||||
|
exists.
|
||||||
|
- **The worker *logic* is an Application service over ports**, not Flowable-aware code. `OpenZaakWorker`
|
||||||
|
takes an acquired job (topic + the registration id it carries), calls `IAclClient` and
|
||||||
|
`IRegistrationStore`, and returns the zaak URL to complete with. The **polling loop** is a thin
|
||||||
|
`BackgroundService` in `Infrastructure` that fetches jobs via the Workflow Client and feeds them to
|
||||||
|
the worker. So the orchestration is covered by fast unit tests against fakes; only the REST framing
|
||||||
|
needs a container integration test.
|
||||||
|
|
||||||
|
## Scope decisions for the minimal slice
|
||||||
|
|
||||||
|
- **Registration persistence is in-memory.** The walking skeleton's *read* path is fed by
|
||||||
|
NRC → Event Subscriber → projection (S-06, #7), not by the domain database. An EF-backed domain
|
||||||
|
store buys nothing the demo needs yet, so it is a documented follow-up; the `IRegistrationStore`
|
||||||
|
port keeps that change additive. (PRD §88 envisions EF Core for the domain DB eventually.)
|
||||||
|
- **The aggregate's state machine is minimal:** `INGEDIEND` on submission. Later flows (withdrawal,
|
||||||
|
beoordeling, herregistratie) add states in their own slices — they are out of scope here.
|
||||||
|
- **No bsn flows to ZGW yet.** The ACL `OpenZaak` operation already default-fills the ZGW-mandatory
|
||||||
|
fields (ADR-0003) and takes the bsn as its domain payload; the domain hands it through unchanged.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Positive:** the submit request is decoupled from ACL/OpenZaak latency; the documented Common
|
||||||
|
Ground pattern (external-task worker) is realised; both coupling rules (§8.1, §8.2) hold with the
|
||||||
|
Flowable knowledge isolated to one Infrastructure class; the orchestration is unit-testable.
|
||||||
|
- **Negative / deferred:**
|
||||||
|
- Eventual consistency: immediately after `POST /registrations` the aggregate has no zaak URL yet.
|
||||||
|
Acceptable — the read side is the projection, not the domain store.
|
||||||
|
- In-memory registration state is lost on restart; fine for the skeleton, replaced by an EF store
|
||||||
|
in a follow-up.
|
||||||
|
- The worker polls (no push); poll interval is a tuning knob, not a correctness concern, since
|
||||||
|
Flowable holds the job until completed.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Synchronous acquire+complete inside the `POST /registrations` request** — rejected: simpler and
|
||||||
|
deterministic, but couples the submit request to ACL/OpenZaak latency and failure, and is not the
|
||||||
|
external-task worker pattern PRD §36 mandates. It would also make the request fail if OpenZaak is
|
||||||
|
briefly down, instead of the job simply staying parked for the worker to retry.
|
||||||
|
- **A standalone Workflow Client service, separate from the Domain Service** — rejected for this
|
||||||
|
slice: the worker needs the domain's aggregate store and the ACL client anyway, and PRD §9 places
|
||||||
|
the Workflow Client inside the Domain Service deployment. A separate process adds a hop and a
|
||||||
|
shared store for no current benefit.
|
||||||
|
- **Flowable pushes to a webhook instead of being polled** — rejected: Flowable's external-worker
|
||||||
|
model is pull-based (acquire/lock/complete); a push shim would re-implement it with weaker
|
||||||
|
delivery guarantees.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# ADR-0010: The BFF validates Keycloak tokens and is the portals' only backend
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-01
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-07 (#8); proposal #63; builds on ADR-0001 (loose coupling, §8.3), S-02 (#3, Keycloak realms), S-05 (#6, Domain Service), S-06 (#7, read projection)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-07 (#8) adds the **BFF (Backend-for-Frontend)** — the single backend the Angular portals talk
|
||||||
|
to (CLAUDE.md §8.3). For the walking skeleton it exposes two endpoints and fans out to services
|
||||||
|
already built:
|
||||||
|
|
||||||
|
- `POST /self-service/registrations` → Domain Service `POST /registrations` (S-05).
|
||||||
|
- `GET /openbaar/register?q=…` → projection-api `GET /register` (S-06).
|
||||||
|
|
||||||
|
It must validate tokens issued by Keycloak (S-02). This is an ADR-worthy moment (§14): a new
|
||||||
|
dependency (JWT bearer authentication) and two new service boundaries (BFF→domain, BFF→projection).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The BFF is the portals' only backend; it validates Keycloak `digid`-realm JWTs on the
|
||||||
|
self-service endpoint, leaves the openbaar lookup anonymous, and fans out to the domain and
|
||||||
|
projection over typed HTTP clients.**
|
||||||
|
|
||||||
|
- **Auth model.** `POST /self-service/registrations` requires a valid `digid`-realm bearer token;
|
||||||
|
the BFF reads the `bsn` claim and forwards it to the domain. Missing / invalid / expired token →
|
||||||
|
**401**. `GET /openbaar/register` is **anonymous** — the openbaar register is a public lookup
|
||||||
|
(S-09), so no token is required.
|
||||||
|
- **Portals talk only to the BFF (§8.3).** They never call the Domain Service, ACL, projection, or
|
||||||
|
OpenZaak directly. The BFF orchestrates via typed `HttpClient`s whose base URLs come from config.
|
||||||
|
Downstream calls are unauthenticated on the internal network for the walking skeleton; a
|
||||||
|
service-to-service auth story (e.g. client-credentials) is a later slice, not this one.
|
||||||
|
- **Validation is `Microsoft.AspNetCore.Authentication.JwtBearer`** pointed at the Keycloak `digid`
|
||||||
|
realm authority. **New dependency justification:** it gives us standards-based OIDC/JWT validation
|
||||||
|
(signature, issuer, expiry, audience) maintained by the framework; rolling our own JWT validation
|
||||||
|
would be error-prone security code; the risk is a first-party ASP.NET Core package — minimal.
|
||||||
|
- **Tests mint their own tokens.** `WebApplicationFactory` tests override the bearer options with a
|
||||||
|
**test signing key**, so valid / invalid / expired tokens are minted in-process without a live
|
||||||
|
Keycloak. Real Keycloak validation is exercised by a live-stack `verify-bff` check.
|
||||||
|
- **OpenAPI is generated and committed** (`services/bff/openapi.json`) from .NET's built-in OpenAPI,
|
||||||
|
so S-08's Angular client is generated from the spec, never hand-written (§10).
|
||||||
|
|
||||||
|
## Known wrinkle — container OIDC issuer mismatch
|
||||||
|
|
||||||
|
Keycloak stamps tokens with an `iss` equal to its **browser-facing** URL (what the portal used to
|
||||||
|
log in), which differs from the BFF's **in-container** authority (`http://keycloak:8080/realms/digid`).
|
||||||
|
Strict issuer validation then rejects otherwise-valid tokens. Unit tests avoid this (test key).
|
||||||
|
`verify-bff` handles it by aligning the configured authority/issuer with the token's `iss` (and, if
|
||||||
|
needed, disabling metadata address rewriting). Recorded so it is not rediscovered each time.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Positive:** the walking skeleton gains its front door; §8.3 holds with all portal traffic going
|
||||||
|
through one backend; token validation is standard and testable without infra; the committed
|
||||||
|
OpenAPI unblocks S-08.
|
||||||
|
- **Negative / deferred:**
|
||||||
|
- Downstream service-to-service auth is deferred (internal-network trust for now).
|
||||||
|
- The openbaar endpoint is anonymous; when public-safe field filtering tightens (S-09) it stays
|
||||||
|
anonymous but the projection query narrows.
|
||||||
|
- The issuer-mismatch handling is dev-oriented; a production reverse-proxy setup would align the
|
||||||
|
browser and internal issuer URLs instead.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Token-gate the openbaar endpoint too** — rejected: the openbaar register is public by design
|
||||||
|
(S-09); requiring a login would contradict the slice's intent.
|
||||||
|
- **Validate tokens by calling Keycloak's introspection endpoint per request** — rejected: adds a
|
||||||
|
network hop per call and a Keycloak dependency on the hot path; local JWT signature validation via
|
||||||
|
the realm's JWKS is the standard, faster choice.
|
||||||
|
- **Hand-written JWT parsing** — rejected: security-sensitive code we shouldn't own when a
|
||||||
|
first-party validator exists.
|
||||||
|
- **Generate the OpenAPI client by hand / keep the spec uncommitted** — rejected: §10 requires a
|
||||||
|
generated client from a committed spec.
|
||||||
@@ -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".
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user