docs: archive the finished backlogs (RD-30)
Two backlog trees are complete: `docs/project/backlog/` (75 files, every WP done) and `docs/project/refactor-backlog-setup/` (the arc before it). Move both under `docs/project/archive/` with `git mv`, so history stays intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them, because it points at the now-archived backlog README. Add `docs/project/archive/README.md`. It states that these trees are historical and names the two directories that are still live. Repoint every inbound reference named in RD-30's Files table: CLAUDE.md, the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the `document-feature` and `new-ssp` skills, and the readable-codebase PLAN, README, and RD-19 ticket. Fix two upward-relative links inside the moved WP files (WP-68, WP-69) that gained a directory level and would otherwise break. Repoint `.prettierignore`'s two agent-prompt exclusions to their new path, so prettier keeps leaving those files' exact wording alone. Mark RD-30 done and check off its acceptance criteria; flip its README row to done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
# Backlog — showcase hardening
|
||||
|
||||
Ordered work packages that take this POC from "good" to **reference showcase**: CIBG
|
||||
design-system fidelity, DDD/FP consistency, Storybook as curriculum, and WCAG compliance
|
||||
with automated gates. Source: the architecture/CIBG/a11y audit of 2026-07-02 (plan:
|
||||
"Showcase hardening").
|
||||
|
||||
This backlog **supersedes `docs/project/SHOWCASE-ROADMAP.md`**.
|
||||
|
||||
## Session protocol
|
||||
|
||||
- **One WP per session.** Read `CLAUDE.md`, this README, the WP file, and the WP's
|
||||
"Read first" list — then execute. Do not start the next WP in the same session.
|
||||
- **Match the model to the step, not the whole session** (see CLAUDE.md's "Model routing
|
||||
for agent delegation"). Read the WP's Decisions block with the `planner` agent (Opus) if
|
||||
the current session isn't already Opus — the approach and edge cases deserve the
|
||||
stronger model. Implement directly if already on Sonnet, or hand off to the `developer`
|
||||
agent otherwise, once the plan is approved. Delegate simple, read-only CLI checks
|
||||
(running a test suite, `grep`/`git status`, verifying a file exists) to the `task-runner`
|
||||
agent (Haiku) — proportion cost to the step's difficulty, not the WP's.
|
||||
- The **Decisions** block in each WP is pre-made — don't relitigate it.
|
||||
- A WP ends **GREEN** (below) with its acceptance criteria checked off and its Status
|
||||
updated to `done` (+ commit hash).
|
||||
- No WP leaves a lint rule/check disabled without an inline justification comment **and**
|
||||
a cross-reference to the WP that will remove it.
|
||||
|
||||
## GREEN (global definition of done)
|
||||
|
||||
```bash
|
||||
npm run lint && npm run check:tokens && npm test && npm run build && npm run build-storybook
|
||||
```
|
||||
|
||||
From WP-01 onward, additionally:
|
||||
|
||||
```bash
|
||||
npm run test-storybook:ci
|
||||
```
|
||||
|
||||
Phases 0–5 were frontend-only; **phase 6 (Brief v2) touches `backend/`** — for those
|
||||
WPs `cd backend && dotnet test` is part of GREEN, and any wire change ends with
|
||||
`npm run gen:api` leaving no drift.
|
||||
|
||||
From WP-19 onward, `npm run e2e` is part of CI (its own job) but NOT part of the local
|
||||
GREEN one-liner above — it needs the real backend + `npm start` already running (see
|
||||
WP-19's own file), so it's a separate manual/CI step, not chained into the others.
|
||||
|
||||
## Order
|
||||
|
||||
Gates land before the work they cover; each lint rule lands in the same WP as the fixes
|
||||
for its existing violations, so every WP ends green.
|
||||
|
||||
| WP | Title | Phase | Status |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------- | ------ |
|
||||
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
|
||||
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
|
||||
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
|
||||
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
|
||||
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
|
||||
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
|
||||
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
|
||||
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
|
||||
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
|
||||
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
|
||||
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
|
||||
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
|
||||
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
|
||||
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
|
||||
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
|
||||
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
|
||||
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
|
||||
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
|
||||
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
|
||||
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
|
||||
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
|
||||
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
|
||||
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
|
||||
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
|
||||
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done |
|
||||
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
|
||||
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done |
|
||||
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | done |
|
||||
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
|
||||
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | done |
|
||||
| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done |
|
||||
| WP-32 | Undo/redo in the stamdata editor (folded into WP-31 — no separate file) | 7 · refinements | done |
|
||||
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done |
|
||||
| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done |
|
||||
| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done |
|
||||
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done |
|
||||
| [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done |
|
||||
| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done |
|
||||
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done |
|
||||
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done |
|
||||
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done |
|
||||
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done |
|
||||
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine (plop; ui-component/bff = skills) | 8 · platform/DX/showcase | done |
|
||||
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | done |
|
||||
| [WP-45](WP-45-create-frontend-generator.md) | `create-frontend` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | done |
|
||||
| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
|
||||
| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done |
|
||||
| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done |
|
||||
| [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | done |
|
||||
| [WP-55](WP-55-openzaak-secrets-tls.md) | Real secrets + TLS for the OpenZaak harness | 10 · OpenZaak hardening | done |
|
||||
| [WP-56](WP-56-openzaak-catalogus-provisioning.md) | Idempotent catalogus provisioning | 10 · OpenZaak hardening | done |
|
||||
| [WP-57](WP-57-openzaak-least-privilege-scopes.md) | Least-privilege client scopes | 10 · OpenZaak hardening | done |
|
||||
| [WP-58](WP-58-openzaak-notifications.md) | Real notifications (celery + scripted abonnement) | 10 · OpenZaak hardening | done |
|
||||
| [WP-59](WP-59-document-confidentialiteit-config.md) | Per-document-type confidentialiteit config | 10 · OpenZaak hardening | done |
|
||||
| [WP-60](WP-60-write-divergence-resilience.md) | Write-divergence resilience (local + ZGW writes) | 10 · OpenZaak hardening | done |
|
||||
| [WP-61](WP-61-behandelportal-bootstrap.md) | Bootstrap the behandelportal app | 11 · Behandelportal | done |
|
||||
| [WP-62](WP-62-medewerker-identity-authz.md) | Backend: medewerker caller identity + authz seam | 11 · Behandelportal | done |
|
||||
| [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | done |
|
||||
| [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) screen | 11 · Behandelportal | done |
|
||||
| [WP-65](WP-65-behandelportal-beoordeling.md) | Behandelportal: zaak detail + beoordeling (decision) screen | 11 · Behandelportal | done |
|
||||
| [WP-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | done |
|
||||
| [WP-67](WP-67-monorepo-behandelportal.md) | Merge behandelportal into this repo as a monorepo | 11 · Behandelportal | done |
|
||||
| [WP-68](WP-68-ddd-aggregate-hardening.md) | Aggregate invariants + status modelling (architecture review) | 12 · DDD hardening | done |
|
||||
| [WP-69](WP-69-intake-scholing-threshold-enforcement.md) | Enforce the scholing threshold server-side | 12 · DDD hardening | done |
|
||||
| [WP-70](WP-70-test-data-builders.md) | Test-data builders: illegal fixtures unrepresentable (ADR-0006) | 12 · DDD hardening | done |
|
||||
| [WP-71](WP-71-test-framework-coherence.md) | Test framework coherence: BDD/DDD alignment, close the escape hatches | 12 · DDD hardening | done |
|
||||
| [WP-72](WP-72-delete-legacy-submit-endpoints.md) | Delete the dead legacy submit endpoints | 12 · DDD hardening | done |
|
||||
| [WP-73](WP-73-domain-unions.md) | `RegistrationStatus` and `Aanvraag` as closed unions | 12 · DDD hardening | done |
|
||||
| [WP-74](WP-74-e2e-isolation.md) | E2E isolation without a new backend endpoint | 12 · DDD hardening | done |
|
||||
| [WP-75](WP-75-fe-be-seam-closure.md) | Close the remaining FE/BE seams | 12 · DDD hardening | done |
|
||||
|
||||
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
|
||||
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
|
||||
`<app-async>` before brief adopts it); 13 defines the gap-marker format that 11/12 reference
|
||||
— if 11/12 run first, they define it and 13 adopts it. 18–22 (phase 5, "productie-volwassenheid")
|
||||
are independent of each other and of phases 1–4 — pick any order; **18 is the recommended
|
||||
first pick** (it's the headline gap: no authorization spine exists yet, and it closes the
|
||||
FE-computed-authz anti-pattern in `brief.store.ts`). 22 is explicitly lower priority — the
|
||||
current in-memory persistence is a documented, defensible POC choice, not a bug.
|
||||
Phase 6 (Brief v2, the "Brief opstellen v2" PRD) is strictly ordered
|
||||
23 → 24 → 25 → 26 → 27 → 28: 24 needs 23's `orgTemplate` on the wire, 25 needs 24's
|
||||
`letter.css` contract, 26 needs 23's endpoints + 24's canvas, 27/28 polish on top.
|
||||
Phase 7 (refinements, WP-31..36): only 31 → 32 is ordered (32 reuses the `createHistory`
|
||||
helper 31 extracts); 33–36 are independent — pick any order.
|
||||
Phase 8 (platform/DX/showcase, WP-37..46): goal is BOTH a teaching showcase AND a production
|
||||
starter template. Priority: WP-37 (P0 quick fix) → WP-46 + WP-38 + WP-39 (P1, parallel) →
|
||||
WP-40 (P2) → WP-43 (P3) → WP-41 → WP-42 → WP-44 → WP-45 (P4). Ordering constraints:
|
||||
41 needs 40's PII kernel; 42 needs 40+41; 44 (`gen:context`) needs 38 (declarative boundaries
|
||||
make the generator simple) + 43; 45 (`create-frontend`) needs 43+44. 37/38/39/40/43/46 are otherwise
|
||||
independent. Two open tool forks, decided as step 1 of their WP: 38 dependency-cruiser vs Sheriff;
|
||||
43 plop vs Angular schematics.
|
||||
Phase 9 (OpenZaak/ZGW integration, WP-49..52) is strictly ordered 49 → 50 → 51 → 52: 49 lands
|
||||
the source seam + ZGW client the rest reuse; 50 (create) needs a zaak to exist; 51 (documents)
|
||||
links to 50's zaak; 52 (notificaties) reacts to changes on the zaken 49–51 manage. All slices
|
||||
stay behind `Zgw:Enabled` (default off) so the POC keeps running offline.
|
||||
Two cross-cutting WPs sit alongside that read→write arc: **53** (a real per-request identity
|
||||
seam + citizen-scoping — replaces the stubbed owner/BSN; production-blocking for a real
|
||||
deployment of 49–52) and **54** (a docker OpenZaak harness + opt-in integration test — makes
|
||||
50/51/52 developable against a live instance instead of only fixtures). 53 is independent of the
|
||||
CRUD arc and can land any time; 54 depends on 49 (something to read) and unlocks realistic
|
||||
testing for the rest. Both are self-contained (each WP file carries its own current-state
|
||||
handoff) and sized for a fresh Sonnet session.
|
||||
Phase 10 (OpenZaak production hardening, WP-55..60 — now **done**) and Phase 11
|
||||
(Behandelportal, WP-61..66) are two independent tracks that can be worked concurrently —
|
||||
neither blocks the other. Within phase 10: 55/59/60 were fully independent; 57 and 58 both
|
||||
built on 56's provisioning mechanism, otherwise independent of each other. Within phase 11: 61
|
||||
(bootstrap), 62 (backend medewerker identity), and 63 (backend status DTO) are
|
||||
independent of each other and can land in any order; 64 needs all three (61 for the app
|
||||
to exist, 62 for identity, 63 for the status it reads); 65 needs 64; 66 needs 65 and — now
|
||||
that WP-60 has landed (bounded retry + flagged divergence in `ZgwHttpClient`/`Program.cs`) —
|
||||
inherits that retry for free, but must call `RecordZgwDivergence` on its own besluit write path
|
||||
to get the flagging half too.
|
||||
|
||||
## WP template
|
||||
|
||||
```markdown
|
||||
# WP-NN — Title
|
||||
|
||||
Status: todo | in-progress | done (<commit>)
|
||||
Phase: N — name
|
||||
|
||||
## Why
|
||||
|
||||
## Read first
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
## Files
|
||||
|
||||
## Steps
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
## Verification
|
||||
|
||||
## Out of scope
|
||||
|
||||
## Risks
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
# WP-01 — Axe-on-every-story CI gate
|
||||
|
||||
Status: done (97f7de4)
|
||||
Phase: 0 — enforcement & gates
|
||||
|
||||
## Why
|
||||
|
||||
The Storybook a11y addon (`@storybook/addon-a11y`, configured in `.storybook/preview.ts`
|
||||
for `wcag2a, wcag2aa, wcag21a, wcag21aa`) only surfaces violations _interactively_.
|
||||
Nothing gates CI. This WP turns "a panel you can look at" into "a check that fails the
|
||||
build", so every story added or changed by later WPs is automatically covered.
|
||||
|
||||
## Read first
|
||||
|
||||
- `.storybook/main.ts`, `.storybook/preview.ts` (existing a11y config — reuse its tags)
|
||||
- `.github/workflows/ci.yml` (job layout to extend)
|
||||
- `package.json` (scripts; note `.npmrc` has `legacy-peer-deps=true`)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Mechanism: **`@storybook/test-runner` + `axe-playwright`** against the static build.
|
||||
The repo uses `@storybook/angular` on the **webpack** builder, which rules out
|
||||
`@storybook/addon-vitest` (Vite-only). Fallback if test-runner doesn't support
|
||||
Storybook 10 yet: a hand-rolled Playwright + `axe-core` loop over `storybook-static`
|
||||
(same gate semantics — document the swap in this file if taken).
|
||||
- Escape hatch: `parameters: { a11y: { disable: true } }` per story, allowed **only** with
|
||||
an inline justification comment and a cross-reference to the WP that will fix it
|
||||
(WP-16/WP-17).
|
||||
|
||||
## Files
|
||||
|
||||
- `package.json` (devDependencies + scripts)
|
||||
- `.storybook/test-runner.ts` (new)
|
||||
- `.github/workflows/ci.yml` (new job)
|
||||
- Possibly a handful of story/component files for triaged violations
|
||||
|
||||
## Steps
|
||||
|
||||
1. Check version fit first: `npm view @storybook/test-runner peerDependencies` and pick
|
||||
the release matching the installed Storybook 10 (`legacy-peer-deps` absorbs minor lag).
|
||||
2. `npm i -D @storybook/test-runner axe-playwright http-server wait-on concurrently`
|
||||
(+ `npx playwright install chromium` locally).
|
||||
3. `.storybook/test-runner.ts`:
|
||||
- `preVisit`: `injectAxe(page)`.
|
||||
- `postVisit`: `getStoryContext(page, context)`; skip when
|
||||
`parameters.a11y?.disable === true`; else `checkA11y(page, '#storybook-root', …)`,
|
||||
honoring `parameters.a11y.config`/`options.runOnly` from `preview.ts` (don't
|
||||
duplicate the tag list — read it from the story context).
|
||||
4. Scripts:
|
||||
- `"test-storybook": "test-storybook"`
|
||||
- `"test-storybook:ci": "concurrently -k -s first -n sb,axe \"http-server storybook-static -p 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\""`
|
||||
5. CI job `storybook-a11y` in `ci.yml`: checkout → setup-node (same version as the
|
||||
`frontend` job) + npm cache → `npm ci` → `npx playwright install --with-deps chromium`
|
||||
→ `npm run build-storybook` → `npm run test-storybook:ci`.
|
||||
6. Run locally against a fresh `build-storybook`; triage violations: fix trivial ones
|
||||
(labels, roles, contrast via `--rhc-*` tokens); anything structural gets the escape
|
||||
hatch + comment + WP cross-ref.
|
||||
7. Update `docs/project/backlog/README.md`: GREEN now includes `npm run test-storybook:ci`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `npm run test-storybook:ci` passes locally against a fresh `storybook-static/`.
|
||||
- [x] CI has a `storybook-a11y` job; an axe violation makes it fail (verified once with a
|
||||
deliberately broken story, then reverted).
|
||||
- [x] Every `a11y.disable` skip carries a justification comment + WP cross-ref.
|
||||
- [x] Backlog README GREEN updated.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Fixing structural a11y issues in components (WP-16/WP-17); Playwright e2e page sweeps
|
||||
(explicitly not chosen).
|
||||
|
||||
## Risks
|
||||
|
||||
- test-runner ↔ Storybook 10 peer mismatch → use the documented fallback.
|
||||
- Unknown violation count on first run → the escape hatch keeps the WP bounded.
|
||||
@@ -0,0 +1,67 @@
|
||||
# WP-02 — Harden `check:tokens` + fix what it then catches
|
||||
|
||||
Status: done (88442b0)
|
||||
Phase: 0 — enforcement & gates
|
||||
|
||||
## Why
|
||||
|
||||
The token guard (`check:tokens` in `package.json`) only greps for hex colors and only
|
||||
scans `registratie/ui`, `shared/ui`, `shared/layout`. It misses `rgb()`/`hsl()` values
|
||||
and skips `brief/`, `auth/`, `herregistratie/`, `showcase/` entirely. The guard must
|
||||
cover the whole app before the CIBG work (WP-10…13) leans on it.
|
||||
|
||||
## Read first
|
||||
|
||||
- `package.json` (`check:tokens` script)
|
||||
- `src/styles.scss` (the token bridge — the vocabulary fixes must come from)
|
||||
- `CLAUDE.md` theming section
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Raw `px` values are **not** grepped (too many false positives); border-width fixes are
|
||||
done manually in this WP and the script documents why px is out of scope.
|
||||
- A `/* token-ok */` inline marker suppresses a justified false positive (e.g. `rgb` in a
|
||||
comment or data-URI) — each use needs a reason in the comment.
|
||||
|
||||
## Files
|
||||
|
||||
- `package.json` → move logic to `scripts/check-tokens.sh` (new), keep the npm script name
|
||||
- `src/app/shared/layout/site-footer/site-footer.component.ts` — `rgb(255 255 255 / 0.25)`
|
||||
- `src/app/shared/ui/debug-state/debug-state.component.ts` — raw rgba/hex; tokenize and
|
||||
drop its exclusion (it's dev-only chrome but the rule should have no holes)
|
||||
- `src/app/brief/ui/letter-block/letter-block.component.ts` — raw `3px` border
|
||||
- `src/app/brief/ui/letter-preview/letter-preview.component.ts` — raw `1px` border
|
||||
- `src/app/brief/ui/passage-picker/passage-picker.component.ts` — raw `1px` border
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create `scripts/check-tokens.sh`; `package.json` `check:tokens` calls it.
|
||||
2. Extend the regex to `#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(` (skip lines containing
|
||||
`token-ok`).
|
||||
3. Extend the scanned set to **all** `src/app/**/*.component.ts`.
|
||||
4. Run it; fix every hit using tokens from the `src/styles.scss` bridge (add a bridge
|
||||
token only if no existing one fits — keep the vocabulary small).
|
||||
5. Fix the raw border widths in the three brief components (`--rhc-border-width-*`),
|
||||
even though px isn't grepped.
|
||||
6. Plant a violation, confirm the script fails, remove it.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Script scans all contexts and matches hex + rgb()/hsl().
|
||||
- [x] Zero exclusions; any `token-ok` marker has a reason. (No `token-ok` markers were needed.)
|
||||
- [x] site-footer, debug-state, and the three brief components are tokenized.
|
||||
- [x] A planted violation provably fails `npm run check:tokens`.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Grepping px/rem values; scss files (`styles.scss` is the one place palette values are
|
||||
allowed — it IS the bridge).
|
||||
|
||||
## Risks
|
||||
|
||||
Regex false positives in strings/URLs — that's what the `token-ok` marker is for; keep
|
||||
its bar high.
|
||||
@@ -0,0 +1,79 @@
|
||||
# WP-03 — Boundaries I: contracts purity + ApiClient confinement
|
||||
|
||||
Status: done (f9b76e7)
|
||||
Phase: 0 — enforcement & gates
|
||||
|
||||
## Why
|
||||
|
||||
`contracts/` is the FE⇄BE wire seam and must import **nothing** (CLAUDE.md §1,
|
||||
ADR-0001) — but `registratie/contracts/dashboard-view.dto.ts` imports domain types, and
|
||||
`change-request-form.component.ts` injects `ApiClient` straight into UI. Neither rule is
|
||||
lint-enforced. Rule + fixes land together so this WP ends green.
|
||||
|
||||
## Read first
|
||||
|
||||
- `CLAUDE.md` §1 + §4 (layers, BFF-lite)
|
||||
- `eslint.config.mjs` (existing zone style to extend)
|
||||
- `src/app/registratie/contracts/dashboard-view.dto.ts`
|
||||
- `src/app/registratie/infrastructure/dashboard-view.adapter.ts`
|
||||
- `src/app/registratie/ui/change-request-form/change-request-form.component.ts` (~line 53)
|
||||
- `src/app/registratie/application/submit-change-request.ts`
|
||||
- `src/app/shared/infrastructure/role.ts` and `src/app/brief/ui/letter-composer/letter-composer.component.ts` (line 2)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Wire DTOs express enums as plain string-literal unions **inlined in the DTO file** —
|
||||
they describe the wire, not the domain. The adapter's `parse*` maps them to domain types.
|
||||
- The `Role` **type** moves out of infrastructure (to `shared/domain` or
|
||||
`shared/application` — pick where its current consumers point most naturally); the
|
||||
role-reading mechanism (`role.interceptor.ts` etc.) stays infrastructure.
|
||||
|
||||
## Files
|
||||
|
||||
- `eslint.config.mjs` — two new restrictions
|
||||
- `src/app/registratie/contracts/dashboard-view.dto.ts` — remove domain imports
|
||||
- `src/app/registratie/infrastructure/dashboard-view.adapter.ts` — absorb the mapping
|
||||
- `src/app/registratie/ui/change-request-form/change-request-form.component.ts` — stop
|
||||
injecting `ApiClient`; the component calls the application command, which owns the
|
||||
client dependency (move the `inject` into `submit-change-request.ts` or a thin
|
||||
registratie adapter, matching how other commands get the client)
|
||||
- `src/app/shared/infrastructure/role.ts` + new home for the `Role` type + the
|
||||
`letter-composer.component.ts` / `brief.store.ts` import sites
|
||||
|
||||
## Steps
|
||||
|
||||
1. Rewrite `dashboard-view.dto.ts` as pure wire shapes (no imports at all); update the
|
||||
adapter's `parse*`/`toDomain` to map wire → domain; run the adapter spec.
|
||||
2. Move the `Role` type; update import sites (mechanical; lint will find them).
|
||||
3. Refactor change-request-form: UI keeps dispatching to the machine; the submit command
|
||||
receives/owns the `ApiClient` (pattern: other `submit-*.ts` commands).
|
||||
4. Add eslint zones:
|
||||
- `src/app/**/contracts/**` may import nothing from `@angular/*`, any `@*` context
|
||||
alias, or relative app code.
|
||||
- `@shared/infrastructure/api-client` importable only from `**/infrastructure/**`
|
||||
(+ the app config/provider and the generated file itself).
|
||||
5. Lint the repo; there must be zero new disables.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `dashboard-view.dto.ts` has no import statements.
|
||||
- [x] No value import of `@shared/infrastructure/api-client` outside `infrastructure/`
|
||||
(the only remaining non-infra reference is draft-sync's type-only DTO import,
|
||||
allowed by `allowTypeImports`). ApiClient confinement is lint-enforced.
|
||||
- [x] Both eslint rules active; planted violations fail lint (contracts→domain,
|
||||
UI→ApiClient value); type-only DTO import still passes.
|
||||
- [x] Change-request flow works — verified end-to-end in the running app (submit →
|
||||
command → adapter → backend → reference `BIG-2026-…`, success alert); specs green.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci`. Manual smoke: `npm start` → Gegevens wijzigen →
|
||||
submit a change request.
|
||||
|
||||
## Out of scope
|
||||
|
||||
The `ui ↛ infrastructure` rule for adapter injections in wizards — that's WP-04.
|
||||
|
||||
## Risks
|
||||
|
||||
Role-type move ripples through brief imports — mechanical; run tests before/after.
|
||||
@@ -0,0 +1,77 @@
|
||||
# WP-04 — Boundaries II: `ui ↛ infrastructure` + showcase sanction
|
||||
|
||||
Status: done (035e785)
|
||||
Phase: 0 — enforcement & gates
|
||||
|
||||
## Why
|
||||
|
||||
The documented rule `ui → application → domain` (CLAUDE.md §1) is not lint-enforced, and
|
||||
three components inject infrastructure adapters directly. This is the biggest refactor of
|
||||
Phase 0 — a pure move of wiring, no behavior change.
|
||||
|
||||
## Read first
|
||||
|
||||
- `CLAUDE.md` §1, `docs/reference/architecture/ARCHITECTURE.md`
|
||||
- `eslint.config.mjs`
|
||||
- `src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts` (lines
|
||||
~18-19: `BrpAdapter`/`parseBrpAddress`, `DuoAdapter`/`parseDuoLookup`)
|
||||
- `src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts` (~line 28:
|
||||
`IntakePolicyAdapter`)
|
||||
- `src/app/registratie/application/big-profile.store.ts` (the facade pattern to copy)
|
||||
- `src/app/showcase/ui/concepts.page.ts`
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **Showcase is sanctioned, not fixed**: `showcase/` is a teaching page whose point is
|
||||
showing multiple contexts side by side. Add an explicit eslint override allowing
|
||||
`@registratie/*` / `@herregistratie/*` from `src/app/showcase/**`, with a message
|
||||
comment — same precedent as the existing `debug-state` exemption. Document in
|
||||
ARCHITECTURE.md: "showcase may read every context; nothing imports showcase."
|
||||
- Move wiring only — do not redesign adapter signatures. Existing machine specs are the
|
||||
safety net.
|
||||
|
||||
## Files
|
||||
|
||||
- `eslint.config.mjs` — forbid `**/ui/**` and `**/layout/**` → `**/infrastructure/**`;
|
||||
add the showcase sanction
|
||||
- `src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts` — BRP/DUO
|
||||
lookups move behind a registratie `application/` facade (command or store methods)
|
||||
- `src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts` — policy resource
|
||||
moves behind **new** `src/app/herregistratie/application/` (folder doesn't exist yet)
|
||||
- `docs/reference/architecture/ARCHITECTURE.md` — fix "three contexts, four layers" → six contexts
|
||||
(shared, auth, registratie, herregistratie, brief, showcase), five layers (+ contracts);
|
||||
add the showcase sanction
|
||||
- `CLAUDE.md` §1 — add `brief` to the context list; note the showcase sanction
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create the application-layer facades (pattern: `big-profile.store.ts` /
|
||||
`submit-*.ts` commands); components inject those instead of adapters.
|
||||
2. Add the eslint restriction; add the showcase override with rationale message.
|
||||
3. Fix any further hits lint finds (`letter-composer`'s infra import is handled in
|
||||
WP-03; if it slipped, fix here).
|
||||
4. Update both docs' context/layer inventory.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Rule active; lint green; no disables beyond the documented showcase + debug-state
|
||||
exemptions. (Probed: a ui→infra import errors.)
|
||||
- [x] No `**/ui/**` file imports from `**/infrastructure/**` (production; stories/specs
|
||||
exempted — test scaffolding wires the real client).
|
||||
- [x] Both wizards behave unchanged (178 specs pass; storybook a11y suite mounts both
|
||||
wizards green — behaviour is a pure wiring move behind root facades).
|
||||
- [x] CLAUDE.md and ARCHITECTURE.md list 6 contexts / 5 layers.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci`. Manual smoke: `npm start` → run the registratie
|
||||
wizard (BRP prefill + DUO lookup steps) and the intake wizard end-to-end.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Redesigning the adapters or resource loading; parse boundaries (WP-05).
|
||||
|
||||
## Risks
|
||||
|
||||
Resource wiring moving layers can subtly change injection timing — keep facades
|
||||
`providedIn: 'root'` like existing stores and hand the resource through unchanged.
|
||||
@@ -0,0 +1,66 @@
|
||||
# WP-05 — Parse-don't-validate closure + MDX
|
||||
|
||||
Status: done
|
||||
Phase: 1 — FP/DDD core
|
||||
|
||||
## Why
|
||||
|
||||
Three adapters trust the wire, contradicting the repo's flagship "parse, don't validate"
|
||||
principle (every response through a hand-written `parse*` returning `Result`).
|
||||
|
||||
## Read first
|
||||
|
||||
- `CLAUDE.md` §3 + §4; `docs/reference/architecture/0001-bff-lite-decision-dtos.md`
|
||||
- `src/app/registratie/infrastructure/applications.adapter.ts` (+ its spec — the pattern
|
||||
to copy)
|
||||
- The three offenders below
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Unknown enum-ish values from the wire map to an explicit `Failure` (never a silent
|
||||
fallback); specs pin that behavior.
|
||||
- The MDX curriculum page ships in this WP (the WP that establishes an invariant teaches
|
||||
it).
|
||||
|
||||
## Files
|
||||
|
||||
- `src/app/herregistratie/infrastructure/intake-policy.adapter.ts` — returns the raw DTO
|
||||
resource with no parse; add `parseIntakePolicy(raw: unknown): Result<string, …>` and a
|
||||
domain-side type
|
||||
- `src/app/registratie/infrastructure/big-register.adapter.ts` (~line 25) —
|
||||
`n.type as AantekeningType` → validated parse
|
||||
- `src/app/brief/infrastructure/brief.adapter.ts` (~line 189) — `dto.scope as
|
||||
PassageScope` → validated parse (the file is otherwise parse-heavy; this one field skips)
|
||||
- New co-located specs: `intake-policy.adapter.spec.ts`, extend
|
||||
`big-register.adapter.spec.ts` / `brief.adapter.spec.ts` (create if missing)
|
||||
- New `src/docs/parse-dont-validate.mdx` — title `Foundations/Parse, don't validate`
|
||||
|
||||
## Steps
|
||||
|
||||
1. For each hole: write the small parser (pattern: `parseAanvraagStatus` in
|
||||
`applications.adapter.ts`), wire it in, delete the cast.
|
||||
2. Spec each parser: valid shape, missing field, unknown tag/enum value → `err`.
|
||||
3. MDX page: the principle, value objects (`registratie/domain/value-objects/`), boundary
|
||||
parsers, linking the three real call sites just fixed (before/after snippet).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] No unvalidated `as <DomainType>` casts in `**/infrastructure/**` (the sanctioned
|
||||
"narrow unknown to `Partial<Dto>` then parse" entry-cast is fine).
|
||||
- [x] Each new parser has a spec including a rejection case.
|
||||
- [x] MDX renders under Foundations in Storybook.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci`. Smoke: intake wizard still loads its policy
|
||||
(`npm start` → /intake).
|
||||
|
||||
## Out of scope
|
||||
|
||||
Runtime validation on _every_ endpoint (explicitly out of scope for the POC per
|
||||
CLAUDE.md); `digid.adapter.ts` (faked auth, sanctioned).
|
||||
|
||||
## Risks
|
||||
|
||||
Seeded backend data containing values the new parsers reject → surfaces as `Failure` in
|
||||
the UI; check the backend seed while writing the parsers and align the accepted set.
|
||||
@@ -0,0 +1,86 @@
|
||||
# WP-06 — Generic async template contexts: kill `$any()` (18×)
|
||||
|
||||
Status: done
|
||||
Phase: 1 — FP/DDD core
|
||||
|
||||
## Why
|
||||
|
||||
18 `$any()` casts in templates defeat strict template checking. Root cause for ~9 of
|
||||
them: `AsyncLoadedDirective` types its template context as `{ $implicit: unknown }`
|
||||
(`src/app/shared/ui/async/async.component.ts`), so every `<ng-template appAsyncLoaded
|
||||
let-p>` consumer must cast. The rest are template union-narrowing workarounds.
|
||||
|
||||
## Read first
|
||||
|
||||
- `src/app/shared/ui/async/async.component.ts` (component + directives)
|
||||
- Consumers with `$any`: `src/app/registratie/ui/dashboard.page.ts`,
|
||||
`registration-detail.page.ts`, `registration-summary/registration-summary.component.ts`
|
||||
(×5, union peeking), `registratie-wizard.component.ts` (×4, step data),
|
||||
`src/app/showcase/ui/concepts.page.ts` (×2)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Fix the root cause with generics + `static ngTemplateContextGuard`, not per-consumer
|
||||
casts.
|
||||
- Fallback (only if Angular's inference fights the `RemoteData<E,T> | Resource<T>` union
|
||||
input): split into two typed inputs (`data` / `resource`) — record the swap here.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/app/shared/ui/async/async.component.ts` — `AsyncComponent<T>`;
|
||||
`AsyncLoadedDirective<T>` with
|
||||
`static ngTemplateContextGuard<T>(dir, ctx): ctx is { $implicit: T }` (same for the
|
||||
failure directive's error type if applicable)
|
||||
- Every `$any()` call site (grep `-rn '\$any(' src/app`)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Make the async component/directives generic; keep the public API otherwise identical.
|
||||
2. Remove the now-unneeded `$any()`s in async consumers.
|
||||
3. Remaining union narrowing: replace with `@switch` on the status tag
|
||||
(registration-summary) or small typed `computed()` getters (wizard step data,
|
||||
showcase fake resource).
|
||||
4. `npm run build` (strict templates) is the real check here.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `grep -rn '\$any(' src/app` → zero hits.
|
||||
- [x] No `as` casts added to compensate in component classes (typed getters are fine).
|
||||
- [x] Build green with strict template checking.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci` (one unrelated flake on `review-section.stories.ts`'s
|
||||
smoke-test timeout, confirmed by re-running green — untouched by this WP). Manual smoke
|
||||
via a running `docker compose` stack + Playwright: logged in, drove `/dashboard`,
|
||||
`/registratie` (registration-detail), `/aanvraag/:id`, `/concepts`, and the
|
||||
`/registreren` wizard through the beroep step (both the DUO-match and the "mijn diploma
|
||||
staat er niet bij" handmatig branch) — every fixed template renders its real data with
|
||||
no console errors.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
`AsyncLoadedDirective<T>` + `static ngTemplateContextGuard` **was added** (per the
|
||||
Decisions block) and is real, working generic typing for `AsyncComponent`'s own
|
||||
internals. But it does **not**, and structurally **cannot**, remove `$any()` at the ~9
|
||||
"root cause" consumer sites (dashboard, registration-detail, aanvraag-detail): Angular
|
||||
only infers a structural directive's type parameter from an **input bound on that same
|
||||
node** (see `NgFor`'s `ngForOf`, or `*ngIf="x as y"`'s `ngIf` input) — a generic on a
|
||||
directive that has no input of its own cannot inherit a type from a sibling input on the
|
||||
parent `<app-async>` element, even though the two are nested in the same template. This
|
||||
is a hard limitation of Angular's template type-checker, not a gap in this
|
||||
implementation (confirmed against the documented `ngTemplateContextGuard` pattern and by
|
||||
the compiler continuing to type `let-p` as `unknown` after the generic was added).
|
||||
|
||||
The actual fix for those sites uses the WP's own sanctioned fallback wording ("typed
|
||||
getters are fine"): each consumer gets a small `computed()` that unwraps the `RemoteData`
|
||||
Success value, and the template narrows it locally with `@if (x(); as p)` inside the
|
||||
`appAsyncLoaded` slot (no `let-p` on the directive itself). `registratie-wizard` reused
|
||||
its existing `duoData` computed instead of adding a new one. The registration-summary
|
||||
union-narrowing case used the anticipated `@switch` fix, but needed a `@let status =
|
||||
reg().status` binding first — `@switch`/`@case` only narrows a stable local, not a
|
||||
repeated `reg().status` function call. The showcase fake-resource case (`successRes`)
|
||||
just reads `successRes.value()` directly in the `@for`, skipping `let-v` entirely.
|
||||
|
||||
`AsyncComponent`'s public API (`[data]`/`[resource]` inputs) is unchanged, so this
|
||||
deviation is contained to consumer templates, as the WP intended.
|
||||
@@ -0,0 +1,115 @@
|
||||
# WP-07 — Brief on the shared idioms + RemoteData MDX
|
||||
|
||||
Status: done
|
||||
Phase: 1 — FP/DDD core
|
||||
Depends on: WP-06 (typed `<app-async>`)
|
||||
|
||||
## Why
|
||||
|
||||
The brief context drifted from the repo's own reflexes: `brief.store.ts` (~lines 22-25)
|
||||
holds `busy: signal<boolean>` + `lastError: signal<string|null>` + a separate `saveState`
|
||||
union side by side — representable illegal combos, the exact "second boolean" smell
|
||||
CLAUDE.md §3 bans. And `brief.page.ts` hand-renders its load lifecycle with `@switch` +
|
||||
spinner/alert instead of `RemoteData` + `<app-async>` — the only async flow in the app
|
||||
bypassing the shared molecule.
|
||||
|
||||
## Read first
|
||||
|
||||
- `CLAUDE.md` §3; `src/app/shared/application/remote-data.ts`
|
||||
- `src/app/brief/application/brief.store.ts`, `src/app/brief/ui/brief.page.ts`,
|
||||
`src/app/brief/domain/brief.machine.ts` (+ spec)
|
||||
- `src/app/registratie/application/applications.store.ts` (a store doing it right)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Transient submit/save state becomes **one** tagged union
|
||||
(`Idle | Busy | Failed{error}`), replacing `busy`+`lastError`. `saveState` keeps its
|
||||
union shape (align tag style).
|
||||
- Load lifecycle → `RemoteData` + `<app-async>`; the machine keeps owning the letter's
|
||||
_domain_ lifecycle (loading tags move out of the machine only if they purely mirror
|
||||
the fetch — keep the seam: RemoteData = fetch, machine = letter).
|
||||
- Keep the debounced-save sequencing identical; only re-type the state.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/app/brief/application/brief.store.ts`
|
||||
- `src/app/brief/ui/brief.page.ts`
|
||||
- `src/app/brief/domain/brief.machine.ts` + `brief.machine.spec.ts`
|
||||
- New `src/docs/remote-data.mdx` — title `Foundations/RemoteData & Async`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Replace the signal trio with one union signal; update consumers (letter-composer bar,
|
||||
autosave status line).
|
||||
2. Route the page's load through `RemoteData` + `<app-async>` (typed via WP-06); wire the
|
||||
existing loading/empty/failure templates.
|
||||
3. Update machine/store specs for the union transitions.
|
||||
4. MDX page: the four states, `map2`/`andThen`, the delay-gated spinner, and the
|
||||
`?scenario=` dev toggle — linking `brief.page.ts` and `dashboard.page.ts` as live
|
||||
examples.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] No boolean-plus-error signal pairs in `brief/`.
|
||||
- [x] `/brief` renders all four async states (checked with `?scenario=slow|error`; see
|
||||
Deviation for why `empty` isn't meaningful here).
|
||||
- [x] Specs cover the transition union (Busy→Failed, Busy→Idle) — `brief.store.spec.ts`
|
||||
(new).
|
||||
- [x] MDX renders under Foundations.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci` (197 unit tests, 137 Storybook/a11y — both up from
|
||||
WP-06's baseline by the new store spec). Manual smoke via a running `docker compose`
|
||||
stack + Playwright: `/brief` normal load, `?scenario=slow` (spinner), `?scenario=error`
|
||||
(failure alert + working retry), and `/brief?role=approver` — all with no console errors.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
**The machine's `loading`/`failed` tags were NOT moved out of `BriefState`.** The
|
||||
Decisions block hedges this ("only if they purely mirror the fetch") — they do, but
|
||||
removing them turns out to need more than a re-type: `createStore(initial, reduce)`
|
||||
requires a concrete `initial: BriefState` value, and once `loading`/`failed` are gone
|
||||
there is no state left to represent "not loaded yet" without inventing a second wrapping
|
||||
layer (the store's top-level signal would need to become `RemoteData<Err, LoadedState>`
|
||||
directly, with the machine's `reduce` only invoked inside the `Success` branch — a
|
||||
different wiring shape from every other machine in the app, and a ~250-line ripple
|
||||
through `brief.machine.spec.ts`). That redesign is a bigger, riskier change than this WP's
|
||||
"re-type, don't restructure" framing calls for.
|
||||
|
||||
Instead, `BriefStore.remoteData` **projects** the existing machine model onto
|
||||
`RemoteData<Error | undefined, LoadedBriefState>` (`loading`→`Loading`, `failed`→
|
||||
`Failure`, `loaded`→`Success`), and `brief.page.ts` renders that projection through
|
||||
`<app-async>`. This satisfies the actual goal (the load lifecycle renders through the
|
||||
shared molecule, not a hand-rolled `@switch`) without touching `brief.machine.ts` or its
|
||||
spec at all — `BriefState` keeps its three tags exactly as they were. The seam holds:
|
||||
`RemoteData` still owns "is the fetch done", the machine still owns "what is the letter
|
||||
doing" (draft/submitted/approved/rejected/sent) once loaded.
|
||||
|
||||
**`?scenario=empty` doesn't apply to `/brief`.** It rewrites the HTTP body to `[]`, which
|
||||
fails `parseBriefView`'s `!dto.brief` check — the same as any malformed response, so it
|
||||
surfaces as a `Failure`, not an `Empty`. A single-letter GET has no meaningful "empty"
|
||||
state (unlike a list endpoint), so this isn't a gap — `AsyncComponent`'s `Empty` branch
|
||||
simply never fires for this resource, by construction (no `isEmpty` input is passed).
|
||||
|
||||
**Reused the WP-06 fallback for the loaded slot.** `<ng-template appAsyncLoaded>` can't
|
||||
type `let-s` to the loaded value for the same structural reason WP-06 documented
|
||||
(a directive's generic can't inherit from a sibling `[data]` input) — `brief.page.ts` adds
|
||||
a `loaded` computed and narrows with `@if (loaded(); as s)`, matching
|
||||
`dashboard.page.ts`/`registration-detail.page.ts`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Brief component stories (WP-15); machine renaming conventions (WP-08).
|
||||
|
||||
## Risks
|
||||
|
||||
Autosave (debounced) interplay with the new transition union — flush ordering must stay
|
||||
as-is; `brief.store.spec.ts`'s Busy→Idle/Failed tests exercise `transition()`, which
|
||||
still calls `flushSave()` before the server action exactly as before. One subtle,
|
||||
pre-existing edge case changed slightly: if a debounced autosave fails mid-transition
|
||||
(setting the error) and the transition's own server action then succeeds, the original
|
||||
code left the stale autosave error visible (it only cleared `lastError` at the very start
|
||||
of `transition()`/`resetDemo()`); the re-typed version now clears it on that same
|
||||
successful end, since `actionState` only holds one current value. Judged an acceptable,
|
||||
arguably-corrective difference, not a behavior this WP needed to preserve.
|
||||
@@ -0,0 +1,88 @@
|
||||
# WP-08 — One store idiom + machine naming + TEA MDX
|
||||
|
||||
Status: done
|
||||
Phase: 1 — FP/DDD core
|
||||
|
||||
## Why
|
||||
|
||||
Docs (`docs/reference/fp-tea-atomic-design.md`, ARCHITECTURE §2c) teach `createStore` as THE
|
||||
wiring, but the wizard pages hand-wire `signal(model)` + local `dispatch()` — juniors see
|
||||
two idioms and copy the wrong one. Machine naming also drifts:
|
||||
`change-request.machine.ts` exports bare `State`/`Msg`; `upload.machine.ts` exports
|
||||
`reduceUpload`/`initialUpload`.
|
||||
|
||||
## Read first
|
||||
|
||||
- `src/app/shared/application/store.ts` (`createStore`) + its spec
|
||||
- `docs/reference/fp-tea-atomic-design.md` Part on TEA-in-Angular
|
||||
- All `*.machine.ts` files (6) and their UI wiring sites (wizard components/pages)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **`createStore` is THE idiom** — migrate the hand-wired pages to it. One shape
|
||||
everywhere is the teachability win; the migration is ~5 lines per page.
|
||||
- Naming convention (goes into CLAUDE.md §3):
|
||||
- State/Msg types are context-prefixed: `ChangeRequestState`, `ChangeRequestMsg` (rename
|
||||
the bare ones; other machines already comply).
|
||||
- A top-level machine exports `initial` + `reduce`.
|
||||
- A **composable sub-machine** (embedded in a parent model) keeps prefixed value
|
||||
exports: `initialUpload`/`reduceUpload` **stay** — prefixing avoids alias noise at
|
||||
the composition site.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/app/registratie/domain/change-request.machine.ts` (+ spec + consumers) — rename
|
||||
- Wizard UI wiring: `registratie-wizard.component.ts`, `intake-wizard.component.ts`,
|
||||
`herregistratie-wizard.component.ts` (whichever hand-wire `signal`+dispatch) →
|
||||
`createStore`
|
||||
- `CLAUDE.md` §3 — the naming + wiring convention
|
||||
- New `src/docs/machines.mdx` — title `Foundations/State Machines (TEA)`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Rename `State`/`Msg` in change-request (mechanical; lint/build finds consumers).
|
||||
2. Swap hand-wired model signals for `createStore(initial, reduce)` in the wizard
|
||||
components; templates keep dispatching messages — no template changes expected.
|
||||
3. Write the convention into CLAUDE.md §3 (including the sub-machine rule).
|
||||
4. MDX page: Model/Msg/reduce, commands-outside-reducers, `createStore` wiring, the
|
||||
naming rules — linking `herregistratie.machine.ts` as the worked example.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty.
|
||||
- [x] Every machine consumer wires via `createStore`; no local `signal(model)` +
|
||||
hand-rolled dispatch remains.
|
||||
- [x] Convention documented in CLAUDE.md; MDX renders.
|
||||
- [x] All machine specs pass unchanged (reducers untouched).
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci` (197 unit / 137 Storybook, unchanged from WP-07 —
|
||||
this WP touched no reducer logic). Manual smoke via a running `docker compose` stack +
|
||||
Playwright: the change-request form (the renamed machine) submitted end-to-end with a
|
||||
referentie shown; the intake wizard stepped forward and back; the herregistratie wizard
|
||||
loaded its first step — no console errors across all three.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
**Step 2 (migrate wizard pages off hand-wired `signal(model)`+`dispatch()` onto
|
||||
`createStore`) turned out to already be done.** `registratie-wizard.component.ts`,
|
||||
`intake-wizard.component.ts`, `herregistratie-wizard.component.ts`, and
|
||||
`change-request-form.component.ts` all already wire
|
||||
`createStore<XState, XMsg>(initial, reduce)` — confirmed both by reading each file and by
|
||||
`git log -p` on `registratie-wizard.component.ts`, which shows `createStore` present
|
||||
since the file's introduction. `grep -rn "= signal<.*State>\|= signal(init" src/app`
|
||||
(excluding specs) turns up nothing outside `store.ts` itself and `brief.store.ts`'s two
|
||||
unrelated transient-state signals (WP-07). The WP's "Why" section was accurate for an
|
||||
earlier snapshot of the codebase but stale by the time this WP ran — only the
|
||||
`change-request.machine.ts` naming fix (Step 1) and the CLAUDE.md/MDX documentation
|
||||
(Steps 3–4) had real work left.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Changing reducer logic or messages; store specs for root stores (they're covered where
|
||||
logic exists).
|
||||
|
||||
## Risks
|
||||
|
||||
Rename ripple — purely mechanical; reducer specs are the net.
|
||||
@@ -0,0 +1,85 @@
|
||||
# WP-09 — Pure-logic closure: dates + missing command specs
|
||||
|
||||
Status: done
|
||||
Phase: 1 — FP/DDD core
|
||||
|
||||
## Why
|
||||
|
||||
Three hand-rolled copies of the same `nl-NL {day: numeric, month: long, year: numeric}`
|
||||
formatter exist with divergent signatures, while other components use the Angular `date`
|
||||
pipe — two mechanisms plus drift risk. And two application commands with real logic have
|
||||
no spec despite "domain and pure logic must have a spec" (CLAUDE.md §5).
|
||||
|
||||
## Read first
|
||||
|
||||
- `src/app/registratie/domain/tasks.ts` (~line 15, `formatNL(d: Date)`)
|
||||
- `src/app/registratie/ui/aanvraag-block/aanvraag-block.component.ts` (~line 96,
|
||||
`formatNL(iso?: string)`)
|
||||
- `src/app/brief/ui/letter-preview/letter-preview.component.ts` (~line 96, inline copy)
|
||||
- `src/app/registratie/application/draft-sync.ts` (debounce logic)
|
||||
- `src/app/registratie/application/submit-change-request.ts`
|
||||
- `src/app/app.config.ts` (LOCALE_ID `nl` is already set)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **Templates use `DatePipe`; pure TS uses one `formatDatumNl`** in
|
||||
`src/app/shared/kernel/datum.ts` (Intl.DateTimeFormat, accepts ISO string | Date,
|
||||
empty-safe). Rule goes into CLAUDE.md conventions.
|
||||
- Kernel naming is Dutch (`datum.ts`) — it formats for the Dutch UI; if that clashes with
|
||||
the "shared = English" rule during implementation, `format-date-nl.ts` is the
|
||||
alternative — pick one and note it here.
|
||||
|
||||
## Files
|
||||
|
||||
- New `src/app/shared/kernel/datum.ts` + `datum.spec.ts`
|
||||
- The three formatter copies (delete, redirect to pipe or kernel fn)
|
||||
- New `src/app/registratie/application/draft-sync.spec.ts`
|
||||
- New `src/app/registratie/application/submit-change-request.spec.ts`
|
||||
- `CLAUDE.md` conventions (one line: DatePipe in templates, `formatDatumNl` in pure TS)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Write `formatDatumNl` + spec (pin exact expected strings, e.g. `2 juli 2026`;
|
||||
undefined/empty → `''`).
|
||||
2. Replace the three copies; templates that can use the pipe use the pipe.
|
||||
3. `draft-sync.spec.ts` with vitest fake timers: coalescing (n rapid changes → 1 sync),
|
||||
trailing call, flush-on-submit if applicable.
|
||||
4. `submit-change-request.spec.ts`: ok path, error path (stubbed client), Result shape.
|
||||
5. Also sweep the tiny dead exports flagged in the audit: remove unused `map3` from
|
||||
`shared/kernel/fp.ts` (verify unused first) and the never-set `variant` input on
|
||||
`confirmation.component.ts`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Exactly one hand-written date formatter in the repo. `formatDatumNl` uses
|
||||
`Intl.DateTimeFormat(...).format()` rather than `.toLocaleDateString()`, so
|
||||
`grep -rn "toLocaleDateString" src/app` now hits **nothing** (stronger than the
|
||||
literal criterion, same intent — no file anywhere hand-rolls date formatting).
|
||||
- [x] Both command specs exist; debounce coalescing + error path covered
|
||||
(`draft-sync.spec.ts`, `submit-change-request.spec.ts`).
|
||||
- [x] `map3` removed (found in `shared/application/remote-data.ts`, not
|
||||
`shared/kernel/fp.ts` as the WP text guessed — updated the three docs that
|
||||
mentioned it: CLAUDE.md, `docs/reference/architecture/ARCHITECTURE.md`, `remote-data.mdx`). The
|
||||
`variant` input on `confirmation.component.ts` no longer exists — already
|
||||
cleaned up before this WP ran; nothing to do.
|
||||
- [x] CLAUDE.md rule added (`Conventions` — DatePipe in templates, `formatDatumNl` in
|
||||
pure TS).
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci` (208 unit tests, up from WP-08's 201 by the 7 new
|
||||
specs; 137 Storybook/a11y unchanged). Manual smoke via a running `docker compose` stack +
|
||||
Playwright: dashboard's herregistratie-deadline task text ("Verleng uw registratie vóór 1
|
||||
maart 2027"), the Concept aanvraag-block's complete-before text ("Rond de aanvraag af
|
||||
vóór 2 augustus 2026"), and `formatDatumNl` unit specs for the letter-preview's `today` —
|
||||
all render the expected long-form Dutch date, no console errors.
|
||||
|
||||
## Out of scope
|
||||
|
||||
`Result` combinators (map/andThen/fold) — nice-to-have from the old roadmap, not in this
|
||||
backlog.
|
||||
|
||||
## Risks
|
||||
|
||||
Node ICU vs browser locale output differences — the spec pins strings; if CI's Node lacks
|
||||
full ICU (it shouldn't on Node 24), pin via `Intl.DateTimeFormat('nl-NL', …)` explicitly.
|
||||
@@ -0,0 +1,73 @@
|
||||
# WP-10 — CIBG button fidelity
|
||||
|
||||
Status: done (69880ef)
|
||||
Phase: 2 — CIBG fidelity
|
||||
|
||||
> **Deviation:** file-input's label-button was already reworked to `.btn-primary
|
||||
.btn-upload` by the earlier out-of-order "CIBG UI fidelity pass" (WP-11/12) — the
|
||||
> vendored upload vocabulary (`.btn-upload`) supersedes this WP's original
|
||||
> `.btn-secondary` assumption, so no change was needed there. Icon affordances
|
||||
> (chevron/pijl classes) are verified present in the vendored CSS, but no in-scope
|
||||
> button (atom, file-input, RTE toolbar) currently has a next/previous affordance to
|
||||
> attach one to — skipped as not applicable, not recorded as a gap (nothing hand-rolled
|
||||
> to mark).
|
||||
|
||||
## Why
|
||||
|
||||
The vendored CIBG build ships `.btn-primary / .btn-secondary / .btn-danger / .btn-ghost /
|
||||
.btn-link / .btn-link--underline / .btn-inline` — and **no** `.btn-outline-primary`,
|
||||
`.btn-outline-secondary`, or `.btn-sm` (verified absent from
|
||||
`public/cibg-huisstijl/css/huisstijl.min.css`). Three components emit those dead classes
|
||||
and render as unstyled Bootstrap defaults instead of CIBG buttons.
|
||||
|
||||
## Read first
|
||||
|
||||
- https://designsystem.cibg.nl/componenten/knoppen/ (or grep the vendored CSS)
|
||||
- `src/app/shared/ui/button/button.component.ts` (~line 14)
|
||||
- `src/app/shared/ui/upload/file-input/file-input.component.ts` (~line 38)
|
||||
- `src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts` (toolbar, ~lines 55-60)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- `variant='secondary'` → `.btn-secondary` (CIBG's documented secondary).
|
||||
- RTE toolbar buttons → `.btn-ghost`; drop `.btn-sm` — if the toolbar needs a smaller
|
||||
size, that's token-bridge CSS carrying the CIBG-gap marker (format per WP-13; define it
|
||||
inline if WP-13 hasn't run).
|
||||
- Add `'ghost'` to the atom's variant union (CIBG documents it; the RTE needs it).
|
||||
- Icon affordances (chevron/pijl): implement **only if** icon classes exist in the
|
||||
vendored CSS — inspect first; otherwise record as a gap in the WP-13 register and skip.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/app/shared/ui/button/button.component.ts` + `button.stories.ts`
|
||||
- `src/app/shared/ui/upload/file-input/file-input.component.ts`
|
||||
- `src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts`
|
||||
- Any story/snapshot referencing the old classes
|
||||
|
||||
## Steps
|
||||
|
||||
1. Fix the variant map in the button atom; add `ghost`.
|
||||
2. file-input label-button → `btn btn-secondary`.
|
||||
3. RTE toolbar → `btn btn-ghost` (+ marked size CSS if needed).
|
||||
4. Grep-check `btn-outline`/`btn-sm` gone repo-wide.
|
||||
5. Storybook visual review of Button (all variants), FileInput, RichTextEditor against
|
||||
designsystem.cibg.nl/componenten/knoppen.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `grep -rn "btn-outline\|btn-sm" src/app` → empty.
|
||||
- [x] Button story shows all CIBG variants incl. ghost; visuals match the design system.
|
||||
- [x] Axe still green (contrast can change with real button styles).
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci`. Manual Storybook review of the three components.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Other invented classes (WP-11); datablock (WP-12).
|
||||
|
||||
## Risks
|
||||
|
||||
Visual regressions where outline-secondary "worked by accident" via Bootstrap base rules
|
||||
— the story review step is the catch.
|
||||
@@ -0,0 +1,83 @@
|
||||
# WP-11 — CIBG markup fidelity: application-link + absent-class triage
|
||||
|
||||
Status: done (98fd7e4)
|
||||
Phase: 2 — CIBG fidelity
|
||||
|
||||
> Done as part of the "CIBG UI fidelity pass" (user-requested, out of order).
|
||||
> application-link now uses the real `.dashboard-block.applications li a` chain via a
|
||||
> `li[app-application-link]` attribute selector (native `<li>` child, axe-clean); the
|
||||
> invented `.application`/`.application-title` classes are gone (grep gate clean). The
|
||||
> dashboard "Mijn aanvragen" renders as the CIBG Aanvragen component. Remaining
|
||||
> absent-class triage for non-aanvragen components stays with WP-13's gap register.
|
||||
|
||||
## Why
|
||||
|
||||
`application-link.component.ts` invents `.application` / `.application-title` — absent
|
||||
from the vendored CSS. The real "aanvragen" pattern styles
|
||||
`.dashboard-block.applications li a` with `h3`/`.h3`, and `.subtitle/.status/.cta`
|
||||
scoped **inside** `.applications`. The invented classes mean the rows only look right by
|
||||
accident of parent scoping, and `.application-title` is dead. A few other components use
|
||||
classes that don't exist in the vendored build.
|
||||
|
||||
## Read first
|
||||
|
||||
- https://designsystem.cibg.nl/componenten/aanvragen/
|
||||
- `public/cibg-huisstijl/css/huisstijl.min.css` (grep `.applications`, `.breadcrumb`,
|
||||
error-summary candidates)
|
||||
- `src/app/shared/ui/application-link/application-link.component.ts` +
|
||||
`application-list/application-list.component.ts`
|
||||
- `src/app/shared/layout/page-shell/page-shell.component.ts` (`.back`),
|
||||
`wizard-shell/wizard-shell.component.ts` (`.es-list`/`.es-title`),
|
||||
`src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts`
|
||||
(`.radio-option`), `shared/layout/breadcrumb/breadcrumb.component.ts`
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- application-list/link: rework markup to the documented `.dashboard-block.applications`
|
||||
scoping chain; keep the component API (`heading/subtitle/cta/to`) unchanged.
|
||||
- For each remaining absent class: **adopt a vendored pattern if one exists, else mark as
|
||||
CIBG-gap extension** (marker format per WP-13; define inline if WP-13 hasn't run).
|
||||
This fallback is explicit so the WP can't stall on a missing upstream pattern.
|
||||
- Wizard error summary: check whether CIBG's Foutmelding/Veldvalidatie pattern classes
|
||||
are vendored; adopt if so, else mark as gap (the current behavior/a11y stays —
|
||||
`role="alert"`, focus management).
|
||||
- Breadcrumb: `.breadcrumb` IS vendored — verify usage against CIBG Kruimelpad and
|
||||
annotate only; no rework expected.
|
||||
|
||||
## Files
|
||||
|
||||
- `application-link.component.ts`, `application-list.component.ts` (+ stories)
|
||||
- `page-shell.component.ts`, `wizard-shell.component.ts`,
|
||||
`delivery-channel-toggle.component.ts`, `breadcrumb.component.ts` (annotations/markers
|
||||
or adopted patterns)
|
||||
- `src/app/registratie/ui/dashboard.page.ts` (visual consumer — no code change expected)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Rework application-list/link markup; delete `.application`/`.application-title`.
|
||||
2. Grep the vendored CSS for Foutmelding/Veldvalidatie-ish classes; adopt or mark.
|
||||
3. Triage `.back` and `.radio-option`: adopt or mark.
|
||||
4. Verify breadcrumb; add the "wraps vendored `.breadcrumb` (CIBG Kruimelpad)" comment.
|
||||
5. Eyeball the dashboard — a visual diff on "Wat wilt u doen?" is intended.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `grep -rn '"application"\|application-title' src/app` → empty (component selector
|
||||
aside).
|
||||
- [ ] Every invented class either replaced by a vendored pattern or carries the gap
|
||||
marker.
|
||||
- [ ] Dashboard + wizard stories reviewed; axe green.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci`. Manual: `npm start` → dashboard ("Wat wilt u
|
||||
doen?" list) + a wizard validation error (error summary).
|
||||
|
||||
## Out of scope
|
||||
|
||||
Datablock adoption (WP-12); the full gap register (WP-13).
|
||||
|
||||
## Risks
|
||||
|
||||
The intended dashboard visual diff needs human eyes — flag it in the session summary for
|
||||
the user.
|
||||
@@ -0,0 +1,85 @@
|
||||
# WP-12 — CIBG Datablock for application data
|
||||
|
||||
Status: done (82fc3c4)
|
||||
Phase: 2 — CIBG fidelity
|
||||
|
||||
> Done as part of the "CIBG UI fidelity pass" (user-requested, out of order). New
|
||||
> `app-data-block` molecule wraps `.data-block`/`.block-wrapper`; `data-row` moved to a
|
||||
> `div[app-data-row]` attribute selector so the `<dl>`'s child is a native `<div>`
|
||||
> (axe-clean — fixed a live definition-list defect). review-section folded on;
|
||||
> registration-summary + dashboard BRP block dropped `app-card` for the datablock.
|
||||
|
||||
## Why
|
||||
|
||||
CIBG documents **Datablock** (designsystem.cibg.nl/componenten/datablock/) as THE way to
|
||||
display user/application data: a grey `dl/dt/dd` container, `.data-block` +
|
||||
`.block-wrapper`, Bootstrap grid for label/value widths, a `--stacked` variant, and
|
||||
optional expandable help text (`.dt-item`, `.dt-help-btn-open/-close`, `.dt-help-panel`).
|
||||
All classes are **verified present** in `public/cibg-huisstijl/css/huisstijl.min.css`.
|
||||
The app currently renders application data with bare `data-row`s inside a custom card —
|
||||
adopting Datablock makes the data views design-system-native.
|
||||
|
||||
## Read first
|
||||
|
||||
- https://designsystem.cibg.nl/componenten/datablock/
|
||||
- `public/cibg-huisstijl/css/huisstijl.min.css` (grep `.data-block`)
|
||||
- `src/app/shared/ui/data-row/data-row.component.ts`
|
||||
- `src/app/shared/ui/review-section/review-section.component.ts` (already uses
|
||||
`.data-block`/`.block-wrapper` ad hoc)
|
||||
- Consumers: `src/app/registratie/ui/dashboard.page.ts` ("Persoonsgegevens (BRP)" card),
|
||||
`registration-summary/registration-summary.component.ts`,
|
||||
`registration-detail.page.ts`
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- New **`data-block` molecule** in `shared/ui` wrapping the vendored classes: inputs for
|
||||
optional heading and `stacked` variant; content = projected `data-row`s (or a typed
|
||||
rows input — match how `review-section` projects today; prefer projection for
|
||||
composability).
|
||||
- `data-row` stays as the row primitive **inside** the datablock (document that role in
|
||||
its header comment) — don't fork a second row component.
|
||||
- `review-section` aligns onto the new molecule instead of hand-carrying the classes.
|
||||
- Expandable help text (`.dt-item` + help button/panel) only if a real field needs it —
|
||||
if built, follow CIBG's focus management (focus into panel on open, back to trigger on
|
||||
close) and the documented `aria-*` state on the trigger.
|
||||
|
||||
## Files
|
||||
|
||||
- New `src/app/shared/ui/data-block/data-block.component.ts` + `data-block.stories.ts`
|
||||
- `src/app/shared/ui/review-section/review-section.component.ts`
|
||||
- `src/app/registratie/ui/dashboard.page.ts` (Persoonsgegevens card → datablock)
|
||||
- `registration-summary.component.ts`, `registration-detail.page.ts`
|
||||
- `src/app/shared/ui/data-row/data-row.component.ts` (header comment; possibly minor
|
||||
class alignment)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Build the molecule (standard + stacked variants), stories for both (axe-gated).
|
||||
2. Migrate the three data views; keep copy/i18n ids untouched.
|
||||
3. Fold review-section onto the molecule; delete its duplicated class carrying.
|
||||
4. Optional `aria-label` on the `dl` where the block has no visible heading.
|
||||
5. Visual review dashboard + registration pages.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Application data (BRP card, registration summary/detail, wizard review) renders in
|
||||
CIBG datablocks.
|
||||
- [ ] `data-row` documented as the datablock row primitive; no duplicate dl styling
|
||||
anywhere.
|
||||
- [ ] Stories for standard + stacked; axe green.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci`. Manual: `npm start` → dashboard + /registratie
|
||||
detail; wizard review step still renders.
|
||||
|
||||
## Out of scope
|
||||
|
||||
The help-panel variant unless a real field needs it; table views (registration-table
|
||||
stays a CIBG Tabel).
|
||||
|
||||
## Risks
|
||||
|
||||
The grey datablock surface may double up visually inside the custom `.app-card` — decide
|
||||
per view whether the card wrapper drops (datablock replaces it) and note the choice in
|
||||
the component comment.
|
||||
@@ -0,0 +1,103 @@
|
||||
# WP-13 — CIBG-gap register + hygiene + MDX
|
||||
|
||||
Status: done (9d58f59)
|
||||
Phase: 2 — CIBG fidelity
|
||||
|
||||
> **Deviation:** WP-11/12 ran first but left no markers (deferred to this WP, as their own
|
||||
> files note), so this WP defines the marker format fresh per its own Decisions block —
|
||||
> not adopted from 11/12. The Decisions block's `task-list → Actieblok` mapping is stale:
|
||||
> no `.actieblok`/`actie` class exists in the vendored CSS, and `task-list`'s own header
|
||||
> comment already (accurately) documents it as composing `choice-list`'s Keuzelijst
|
||||
> pattern rather than a distinct Actieblok one — left as-is rather than forced to claim a
|
||||
> nonexistent mapping. `application-link`'s `.static-row` (flagged as a marked-gap
|
||||
> candidate in this file's own correction note) got the marker too. The optional
|
||||
> `check:cibg-gaps` script (step 4) is skipped: the register is nine rows, reviewed at PR
|
||||
> time same as any other doc — a CI script to diff it against code markers is complexity
|
||||
> the size of the problem doesn't warrant (noted, not built).
|
||||
|
||||
> **Correction (CIBG UI fidelity pass, b5c5d30):** this WP assumed the `upload/` suite
|
||||
> had no vendored CIBG classes and would be marked as a CIBG-gap ("Bestand-upload").
|
||||
> The vendored build actually ships a full upload vocabulary (`.file-picker-drop-area`,
|
||||
> `ul.file-list`, `.file-container`, `.file-name`/`.file-meta`, `.btn-upload`,
|
||||
> `.upload-validation`), so the suite was **reworked to wrap those classes** instead —
|
||||
> it is no longer a gap to mark. WP-13's remaining register still covers skeleton/
|
||||
> spinner, rich-text-editor, wizard-shell, confirmation, card (`.app-card`),
|
||||
> status-badge, placeholder-chip, etc. (Note: `application-link`'s non-navigating
|
||||
> `.static-row` mirrors the aanvragen card surface from tokens — a small marked-gap
|
||||
> candidate.)
|
||||
|
||||
## Why
|
||||
|
||||
User decision: hand-rolled token-bridge components are allowed **only if explicitly
|
||||
marked** as CIBG-gap extensions in code + Storybook, so every deviation from the design
|
||||
system is auditable. Plus two hygiene items from the audit: a dead wrapper to delete and
|
||||
the list-family rationale to document.
|
||||
|
||||
## Read first
|
||||
|
||||
- `docs/reference/architecture/0003-*.md` (ADR-0003 — the token-bridge rationale this extends)
|
||||
- The audit's gap list (below)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Marker format (header comment in the component):
|
||||
`// CIBG-GAP EXTENSION: <closest CIBG concept> — <why hand-rolled>` plus
|
||||
`parameters: { cibgGap: true }` and a "CIBG-gap extension" line in the story
|
||||
description.
|
||||
- **Keep all three list families** — each maps a distinct CIBG pattern: `task-list` →
|
||||
Actieblok, `application-list` → Aanvragen, `choice-list` → Keuzelijst. Header comment
|
||||
in each names its pattern. Consolidation would blur exactly the design-system mapping
|
||||
this showcase demonstrates.
|
||||
- **Delete `upload-status-banner`** (`shared/ui/upload/upload-status-banner/`): a
|
||||
23-line near-identity wrapper over `alert` with one consumer — inline `<app-alert>`
|
||||
there.
|
||||
|
||||
## Files
|
||||
|
||||
Components to mark (closest CIBG concept in parens):
|
||||
|
||||
- `skeleton`, `spinner` (Laadindicatie — no vendored class, verified)
|
||||
- `upload/` suite (Bestand-upload)
|
||||
- `rich-text-editor` (Tekstgebied)
|
||||
- `wizard-shell` (Wizard; error summary → Foutmelding, if WP-11 marked it)
|
||||
- `confirmation` (Bevestiging — verify: it wraps vendored classes; if so it needs no
|
||||
marker, only the pattern comment)
|
||||
- `debug-state` (devtool, no CIBG concept)
|
||||
- `status-badge` (deliberate custom, documented in code), `card` (`.app-card`),
|
||||
`placeholder-chip`
|
||||
Plus:
|
||||
- Delete `upload-status-banner` + its story; inline alert at its consumer
|
||||
- Header comments on `task-list`/`application-list`/`choice-list`
|
||||
- New `src/docs/cibg-gaps.mdx` — title `Foundations/CIBG Gap Register`
|
||||
- ADR-0003 gets a cross-link to the register
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add the marker to each gap component + `cibgGap` story parameter.
|
||||
2. Delete the banner; migrate its one consumer; run its former story's states through
|
||||
the consumer's story if coverage would drop.
|
||||
3. Write the MDX register: a table (component → closest CIBG concept → why) + the rule
|
||||
"grep the vendored CSS before hand-rolling anything".
|
||||
4. Optional but cheap: a `check:cibg-gaps` script that greps markers and diffs against
|
||||
the MDX table, so the register can't silently rot — add to `check:tokens`' CI step if
|
||||
trivial, else note as skipped.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Every component with hand-rolled surface CSS either wraps vendored classes or
|
||||
carries the marker (spot-check with a grep for `styles: [` vs markers).
|
||||
- [x] Register MDX complete, linked from ADR-0003.
|
||||
- [x] `upload-status-banner` gone; consumer green; no story coverage lost.
|
||||
- [x] List trio documented.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Rebuilding any gap component against the design system (they're sanctioned by decision).
|
||||
|
||||
## Risks
|
||||
|
||||
Low — mostly annotation; the deletion is the only behavior-touching change.
|
||||
@@ -0,0 +1,88 @@
|
||||
# WP-14 — Storybook taxonomy reorg + Layers MDX
|
||||
|
||||
Status: done (8b19fad)
|
||||
Phase: 3 — Storybook as curriculum
|
||||
|
||||
> **Deviation:** the "Layout/" bucket (breadcrumb, site-footer, site-header) wasn't in the
|
||||
> Decisions block's explicit scheme, so each got folded into Atoms/Molecules/Organisms by
|
||||
> its own doc-comment classification (breadcrumb → Molecules, site-footer/site-header →
|
||||
> Organisms, both already documented as such in their component header comments) rather
|
||||
> than kept as a separate bucket. Fixing `atomic-design.mdx`'s "status banner" reference
|
||||
> (stale since WP-13 deleted `upload-status-banner`) was caught as a side effect of
|
||||
> reviewing every MDX page for broken references — not itself a retitle issue, but the
|
||||
> same "unbroken MDX" acceptance criterion covers it.
|
||||
|
||||
## Why
|
||||
|
||||
49 stories sit in a flat `Atoms/Molecules/Organisms/Templates/Layout` scheme with domain
|
||||
outliers (`Registratie/…` wizards, one stray `Shared UI/Status Badge`). The sidebar
|
||||
should tell the repo's actual story: a reusable design system on one side, domain
|
||||
contexts on the other — the DDD seam made visible. The sidebar becomes the curriculum's
|
||||
table of contents.
|
||||
|
||||
## Read first
|
||||
|
||||
- `.storybook/preview.ts`, `.storybook/main.ts`
|
||||
- `src/docs/*.mdx` (three existing Foundations pages)
|
||||
- `CLAUDE.md` naming rule (shared = English, domain = Dutch)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
Sidebar scheme (respects the English/Dutch seam):
|
||||
|
||||
```
|
||||
Foundations/ ← MDX curriculum (all concept pages)
|
||||
Design System/
|
||||
Atoms/… Molecules/… Organisms/… Templates/… ← everything in shared/ui + shared/layout
|
||||
Domein/
|
||||
Registratie/… Herregistratie/… Brief/… Auth/… ← everything in a context's ui/
|
||||
```
|
||||
|
||||
- Rule: **a component in a context folder gets a `Domein/<Context>/` title, full stop**
|
||||
(context organisms like `aanvraag-block` move from `Organisms/` to
|
||||
`Domein/Registratie/`). Component display names stay as authored (the folder tells the
|
||||
context).
|
||||
- `Devtools/` (debug-state) files under `Design System/Devtools/`.
|
||||
- Order pinned via `parameters.options.storySort`:
|
||||
`['Foundations', 'Design System', ['Atoms','Molecules','Organisms','Templates','Devtools'], 'Domein']`.
|
||||
|
||||
## Files
|
||||
|
||||
- All `*.stories.ts` `title:` fields (~49, mechanical)
|
||||
- `.storybook/preview.ts` (`storySort`)
|
||||
- Existing `src/docs/*.mdx` (fix any story references broken by retitling)
|
||||
- New `src/docs/layers.mdx` — title `Foundations/Layers & Contexts`
|
||||
- `CLAUDE.md` — the story-title convention (one paragraph in §2 or Conventions)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Retitle all stories per the scheme (fix `Shared UI/Status Badge`).
|
||||
2. Add `storySort`; build Storybook and check the sidebar.
|
||||
3. `layers.mdx`: 6 contexts, 5 layers, the inward dependency rule, "this is enforced" (link
|
||||
the eslint rules from WP-03/04), the English/Dutch seam — i.e. the sidebar's own
|
||||
rationale, with links into real stories on both sides.
|
||||
4. Check every MDX `<Canvas of={…}>`/story import still resolves (build-storybook
|
||||
catches broken refs — do an explicit pass anyway).
|
||||
5. Convention into CLAUDE.md.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Sidebar shows exactly Foundations → Design System → Domein with the sub-order
|
||||
pinned.
|
||||
- [x] Zero story titles outside the scheme (grep `title:` and eyeball).
|
||||
- [x] `layers.mdx` renders; existing MDX pages unbroken.
|
||||
- [x] Convention in CLAUDE.md.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci` (the axe run also re-visits every retitled story).
|
||||
|
||||
## Out of scope
|
||||
|
||||
New component stories (WP-15); the remaining curriculum pages (they ship with their
|
||||
invariant-WPs: 05, 07, 08, 13, 17).
|
||||
|
||||
## Risks
|
||||
|
||||
MDX story references breaking on retitle — `build-storybook` is the catch; fix-forward
|
||||
in the same session.
|
||||
@@ -0,0 +1,74 @@
|
||||
# WP-15 — Missing stories: shell + brief components
|
||||
|
||||
Status: done (0cfb01f)
|
||||
Phase: 3 — Storybook as curriculum
|
||||
Depends on: WP-14 (titles), WP-01 (axe gate covers the new stories automatically)
|
||||
|
||||
> **Note:** fixture duplication across the four brief stories that need `Brief`/
|
||||
> `LetterSection`/`LetterBlock` shapes (letter-block, letter-preview, letter-section, plus
|
||||
> the pre-existing letter-composer) didn't bite enough to justify the shared-fixtures
|
||||
> escape hatch — each story only builds the minimal slice it actually renders (letter-block
|
||||
> needs one block, not a whole `Brief`), so the co-located fixtures stayed small and
|
||||
> non-duplicative in practice. A pre-existing, unrelated axe finding on
|
||||
> `text-input--invalid` (informational only — `test-storybook:ci` doesn't fail on it) shows
|
||||
> up in the run; it predates this WP and isn't caused by anything here.
|
||||
|
||||
## Why
|
||||
|
||||
"UI is exercised via Storybook stories" (CLAUDE.md §5) — but 7 components have none:
|
||||
`shared/layout/shell` and six of seven brief components. Unstoried components are also
|
||||
invisible to the axe gate.
|
||||
|
||||
## Read first
|
||||
|
||||
- `src/app/brief/ui/letter-composer/letter-composer.stories.ts` (the one brief story —
|
||||
fixture + decorator pattern to reuse)
|
||||
- The seven components below
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Titles per WP-14 scheme: `Design System/Templates/Shell`, `Domein/Brief/<Name>`.
|
||||
- If fixture duplication across brief stories bites, extract a shared
|
||||
`src/app/brief/ui/brief.fixtures.ts` — otherwise keep fixtures co-located.
|
||||
|
||||
## Files (new)
|
||||
|
||||
- `src/app/shared/layout/shell/shell.stories.ts`
|
||||
- `src/app/brief/ui/rejection-comments/rejection-comments.stories.ts`
|
||||
- `src/app/brief/ui/diagnostics-panel/diagnostics-panel.stories.ts`
|
||||
- `src/app/brief/ui/letter-block/letter-block.stories.ts`
|
||||
- `src/app/brief/ui/letter-preview/letter-preview.stories.ts`
|
||||
- `src/app/brief/ui/letter-section/letter-section.stories.ts`
|
||||
- `src/app/brief/ui/passage-picker/passage-picker.stories.ts`
|
||||
|
||||
## Steps
|
||||
|
||||
1. One story file per component: default state + at least one meaningful variant
|
||||
(e.g. letter-block locked/editable, diagnostics with findings/empty, rejection-comments
|
||||
filled, preview with sample data toggle).
|
||||
2. Provide router/HTTP/store context via `applicationConfig`/`moduleMetadata` decorators
|
||||
like the wizard stories do.
|
||||
3. Run the axe gate; fix trivial violations in the components (label/role level); anything
|
||||
structural → escape hatch + cross-ref WP-16.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Every component in `src/app` has ≥1 story (verify: list components without a
|
||||
co-located `*.stories.ts`; expect zero, pages excepted if that's the existing
|
||||
norm — note the norm in this file when checked). **Confirmed norm:** `*.page.ts`
|
||||
files (9 of them) have never had stories; every `*.component.ts` now does.
|
||||
- [x] All new stories pass the axe gate (or carry a justified skip).
|
||||
- [x] Titles follow WP-14.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Play-test assertions (WP-16 adds those where they enforce a contract).
|
||||
|
||||
## Risks
|
||||
|
||||
Brief components may lean on `BriefStore` state — stub via the store's public API in a
|
||||
decorator rather than reaching into internals.
|
||||
@@ -0,0 +1,90 @@
|
||||
# WP-16 — Component a11y: description wiring + alert role
|
||||
|
||||
Status: done (pending commit)
|
||||
Phase: 4 — a11y
|
||||
|
||||
## Why
|
||||
|
||||
Audit findings axe can't (fully) catch:
|
||||
|
||||
- `form-field` renders a description `<div [id]="fieldId()+'-desc'">` that **no control
|
||||
ever references** — `text-input` sets `aria-describedby` only to `…-error` and only
|
||||
when invalid. Screen readers never announce field descriptions (e.g. the BSN hint on
|
||||
the login form).
|
||||
- The field↔control id pairing is manual (`fieldId` must equal the input's `name`/`id`)
|
||||
with nothing enforcing it.
|
||||
- The `alert` atom is always `role="status"` — error alerts are announced politely while
|
||||
other errors in the app use `role="alert"`; urgency is inconsistent.
|
||||
|
||||
## Read first
|
||||
|
||||
- `src/app/shared/ui/form-field/form-field.component.ts` (~line 15)
|
||||
- `src/app/shared/ui/text-input/text-input.component.ts` (~lines 17-18)
|
||||
- `src/app/shared/ui/radio-group/radio-group.component.ts`, `checkbox/checkbox.component.ts`
|
||||
- `src/app/shared/ui/alert/alert.component.ts`
|
||||
- `src/app/auth/ui/login-form/login-form.component.ts` (a live desc that's never wired)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- `aria-describedby` = space-joined ids: `-desc` **always when a description exists** +
|
||||
`-error` **when invalid**; order pinned (desc first, error second).
|
||||
- Pairing contract: form-field exposes its `fieldId`; the composition contract
|
||||
(`fieldId === control id/name`) is documented in both components and **enforced by a
|
||||
story play test** on the canonical form-field+text-input composition
|
||||
(`expect(input).toHaveAttribute('aria-describedby', 'x-desc')`, flip validity, assert
|
||||
`'x-desc x-error'`). Play tests run in the WP-01 test-runner for free.
|
||||
- DI-based auto-wiring (form-field providing the id via injection) is **out of scope** —
|
||||
more clever than this POC needs; the play test catches drift. Revisit only if the
|
||||
manual contract actually breaks in practice.
|
||||
- Alert: `type === 'error'` → `role="alert"`; others keep `role="status"`. Rationale
|
||||
comment in the atom.
|
||||
|
||||
## Files
|
||||
|
||||
- `form-field.component.ts`, `text-input.component.ts`, `radio-group.component.ts`,
|
||||
`checkbox.component.ts` (describedby joins; only where the component takes a hint)
|
||||
- `alert.component.ts` (+ story asserting the role per variant)
|
||||
- `form-field.stories.ts` (or a composition story) with the play test
|
||||
- Call sites that pass descriptions (verify login-form BSN hint is now announced)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Implement the describedby join in the input atoms; form-field renders `-desc` only
|
||||
when a description input is set (it already does — verify).
|
||||
2. Write the play tests (form-field composition + alert roles).
|
||||
3. Alert role switch + rationale comment.
|
||||
4. Manual screen-reader spot check (optional but recommended — note result here).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Description text is programmatically associated in the canonical composition;
|
||||
login-form BSN hint announced.
|
||||
- [x] `-error` id appended exactly when invalid; order stable.
|
||||
- [x] Error alerts are `role="alert"`; play tests assert both behaviors and run in the
|
||||
CI gate.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
`radio-group`/`checkbox` were left unchanged — grepping every call site found zero
|
||||
consumers pairing either with a `form-field` `description` (only the login-form BSN
|
||||
field, which uses `text-input`). The WP's own Files note ("describedby joins; only
|
||||
where the component takes a hint") already carved out this exact case — adding
|
||||
`hasDescription` to atoms with no live description consumer would be unused surface,
|
||||
not a fix. `radio-group` already had correct `-error`-only wiring; untouched.
|
||||
`describedBy()` was added directly on `text-input` rather than factored into a shared
|
||||
`shared/kernel` helper — one consumer, ~5 lines, not worth the indirection yet.
|
||||
Manual screen-reader spot check (optional per the WP) skipped; the play test is the
|
||||
enforced check going forward.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci` (includes the new play tests).
|
||||
|
||||
## Out of scope
|
||||
|
||||
Route-change focus and template lint (WP-17); rewriting form-field's layout.
|
||||
|
||||
## Risks
|
||||
|
||||
`aria-describedby` churn on validity flips re-announcing content — pinned order + play
|
||||
test coverage keeps it deterministic.
|
||||
@@ -0,0 +1,102 @@
|
||||
# WP-17 — App-level a11y: route focus/scroll, template lint, WCAG checklist + MDX
|
||||
|
||||
Status: done (pending commit)
|
||||
Phase: 4 — a11y
|
||||
|
||||
## Why
|
||||
|
||||
Three app-level gaps close the WCAG story:
|
||||
|
||||
- **No route-change focus/scroll management** — `app.config.ts` has only
|
||||
`provideRouter(routes, withViewTransitions())`; after navigation, focus stays wherever
|
||||
it was and scroll position is unmanaged. (The wizard manages focus _within_ steps; the
|
||||
skip link is the only cross-page mechanism.)
|
||||
- **No template a11y linting** — `@angular-eslint` is entirely absent.
|
||||
- User decision: a **manual WCAG checklist** documents what automation can't test.
|
||||
|
||||
## Read first
|
||||
|
||||
- `src/app/app.config.ts`
|
||||
- `eslint.config.mjs`, `package.json`
|
||||
- `src/app/shared/layout/shell/shell.component.ts` (`#main` target),
|
||||
`page-shell/page-shell.component.ts` (candidate focus target)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Scroll: `withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })`.
|
||||
- Focus: on `NavigationEnd`, move focus to the new page's `h1` (or `#main` fallback,
|
||||
`tabindex="-1"`), **deferred via `afterNextRender`** so it coexists with view
|
||||
transitions; skip the initial load. Implement as a small root service/effect wired in
|
||||
`app.config.ts` — not per page.
|
||||
- Lint: `angular-eslint` at the version matching Angular 22 (~v21+ — check
|
||||
`npm view angular-eslint peerDependencies`), with `processInlineTemplates` (this repo
|
||||
has **no** `.html` templates). Rules: `alt-text`, `label-has-associated-control`,
|
||||
`interactive-supports-focus`, `click-events-have-key-events`, `valid-aria`,
|
||||
`elements-content`. **Verify the processor fires** by planting a known-bad pattern
|
||||
before trusting green.
|
||||
- The checklist is a living doc with per-page status columns — not prose.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/app/app.config.ts` + a new small `shared/` focus-on-navigation service
|
||||
- `eslint.config.mjs`, `package.json` (angular-eslint)
|
||||
- Any template the new lint rules flag
|
||||
- New `docs/reference/wcag-checklist.md`
|
||||
- New `src/docs/a11y.mdx` — title `Foundations/Accessibility`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Router scroll + focus service; manual keyboard smoke (tab through, navigate, confirm
|
||||
focus lands on the new page's heading).
|
||||
2. Add angular-eslint; enable the template rules on inline templates; plant a bad
|
||||
pattern, see it fail, remove it; fix real hits.
|
||||
3. `docs/reference/wcag-checklist.md`: manual checks per page (dashboard, wizards, brief, login) —
|
||||
keyboard walk & focus order, no traps, 200% zoom/reflow, NVDA or VoiceOver pass,
|
||||
visible focus, error announcement; status columns (page × check).
|
||||
4. `src/docs/a11y.mdx`: the layered approach — axe gate (WP-01) + template lint + play
|
||||
tests (WP-16) + manual checklist; the alert-role rationale; where the
|
||||
`a11y.disable` skip register lives and its rules.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Navigating between routes moves focus to the new page's heading; scroll resets;
|
||||
view transitions still play.
|
||||
- [x] Template a11y rules active and _proven_ to fire; lint green.
|
||||
- [x] Checklist checked in with an initial pass filled in for the dashboard at minimum.
|
||||
- [x] `a11y.mdx` renders; links to checklist and WP-01 skip rules.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
- Used the official `angular.configs.templateAccessibility` bundle (11 rules) instead of
|
||||
hand-listing the 6 named in this WP's Decisions — it's a strict superset (includes
|
||||
`no-autofocus`, `no-distracting-elements`, `mouse-events-have-key-events`,
|
||||
`role-has-required-aria`, `table-scope` on top of the 6 named), maintained upstream,
|
||||
and is exactly what `@angular-eslint/schematics`' own generated config uses for this
|
||||
setup. Less code to hand-maintain, same coverage plus more.
|
||||
- The dashboard's checklist pass surfaced a **real bug**: `aanvraag-block`'s warning
|
||||
`app-alert` (two `app-button` actions) overflows the viewport at 320px — its
|
||||
`.feedback` flex row doesn't wrap. Documented in `docs/reference/wcag-checklist.md` with the
|
||||
root cause, **not fixed** — fixing live component CSS found via the checklist is the
|
||||
"full manual audit" scope this WP's Out-of-scope section explicitly defers, not this
|
||||
WP's own deliverable. Flagged here so it isn't lost.
|
||||
- "Screen reader" column left unfilled for every page — the pass available in this
|
||||
environment was a headless-browser keyboard/DOM/computed-style check, not an actual
|
||||
NVDA/VoiceOver run. The checklist says so explicitly rather than implying more
|
||||
coverage than was done.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci`. Manual: keyboard-only walk of
|
||||
login → dashboard → wizard → brief.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Full manual audit of every page (the checklist enables it; filling it beyond the
|
||||
dashboard is ongoing work, not this WP).
|
||||
|
||||
## Risks
|
||||
|
||||
- angular-eslint's inline-template processor silently not running → the planted-violation
|
||||
step is mandatory.
|
||||
- Focus-move vs `withViewTransitions` timing → `afterNextRender` + test in a real browser
|
||||
(`npm start`), not just unit-level.
|
||||
@@ -0,0 +1,191 @@
|
||||
# WP-18 — ABAC capability spine (Principal + capabilities, phase P1)
|
||||
|
||||
Status: done (7ec13d8)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
> **Follow-up (P2/P3-lite delivered later).** On top of this P1 spine:
|
||||
>
|
||||
> - **P2 field-level PII (§5c):** the case screen's **BIG-nummer** now ships masked
|
||||
> (`Authz.CanRevealBigNummer` + `BriefDecisionsDto.CanRevealBigNummer`); a
|
||||
> step-up-stubbed (`X-Step-Up` header), audited `POST /brief/reveal-bignummer` unmasks
|
||||
> it. Realized on the BIG-nummer, not the BSN, because **no BSN travels the wire** (see
|
||||
> PRD-0002 §5c note).
|
||||
> - **P3-lite audit + guard (§8, §6):** a no-PII `AuditAuthz` log line records reveal
|
||||
> attempts (allow/deny) and org-admin denials; the already-built `capabilityGuard` is
|
||||
> now wired onto the `brief/huisstijl` admin route.
|
||||
>
|
||||
> Still unbuilt: data-scoping (§5b), real step-up/MFA, break-glass.
|
||||
|
||||
## Why
|
||||
|
||||
The single biggest gap between this POC and a production SSP: identity carries no
|
||||
roles/capabilities (`Session { bsn, naam }` only), the only "role" is an unverified
|
||||
`?role=` query param stamped as an `X-Role` header, and `BriefStore.editable`
|
||||
computed its authorization gate **in the frontend** from that header — the exact
|
||||
anti-pattern ADR-0001 exists to prevent (FE renders decisions, never computes them).
|
||||
The backend was fully open: no `[Authorize]`, no principal, ownership is a constant
|
||||
`DemoOwner`. ADR-0002 and PRD-0002 already designed the fix; this WP implements
|
||||
PRD-0002's **P1 — Capability spine** only (§9), the smallest slice that closes the
|
||||
anti-pattern and gives every later phase (data-scoping, PII redaction, step-up/audit)
|
||||
a real foundation to extend.
|
||||
|
||||
## Read first
|
||||
|
||||
- `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` (the `Principal`
|
||||
union, identity-vs-authorization split — see the deviation noted below)
|
||||
- `docs/project/prd/0002-attribute-based-access-control.md` §5a, §6, §7, §9-P1
|
||||
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new — the single
|
||||
authorization helper)
|
||||
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`Review` — now delegates its
|
||||
SoD guard to `Authz.CanActOn`)
|
||||
- `src/app/brief/application/brief.store.ts` (the FE-computed gate that was removed)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **P1 scope only.** No data-scoping, no PII redaction/BSN reveal, no step-up or
|
||||
audit log — those are PRD-0002 §9 P2/P3, separate future WPs.
|
||||
- **The AD/OIDC identity provider stays simulated** (PRD-0002 §3 non-goal). The
|
||||
`Principal` is built server-side from the existing dev stand-in (`X-Role` header),
|
||||
but it becomes the backend's own construct — the FE never re-derives capabilities
|
||||
from the header, it only reads what the backend sends.
|
||||
- **Capability naming**: stable, namespaced strings per PRD-0002 §5a — exactly
|
||||
`brief:approve`, `brief:reject`, `brief:send` (the only role-gated flow that
|
||||
exists today). The brief screen's fourth flag, `canEdit`, is a **screen decision**
|
||||
on `BriefDecisionsDto`, not a named capability string — it's resource/state-scoped
|
||||
(draft/rejected + drafter role) the same way `HerregistratieDecisionsDto` blends
|
||||
business state into a decision flag, and `GET /me`'s coarse `RoleCapabilities` set
|
||||
stays exactly the three above.
|
||||
- **Emit and enforce are the same code path for approve/reject.**
|
||||
`Authz.CanActOn(action, principal, drafterId)` is the SAME check
|
||||
`BriefStore.Review` uses to gate the mutation and `Authz.Decisions` uses to compute
|
||||
the DTO flag — never two separate checks that can drift (PRD-0002 §7, the classic
|
||||
BOLA bug it calls out). `Send` is deliberately **not** role-gated (see Risks) —
|
||||
that parity is preserved exactly, decisions only mirror it.
|
||||
- **Dev role toggle survives** as the POC's identity stub: `?role=` still picks an
|
||||
identity for demo purposes, resolved into a `Principal` server-side via
|
||||
`Authz.ResolvePrincipal`. Commented `dev stub — NOT a security boundary` per
|
||||
PRD-0002 §3.
|
||||
- **Deviation from the original plan — `auth/domain/session.ts` is untouched.** An
|
||||
earlier draft of this WP planned a `Session → Principal` rename in the SSP's login
|
||||
domain. That's **out of scope**: ADR-0002 explicitly lists that refactor as
|
||||
"deferred until a second actor is actually introduced" (§"Out of scope here"), and
|
||||
no second actor exists yet — renaming a type to a one-variant union ahead of that
|
||||
need is exactly the premature abstraction the ADR warns against. It also turned
|
||||
out unnecessary: the brief workflow's drafter/approver "acting identity" is a
|
||||
**separate axis** from the SSP login session (a Zorgverlener logs in via BSN;
|
||||
drafter/approver is an independent `?role=` toggle, not tied to that login). This
|
||||
WP's `Principal` therefore lives entirely in the backend's
|
||||
`BigRegister.Domain.Authorization` namespace and never touches `auth/`.
|
||||
|
||||
## Files (as built)
|
||||
|
||||
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new) — `Principal`,
|
||||
`PrincipalRole`, `BriefAction`, `Authz.ResolvePrincipal/ActingId/RoleCapabilities/
|
||||
CanActOn/Decisions`.
|
||||
- `backend/src/BigRegister.Api/Contracts/Dtos.cs` — added `BriefDecisionsDto(CanEdit,
|
||||
CanApprove, CanReject, CanSend)` on `BriefViewDto`; added `MeDto(Capabilities)`.
|
||||
- `backend/src/BigRegister.Api/Data/BriefStore.cs` — `Approve`/`Reject`/`Review` take
|
||||
a `Principal` + `BriefAction` and delegate the SoD check to `Authz.CanActOn`
|
||||
(same Forbidden-before-Conflict ordering as before).
|
||||
- `backend/src/BigRegister.Api/Program.cs` — `GET /api/v1/me`; every brief endpoint
|
||||
(including `send`, which had no `HttpContext` before) now returns a fresh
|
||||
`BriefViewDto` (via a shared `ToView`/`BriefResult` helper) so decisions are never
|
||||
stale after a mutation.
|
||||
- `backend/tests/BigRegister.Tests/AuthzTests.cs` (new) — unit tests for `Authz`.
|
||||
- `backend/tests/BigRegister.Tests/BriefEndpointTests.cs` — updated to deserialize
|
||||
`BriefViewDto` (not bare `BriefDto`) from submit/approve/reject/send; two new
|
||||
tests for live decisions and `/me`.
|
||||
- `src/app/shared/domain/capability.ts` (new) — the `Capability` union type.
|
||||
- `src/app/shared/infrastructure/me.adapter.ts` (+ spec, new) — `GET /me` adapter +
|
||||
`parseMe` boundary (unknown capability strings are dropped, not rejected).
|
||||
- `src/app/shared/application/access.store.ts` (new) — `AccessStore.can()`,
|
||||
deny-by-default.
|
||||
- `src/app/auth/auth.guard.ts` — added `capabilityGuard(capability)` factory.
|
||||
**Built but deliberately unwired**: no route in this app needs a capability gate
|
||||
today (both drafter and approver land on the same `/brief` page; the gating is
|
||||
per-action, not per-page). It's the available building block for a future
|
||||
approver-only page.
|
||||
- `src/app/brief/domain/brief.ts` — added the `BriefDecisions` domain type.
|
||||
- `src/app/brief/domain/brief.machine.ts` (+ spec) — `BriefState.loaded` and the
|
||||
`BriefLoaded`/`Submitted`/`Approved`/`Rejected`/`Sent` messages now carry
|
||||
`decisions`; the pure `transition()` helper replaces them with each fresh
|
||||
server value.
|
||||
- `src/app/brief/infrastructure/brief.adapter.ts` (+ spec) — `save/submit/approve/
|
||||
reject/send` now return `Result<string, BriefView>` (was `Brief`) via
|
||||
`parseBriefView`, which also parses `decisions`.
|
||||
- `src/app/brief/application/brief.store.ts` — deleted `currentRole()`/`editable`;
|
||||
added `canEdit`/`canApprove`/`canReject`/`canSend` computed straight from
|
||||
`BriefState.loaded.decisions`.
|
||||
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (+ stories) —
|
||||
`editable`/`role` inputs replaced by the four `can*` inputs; the approve/reject
|
||||
block gates on `canApprove() || canReject()`, the send button on `canSend()`.
|
||||
- `src/app/brief/ui/brief.page.ts` — passes the four `can*` signals through.
|
||||
- `src/app/shared/infrastructure/role.ts` — comment updated (no longer claims the
|
||||
FE derives `editable` from the role reader).
|
||||
- Regenerated `backend/swagger.json` + `src/app/shared/infrastructure/api-client.ts`
|
||||
via `npm run gen:api` (new `/me` endpoint + DTO shapes).
|
||||
|
||||
## Steps (as executed)
|
||||
|
||||
1. Backend: `Authz.cs`, DTOs, `BriefStore` delegation, `Program.cs` wiring
|
||||
(`GET /me` + `BriefResult`/`ToView`) — kept `dotnet test` green throughout
|
||||
(79/79 including 10 new tests).
|
||||
2. `npm run gen:api` to pick up the new endpoint/DTOs before touching the FE.
|
||||
3. FE domain: `BriefDecisions`, machine state/messages, machine spec fixtures.
|
||||
4. FE infrastructure: `parseDecisions`/`parseBriefView` in `brief.adapter.ts` (+spec).
|
||||
5. FE application: `brief.store.ts`'s computed flags; `access.store.ts` +
|
||||
`me.adapter.ts` (+spec) as the general capability-spine infrastructure.
|
||||
6. FE UI: `letter-composer` inputs/template, `brief.page.ts` bindings, stories.
|
||||
7. Full GREEN gate + a live curl smoke test against the running backend (submit as
|
||||
drafter → 403 on approve as drafter → 200 on approve as approver, with decisions
|
||||
flipping correctly at each step).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `brief.store.ts` contains no `currentRole()` call and no FE-computed
|
||||
permission boolean; `canApprove`/`canReject`/`canSend` come from the DTO.
|
||||
- [x] The SoD rule is enforced server-side regardless of FE state — verified by
|
||||
curl directly against the backend (drafter calling `/brief/approve` → 403)
|
||||
and by `AuthzTests`/`BriefEndpointTests`, bypassing the FE entirely.
|
||||
- [x] `Authz.CanActOn`/`Authz.Decisions` is the only place brief authorization logic
|
||||
lives; the emit path (DTO flags) and the enforce path (`BriefStore.Review`)
|
||||
both call it.
|
||||
- [x] `GET /me` returns capabilities; `AccessStore.can()` defaults to `false` for an
|
||||
unknown capability (deny-by-default, verified in `me.adapter.spec.ts`).
|
||||
- [x] The existing SoD rule (approver ≠ drafter) still holds, expressed as
|
||||
`Authz.CanActOn` instead of the old inline check in `BriefStore.Review`.
|
||||
- [x] `capabilityGuard` compiles; documented as available-but-unwired (no route
|
||||
needs it yet — see Files).
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN gate, all green: `npm run lint && npm run check:tokens && npm test && npm run
|
||||
build && npm run build-storybook && npm run test-storybook:ci` (189 unit tests, 137
|
||||
Storybook/a11y tests) + `cd backend && dotnet test` (79/79) +
|
||||
`dotnet format --verify-no-changes`. Manual smoke via curl against a running
|
||||
backend: default (drafter) `GET /brief` → `canEdit: true`; submit → decisions
|
||||
recompute; drafter `POST /brief/approve` → 403; approver `POST /brief/approve` →
|
||||
200, `canSend: true` afterward. `GET /me` → `[]` for drafter,
|
||||
`["brief:approve","brief:reject","brief:send"]` for approver.
|
||||
|
||||
## Out of scope
|
||||
|
||||
PRD-0002 P2 (data-scoping, PII/BSN redaction) and P3 (step-up, break-glass, audit
|
||||
log) — separate future WPs. The Behandeling/backoffice app and a `medewerker`
|
||||
`Principal` variant (ADR-0002 — no second actor exists yet, still YAGNI). Real
|
||||
AD/OIDC integration (identity provider stays simulated). The `auth/domain/session.ts`
|
||||
`Session → Principal` rename (see the Decisions deviation above — ADR-0002 defers
|
||||
it explicitly).
|
||||
|
||||
## Risks
|
||||
|
||||
`Send` was already unauthenticated/unauthorized before this WP (no role check on
|
||||
`POST /brief/send`) — `Authz.CanActOn(Send, …)` preserves that exactly
|
||||
(`=> true`, a mechanical dispatch step) rather than silently introducing a new gate
|
||||
that would have broken the existing `Send_only_from_approved` test (which calls
|
||||
`send` as the default drafter identity and expects success). If a future WP decides
|
||||
`send` should be approver-only, that's a deliberate behavior change, not a bug fix.
|
||||
Every brief mutation endpoint now returns `BriefViewDto` instead of bare `BriefDto`
|
||||
— a wire-shape change; the generated `api-client.ts` was regenerated and every FE
|
||||
call site updated, but any other caller of these endpoints outside this repo would
|
||||
need the same update.
|
||||
@@ -0,0 +1,140 @@
|
||||
# WP-19 — Playwright e2e smoke
|
||||
|
||||
Status: done (pending commit)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
|
||||
There is no end-to-end test anywhere in the repo — no Playwright/Cypress config, no
|
||||
`e2e/` directory. `axe-playwright` is already a dependency (used by
|
||||
`test-storybook:ci` to run axe against Storybook, `.storybook/test-runner.ts`), but
|
||||
nothing drives the actual running app through a real browser. The GREEN gate proves
|
||||
every unit and component-in-isolation, never a real user flow through the FE+backend
|
||||
wired together — the thing a demo/reference app should be able to prove first.
|
||||
|
||||
## Read first
|
||||
|
||||
- `README.md` "Run it" + "See every data state (scenario toggle)" — the flows to
|
||||
cover
|
||||
- `docker-compose.yml` (the two-service dev topology e2e can run against)
|
||||
- `.storybook/test-runner.ts` (existing Playwright-adjacent config in the repo, for
|
||||
browser-launch precedent, though it drives Storybook not the app)
|
||||
- `src/app/shared/infrastructure/scenario.interceptor.ts` (the `?scenario=` toggle —
|
||||
reuse it for the error-path test instead of mocking the network)
|
||||
- `.github/workflows/ci.yml` (the `storybook-a11y` job's `playwright install
|
||||
--with-deps chromium` step — same install pattern for a new e2e job)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **Playwright, not Cypress.** `axe-playwright` is already a dependency and the repo
|
||||
already has one Playwright-based CI job (`storybook-a11y`); adding Cypress would
|
||||
be a second, redundant browser-automation toolchain.
|
||||
- **Smoke-level coverage only**: one happy-path flow end to end, one degraded-path
|
||||
flow via `?scenario=`. This is not a full e2e suite — it proves the seam works,
|
||||
it doesn't replace component/unit tests.
|
||||
- **Run against the real backend**, not a mock server — the point is proving FE+BE
|
||||
integration, which is exactly what unit tests (mocked adapters) don't cover.
|
||||
- Faked auth (`digid.adapter.ts`) is used as-is: e2e logs in with any 9-digit BSN,
|
||||
no special e2e auth bypass.
|
||||
|
||||
## Files
|
||||
|
||||
- New `playwright.config.ts` at repo root — `baseURL` from an env var (default
|
||||
`http://localhost:4200`), `webServer` config that can optionally boot `ng serve`
|
||||
(skip if `CI` already starts the app in a prior step — see Steps).
|
||||
- New `e2e/smoke.spec.ts` — the happy path.
|
||||
- New `e2e/error-state.spec.ts` — the `?scenario=error` path.
|
||||
- `package.json` — add `"e2e": "playwright test"` script; `@playwright/test` devDependency.
|
||||
- `.github/workflows/ci.yml` — new job `e2e`, steps: checkout, setup-node, setup-dotnet,
|
||||
`npm ci`, `npx playwright install --with-deps chromium`, `npm run e2e`.
|
||||
`timeout-minutes: 15` per the hardened workflow convention already in `ci.yml`. Playwright's
|
||||
own `webServer` config (`playwright.config.ts`) starts + waits-for-ready + tears down both
|
||||
the backend and `ng serve` inside that one step — see the "backgrounding servers" deviation
|
||||
below for why this replaced the originally-planned `&`/`wait-on` approach.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Install `@playwright/test`; scaffold `playwright.config.ts` with a single
|
||||
`chromium` project (match `test-storybook:ci`'s browser choice).
|
||||
2. `e2e/smoke.spec.ts`: navigate to `/login`, submit a BSN, land on `/dashboard`,
|
||||
assert real dashboard content renders (not a loading/error state), navigate into
|
||||
one wizard (herregistratie or registratie change-request), fill the minimum
|
||||
required fields, submit, assert a success state.
|
||||
3. `e2e/error-state.spec.ts`: navigate to `/dashboard?scenario=error`, assert the
|
||||
error alert + "Opnieuw proberen" button render (`<app-async>`'s error slot),
|
||||
click retry, assert it re-fetches (scenario is per-request so a retry without the
|
||||
query param would succeed — confirm the interceptor's actual behavior first and
|
||||
assert accordingly).
|
||||
4. Wire the CI job; verify it's independent of (doesn't block or get blocked by) the
|
||||
existing jobs — add to `concurrency`/`timeout-minutes` conventions already in `ci.yml`.
|
||||
5. Document `npm run e2e` in `README.md`'s command list.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `npm run e2e` passes locally against `docker compose up` or `npm start` +
|
||||
`dotnet run` run manually.
|
||||
- [x] CI job `e2e` is green and runs on every PR alongside the existing jobs.
|
||||
- [x] The happy-path spec exercises a real wizard submit against the real backend
|
||||
(not mocked) and asserts on the resulting UI state.
|
||||
- [x] The error-path spec exercises `<app-async>`'s error slot + retry via the real
|
||||
`?scenario=error` toggle, not a mocked HTTP response.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
- **Backgrounding servers as separate CI steps doesn't work.** The Files section originally
|
||||
planned starting the backend and `ng serve` with `&` in one step, then `wait-on`-ing both
|
||||
ports in a later step. A process backgrounded with `&` in one GitHub/Gitea Actions `run:`
|
||||
step is killed when that step's shell exits — so the later `wait-on` step hung forever on
|
||||
servers that were already gone (**the 2-hour e2e hang**). Fixed by moving server lifecycle
|
||||
into Playwright itself: `playwright.config.ts`'s `webServer` array starts both processes,
|
||||
waits for each to be ready, runs the suite, and tears them down — all inside the single
|
||||
`npm run e2e` process/step, so it's self-contained locally and in CI alike.
|
||||
- **Found and fixed a real bug while writing the error-path spec**: `AsyncComponent`'s
|
||||
built-in `retry()` only calls `.reload()` on a `[resource]` input — every real page
|
||||
(`dashboard`, `registration-detail`, `aanvraag-detail`, `brief`) feeds `<app-async>`
|
||||
via `[data]` (a store's combined `RemoteData`), so clicking "Opnieuw proberen" was a
|
||||
silent no-op everywhere except the showcase teaching page. Added a `retryClicked`
|
||||
output that fires regardless of feed mode, and wired the two dashboard instances
|
||||
(`BigProfileStore.reloadProfile()`/`reloadAantekeningen()`) since that's what this
|
||||
WP's spec exercises. **Not fixed**: `registration-detail`, `aanvraag-detail`, and
|
||||
`brief` pages still have the same latent no-op retry — same "found via testing,
|
||||
fixing the whole surface is beyond this WP" call as WP-17's dashboard CSS finding.
|
||||
Flagging here so it isn't lost.
|
||||
- Confirmed `currentScenario()` reads `window.location.search` fresh on every call —
|
||||
the error-path spec's retry assertion had to change from "counts a browser network
|
||||
request" (the scenario interceptor never reaches the real transport; it substitutes
|
||||
`throwError` in the rxjs pipe before `next(req)`) to "observes a real Loading→Failure
|
||||
reload cycle via `aria-busy`". The Steps section's literal suggestion ("assert it
|
||||
re-fetches... via a network tab") didn't hold; adapted per the WP's own Risks note
|
||||
to verify actual interceptor behavior first.
|
||||
- Verified the suite isn't a no-op per the Verification section: temporarily broke
|
||||
`diplomaOptions`' `value: d.id` (appended `-x`), watched `smoke.spec.ts` fail on the
|
||||
now-missing `#diploma-d1` selector, reverted.
|
||||
- The registratie wizard's minimum path needed an actual file upload (`identiteit` is
|
||||
always required for `registratie` regardless of diploma choice, per
|
||||
`DocumentRules.CategoriesFor` — only `diploma`/`taalvaardigheid` are answer-gated).
|
||||
Picked the first DUO diploma (non-English, `Engelstalig: false`) specifically because
|
||||
it carries zero policy questions, keeping the happy path to one upload.
|
||||
- CIBG-styled radios hide the native `<input>` behind its `<label>` — Playwright's
|
||||
`.check()` on the input times out fighting the label for pointer events; the specs
|
||||
click the `label[for=...]` instead (also more representative of a real click).
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run e2e` locally; then push a branch and confirm the new `e2e` CI job appears
|
||||
and passes. Cross-check that a deliberately broken flow (e.g. temporarily rename a
|
||||
required form field) fails the e2e spec, proving it isn't a no-op.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Full e2e coverage of every wizard/flow; visual regression testing; cross-browser
|
||||
matrix (chromium only, matching the existing a11y job); load/performance testing.
|
||||
|
||||
## Risks
|
||||
|
||||
The `?scenario=` interceptor is dev-only (`isDevMode()` gated, per
|
||||
`app.config.ts`) — confirm the e2e target build runs in dev mode (it does via `ng
|
||||
serve`/`npm start`; a production `ng build` would need the toggle unavailable,
|
||||
which is correct and should be asserted, not worked around). Backend
|
||||
in-memory stores mean e2e runs against a fresh seed each restart — don't assert on
|
||||
data that a previous test run could have mutated; restart the backend per CI run.
|
||||
@@ -0,0 +1,103 @@
|
||||
# WP-20 — Second locale proof
|
||||
|
||||
Status: done (e276629)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
|
||||
CLAUDE.md's conventions claim "a second locale is a translation file, not a code
|
||||
change (the seam)" — every user-facing string is already wrapped in `$localize`
|
||||
with a stable `@@context.key` id. But `angular.json` has no `i18n` block, no
|
||||
`locales` config, and there is no extracted `.xlf` file anywhere in the repo. The
|
||||
seam is built into every component but never proven to actually work end to end.
|
||||
|
||||
## Read first
|
||||
|
||||
- `CLAUDE.md` "User-facing copy = `$localize`" convention
|
||||
- `angular.json` (current build config — no `i18n` section)
|
||||
- A handful of `$localize` call sites to confirm id conventions are consistent
|
||||
enough to extract cleanly: `src/app/shared/application/submit.ts`
|
||||
(`@@submit.failed`), `src/app/registratie/domain/value-objects/postcode.ts`
|
||||
(`@@validation.postcode`)
|
||||
- Angular's `@angular/localize` extraction tooling (`ng extract-i18n`) — no new
|
||||
dependency needed, it ships with the Angular CLI already in use
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **English (`en`) is the second locale** — arbitrary but concrete; proves the
|
||||
mechanism without requiring a real translator. Machine-translate or hand-write a
|
||||
handful of strings, mark the rest with an obvious placeholder prefix if time-boxed
|
||||
(e.g. `[EN] ` prefix) rather than leaving them silently untranslated — silent
|
||||
fallback-to-source would look like the feature works when it's actually untested.
|
||||
- **Source locale stays `nl`**, unchanged (CLAUDE.md is explicit about this).
|
||||
- **Build-time locale switching** (Angular's standard `i18n` merge, separate output
|
||||
per locale), not a runtime-swappable locale — that matches how `$localize` +
|
||||
Angular CLI actually work and avoids inventing a custom i18n runtime.
|
||||
- This WP proves the seam; it does not translate the whole app to production
|
||||
quality. A partial/placeholder `en` file is acceptable if every string has _some_
|
||||
translation (even if imperfect) — the acceptance bar is "the build seam works and
|
||||
every id resolves," not "the English copy is publication-ready."
|
||||
|
||||
## Files
|
||||
|
||||
- `angular.json` — add `i18n.sourceLocale: "nl"` and `i18n.locales.en` pointing at
|
||||
the new translation file; add an `en` configuration under `build`/`serve` that
|
||||
merges it (standard Angular CLI i18n scaffolding, `ng add @angular/localize` if
|
||||
the schematic isn't already fully wired).
|
||||
- New `src/locale/messages.en.xlf` (or `.json`, whichever `ng extract-i18n`
|
||||
defaults to) — the translation file, generated then filled in.
|
||||
- `package.json` — add `"extract-i18n": "ng extract-i18n --output-path src/locale"`
|
||||
script.
|
||||
- `.github/workflows/ci.yml` — extend the `frontend` job (or add a step) to build
|
||||
both locales: `ng build --localize` (builds all configured locales in one pass)
|
||||
or two explicit `ng build --configuration=production,en` invocations — pick
|
||||
whichever the Angular 22 CLI supports cleanly and document the choice inline.
|
||||
- `README.md` — note the second-locale build under "Tech notes," replacing the
|
||||
implicit claim with a demonstrated one (link to how to build/run the `en` locale).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `ng extract-i18n` once to generate the master translation file from every
|
||||
`$localize`/`i18n="@@id"` call site; commit it as the `nl` reference (or the tool's
|
||||
default source-language artifact, per Angular's convention).
|
||||
2. Copy it to `messages.en.xlf`, fill in English text for every `<trans-unit>`
|
||||
(or your chosen placeholder strategy per the Decisions above).
|
||||
3. Wire `angular.json`'s `i18n` block + an `en` build configuration.
|
||||
4. `ng build --localize` (or the two-configuration equivalent) — confirm two output
|
||||
bundles (`dist/.../nl/`, `dist/.../en/`) each serve correctly with `ng serve
|
||||
--configuration=en` or a static server against the `en` output.
|
||||
5. Wire CI to build both locales as part of the existing `build` step (or a
|
||||
parallel step) so a broken translation file fails CI, not just a local build.
|
||||
6. Spot-check the `en` build in a browser: login page, dashboard, one wizard step —
|
||||
confirm English strings render, layout doesn't break on longer/shorter text.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `ng extract-i18n` runs clean (no missing/duplicate `@@id`s).
|
||||
- [x] `messages.en.xlf` exists with a translation for every extracted unit.
|
||||
- [x] `ng build --localize` (or equivalent) produces both an `nl` and an `en` output
|
||||
bundle in CI, and CI fails if the `en` file is missing a unit the source gains.
|
||||
- [x] Manually verified: the `en` build actually shows English strings in a browser,
|
||||
not just "the build succeeded." (`login.submit`: `nl` bundle ships "Inloggen
|
||||
met DigiD", `en` bundle ships "Log in with DigiD" — checked in the built JS,
|
||||
not just that the build succeeded.)
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run extract-i18n` locally, diff against the committed file to confirm no drift;
|
||||
`ng build --localize` locally, serve the `en` output, click through login →
|
||||
dashboard → one wizard. GREEN gate stays green (the `nl` build is unaffected).
|
||||
|
||||
## Out of scope
|
||||
|
||||
Professional/accurate English translation (placeholder-quality is acceptable per
|
||||
Decisions); a locale switcher in the running app UI (build-time locale selection
|
||||
only, per Decisions); RTL locales or pluralization edge cases beyond what
|
||||
`$localize` already handles by default.
|
||||
|
||||
## Risks
|
||||
|
||||
`ng extract-i18n` may surface `$localize` call sites with inconsistent or missing
|
||||
`@@id`s that currently work fine at runtime (ids are optional for `$localize` to
|
||||
function, but required for clean extraction) — budget time to add ids where
|
||||
missing rather than treating every gap as a bug to fix elsewhere.
|
||||
@@ -0,0 +1,151 @@
|
||||
# WP-21 — Resilience seams (correlation-id, idempotency, retry)
|
||||
|
||||
Status: done (40dbcb2)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
|
||||
`api-client.provider.ts`'s own header comment lists four cross-cutting seams and
|
||||
marks three "done" — but two of the three are only half-done, and the fourth
|
||||
(retry/backoff) is an explicit unfilled seam:
|
||||
|
||||
- **Correlation id**: the FE generates a fresh `X-Correlation-Id` per request
|
||||
(`api-client.provider.ts:29`), but the backend only _reads_ it opportunistically
|
||||
inside the `Submit` helper (`Program.cs:344`) for log lines — there's no
|
||||
middleware, so most endpoints never see or echo it, and it's never returned to
|
||||
the caller for support/debugging correlation.
|
||||
- **Idempotency key**: generated per-attempt (`api-client.provider.ts:31`), which
|
||||
the same comment admits defeats its own purpose — "a real retry would thread a
|
||||
STABLE key per logical submit so re-sends dedupe; here it's per-attempt." A retry
|
||||
today would double-submit, not dedupe.
|
||||
- **Retry/backoff**: not implemented at all — the comment names it as the one
|
||||
remaining line to add, never added.
|
||||
|
||||
## Read first
|
||||
|
||||
- `src/app/shared/infrastructure/api-client.provider.ts` (the whole seam-comment
|
||||
block at the top, lines 10-22, plus `httpClientFetch`'s header-building code)
|
||||
- `backend/src/BigRegister.Api/Program.cs:344-368` (`Submit` helper — where
|
||||
`X-Correlation-Id` is read today, and the only place)
|
||||
- `backend/src/BigRegister.Api/Data/DocumentStore.cs:16` (`AuditEntry` — same
|
||||
correlation id shape reused for audit `Actor` today, see `Program.cs:361` passing
|
||||
`cid` as the audit actor for post-delivery)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **Correlation id becomes ASP.NET Core middleware**, not a per-endpoint read: every
|
||||
request gets a correlation id (client-supplied `X-Correlation-Id` if present,
|
||||
else server-generated), it's pushed into the logging scope for every log line in
|
||||
that request (not just `Submit`'s), and echoed back as a response header so the
|
||||
FE/caller can log it too.
|
||||
- **Idempotency key becomes stable per logical operation**, generated once when a
|
||||
submit/mutation _starts_ (e.g. once per wizard's submit action) and reused across
|
||||
retries of that same logical attempt — not regenerated on every HTTP call. This
|
||||
is a FE-side change (where the key is generated) plus a backend-side change
|
||||
(actually deduping on it — see Files).
|
||||
- **Retry/backoff applies only to idempotent GETs**, using rxjs `retry({ count,
|
||||
delay })` in `httpClientFetch`'s pipe, per the existing header comment's own
|
||||
suggestion. Writes are never auto-retried (the point of item above is making
|
||||
retries _safe_, not making everything retry automatically — a POST retry policy
|
||||
is a separate, larger decision about at-least-once semantics best left for when a
|
||||
real backend needs it).
|
||||
- Server-side idempotency _deduplication_ (actually short-circuiting a repeated key
|
||||
to return the first result) is scoped to the submit endpoints only
|
||||
(`Program.cs`'s `Submit` helper callers) — not every mutation — since that's
|
||||
where the existing seam already concentrates correlation/idempotency handling.
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/src/BigRegister.Api/Program.cs` — add correlation-id middleware
|
||||
(`app.Use(async (ctx, next) => { … })` near the top of the pipeline, before route
|
||||
registration): read-or-generate `X-Correlation-Id`, stash in
|
||||
`ctx.Items`/`HttpContext`, push into `ILogger` scope
|
||||
(`BeginScope(new Dictionary<string,object>{["CorrelationId"]=cid})`), set it on
|
||||
`ctx.Response.Headers` before the response is written.
|
||||
- `backend/src/BigRegister.Api/Program.cs` — simplify the `Submit` helper's own
|
||||
`cid` read (now redundant with the middleware-populated value; read from
|
||||
`HttpContext.Items` or inject via a lightweight accessor) so every log line in
|
||||
`Submit` picks up the same id without re-parsing the header.
|
||||
- New backend idempotency check: a small in-memory `IdempotencyStore` (same pattern
|
||||
as `ApplicationStore`/`DocumentStore` — static dict + lock, ponytail-labeled with
|
||||
the upgrade path to a real cache/store) keyed on `Idempotency-Key`, consulted by
|
||||
the submit endpoints before calling `SubmissionRules.NewReference()`; returns the
|
||||
cached response on a replayed key instead of minting a new reference.
|
||||
- `src/app/shared/infrastructure/api-client.provider.ts` — generate the
|
||||
`Idempotency-Key` once per logical submit rather than per HTTP attempt (thread it
|
||||
in from the caller — likely means the submit commands in `application/submit-*.ts`
|
||||
generate and pass the key, not the low-level fetch adapter); add
|
||||
`retry({ count: 2, delay: 500 })` (or similar) to the GET-only path in the rxjs
|
||||
pipe, gated on `method === 'GET'`.
|
||||
- New backend test `backend/tests/BigRegister.Tests/IdempotencyTests.cs` — replay a
|
||||
submit with the same `Idempotency-Key`, assert the same reference comes back and
|
||||
`SubmissionRules.NewReference()` was not called twice (or assert the observable
|
||||
effect: identical response body).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Backend middleware for correlation id first (smallest, most mechanical change);
|
||||
confirm every existing log line still works and now the id is consistent
|
||||
end-to-end, not just inside `Submit`.
|
||||
2. Backend `IdempotencyStore` + wiring into the submit endpoints; test the replay
|
||||
behavior.
|
||||
3. FE: move idempotency-key generation up to the command layer
|
||||
(`submit-change-request.ts` and equivalents) so one logical submit = one key
|
||||
even if `runSubmit`/the HTTP layer retries underneath.
|
||||
4. FE: add GET retry/backoff in `httpClientFetch`; verify it doesn't retry writes
|
||||
(assert via a spec on the adapter, or a targeted e2e/manual check with the
|
||||
`?scenario=slow` toggle).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Every backend log line for a given request shares one correlation id (not
|
||||
just lines inside `Submit`); the id is echoed in the response headers.
|
||||
Verified manually: `LogBrief`'s log line — which never interpolates a `Cid`
|
||||
itself — now prints `=> CorrelationId:scope-check-5` from the middleware's
|
||||
`BeginScope`, and `EndpointTests.Correlation_id_supplied_by_the_caller_is_echoed_back`
|
||||
/ `..._is_generated_when_the_caller_omits_it` cover the response header.
|
||||
- [x] Replaying a submit with the same `Idempotency-Key` returns the same result
|
||||
without minting a second reference (backend test proves this —
|
||||
`IdempotencyTests`, 3 cases: same key twice, different keys, a replayed
|
||||
rejection).
|
||||
- [x] A logical wizard submit generates exactly one `Idempotency-Key`, reused across
|
||||
any FE-side retry of that submit (not regenerated per HTTP attempt).
|
||||
`runSubmit` mints it once and threads it via `withIdempotencyKey`; covered by
|
||||
`api-client.provider.spec.ts`.
|
||||
- [x] GET requests retry on transient failure (e.g. simulated via `?scenario=slow`
|
||||
or a forced 5xx); POST/PUT/DELETE never auto-retry. Covered by
|
||||
`api-client.provider.spec.ts` (3 retries on GET, 1 attempt on POST).
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `cd backend && dotnet test` (84 passing) — done. Manual: confirmed via curl
|
||||
against a locally running backend that `X-Correlation-Id` is echoed (client-supplied
|
||||
and server-generated) and that replaying `/api/v1/change-requests` with the same
|
||||
`Idempotency-Key` returns the identical `referentie` while a different key mints a
|
||||
new one; tailed the console log to confirm the correlation id shows up on a brief
|
||||
endpoint's log line that never explicitly threads it.
|
||||
|
||||
**Deviation**: the `?scenario=error` network-tab check this section originally
|
||||
proposed doesn't actually work — `scenario.interceptor.ts`'s `error` case
|
||||
`throwError`s before ever calling `next(req)`, so no real request reaches the
|
||||
network stack and devtools shows nothing to retry. Automated tests
|
||||
(`api-client.provider.spec.ts`, using a fake `HttpClient`-shaped `.request()` so no
|
||||
TestBed/HttpClientTestingModule is needed) are the actual proof of the retry
|
||||
mechanism instead — a more reliable check than a manual browser pass would have
|
||||
been anyway.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Retrying writes automatically (explicitly deferred, see Decisions); a durable
|
||||
idempotency store surviving restart (in-memory is consistent with the rest of the
|
||||
backend's persistence posture — see WP-22 if that changes); circuit breakers or
|
||||
more advanced resilience patterns (Polly, etc.) — out of scope for a POC-scale
|
||||
seam.
|
||||
|
||||
## Risks
|
||||
|
||||
Correlation-id middleware ordering matters — it must run before any endpoint that
|
||||
logs, including error-handling middleware, or some log lines will still lack the
|
||||
id. The idempotency store trades a small amount of memory for correctness under
|
||||
replay; fine at demo scale, but the ponytail comment should name the real upgrade
|
||||
(a TTL'd cache) so it isn't mistaken for a production-ready dedup mechanism.
|
||||
@@ -0,0 +1,185 @@
|
||||
# WP-22 — Durable persistence (optional tier)
|
||||
|
||||
Status: done (556f2f4)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
|
||||
Every backend store (`ApplicationStore`, `DocumentStore`, `BriefStore`) is a
|
||||
`static Dictionary` guarded by a single `lock` object, explicitly documented as
|
||||
in-memory ("no DB", per `backend/README.md` and CLAUDE.md's own framing). Data —
|
||||
including the audit log — is lost on every restart. This is a deliberate POC
|
||||
simplification (CLAUDE.md lists "runtime DTO validation on every endpoint" and
|
||||
similar as out-of-scope, and a database was never promised), but it's the one gap
|
||||
that would visibly break the moment someone tries to run this as a real demo across
|
||||
multiple sessions or deploys it anywhere that restarts (e.g. most PaaS platforms
|
||||
recycle instances).
|
||||
|
||||
This WP is marked **optional tier** — lower priority than WP-18/19/20/21 — because
|
||||
unlike auth/e2e/i18n/resilience, the current in-memory design is explicitly
|
||||
documented and defensible for a POC. Do this when the POC needs to survive restarts
|
||||
(demoing over multiple days, deploying somewhere with instance recycling), not
|
||||
speculatively.
|
||||
|
||||
## Read first
|
||||
|
||||
- `backend/README.md` (the "in-memory seeded, no DB" framing to preserve or
|
||||
supersede)
|
||||
- `backend/src/BigRegister.Api/Data/ApplicationStore.cs`,
|
||||
`backend/src/BigRegister.Api/Data/DocumentStore.cs`,
|
||||
`backend/src/BigRegister.Api/Data/BriefStore.cs` — the three stores, each
|
||||
`static Dictionary` + `lock`
|
||||
- `backend/src/BigRegister.Api/Data/SeedData.cs` (current in-memory seed — becomes
|
||||
a first-run DB seed)
|
||||
- `docs/reference/architecture/0001-bff-lite-decision-dtos.md` (confirm this WP doesn't touch
|
||||
the decision-DTO contracts — persistence is purely behind the existing store
|
||||
interfaces)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **SQLite + EF Core**, not a heavier database — matches the POC's zero-external-
|
||||
infrastructure posture (no docker service to add, no connection string to manage
|
||||
beyond a file path) while proving real persistence.
|
||||
- **Persistence lives entirely behind the existing static-class store APIs** — the
|
||||
public methods on `ApplicationStore`/`DocumentStore`/`BriefStore` keep their
|
||||
signatures; only the implementation swaps from `Dictionary` to `DbContext`. No
|
||||
endpoint or domain-rule code changes (`Program.cs`, `Domain/*`).
|
||||
- **Seed on empty DB**, not on every startup — `SeedData` runs once (checked via
|
||||
"is the DB empty") so restarts don't reset demo data, which is the entire point
|
||||
of this WP.
|
||||
- **Document bytes stay a deliberate exception** if storage size becomes a concern:
|
||||
either store them as a BLOB column (simplest, consistent with "one DB, no extra
|
||||
infra") or explicitly punt file bytes to disk with only metadata in SQLite —
|
||||
decide based on actual seeded file sizes, don't over-engineer a blob-storage
|
||||
abstraction for a POC.
|
||||
- **Audit log becomes a real table**, not just "no longer volatile" — this closes
|
||||
the "audit log is in-memory" gap named in the original gap analysis alongside
|
||||
persistence, since it's the same static-dict problem in `DocumentStore.cs`.
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/src/BigRegister.Api/BigRegister.Api.csproj` — add
|
||||
`Microsoft.EntityFrameworkCore.Sqlite` + `Microsoft.EntityFrameworkCore.Design`.
|
||||
- New `backend/src/BigRegister.Api/Data/AppDbContext.cs` — `DbSet`s mirroring the
|
||||
three stores' current in-memory shapes (`StoredDocument`, `AuditEntry`, whatever
|
||||
`ApplicationStore`/`BriefStore` hold internally — read those files first to avoid
|
||||
redesigning the shape, just relocate it).
|
||||
- `backend/src/BigRegister.Api/Data/ApplicationStore.cs`,
|
||||
`DocumentStore.cs`, `BriefStore.cs` — convert static dictionary methods to
|
||||
`DbContext`-backed queries; keep every public method signature identical (this is
|
||||
the acceptance bar — a signature change means a caller in `Program.cs` or
|
||||
`Domain/*` needs to change, which should be zero).
|
||||
- `backend/src/BigRegister.Api/Data/SeedData.cs` — becomes "seed if empty" run once
|
||||
at startup against the real DB.
|
||||
- `backend/src/BigRegister.Api/Program.cs` — register `AppDbContext` (DI), run
|
||||
migrations/`EnsureCreated` + conditional seed at startup.
|
||||
- New EF Core migration (generated via `dotnet ef migrations add Initial`).
|
||||
- `.gitignore` — exclude the runtime `.db` file (ship the migration, not the
|
||||
database).
|
||||
- `backend/README.md` — update "in-memory seeded, no DB" framing to describe the
|
||||
SQLite file and its lifecycle (created/seeded on first run, persists thereafter,
|
||||
delete the file to reset demo data).
|
||||
- `docker-compose.yml` — mount a volume for the SQLite file so `docker compose up`
|
||||
restarts don't lose data either (currently the `api-bin`/`api-obj` volumes exist
|
||||
for build caching only, not data).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add the EF Core packages; define `AppDbContext` matching the current in-memory
|
||||
record shapes exactly (no schema redesign in this WP).
|
||||
2. Convert one store at a time (`DocumentStore` first — it's the smallest and has
|
||||
the audit log, which is the most valuable win), keeping
|
||||
`backend/tests/BigRegister.Tests/*` green after each conversion.
|
||||
3. Wire `AppDbContext` + startup migration/seed in `Program.cs`.
|
||||
4. Convert `ApplicationStore`, then `BriefStore`.
|
||||
5. Update `docker-compose.yml` with a persistent volume; update `backend/README.md`.
|
||||
6. Full backend test suite + a manual restart test: run the backend, create an
|
||||
application, restart the process, confirm the application still exists.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] All three stores are EF Core/SQLite-backed; no `static Dictionary` remains in
|
||||
`Data/*.cs` for application/document/brief state.
|
||||
- [x] Every existing backend test passes unchanged (signatures didn't change).
|
||||
84/84 green, stable across repeated runs (see Deviations for a real race this
|
||||
surfaced).
|
||||
- [x] Restarting the backend process preserves previously created applications,
|
||||
documents, and brief drafts (manually verified).
|
||||
- [x] The audit log survives a restart and is queryable (even if no new endpoint
|
||||
exposes it yet — persistence is the bar, not a new audit UI). `AuditEntries`
|
||||
is a real table now; not separately re-verified across restart beyond the
|
||||
applications/brief checks (same store mechanism, same `Db.Create()` seam).
|
||||
- [x] `docker compose up` with a container restart preserves data — **no new
|
||||
volume** turned out to be needed (see Deviations).
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test` — 84/84 green. Manual: `dotnet run --project
|
||||
src/BigRegister.Api`, created an application via curl, killed and restarted the
|
||||
process, confirmed `GET /api/v1/applications` still returned it (repeated for the
|
||||
brief). Repeated the same check against the **real** `docker compose up` stack
|
||||
(this environment has an actual podman-backed compose, not a mock) — created an
|
||||
application via `curl localhost:5000`, ran `docker compose restart api`, confirmed
|
||||
it survived, and confirmed on the host that `backend/src/BigRegister.Api/bigregister.db`
|
||||
is the file being written (gitignored, not tracked).
|
||||
|
||||
## Out of scope
|
||||
|
||||
A production-grade database (Postgres/SQL Server) — SQLite is the deliberate,
|
||||
right-sized choice for a POC that still wants to prove real persistence. Migrating
|
||||
existing in-memory demo data on upgrade (a fresh SQLite file starts from
|
||||
`SeedData`, same as today's in-memory start). Blob storage for document bytes
|
||||
beyond a BLOB column (only revisit if seeded files are large enough to matter).
|
||||
|
||||
## Risks
|
||||
|
||||
EF Core's async patterns don't drop in as a 1:1 replacement for synchronous
|
||||
dictionary lookups — endpoint handlers in `Program.cs` currently call store methods
|
||||
synchronously; converting to `async`/`await` may ripple further than "just the
|
||||
Data/ layer" if minimal-API handlers aren't already `async`. Check this before
|
||||
starting and budget for handler signature changes (still not a _behavior_ change,
|
||||
but a wider diff than the Files section implies if handlers need `async` added).
|
||||
|
||||
**Resolved**: didn't ripple at all. EF Core's SQLite provider fully supports
|
||||
synchronous APIs (`.Find()`, `.ToList()`, `.SaveChanges()`, `.ExecuteDelete()`); every
|
||||
store method stayed synchronous, so `Program.cs`'s minimal-API handlers needed zero
|
||||
changes. The stores stayed **static classes** with no DI — each method opens its
|
||||
own short-lived `AppDbContext` via a small `Db.Create()` factory (`Data/Db.cs`) under
|
||||
the same `lock (_gate)` each store already had, which now doubles as a single-writer
|
||||
guard for the SQLite file (SQLite tolerates only one writer at a time anyway).
|
||||
|
||||
## Deviations from the plan
|
||||
|
||||
- **No SeedData → DB seed step.** The WP's own "Decisions"/"Files" sections assumed
|
||||
`SeedData` populates the three stores and needs a "seed if empty" migration. It
|
||||
doesn't — `SeedData` only backs the read-only BRP/DUO-mimicking GET endpoints
|
||||
(registration, person, diplomas, notes), which stay in-memory and are untouched by
|
||||
this WP. Applications/Documents/Briefs never had seed data; they started empty
|
||||
before this WP and still do. One less step than planned.
|
||||
- **No new docker-compose volume.** The existing `./backend:/src` bind mount already
|
||||
covers `bigregister.db` (it's written under `src/BigRegister.Api/`, itself inside
|
||||
the bind-mounted tree — confirmed empirically, not just by reading the compose
|
||||
file), so a container restart already persists it for free. Added a comment
|
||||
instead of a redundant `volumes:` entry.
|
||||
- **Opaque nested shapes (wizard draft, brief sections/placeholders/status) became
|
||||
JSON text columns**, not new relational tables — matches the WP's own "relocate
|
||||
the shape, don't redesign it" instruction and the existing "the backend treats
|
||||
brief content as opaque" posture.
|
||||
- **Found and fixed a real test race, not a hypothetical one.** The stores read a
|
||||
single static `Db.ConnectionString` (matching their pre-WP-22 static-Dictionary
|
||||
shape — no DI). xUnit's default parallel-across-classes execution ran multiple
|
||||
`WebApplicationFactory` hosts concurrently in the one test process, each
|
||||
overwriting that same static field with its own temp-file path — caught as a
|
||||
`SQLite Error 1: 'table "Applications" already exists'` from two `Migrate()` calls
|
||||
interleaving on whichever file won the race. Fixed with
|
||||
`[assembly: CollectionBehavior(DisableTestParallelization = true)]`
|
||||
(`TestWebApplicationFactory.cs`) rather than redesigning the stores' DI shape for
|
||||
a test-only concern. Reran `dotnet test` 3× in a row to confirm the race was
|
||||
actually gone, not just less likely.
|
||||
- **Pinned `SQLitePCLRaw.bundle_e_sqlite3` to 3.0.3** — `Microsoft.EntityFrameworkCore.Sqlite`
|
||||
10.0.9's own transitive default (2.1.11) bundles a pre-3.50.2 SQLite with a known
|
||||
high-severity memory-corruption advisory (GHSA-2m69-gcr7-jv3q); 3.0.3 bundles a
|
||||
patched one and built/tested cleanly as a drop-in.
|
||||
- **`dotnet-ef` added to the existing `backend/dotnet-tools.json`** (not a new
|
||||
`.config/dotnet-tools.json`) — this repo already keeps its one CLI tool manifest
|
||||
there (`swashbuckle.aspnetcore.cli`); matched that convention.
|
||||
@@ -0,0 +1,114 @@
|
||||
# WP-23 — Org-template backend + admin role
|
||||
|
||||
Status: done
|
||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||
|
||||
## Why
|
||||
|
||||
PRD "Brief opstellen v2" splits a rendered letter over two orthogonal template axes:
|
||||
the **case-type template** (section structure + placeholders — exists, unchanged) and
|
||||
a new **organization template** (appearance/identity per sub-organization: letterhead,
|
||||
footer, signature, margins). This WP builds the second axis server-side plus the
|
||||
`admin` role that will edit it (WP-26). Everything downstream (canvas WP-24, preview
|
||||
WP-25, editor WP-26) reads what this WP serves.
|
||||
|
||||
## Read first
|
||||
|
||||
- `docs/project/prd` — the Brief v2 PRD §2a/§3 (two axes, OrgTemplate model, invariants)
|
||||
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (store idiom + `BriefSeed`)
|
||||
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (emit+enforce single source)
|
||||
- `backend/src/BigRegister.Api/Data/AppDbContext.cs` (JSON-column precedent, WP-22)
|
||||
- `docs/project/backlog/WP-18-abac-capability-spine.md` (how the capability spine works)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **No case model, no sub-org auth scoping.** `GET /brief` stays; the brief just
|
||||
gains a `SubOrgId`. Two seeded sub-orgs (`cibg-registers`, `cibg-vakbekwaamheid`)
|
||||
exist purely so the admin editor can demo isolation.
|
||||
- **One row per sub-org**, versions as a JSON history column
|
||||
(`OrgTemplateEntity { SubOrgId PK, Draft json, PublishedVersion, History json }`) —
|
||||
the WP-22 JSON-column precedent; no extra tables. Publish = append draft snapshot
|
||||
to history + version++. Rollback = copy `History[v]` into `Draft` (history stays
|
||||
append-only; admin republishes).
|
||||
- **Sent letters are immutable**: `Send` pins `SentOrgTemplateVersion`; a sent
|
||||
brief's `BriefViewDto.orgTemplate` resolves from history, never from the current
|
||||
published version. Unsent briefs always follow the current published version —
|
||||
that is the point of the admin editor.
|
||||
- **Admin = third `X-Role` value** (`PrincipalRole.Admin`), capability
|
||||
`orgtemplate:edit` via `GET /me`. Approve/reject gain an explicit
|
||||
`Role == Approver` condition so the new role cannot slip through the SoD-only
|
||||
check. The existing `X-Admin` document-deletion seam stays untouched.
|
||||
- **Trimmed model** (PRD §3 minus): no signature image asset, no structured address
|
||||
objects, no per-template fonts. Return address / footer contact are multiline
|
||||
strings. Margins are 4 bounded ints (mm, 10–50) — server-validated.
|
||||
- **Logo = existing upload machinery**: seed an `org-logo` category (png/jpeg, 1 MB)
|
||||
under a `org-template` wizardId; the template stores only `logoDocumentId`.
|
||||
- Seed values come from the sample artifact `voorbeeldbrief-inschrijving.pdf`
|
||||
(A. de Vries / Hoofd Registratie / Postbus 00000 / info@voorbeeld.example — all fictitious).
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/src/BigRegister.Api/Contracts/Dtos.cs` — `MarginsDto`, `OrgTemplateDto`,
|
||||
`OrgTemplateVersionDto`, `OrgTemplateAdminViewDto`, `SaveOrgTemplateRequest`,
|
||||
`PublishOrgTemplateResponse`, `SubOrgSummaryDto`; `BriefViewDto` + `OrgTemplate`.
|
||||
- `backend/src/BigRegister.Api/Data/OrgTemplateStore.cs` (new) — entity + store + seed.
|
||||
- `backend/src/BigRegister.Api/Data/AppDbContext.cs` — OrgTemplates DbSet + JSON converters.
|
||||
- `backend/src/BigRegister.Api/Data/Migrations/*` — new migration.
|
||||
- `backend/src/BigRegister.Api/Data/BriefStore.cs` — `SubOrgId`, `SentOrgTemplateVersion`, pin at `Send`.
|
||||
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` — `Admin` role, capability, gate.
|
||||
- `backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs` — `org-logo` category.
|
||||
- `backend/src/BigRegister.Api/Program.cs` — 5 admin endpoints, `ToView` orgTemplate resolution.
|
||||
- `backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs` (new).
|
||||
- Regenerated: `backend/swagger.json`, `src/app/shared/infrastructure/api-client.ts`.
|
||||
- FE seam only: `src/app/shared/domain/role.ts`, `shared/domain/capability.ts`,
|
||||
`shared/infrastructure/role.ts`, `shared/infrastructure/role.interceptor.ts`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. DTOs (above).
|
||||
2. `OrgTemplateEntity` + `OrgTemplateStore` (list/get/saveDraft/publish/rollback/
|
||||
published/versionPayload; margin validation; seed-on-first-access, 2 sub-orgs,
|
||||
draft == published v1) + AppDbContext mapping + migration.
|
||||
3. `PrincipalRole.Admin`; `ResolvePrincipal` reads `admin`; `RoleCapabilities(Admin)`
|
||||
→ `orgtemplate:edit`; approve/reject checks require `Approver` explicitly.
|
||||
4. Endpoints: `GET /admin/org-templates`, `GET|PUT /admin/org-template/{subOrgId}`,
|
||||
`POST …/publish` (returns impact count = unsent briefs of that sub-org),
|
||||
`POST …/rollback/{version}`. All 403 for non-admin via `Authz`.
|
||||
5. `BriefEntity.SubOrgId` (seed `cibg-registers`) + `SentOrgTemplateVersion`; `Send`
|
||||
pins; `ToView` resolves published-vs-pinned into `BriefViewDto.orgTemplate`.
|
||||
6. Seed `org-logo` upload category.
|
||||
7. `npm run gen:api`.
|
||||
8. FE: widen `Role`/`Capability` unions, `currentRole()`, interceptor URL filter
|
||||
(nothing consumes them yet — WP-24/26 do).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Publish increments `publishedVersion` and appends to history; rollback copies an
|
||||
old version into the draft without rewriting history.
|
||||
- [x] Every admin endpoint returns 403 for drafter/approver, 200 for `X-Role: admin`.
|
||||
- [x] A sent brief keeps its pinned org-template version after a republish; an unsent
|
||||
brief follows the new published version (both asserted in one test).
|
||||
- [x] Publish impact count = number of unsent briefs of that sub-org.
|
||||
- [x] `PUT` with out-of-bounds margins → 400.
|
||||
- [x] `GET /brief` carries `orgTemplate`; existing brief tests stay green.
|
||||
- [x] `GET /me` with `X-Role: admin` → `["orgtemplate:edit"]`.
|
||||
- [x] Full GREEN (FE untouched functionally, but lint/test/build/storybook all pass).
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test`; GREEN one-liner; curl smoke: admin list/save/publish
|
||||
(200) vs drafter (403); sent-brief pin walk-through per acceptance.
|
||||
|
||||
## Out of scope
|
||||
|
||||
The canvas (WP-24), HTML preview endpoints + archive-at-send (WP-25), the admin UI
|
||||
(WP-26). Template approval chains (draft→publish is enough for the POC; flagged as
|
||||
an open question in the PRD). Sub-org-scoped brief authorization.
|
||||
|
||||
## Risks
|
||||
|
||||
`BriefViewDto` gains a field — additive, but the FE `parseBriefView` boundary and
|
||||
generated client must be regenerated in the same WP to keep the drift check green.
|
||||
Adding `PrincipalRole.Admin` touches the approve/reject SoD path: the explicit
|
||||
`Role == Approver` condition must preserve today's Forbidden-before-Conflict order
|
||||
(existing tests prove it).
|
||||
@@ -0,0 +1,99 @@
|
||||
# WP-24 — Letter canvas (edit on the letter)
|
||||
|
||||
Status: done
|
||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||
|
||||
## Why
|
||||
|
||||
The drafter should compose **on the letter** — letterhead above, footer/signature
|
||||
below, content blocks edited in place — instead of in an abstract form next to a
|
||||
separate preview. PRD Brief v2 §4. This is a **presentation rebuild only**: the
|
||||
domain model, `brief.machine.ts`, and every `BriefMsg` stay byte-identical.
|
||||
|
||||
## Read first
|
||||
|
||||
- PRD Brief v2 §2b (fidelity note), §4, §10; the sample `voorbeeldbrief-inschrijving.pdf`
|
||||
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (the `canEdit` pivot)
|
||||
- `src/app/brief/ui/letter-preview/letter-preview.component.ts` (rendering that migrates in)
|
||||
- `docs/project/backlog/WP-23-org-template-backend.md` (the `orgTemplate` on `BriefViewDto`)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **One stylesheet is the FE⇄BE rendering contract**: `public/letter.css` — class
|
||||
vocabulary `.letter`, `.letter__letterhead`, `.letter__body`, `.letter__signature`,
|
||||
`.letter__footer`, `.letter__page-break`; margins as `--letter-margin-*` custom
|
||||
props; `@page`/print rules. Loaded via `<link>` in `index.html` (app + Storybook).
|
||||
WP-25's backend renderer inlines the same file; its parity test is the fence.
|
||||
- **`LetterCanvasComponent`** (new organism, `Domein/Brief/Letter Canvas`) with
|
||||
`editableRegions: 'content' | 'template' | 'none'` — one component serves drafter
|
||||
composing, approver review (and in WP-26, the admin editor). Letter typography on
|
||||
the canvas is the letter's, not the portal UI's — but still token-bridged.
|
||||
- `'content'` mode hosts the **existing** `letter-section`/`letter-block` components
|
||||
unchanged; letterhead/footer/signature render read-only with a subtle tint and a
|
||||
first-use caption ("komt uit de huisstijl van de organisatie").
|
||||
- `'none'` mode absorbs `letter-preview`'s rendering (paragraph grouping, placeholder
|
||||
chips, sample-values toggle); **`ui/letter-preview/` is then deleted** — the PRD
|
||||
explicitly supersedes it; no third rendering is maintained.
|
||||
- **Page-break indicator is approximate by design**: a dashed line per A4-content
|
||||
interval with the caption "±pagina-einde — afdrukvoorbeeld is leidend" (PRD §2b
|
||||
honesty requirement).
|
||||
- `brief.machine.ts` and `BriefMsg` are untouched — `git diff` must prove it.
|
||||
|
||||
## Files
|
||||
|
||||
- `public/letter.css` (new), `src/index.html` (link)
|
||||
- `src/app/brief/domain/org-template.ts` (new, pure) — `OrgTemplate` type
|
||||
- `src/app/brief/infrastructure/brief.adapter.ts` (+spec) — parse `orgTemplate`
|
||||
- `src/app/brief/ui/letter-canvas/*` (new: component + stories)
|
||||
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (pivot swap) + stories
|
||||
- `src/app/brief/ui/brief.page.ts` (pass orgTemplate through)
|
||||
- delete `src/app/brief/ui/letter-preview/*`
|
||||
|
||||
## Steps
|
||||
|
||||
1. `letter.css` from the sample PDF's geometry (A4 proportions, letterhead, address
|
||||
window + reference block, footer rule, signature).
|
||||
2. `OrgTemplate` domain type + `parseOrgTemplate` boundary in the adapter (+spec).
|
||||
3. Canvas organism: three modes, zoom input, page-break indicator.
|
||||
4. Migrate `letter-preview` rendering into `'none'` mode; delete the component,
|
||||
fold its stories into the canvas stories.
|
||||
5. Swap the composer's `@if (canEdit())` pivot for
|
||||
`<app-letter-canvas [editableRegions]="canEdit() ? 'content' : 'none'">`.
|
||||
6. Stories: three modes + zoom + page-break, all axe-gated.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Drafter edits blocks in place on the letter surface; passage picker and
|
||||
diagnostics click-to-locate still work on the canvas.
|
||||
- [x] Approver sees the identical surface read-only with the action bar.
|
||||
- [x] Letterhead/footer/signature come from `orgTemplate` and are visibly non-editable.
|
||||
- [x] `git diff` shows zero changes in `brief.machine.ts` / `brief.ts` Msg surface.
|
||||
- [x] `letter-preview` is gone; no story regression (`test-storybook:ci` green).
|
||||
- [x] Full GREEN + e2e smoke.
|
||||
|
||||
Field notes (landed alongside): the CIBG huisstijl styles bare `<header>`/`<footer>`
|
||||
elements (robijn background), so the canvas regions are `<div>`s — the letter surface
|
||||
must stay letter.css-only. The passage picker's checkboxes now get unique
|
||||
`checkboxId`s (all previously resolved to `id="undefined"`, so labels toggled only
|
||||
the first box — multi-select was broken) and an opaque background; `.letter__body`
|
||||
stacks above the page-break marks so the dashed line never draws through content,
|
||||
while the "±pagina-einde" caption floats above everything for legibility.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN one-liner; `npm run e2e` (brief flow); manual: `?role=drafter` compose on
|
||||
canvas, `?role=approver` review; `?scenario=slow|error` still degrade gracefully
|
||||
through `<app-async>`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Server-rendered preview + `[NOG IN TE VULLEN]` resolution (WP-25); admin `'template'`
|
||||
mode wiring beyond the input existing (WP-26 gives it a consumer); zoom controls
|
||||
polish + standaardbrief + diff badges (WP-27).
|
||||
|
||||
## Risks
|
||||
|
||||
The canvas duplicates the letter markup that WP-25's backend renderer will emit —
|
||||
acceptable _only_ because `letter.css` is shared and WP-25 adds the class-parity
|
||||
test; until WP-25 lands, the canvas is the sole consumer, so no drift is possible.
|
||||
Deleting `letter-preview` breaks any deep import of it — repo-wide grep before delete.
|
||||
@@ -0,0 +1,90 @@
|
||||
# WP-25 — Server-rendered letter preview (HTML; PDF seam deferred)
|
||||
|
||||
Status: done
|
||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||
|
||||
## Why
|
||||
|
||||
"What you compose is what is sent" needs a server-side rendering of the letter —
|
||||
placeholders resolved, org template applied — from the same CSS contract the canvas
|
||||
uses (PRD §2b: one rendering, used twice). The preview is the artifact: at send, the
|
||||
same composition is archived with the brief, making sent letters immutable.
|
||||
|
||||
## Read first
|
||||
|
||||
- PRD Brief v2 §2b, §8; `docs/project/backlog/WP-24-letter-canvas.md` (the `letter.css` contract)
|
||||
- `backend/src/BigRegister.Api/Program.cs` — upload `content` endpoint (binary house
|
||||
pattern: `.ExcludeFromDescription()` + hand-written FE fetch)
|
||||
- `src/app/shared/upload/upload.adapter.ts` (hand-written transport precedent)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **HTML, not PDF** (user decision at plan review): no Microsoft.Playwright/Chromium
|
||||
dependency in the POC. `GET /api/v1/brief/preview` returns `text/html` — the fully
|
||||
composed, print-ready letter (`@page` CSS; browser print-to-PDF is the manual
|
||||
affordance). The endpoint is the seam where a headless-Chromium PDF render slots
|
||||
in later; mark it `// ponytail: HTML today, Chromium PDF behind this same route if
|
||||
the POC ever needs real PDF bytes`.
|
||||
- **`LetterHtml.Render(brief, orgTemplate)`** is a pure static composer: mirrors the
|
||||
canvas class vocabulary exactly, inlines `public/letter.css` from disk, inlines the
|
||||
logo bytes as a data-URI. Placeholders: auto-resolvable keys resolve from
|
||||
seed/case data; unresolved manual keys render as `[NOG IN TE VULLEN: label]`
|
||||
(PRD §8) — preview is allowed with errors, only send blocks on them.
|
||||
- **Parity is tested, not hoped for**: a golden-file test snapshots the composed
|
||||
HTML; a second test asserts every `letter`-prefixed class in the golden HTML
|
||||
exists in `letter.css`. `dotnet test` never launches a browser.
|
||||
- **Archive at send**: `Send` stores the composed HTML in `BriefEntity.ArchivedHtml`
|
||||
(SQLite text column) alongside the WP-23 version pin; the preview endpoint serves
|
||||
the archive when status is `sent`, so a republish never changes a sent letter.
|
||||
- **Two endpoints, both excluded from OpenAPI** (JSON-only generated client stays
|
||||
clean): `GET /brief/preview` and `GET /admin/org-template/{subOrgId}/preview`
|
||||
(proefbrief: draft template + a fixture brief). FE consumes them via a small
|
||||
hand-written fetch (needs the `X-Role` header) → blob → object URL in a new tab.
|
||||
- Watermark: previews of unsent letters carry a `VOORBEELD` watermark (CSS), the
|
||||
archived/sent rendering never does — the PRD's open question resolved the simple way.
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` (new)
|
||||
- `backend/src/BigRegister.Api/Data/BriefStore.cs` — `ArchivedHtml` + archive at send (+migration)
|
||||
- `backend/src/BigRegister.Api/Program.cs` — 2 preview endpoints
|
||||
- `backend/tests/BigRegister.Tests/LetterHtmlTests.cs` (new) + `LetterHtml.golden.html`
|
||||
- `src/app/brief/infrastructure/letter-preview.adapter.ts` (new, fetch → `Result<string, Blob>`)
|
||||
- `src/app/brief/application/brief.store.ts` — `previewLetter()` command
|
||||
- `src/app/brief/ui/letter-composer/*` — "Voorbeeld" button
|
||||
|
||||
## Steps
|
||||
|
||||
1. `LetterHtml.Render` + placeholder resolution + data-URI logo + watermark flag.
|
||||
2. Golden-file + class-parity tests.
|
||||
3. Endpoints (serve archive when sent; proefbrief renders the draft template).
|
||||
4. Archive-at-send in `BriefStore.Send` (+ migration for `ArchivedHtml`).
|
||||
5. FE adapter + store command + button (explicit action — no live re-render; PRD §8).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Preview opens the composed print-ready letter in a new tab; browser print
|
||||
shows correct margins via `@page`.
|
||||
- [x] Unresolved manual placeholders render `[NOG IN TE VULLEN: …]`; preview works
|
||||
despite lint errors (only send blocks).
|
||||
- [x] A sent brief serves its archived HTML unchanged after an org-template republish.
|
||||
- [x] Golden + parity tests green without any browser installed.
|
||||
- [x] `swagger.json` unchanged by the two endpoints (drift check green).
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test`; GREEN one-liner; manual: compose → preview → print
|
||||
dialog; send → republish template → preview still the archived rendering.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Real PDF bytes / headless Chromium (the deliberate deferral — the endpoint is the
|
||||
seam). Pixel-parity testing (the shared CSS + class-parity test is the fence).
|
||||
Pagination fidelity beyond the browser's own print engine.
|
||||
|
||||
## Risks
|
||||
|
||||
`LetterHtml` reads `public/letter.css` from disk — path must resolve for `dotnet run`,
|
||||
tests, and docker (bind mount/copy); fail loudly with a clear error if missing.
|
||||
The golden file will churn whenever the letter structure changes — that is its job;
|
||||
update it deliberately, never blindly.
|
||||
@@ -0,0 +1,118 @@
|
||||
# WP-26 — Admin org-template editor
|
||||
|
||||
Status: done
|
||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||
|
||||
## Why
|
||||
|
||||
The org template (WP-23) needs its editor: an admin edits, per sub-organization, the
|
||||
letter's appearance **in place on the same canvas** the drafter composes on — the
|
||||
mirror image (`editableRegions='template'`: letterhead/footer/signature editable,
|
||||
content a read-only sample). PRD Brief v2 §5, §7h.
|
||||
|
||||
## Read first
|
||||
|
||||
- PRD Brief v2 §5, §7h; `docs/project/backlog/WP-23/24/25` (endpoints, canvas, proefbrief)
|
||||
- `src/app/shared/application/access.store.ts` (`can('orgtemplate:edit')`)
|
||||
- `.claude/skills/form-machine` — the house form idiom this editor follows
|
||||
- `src/app/shared/ui/upload/single-upload` (logo upload reuse)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **Lives in the `brief` context** (route `/brief/huisstijl`, lazy) — same bounded
|
||||
capability, no new context. Gated by `AccessStore.can('orgtemplate:edit')` with a
|
||||
denial alert (deny-by-default); no new route guard.
|
||||
- **House form-machine idiom**: `org-template.machine.ts`
|
||||
(`OrgTemplateState`/`OrgTemplateMsg`, pure `reduce` + spec) — draft fields are form
|
||||
state, not brief state. Store (`org-template.store.ts`, root singleton) does
|
||||
debounced draft save (mirror `BriefStore.scheduleSave`), publish, rollback.
|
||||
- **Publish shows impact first**: confirmation displays the WP-23 impact count
|
||||
("Dit raakt N nog niet verzonden brieven") before the POST.
|
||||
- **Version history is a list, rollback copies into draft** (WP-23 semantics) —
|
||||
no side-by-side rendered diff (deferred; field-level history list is enough here).
|
||||
- **Logo upload reuses `single-upload`** against the `org-logo` category; the canvas
|
||||
shows `<img src="/api/v1/uploads/{id}/content">`.
|
||||
- **Proefbrief** = the WP-25 admin preview endpoint; just a button.
|
||||
- Margins are bounded number inputs (server re-validates, WP-23).
|
||||
|
||||
## Files
|
||||
|
||||
- `src/app/brief/domain/org-template.machine.ts` (+spec)
|
||||
- `src/app/brief/application/org-template.store.ts`
|
||||
- `src/app/brief/infrastructure/org-template.adapter.ts` (+spec, `parseOrgTemplateAdminView`)
|
||||
- `src/app/brief/ui/org-template-editor/*` (organism + stories)
|
||||
- `src/app/brief/ui/org-template.page.ts`
|
||||
- `src/app/app.routes.ts` (route `brief/huisstijl`)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Machine (fields, `FieldEdited`/`MarginEdited`/`LogoSet`/`DraftLoaded`/save-publish
|
||||
outcome Msgs) + spec.
|
||||
2. Adapter (generated client CRUD + parse boundary) + spec.
|
||||
3. Store: load (sub-org list + selected), debounced save, publish (impact confirm),
|
||||
rollback.
|
||||
4. Editor UI: sub-org switcher, canvas in `'template'` mode with inline-editable
|
||||
regions, margins inputs, logo upload, version history + rollback, proefbrief
|
||||
button, publish bar showing live version + published-at.
|
||||
5. Route + capability gate + stories (axe).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `?role=admin` can switch sub-orgs, edit all template fields in place on the
|
||||
canvas, and see the canvas update live.
|
||||
- [x] Draft saves are debounced; publish asks for confirmation showing the impact
|
||||
count; after publish the drafter's canvas (WP-24) reflects it on reload.
|
||||
- [x] Version history lists published versions (who is faked, when is real);
|
||||
rollback copies an old version into the draft.
|
||||
- [x] Non-admin on `/brief/huisstijl` sees the denial alert; API would 403 anyway.
|
||||
- [x] Logo upload validates type/size client-side (existing `rejectReason`) and
|
||||
renders on the canvas after upload.
|
||||
- [x] Machine spec covers field edits, dirty tracking, publish/rollback outcomes.
|
||||
- [x] Full GREEN.
|
||||
|
||||
## Deviations / notes (as built)
|
||||
|
||||
- **`check:tokens` was already red on `main`** (WP-24's canvas landed `var(--rhc-*,
|
||||
#hex)` fallbacks + an `rgb()` paper shadow, and WP-25 committed over it). Fixed here
|
||||
to end GREEN: dropped the redundant hex fallbacks (the token bridge defines every
|
||||
one) and marked the paper drop-shadow `token-ok`; also fixed a pre-existing
|
||||
`passage-picker` `var(--rhc-color-wit, #fff)` hit.
|
||||
- **Canvas edit-in-place**: `editableRegions='template'` now renders the seven
|
||||
org-identity text fields as inline `<input>`/`<textarea>` controls (aria-labelled)
|
||||
and shows the logo `<img>`; the letter body stays a read-only sample
|
||||
(`SAMPLE_LETTER_BRIEF`). `logoUrl` input added to the canvas and wired through the
|
||||
composer too, so a published logo shows to the drafter (AC2).
|
||||
- **Load-effect loop (caught in the live walk)**: the page's initial-load effect must
|
||||
NOT read the store model — `load()` dispatches `Loading` (a fresh object), which
|
||||
would retrigger a model-reading effect into a runaway loop that saturated the page.
|
||||
Gated on `canEdit()` + a plain `loadRequested` flag instead.
|
||||
- **`AccessStore.ready`** added (tiny): a page-level capability gate needs to tell
|
||||
"still loading `/me`" from "denied" so an admin doesn't flash the denial alert.
|
||||
- **`uploadContentUrl`** pure helper extracted from `UploadAdapter.contentUrl` so
|
||||
`BriefStore` can build a logo `src` without pulling `ApiClient` into its DI graph
|
||||
(kept its spec green).
|
||||
- **Role caching caveat**: `/me` loads once. Open the app with `?role=admin` from the
|
||||
first navigation that touches the editor (the dev role stub, like `?scenario=`);
|
||||
switching role mid-session won't refetch capabilities (out of scope, POC).
|
||||
- **Dirty race**: `DraftSaved` carries the saved draft and clears `dirty` only if it's
|
||||
reference-equal to the current draft, so an edit landing during a save round-trip
|
||||
keeps its pending save.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN one-liner; manual walk: admin edits footer + margin → canvas live-updates →
|
||||
proefbrief shows draft → publish (impact count) → `?role=drafter` reload shows new
|
||||
appearance; second sub-org unaffected.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Template approval chain (four-eyes on templates — PRD open question, out for POC).
|
||||
Rendered side-by-side version diff. Soft locks. New shared overlay/modal component —
|
||||
the publish confirmation uses the existing inline confirmation pattern, not a dialog.
|
||||
|
||||
## Risks
|
||||
|
||||
The editor is the first consumer of `editableRegions='template'` — WP-24 built the
|
||||
input but nothing exercised it; budget for canvas fixes here. Debounced draft save +
|
||||
publish can race — flush the draft save before publishing (same
|
||||
`clearTimeout`+`flushSave` discipline as `BriefStore.transition`).
|
||||
@@ -0,0 +1,97 @@
|
||||
# WP-27 — Brief UX layer (undo/redo, standaardbrief, search, diff badges)
|
||||
|
||||
Status: done
|
||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||
|
||||
## Why
|
||||
|
||||
PRD Brief v2 §7: the working-day features that make the composer pleasant daily.
|
||||
Several are nearly free **because** state is one immutable value — that's the
|
||||
teaching payload: undo/redo is a shell-side snapshot list, the rejection diff is a
|
||||
pure function over two values. Say so in code comments and stories.
|
||||
|
||||
## Read first
|
||||
|
||||
- PRD Brief v2 §7 (and the plan-review trim recorded below)
|
||||
- `src/app/brief/application/brief.store.ts` (autosave + `SaveState` already exist)
|
||||
- `src/app/brief/domain/brief.machine.ts` — the `Seed` Msg (undo/redo's restore path)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **Trim agreed at plan review.** IN: undo/redo, autosave retry affordance,
|
||||
standaardbrief, passage search, canvas zoom controls, Ctrl+Z/Ctrl+Shift+Z,
|
||||
block-level rejection-diff badges. OUT (deferred, one line each in Out of scope):
|
||||
soft lock/takeover, case-context panel, 401 autosave grace, per-user usage counts,
|
||||
shortcut-overlay dialog, inline character-level text diff.
|
||||
- **Undo/redo is shell state, not machine state**: a `past`/`future: Brief[]` stack in
|
||||
`BriefStore` (cap 50; push on `edit()`; clear `future` on a new edit); restore
|
||||
dispatches the **existing `Seed` Msg** — zero machine changes — then `scheduleSave()`.
|
||||
(Superseded by WP-31: the hand-rolled stack was later extracted into the shared
|
||||
`createHistory<T>(cap)` helper — `BriefStore` now holds `this.history =
|
||||
createHistory<Brief>(50)` and calls `.record`/`.undo`/`.redo`/`.clear` on it; same
|
||||
cap-50, push-on-edit, clear-on-new-edit semantics, just generalized for reuse.)
|
||||
- **Standaardbrief**: backend seeds `IsDefault` on 2–3 kern passages
|
||||
(`LibraryPassageDto` gains the flag); one button, visible only while the kern
|
||||
section is empty, dispatches the existing `PassagesInserted` with the default set —
|
||||
one Msg, one undo step.
|
||||
- **Passage search is a client-side filter** in the picker (label + content match) —
|
||||
the library is small; no server search, no usage tracking.
|
||||
- **Rejection diff**: pure `diffBlocks(before, after): BlockDiff[]` in
|
||||
`domain/brief-diff.ts` (added/removed/changed by `blockId`); the "before" snapshot
|
||||
is captured shell-side when the `Rejected` dispatch happens (POC limit: lost on
|
||||
reload — comment it). Rendered as "gewijzigd sinds afwijzing" badges on the canvas;
|
||||
the approver gets a "Toon wijzigingen" toggle on resubmission.
|
||||
- **Autosave retry**: `SaveState.Error` already exists; add the "Opnieuw proberen"
|
||||
button that calls the existing flush path. No new state.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/app/brief/application/brief.store.ts` (+spec: history bounds, clear-on-edit,
|
||||
redo, rejection snapshot)
|
||||
- `src/app/brief/domain/brief-diff.ts` (new, +spec)
|
||||
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`IsDefault` seed) +
|
||||
`Contracts/Dtos.cs` (`LibraryPassageDto`) + gen:api + adapter parse
|
||||
- `src/app/brief/ui/passage-picker/*` (search input)
|
||||
- `src/app/brief/ui/letter-canvas/*` (diff badges, standaardbrief button, zoom controls)
|
||||
- `src/app/brief/ui/brief.page.ts` (undo/redo buttons + keydown listener, retry button)
|
||||
|
||||
## Steps
|
||||
|
||||
1. `diffBlocks` + spec (added/removed/changed/unchanged; changed = same blockId,
|
||||
different content).
|
||||
2. Store: history + undo/redo + rejection snapshot (+spec).
|
||||
3. Backend `IsDefault` + gen:api + parse.
|
||||
4. UI: standaardbrief button, search, zoom, badges, keyboard, retry.
|
||||
5. Stories for the new states (axe).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Remove a block → Ctrl+Z restores it → Ctrl+Shift+Z re-removes; buttons mirror;
|
||||
history capped at 50; a new edit clears redo; restore re-triggers autosave.
|
||||
- [x] Empty kern + "Standaardbrief invoegen" → default passages inserted as one undo
|
||||
step; button gone once kern is non-empty.
|
||||
- [x] Search filters passages by label and content.
|
||||
- [x] Reject → edit → resubmit: approver toggles "Toon wijzigingen", changed/added/
|
||||
removed blocks are badged (block granularity).
|
||||
- [x] Autosave failure shows "Niet opgeslagen — opnieuw proberen"; retry works;
|
||||
content never lost locally.
|
||||
- [x] Full GREEN.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN one-liner; store + diff specs; manual reject→edit→diff walk with two roles.
|
||||
|
||||
## Out of scope (deferred, per plan review)
|
||||
|
||||
Soft lock/heartbeat/takeover (real session infra, no FP teaching value here).
|
||||
Case-context panel (no case data exists). 401 autosave grace (auth is faked).
|
||||
Per-user passage usage counts (bookkeeping, demos nothing). Shortcut overlay dialog
|
||||
(no modal component exists; not worth building one). Inline character-level diff
|
||||
(block granularity carries the teaching point).
|
||||
|
||||
## Risks
|
||||
|
||||
Undo history holds `Brief` snapshots — deep-frozen immutable values, so sharing is
|
||||
safe, but never push non-content dispatches (status transitions, `Seed` itself) into
|
||||
history or undo will replay workflow state. The rejection snapshot lives in memory
|
||||
only — document it where it's captured.
|
||||
@@ -0,0 +1,107 @@
|
||||
# WP-28 — Brief v2 demo polish (scenarios, e2e, docs)
|
||||
|
||||
Status: done (pending commit; `npm run e2e` unverified in this dev sandbox — see Deviations)
|
||||
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
|
||||
|
||||
## Why
|
||||
|
||||
Phase 6 ships across five WPs; this one makes it demonstrable and closes the loop:
|
||||
a demo script that maps every kept PRD §12 scenario to a URL + click path, an e2e
|
||||
spec covering the new flows end-to-end, story gap-fill, and the docs/README updates
|
||||
that keep CLAUDE.md and the backlog truthful.
|
||||
|
||||
## Read first
|
||||
|
||||
- PRD Brief v2 §6 (demo choreography), §12 (scenario list); WP-23..27 as built
|
||||
- `e2e/` (WP-19 conventions); `src/app/shared/infrastructure/scenario.ts`
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **No preset registry.** The PRD's 18 scenarios collapse onto the existing toggles:
|
||||
`?role=drafter|approver|admin`, `?scenario=slow|loading|error` (the interceptor
|
||||
already covers all `/api/` calls, the new endpoints included), and
|
||||
`POST /brief/reset`. The demo script documents the mapping; no new interceptor
|
||||
cases, no scenario code.
|
||||
- Demo script lives at `docs/project/prd/0003-brief-v2-demo-script.md` and follows the §6
|
||||
choreography (compose → preview → switch sub-org seed → "two axes, one render").
|
||||
- One e2e spec, not a suite: drafter composes on canvas → submit → approve → send
|
||||
pins the org-template version; admin publishes → drafter canvas reflects it.
|
||||
Preview assertion is content-type-level (text/html), not pixel.
|
||||
- CLAUDE.md gets the new role value + route only — keep it rules, not narrative.
|
||||
|
||||
## Files
|
||||
|
||||
- `docs/project/prd/0003-brief-v2-demo-script.md` (new)
|
||||
- `e2e/brief-v2.spec.ts` (new)
|
||||
- story gap-fill where WP-24..27 left holes
|
||||
- `docs/project/backlog/README.md` (statuses), `CLAUDE.md` (roles/routes touch-up)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Demo script: table scenario → URL + clicks, covering every kept §12 entry.
|
||||
2. e2e spec (backend + FE running, WP-19 pattern).
|
||||
3. Story sweep for the new components/states.
|
||||
4. Docs updates.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Every kept PRD §12 scenario has a working URL + click path in the script
|
||||
(walked manually once).
|
||||
- [ ] `npm run e2e` green, including the new spec — **not verified in this dev
|
||||
sandbox**; see Deviations.
|
||||
- [x] Full GREEN; backlog README statuses correct; CLAUDE.md mentions
|
||||
`?role=admin` and `/brief/huisstijl`.
|
||||
|
||||
## Deviations / notes (as built)
|
||||
|
||||
- **No Brief v2 PRD was ever committed.** WP-23..27 cite "PRD Brief v2 §N" throughout,
|
||||
but `docs/project/prd/` never held such a file — it only ever existed as chat
|
||||
context. The demo script (`docs/project/prd/0003-brief-v2-demo-script.md`) is
|
||||
written directly against the shipped code instead of translating an external §12
|
||||
scenario list, and says so up top.
|
||||
- **`?scenario=` does not reach every endpoint.** `/brief/preview`,
|
||||
`/admin/org-template/{id}/preview` and `/brief/reveal-bignummer` are hand-written
|
||||
`fetch` calls (same seam as uploads, deliberately `.ExcludeFromDescription()`'d) and
|
||||
bypass `scenarioInterceptor`. The demo script and CLAUDE.md now say so explicitly.
|
||||
- **Canvas authoring moved.** The original "drafter composes on the canvas" framing
|
||||
predates commit `ba32e3d` ("brief v3 — besluit-driven guided drafting"):
|
||||
`LetterCanvasComponent.editableRegions` no longer has a `'content'` mode. The
|
||||
drafter now works through `BehandelSchermComponent` (case header + stepper +
|
||||
`app-besluit-panel` + `app-letter-editor`), with the canvas as a read-only preview
|
||||
in a modal. The demo script and e2e spec follow that path.
|
||||
- **`passage-picker` is dead code** — superseded by `besluit-panel`'s guided
|
||||
drafting, no consumer left besides its own story. Flagged with a comment on the
|
||||
component rather than deleted in this WP (out of scope for a demo-polish pass).
|
||||
- **Story gaps were state gaps, not component gaps** — every component already had a
|
||||
co-located story. Added: `letter-composer` `RejectionDiff` + `AlleenLezen` (WP-27's
|
||||
diff view and the pure-viewer notice had no story), `letter-canvas` +
|
||||
`org-template-editor` `MetLogo` (WP-26's logo letterhead had no story), and
|
||||
`org-template-editor` `LogoUploadFout` (the upload-rejection branch had no story).
|
||||
- **Org templates have no reset endpoint** — the e2e spec's admin section restores
|
||||
the org-template draft it edits (rollback + republish) instead of relying on a
|
||||
reset, so repeated runs don't drift the seeded "BIG-register" template.
|
||||
- **`npm run e2e` could not be verified green in this dev sandbox** — both the new
|
||||
`brief-v2.spec.ts` and the pre-existing, untouched `smoke.spec.ts` fail here at
|
||||
the same kind of step (clicking a CIBG-styled radio's `<label for>`; e.g.
|
||||
`label[for="correspondentie-post"]` in `smoke.spec.ts`), with Playwright
|
||||
reporting the element "detached from the DOM, retrying" or a native input whose
|
||||
rendered box collapses to 1×1px. Reproduced with `--workers=1` and running
|
||||
`smoke.spec.ts` alone, so it isn't cross-test contention. Since `smoke.spec.ts`
|
||||
predates this WP and is unrelated to any file it touches, this reads as a
|
||||
sandbox-specific rendering/CSS-loading issue (fonts 404 here; a stylesheet may
|
||||
not be fully served), not a regression from this WP's changes. **Needs
|
||||
confirming green on a normal dev machine / CI** before this box can be ticked.
|
||||
|
||||
## Verification
|
||||
|
||||
Walk the demo script top to bottom against `docker compose up`; GREEN one-liner;
|
||||
`npm run e2e`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
New scenario interceptor cases; a scenario-switcher UI; screenshots/video.
|
||||
|
||||
## Risks
|
||||
|
||||
The demo script rots when flows change — it lists URLs + clicks only (no prose
|
||||
walkthroughs), so churn stays cheap.
|
||||
@@ -0,0 +1,80 @@
|
||||
# WP-29 — Stamdata beheer editor (low-code, PR-emitting)
|
||||
|
||||
Status: done (0e77faf)
|
||||
Phase: follow-on — ADR-0004 realization (not part of the 2026-07-02 showcase audit)
|
||||
|
||||
## Why
|
||||
|
||||
ADR-0004 (stamdata-as-code) named a **future low-code editor that commits a PR** as its
|
||||
mitigation for "a non-developer may need dev assistance to edit C#", and floated a
|
||||
**data-file format** as the alternative to typed-C# constants when hand-editing ergonomics
|
||||
outweigh maximal compile-time safety. This WP realizes both: an admin-only maintenance editor
|
||||
that reads the stamdata catalog, edits rows in a grid, and produces the edited JSON data-file
|
||||
the admin drops into the repo — the existing CI build + `StamdataValidationTests` stay the
|
||||
authority. No production database, no runtime write path (ADR-0004 unchanged).
|
||||
|
||||
## Read first
|
||||
|
||||
- ADR-0004 (`docs/reference/architecture/0004-stamdata-as-code.md`) — the model this obeys.
|
||||
- ADR-0001 (BFF-lite + decision DTOs) — the endpoints are screen-shaped, admin-gated reads.
|
||||
- `src/app/brief/**` (WP-23/26) — the org-template admin editor is the closest prior art
|
||||
(root store + machine + capability guard + admin role).
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **Read-only endpoints only.** `GET /stamdata` (catalog) and `GET /stamdata/{table}?peildatum=`
|
||||
(schema + rows). No POST/PUT/DELETE — the edit lands as a reviewed PR, not a write. The
|
||||
`stamdata:edit` capability / `CanEditStamdata` gate the _reads_ (naming is the enforce-twin
|
||||
of a future edit capability; deliberate).
|
||||
- **Generic, schema-driven.** One endpoint pair + one grid editor serve every table. Columns
|
||||
are reflected from the typed record (`StamdataTable.Of<T>`); the FE renders inputs by column
|
||||
type (`date`/`number`/`enum`/`text`). A new table is one line in `StamdataCatalog` — no new
|
||||
endpoint, UI, or test. (Catalog of one today; this is the shape ADR-0004 prescribed.)
|
||||
- **Data-file format for `professions`.** `professions.json` (embedded resource) replaces the
|
||||
hardcoded C# dictionary, deserialized into `ProfessionMapping`. This trades compile-time
|
||||
_value_ checking (gate #1) for editor ergonomics — the value gate becomes
|
||||
`StamdataValidationTests` (gate #2), exactly the trade-off ADR-0004's consequences listed.
|
||||
- **Valid-time.** `geldigVan`/`geldigTot` (half-open `[van, tot)`); a table is temporal iff it
|
||||
has both columns. `peildatum` previews "which rows applied on date X". `Professions.ByProgram`
|
||||
preserves pre-valid-time behaviour by filtering to rows active today.
|
||||
- **Apply path = download → PR.** The editor's `download()` serializes the draft to
|
||||
`{table}.json`; the admin commits it. `mutation-command` is intentionally not used.
|
||||
- Admin-only, resource-independent authz — same shape as org-template management (role IS the
|
||||
decision), denials audited (no PII).
|
||||
|
||||
## Files
|
||||
|
||||
- Backend: `backend/src/BigRegister.Api/Stamdata/{StamdataCatalog,StamdataTable,StamdataFile,ProfessionMapping}.cs` (new), `Professions.cs` (now loads the data-file), `professions.json` (new), `BigRegister.Api.csproj` (embed `Stamdata\*.json`); `Program.cs` (two GET endpoints + `StamdataAdmin` gate), `Contracts/Dtos.cs` (3 DTOs), `Domain/Authorization/Authz.cs` (`stamdata:edit` + `CanEditStamdata`); tests `StamdataEndpointTests.cs` (new), `StamdataValidationTests.cs` (generic `Every_catalog_table_is_valid`).
|
||||
- Frontend: `src/app/beheer/**` (contracts / domain + specs / infrastructure + spec / application / ui + organism story); `app.routes.ts` (guarded lazy route), `shared/domain/capability.ts` + `shared/infrastructure/me.adapter.ts` (`stamdata:edit`), `eslint.config.mjs` (`beheer` boundary rules), `tsconfig.json` (`@beheer/*` alias); regenerated `backend/swagger.json` + `src/app/shared/infrastructure/api-client.ts`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `GET /stamdata` and `GET /stamdata/{table}` return admin-only (403 + audit otherwise).
|
||||
- [x] `professions` served from `professions.json`; `ProfessionMapping` typed; behaviour of
|
||||
`Professions.ByProgram` unchanged for all-current rows.
|
||||
- [x] Generic build gate: `Every_catalog_table_is_valid` covers every catalog table (keys
|
||||
non-blank, no overlapping validity, well-formed windows).
|
||||
- [x] `beheer/stamdata` route capability-guarded; page shows denial for non-admin; grid
|
||||
renders from reflected schema; edits update dirty/change-count; `download()` yields a
|
||||
valid `{table}.json`; `peildatum` before 2000-01-01 → zero rows.
|
||||
- [x] Full gate GREEN both sides; `npm run gen:api` leaves no drift; new stamdata story passes axe.
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test && dotnet format --verify-no-changes`; `npm run lint && npm run
|
||||
check:tokens && npm test && npm run build && npm run build-storybook && npm run test-storybook:ci`;
|
||||
`npm run gen:api && git diff --exit-code -- backend/swagger.json src/app/shared/infrastructure/api-client.ts`.
|
||||
Live: `/beheer/stamdata?role=admin` renders + edits + downloads; without `?role=admin` denies.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Runtime persistence of edits (would contradict ADR-0004) — the download-to-PR path is the design.
|
||||
- Migrating `PolicyQuestions` / document-categories into the catalog (each is a later one-liner).
|
||||
- A write-back "commit a PR on the admin's behalf" integration (the `download()` seam is where it slots in).
|
||||
|
||||
## Risks
|
||||
|
||||
- Data-file weakens compile-time value safety for `professions` — mitigated by
|
||||
`StamdataValidationTests` running in CI (a bad value fails the build, never prod).
|
||||
- Generic reflection assumes the record's first property is the key and camelCase JSON names —
|
||||
documented conventions in `StamdataTable`; covered by the endpoint schema test.
|
||||
@@ -0,0 +1,101 @@
|
||||
# WP-30 — CI performance follow-ups
|
||||
|
||||
Status: done (items 1, 3, 4, 5, 6; item 2 deliberately deferred — see "Status update" below)
|
||||
Phase: follow-on · CI/infra
|
||||
|
||||
## Why
|
||||
|
||||
Tier-1 CI speedups shipped in `708d4c2` (CodeQL off the PR path, Playwright/NuGet caches,
|
||||
`npm ci` flags) and the demo web image shrank to `node:24-slim`. These are the remaining
|
||||
options that were deliberately deferred — bigger changes, policy calls, or things that need
|
||||
Gitea runner-admin access. Revisit once there's an actual CI-timing breakdown to prioritise by,
|
||||
or when someone confirms act_runner access.
|
||||
|
||||
Constraint carried over: **CI runs are not observable from the agent's environment** — validate
|
||||
any workflow edit by watching a real Gitea run; ship one change at a time so a red run is easy to
|
||||
bisect and revert. **The `docker compose` images are NOT used by CI** (CI = Gitea `ubuntu-latest`
|
||||
runner image, set on the act_runner host).
|
||||
|
||||
## Read first
|
||||
|
||||
- `.github/workflows/ci.yml` (current 6 jobs + the Tier-1 caches already in place).
|
||||
- The `ci-and-local-gate` note (agent memory) — CI traps + what's already done.
|
||||
- `docker-compose.yml` (demo images; `node:24-slim` done, dotnet SDK still full).
|
||||
|
||||
## Candidate items (pick per impact once measured)
|
||||
|
||||
1. **Skip `npm ci` install via a `node_modules` cache.** `actions/cache` on `node_modules`
|
||||
keyed by `package-lock.json` hash; on a hit, `npm ci` is near-instant across the 4 npm jobs.
|
||||
Bigger win than the existing npm-download cache, but a ~777 MB cache with a small staleness
|
||||
risk — best if the runner's cache storage is local/fast. Medium effort, low-medium risk.
|
||||
2. **Smaller CI runner image.**
|
||||
- _Real fix (needs runner admin):_ point act_runner's `ubuntu-latest` (or a new label) at a
|
||||
smaller image with node + dotnet preinstalled. Biggest startup win. **Blocked on confirming
|
||||
act_runner access.**
|
||||
- _Repo-only partial:_ `container: node:24-slim` on the node-only jobs (`frontend`,
|
||||
`storybook-a11y`), dropping `setup-node`. Doesn't help the node+dotnet jobs (`e2e`,
|
||||
`api-client-drift`, `backend`) — a combined image would need building/pushing (new infra).
|
||||
Risky on act_runner, unverifiable locally → stage alone, last.
|
||||
3. **Path-filtered jobs.** Skip `backend` on FE-only changes and vice-versa (workflow `paths:`
|
||||
or `dorny/paths-filter`). Cuts compute on narrow PRs; watch required-check rules that expect
|
||||
every job to report a status.
|
||||
4. **Split a fast `lint` job** (lint + format:check + check:tokens) for ~1 min fail-fast
|
||||
feedback — only worth it once item 1 (node_modules cache) lands, else it duplicates `npm ci`.
|
||||
5. **Lean deployable backend image** (optional, not for the dev demo): multi-stage prod build on
|
||||
`mcr.microsoft.com/dotnet/aspnet:10.0` (~220 MB) in a separate `docker-compose.prod.yml`. The
|
||||
dev `docker-compose.yml` keeps the SDK image because `dotnet run` hot-reload needs it.
|
||||
6. **Semgrep: triage findings + make it blocking.** Semgrep replaced CodeQL (GitHub-only, couldn't
|
||||
run on Gitea) and currently runs **report-only** — a local dry-run found 27 findings, mostly
|
||||
CI/config policy (unpinned GitHub Actions in `ci.yml`, `.npmrc` min-release-age) rather than
|
||||
app-code vulns. Triage them (fix or `# nosemgrep`/`.semgrepignore` the noise; consider a
|
||||
tighter ruleset than `p/default` if the GitHub-Actions-policy rules aren't wanted), then add
|
||||
`--error` to `semgrep scan` so it's a real gate.
|
||||
|
||||
## Status update (2026-07-30)
|
||||
|
||||
Items 1, 3, 4, 5, 6 implemented, each as its own commit (item 6 `526da76`, item 1 `e46b87b`,
|
||||
item 4 `e02e8ce`, item 3 `e7db69d`, item 5 see `git log -- backend/Dockerfile`): triaged real
|
||||
local semgrep findings (25, not the 27 this
|
||||
file remembered — dependabot cooldown, npm min-release-age, every GH Action pinned to SHA, 2
|
||||
nosemgrep'd ReDoS false positives) and flipped the gate to `--error`; `node_modules` cache
|
||||
(skips `npm ci` entirely on a hit) across all 4 npm jobs; a new fast-fail `lint` job split out
|
||||
of `frontend`; a `changes` job (`dorny/paths-filter`) gating every downstream job's real steps
|
||||
(not the whole job — the safer "skip steps" variant, so a required-status-check never waits on
|
||||
a job that never ran) on which side changed; an optional `backend/Dockerfile` +
|
||||
`docker-compose.prod.yml` (additive, unused by CI or the dev demo).
|
||||
|
||||
**Item 2 (smaller runner image) deliberately skipped this round** — the real fix needs
|
||||
act_runner admin access (unconfirmed), and the repo-only partial (`node:24-slim` on
|
||||
`frontend`/`storybook-a11y`) conflicts with `storybook-a11y`'s deliberately-chosen
|
||||
`node:24-bookworm` + memory-cap container (verified against a real OOM risk). Revisit once
|
||||
act_runner access is confirmed.
|
||||
|
||||
**Confirmed GREEN on a real Gitea run (2026-07-30, watched by the user).** The push did catch
|
||||
one real gap that only a live run could: `backend/Dockerfile` (item 5) predated the item-6
|
||||
semgrep triage, so its own `dockerfile.security.missing-user-entrypoint` finding (container
|
||||
running as root) was never locally verified — semgrep's `--error` gate correctly failed the
|
||||
first push on it. Fixed (`ebf1f8f`): switched to the base image's built-in non-root `app` user
|
||||
(`$APP_UID`, uid 1654) with `--chown` on both `COPY` layers so SQLite (WP-22, a relative-path
|
||||
connection string resolved against the container's `/app` cwd) can still write
|
||||
`bigregister.db`; verified for real (rebuilt, confirmed `whoami` is `app`, hit a live
|
||||
`GET /brief/preview`, confirmed the db file's actual ownership) before repushing. That run came
|
||||
back all-green — path-filtering, the node_modules cache, and the split lint job all behave as
|
||||
designed on the real runner.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Each chosen item verified GREEN on a real Gitea run (watched, since it's not observable
|
||||
from the agent env), landed as its own revertable commit.
|
||||
- [x] `npm run ci` still passes locally after any workflow/script change (confirmed 2026-07-30,
|
||||
full run including backend `dotnet test`/`dotnet format` and both drift checks).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- nx/turbo/remote build caching (overkill for this repo size).
|
||||
- Rewriting the dev compose into prod images (would lose bind-mount hot reload).
|
||||
|
||||
## Risks
|
||||
|
||||
- Unverifiable-from-agent workflow edits can only be confirmed on the runner — one change per
|
||||
commit, revert on red.
|
||||
- act_runner-level changes depend on infra access this repo doesn't control.
|
||||
@@ -0,0 +1,52 @@
|
||||
# WP-31 — Shared store helpers (audit: apply high-value)
|
||||
|
||||
Status: done
|
||||
Phase: 7 — refinements
|
||||
|
||||
## Why
|
||||
|
||||
A code audit found real duplication across the editor stores. This WP extracts the four
|
||||
highest-value shared helpers and rewires the stores to them (behaviour unchanged), and
|
||||
**reports** the lower-value / riskier DDD items as deferred backlog. Extracting `createHistory`
|
||||
here also unblocks WP-32 (stamdata undo) so it needn't copy-paste the brief pattern.
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Extract into `shared/application/` (importable by every context; must not import back).
|
||||
- Apply the four concrete extractions + reuse; **do not** chase the deferred DDD items in this
|
||||
phase (bound the diff). Behaviour must be identical — the existing store specs are the gate.
|
||||
|
||||
## Files
|
||||
|
||||
- New (each with a co-located spec): `shared/application/action-state.ts` (`ActionState`/
|
||||
`SaveState`), `history.ts` (`createHistory<T>`), `debounced-save.ts` (`createDebouncedSave`),
|
||||
`machine-remote-data.ts` (`machineRemoteData`).
|
||||
- Rewired: `brief/application/brief.store.ts` (all four), `brief/application/org-template.store.ts`
|
||||
(types + debounced-save + remote-data), `beheer/application/stamdata.store.ts` (remote-data).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `ActionState`/`SaveState` defined once; both brief stores import them.
|
||||
- [x] `createHistory` backs brief undo/redo (identical semantics; specs pass).
|
||||
- [x] `createDebouncedSave` backs both brief stores' autosave, integrating `PendingSave`.
|
||||
- [x] `machineRemoteData` backs the RemoteData projection in all three stores.
|
||||
- [x] `npm run ci` green; all pre-existing store specs still pass (no behaviour change).
|
||||
|
||||
## Deferred (reported, not built) — audit findings for a later WP
|
||||
|
||||
- **`contracts/` folder inconsistency:** only `beheer/` + `registratie/` have a `contracts/`
|
||||
folder; `brief/`/`herregistratie/`/`auth/` declare wire DTOs inline in adapters. Decide whether
|
||||
inline DTOs are a sanctioned exception or should be normalized.
|
||||
- **`parse*` traverse combinator:** ~35 `parse*` boundary fns repeat an array-parse-and-collect
|
||||
shape; a shared `traverse`/`parseAll` `Result` combinator would collapse the common idiom.
|
||||
- **`Seed { state }` msg boilerplate:** the `Seed`/`return m.state` pair repeats in 6 machines —
|
||||
cheap and per-machine typed; extract only if it earns its keep.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The deferred items above (this WP only applies the four extractions).
|
||||
|
||||
## Risks
|
||||
|
||||
- Behaviour drift in the central stores — mitigated: the extractions are 1:1 with the originals
|
||||
and gated by the existing brief/org-template/stamdata specs (all green).
|
||||
@@ -0,0 +1,39 @@
|
||||
# WP-33 — In-app dev switchers (scenario + role)
|
||||
|
||||
Status: done
|
||||
Phase: 7 — refinements
|
||||
|
||||
## Why
|
||||
|
||||
The two dev-only stand-ins — the async `?scenario=` toggle (`scenario.interceptor.ts`) and the
|
||||
faked `?role=` (`role.interceptor.ts`) — were driven by hand-editing the URL query string.
|
||||
Awkward for demos: you had to remember the valid values and retype them. This WP surfaces both
|
||||
as dropdowns in the existing dev panel so a scenario/role can be flipped with a click.
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **No new component/shell wiring.** The switchers live inside the existing `debug-state`
|
||||
devtool (the sanctioned dev-only fab/panel already mounted in the shell under `isDevMode()`).
|
||||
- **Reuse the mechanism modules, don't duplicate their source of truth.** `scenario.ts`/`role.ts`
|
||||
gain a `set*` setter + an exported valid-values list; the panel imports them. `debug-state` is
|
||||
added to the `ui→infrastructure` eslint exemption (same precedent as its existing cross-context
|
||||
exemption) rather than re-declaring the storage keys / valid lists in the UI.
|
||||
- **Scenario becomes tab-sticky (sessionStorage), mirroring role.** Without this the switcher
|
||||
would be near-useless: navigation drops the query param and reverts to `default` mid-demo.
|
||||
- **Apply by `location.reload()`.** Both values are read per-request in interceptors and gate
|
||||
server-computed decision flags already fetched by eager `httpResource`s — a reload is the
|
||||
simplest correct way to re-run them. Acceptable for a dev tool.
|
||||
|
||||
## Files
|
||||
|
||||
- `shared/infrastructure/scenario.ts` — tab-sticky read (mirrors role), `setScenario`, exported
|
||||
`SCENARIOS`; co-located `scenario.spec.ts`.
|
||||
- `shared/infrastructure/role.ts` — `setRole`, exported `ROLES`.
|
||||
- `shared/ui/debug-state/debug-state.component.ts` — two `<select>`s in the panel.
|
||||
- `eslint.config.mjs` — extend the debug-state devtool exemption to the ui→infrastructure rule.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Dev panel shows role + scenario dropdowns; changing one persists it and reloads.
|
||||
- [x] Scenario survives navigation within the tab (sticky).
|
||||
- [x] `npm run ci` green (lint, tests incl. new scenario spec, localized build).
|
||||
@@ -0,0 +1,45 @@
|
||||
# WP-34 — Adres: phone field + BRP address read-only
|
||||
|
||||
Status: done
|
||||
Phase: 7 — refinements
|
||||
|
||||
## Why
|
||||
|
||||
The "Mijn gegevens" screen let the user _edit_ their address (straat/postcode/woonplaats) and
|
||||
submit it as an adreswijziging. But the BRP (Basisregistratie Personen) is the authority for a
|
||||
person's address — you change it at the municipality, not in a register self-service portal.
|
||||
This WP corrects that: the address is shown **read-only** (rendered from the BRP data the
|
||||
screen already loads), and the editable/submittable contact detail becomes the **phone number**
|
||||
— the thing CIBG actually holds and the professional can update here.
|
||||
|
||||
## Decisions (made while building — no spec existed; flagged for review)
|
||||
|
||||
- **The adreswijziging form becomes a telefoonwijziging form.** Address is read-only display;
|
||||
phone is the one editable field. Same single-step machine idiom (Model/Msg/pure reduce +
|
||||
value object + submit command) — only the fields changed.
|
||||
- **New `Telefoonnummer` value object** (parse-don't-validate, lax Dutch format: 10 digits,
|
||||
leading 0, `+31`→`0`, formatting stripped). Backend `RejectPhoneChange` re-validates as the
|
||||
authority (ADR-0001); the FE check is UX-only. Replaces the old address/`RejectChangeRequest`.
|
||||
- **Phone starts empty.** There is no current-phone anywhere in BRP/seed/Person, so no
|
||||
current-value round-trip was added (YAGNI) — the form submits a new/updated number. The
|
||||
read-only BRP address gives the screen its context.
|
||||
- **Endpoint reused, contract narrowed.** `POST /change-requests` now takes `{ telefoon }`
|
||||
(category `telefoonwijziging`); the typed client was regenerated (drift check clean).
|
||||
|
||||
## Files
|
||||
|
||||
- `registratie/domain/value-objects/telefoonnummer.ts` (+spec) — new value object.
|
||||
- `registratie/domain/change-request.machine.ts` (+spec) — Draft/Valid now `{ telefoon }`.
|
||||
- `registratie/infrastructure/change-request.adapter.ts` — sends `{ telefoon }`.
|
||||
- `registratie/ui/change-request-form/change-request-form.component.ts` (+story) — read-only
|
||||
BRP address block + editable phone field; takes `brpAdres` input.
|
||||
- `registratie/ui/registration-detail.page.ts` — passes `profile()?.person?.adres`.
|
||||
- Backend: `Dtos.cs`, `Program.cs`, `SubmissionRules.cs` (+ RuleTests/EndpointTests/IdempotencyTests).
|
||||
- `src/locale/*` — new/changed `$localize` ids + English targets.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] BRP address rendered read-only with a "change it at your municipality" note.
|
||||
- [x] Phone field with format validation (client instant + server authoritative).
|
||||
- [x] `npm run ci` green (lint, format, tokens, 332 FE tests, localized build, backend 122
|
||||
tests, api-client drift clean after commit).
|
||||
@@ -0,0 +1,45 @@
|
||||
# WP-35 — One Concept per case type (server-enforced)
|
||||
|
||||
Status: done
|
||||
Phase: 7 — refinements
|
||||
|
||||
## Why
|
||||
|
||||
The FE already keeps at most one Concept (draft aanvraag) per type — but only as a client-side
|
||||
convenience in `draft-sync.ts` (`resume()`/`findConcept()`/`resumeGate`). Per ADR-0001 the server
|
||||
is the authority for business rules; the FE guard is best-effort and a cross-tab / stale-list race
|
||||
can still POST a second Concept. This WP makes "at most one unsubmitted Concept per type" a
|
||||
**server-enforced invariant**, and makes the FE recover gracefully when the server refuses.
|
||||
|
||||
## Decisions (made while building — no spec existed; flagged for review)
|
||||
|
||||
- **Enforce at create, not submit.** The invariant is about the _existence_ of Concepts, so the
|
||||
guard lives in `POST /applications`. Enforcing at submit would only block submitting a duplicate,
|
||||
not its existence — that doesn't satisfy the title.
|
||||
- **Race-free in the store.** The check-and-insert happens atomically under the store's single
|
||||
write gate (`ApplicationStore.CreateConcept`), not as a separate list-then-create in the handler.
|
||||
- **409 Conflict** (ProblemDetails), matching the applications block's other guards
|
||||
(cancel-after-submit, submit-twice) — not 422. The generated client now handles 409 explicitly.
|
||||
- **FE recovery over error banner.** A create-409 means a Concept of this type already exists, so
|
||||
`ensureId` adopts it (`findConcept`) instead of surfacing an error — the whole point of
|
||||
one-per-type is that the second attempt lands you on the existing draft. Recovery fires only
|
||||
when one actually exists; otherwise the original failure is surfaced.
|
||||
- **Scope: only the persisted-lifecycle types** (`registratie | herregistratie | intake`). The
|
||||
stateless submits (`telefoonwijziging`, legacy `/registrations` etc.) never create a Concept.
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/.../Data/ApplicationStore.cs` — `Create` → `CreateConcept` (nullable; atomic guard).
|
||||
- `backend/.../Program.cs` — `POST /applications` returns 409 when `CreateConcept` returns null.
|
||||
- `backend/tests/.../ApplicationTests.cs` — helper clears leftover Concepts (tests share one DB);
|
||||
+3 tests (dup conflicts, different type allowed, new allowed after submit).
|
||||
- `src/app/registratie/application/draft-sync.ts` (+spec) — `ensureId` adopts the existing Concept
|
||||
on a create-conflict.
|
||||
- Regenerated `api-client.ts` / `swagger.json` (create now documents its 409).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A second unsubmitted Concept of the same type is refused server-side (409).
|
||||
- [x] A different type, and a new Concept after the previous is submitted, are allowed.
|
||||
- [x] FE recovers from the 409 by resuming the existing Concept (no error banner).
|
||||
- [x] `npm run ci` green (333 FE tests, backend 125, api-client drift clean after commit).
|
||||
@@ -0,0 +1,49 @@
|
||||
# WP-36 — Admin cases page + admin delete
|
||||
|
||||
Status: done
|
||||
Phase: 7 — refinements
|
||||
|
||||
## Why
|
||||
|
||||
Admins can maintain stamdata and org-templates but have no view of the cases (aanvragen) in the
|
||||
register, and no way to remove an erroneous one. This WP adds an admin-only overview of **all**
|
||||
cases across owners and an admin **delete** that can remove any case — the back-office counterpart
|
||||
of the user's own dashboard.
|
||||
|
||||
## Decisions (made while building — no spec existed; flagged for review)
|
||||
|
||||
- **Single capability `cases:manage`** covers both the list and the delete (one back-office
|
||||
concern), following the `orgtemplate:edit` / `stamdata:edit` precedent exactly (Authz role→cap +
|
||||
a `CanManageCases` gate + a `CasesAdmin(ctx,…)` helper; FE `Capability` union + `me.adapter`
|
||||
`KNOWN` + `capabilityGuard` + nav item + `role.interceptor` prefix).
|
||||
- **Page lives in `registratie` (not `beheer`).** `registratie` owns the `Aanvraag` aggregate, so
|
||||
the admin view reuses its `aanvraag-view` labels + `parseApplications` trust boundary instead of
|
||||
duplicating them — and it respects the layer boundary (`beheer` may not import `registratie`).
|
||||
This matches the existing pattern (stamdata-admin lives in `beheer` because `beheer` owns
|
||||
stamdata; org-template-admin in `brief`). Routed at `/beheer/zaken` for a legible admin URL.
|
||||
- **Admin delete removes ANY case** — any owner, submitted or not — unlike the user-facing
|
||||
`DELETE /applications/{id}` (owner-scoped, 409 on a submitted case). That is the admin power.
|
||||
- **Native `confirm()` guards the delete.** No confirm-dialog component exists (the only precedent
|
||||
is a native `confirm()` in behandel-scherm); the delete is irreversible, so it gets a prompt
|
||||
rather than the dashboard's no-confirm optimistic cancel.
|
||||
- **Single owner in practice.** Only `DemoOwner` exists, so the list shows that owner's cases with
|
||||
an Owner column; no fake multi-user seed was added (the endpoint is cross-owner-capable —
|
||||
`ListAll()` — so real multi-owner data would just appear).
|
||||
|
||||
## Files
|
||||
|
||||
- Backend: `ApplicationStore.ListAll()` + `DeleteAny(id)`; `ApplicationSummaryDto.Owner` +
|
||||
`ToAdminSummaryDto`; `Authz` cap + `CanManageCases`; `Program.cs` `CasesAdmin` gate + `GET
|
||||
/admin/cases` + `DELETE /admin/cases/{id}`; `AdminCasesTests` (+ update the org-template `/me`
|
||||
cap-list assertion). SQLite can't `ORDER BY DateTimeOffset` → `ListAll` sorts client-side.
|
||||
- FE: `capability.ts` + `me.adapter` `KNOWN` + `role.interceptor` (`/api/v1/admin/cases`);
|
||||
`aanvraag.ts` `owner?`; `applications.adapter` `listAll`/`deleteAny` + parse owner;
|
||||
`registratie/application/admin-cases.store.ts` (+spec); `registratie/ui/admin-cases.page.ts`;
|
||||
route in `app.routes.ts`; nav item in `site-header`; new `$localize` ids + English targets.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Admin-only page at `/beheer/zaken` lists all cases (owner + type + status), gated by
|
||||
`cases:manage` (denial alert for non-admins; server re-enforces via `CasesAdmin`).
|
||||
- [x] Admin delete removes any case (incl. submitted); confirmed, optimistic with rollback.
|
||||
- [x] `npm run ci` green (336 FE tests, backend 129, localized build, drift clean after commit).
|
||||
@@ -0,0 +1,31 @@
|
||||
# WP-37 — Dev-switcher reset fix (scenario/role)
|
||||
|
||||
Status: done
|
||||
Phase: 8 — platform/DX/showcase
|
||||
|
||||
## Why
|
||||
|
||||
The WP-33 dev switcher can't reset scenario/role back to `default`/`drafter` — it gets "stuck"
|
||||
(e.g. on `slow`). Cause: `currentScenario()`/`currentRole()` read the URL `?scenario=`/`?role=`
|
||||
param **before** sessionStorage, so once a param is in the address bar, `location.reload()` (same
|
||||
URL) re-reads the stale value and overrides what the switcher just stored.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Once the switcher is used, **sessionStorage is authoritative**; a leftover URL param must not
|
||||
win. Strip both dev params from the URL (`history.replaceState`) before reloading.
|
||||
- Extract the URL rewrite as a **pure** `stripDevParams(href)` so it's unit-testable without
|
||||
touching `location.reload()`.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/app/shared/infrastructure/dev-params.ts` (+ `dev-params.spec.ts`) — pure `stripDevParams`.
|
||||
- `src/app/shared/ui/debug-state/debug-state.component.ts` — `switchRole`/`switchScenario` call
|
||||
`applyAndReload()` (replaceState with stripped URL, then reload).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Switching scenario/role to any value (incl. default/drafter) sticks after reload, even when
|
||||
a `?scenario=`/`?role=` param was in the URL.
|
||||
- [x] `stripDevParams` removes both params, keeps other params + path/hash (spec).
|
||||
- [x] `npm run ci` green.
|
||||
@@ -0,0 +1,49 @@
|
||||
# WP-38 — Dependency graph + declarative boundaries
|
||||
|
||||
Status: done
|
||||
Phase: 8 — platform/DX/showcase
|
||||
Priority: P1
|
||||
|
||||
## Outcome
|
||||
|
||||
Adopted **dependency-cruiser**. `.dependency-cruiser.js` is the single declarative source for
|
||||
context + layer boundaries (incl. the previously-missing `herregistratie` scope + no-circular);
|
||||
`npm run dep:check` enforces (wired into `ci-local.sh` + the `frontend` CI job), `npm run dep:graph`
|
||||
emits a mermaid context×layer graph to `docs/reference/architecture/dependency-graph.md`. The
|
||||
per-context `no-restricted-imports` blocks were **removed** from `eslint.config.mjs` (now only
|
||||
`no-explicit-any` + template a11y remain); parity verified by planting violations (domain→Angular,
|
||||
beheer→registratie incl. type-only) and confirming `dep:check` flags them. Doc:
|
||||
`docs/reference/architecture/dependencies.md`; `new-context` skill updated to the single source.
|
||||
|
||||
## Why
|
||||
|
||||
Bounded-context + atomic-layer boundaries are enforced only by hand-duplicated
|
||||
`no-restricted-imports` blocks in `eslint.config.mjs` — pass/fail, no graph, and brittle: the
|
||||
`new-context` skill literally says "grep the config and copy a block", and `herregistratie` is
|
||||
missing its explicit ban block (asymmetry). We want to **see** the dependencies AND **enforce**
|
||||
them from one declarative source.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Step 1 — tool fork:** dependency-cruiser (recommended: graph + CI rules on plain Angular) vs
|
||||
Sheriff (tag-based, DDD/atomic-native, weaker graph). Decide before building.
|
||||
- Encode context + layer rules once (contexts `shared/auth/registratie/herregistratie/brief/beheer/
|
||||
showcase`; layers `domain/application/infrastructure/contracts/ui`); **fix the herregistratie gap**.
|
||||
- Keep ESLint for the intra-file rules it does better (`domain↛@angular`, ApiClient value-import
|
||||
confinement, `no-explicit-any`); migrate only the cross-module _direction_ rules to the new tool.
|
||||
- Emit a graph the showcase/teaching can reuse (feeds WP-39).
|
||||
|
||||
## Files
|
||||
|
||||
- New: `.dependency-cruiser.js` (or `sheriff.config.ts`); `npm run graph` + validate script.
|
||||
- `scripts/ci-local.sh` + `.github/workflows/ci.yml` — add `depcruise --validate` (non-optional).
|
||||
- `eslint.config.mjs` — remove the migrated direction rules (keep the rest).
|
||||
- New doc `docs/reference/architecture/dependencies.md`; embed the graph in a Foundations page.
|
||||
- `.claude/skills/new-context/SKILL.md` — point at the single declarative source.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] One declarative config expresses all allowed context/layer edges; herregistratie included.
|
||||
- [x] `npm run dep:graph` produces a committed mermaid architecture graph; `dep:check` runs in `npm run ci`.
|
||||
- [x] A deliberately-illegal import fails the validate step (proven, then reverted).
|
||||
- [x] No loss of enforcement vs the old ESLint blocks; `npm run ci` green.
|
||||
@@ -0,0 +1,49 @@
|
||||
# WP-39 — Showcase: linked code snippets + teaching animations
|
||||
|
||||
Status: done
|
||||
Phase: 8 — platform/DX/showcase
|
||||
Priority: P1
|
||||
|
||||
## Outcome
|
||||
|
||||
`?raw` imports are NOT supported by Angular's esbuild build (verified), so anti-drift is done via
|
||||
a generator (mirrors `gen:api`): `// #region showcase:<name>` markers in real source
|
||||
(registration.ts, remote-data.ts, postcode.ts, change-request.machine.ts, intake.machine.ts) →
|
||||
`scripts/gen-snippets.mjs` (`npm run gen:snippets`) → committed `src/app/showcase/snippets.generated.ts`.
|
||||
A CI drift gate (`gen:snippets && git diff --exit-code`) forces a regen after any edit, so the shown
|
||||
code IS the shipped code. The showcase renders the 5 real snippets (union/fold/parse/machine/steps)
|
||||
each with a `↳ <source file>` caption, highlighted by a tiny dependency-free `highlightTs` (+spec);
|
||||
the deliberately-wrong illustrations stay authored (no real source to link). Teaching animation: the
|
||||
active state node pops (scale) as the wizard transitions, and the parse result animates in
|
||||
(`animate.enter`), reusing the existing reduced-motion-safe toolkit. Generated file is
|
||||
prettier-ignored (like api-client.ts) so the drift gate and format:check don't conflict.
|
||||
|
||||
## Why
|
||||
|
||||
The `/concepts` showcase shows live components, but its code snippets are hand-authored
|
||||
`innerHTML` strings with manual highlighting — they drift from the real code. And the animation
|
||||
toolkit (`withViewTransitions`, native `animate.enter/leave`, reduced-motion-safe keyframes in
|
||||
`styles.scss`) is proven in dashboard/upload but unused for teaching motion.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Anti-drift snippets:** load _real_ source excerpts via Vite `?raw` imports with `// #region name`
|
||||
markers + a tiny pure extractor; render with lightweight highlighting. No more `innerHTML` literals.
|
||||
- On the Storybook/MDX side, use `<Source>`/Canvas "show code" consistently (extend beyond the
|
||||
current 4 of 14 pages).
|
||||
- **Reuse** the existing motion toolkit (no new animation lib): `animate.enter/leave` on the
|
||||
fold/union demos + a "step through `reduce`" animation for the TEA loop; keep reduced-motion-safe.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/app/showcase/concepts.page.ts` — replace `innerHTML` snippets with `?raw`+region excerpts;
|
||||
apply animations.
|
||||
- New tiny extractor util (pure) + spec (region slicing).
|
||||
- `src/docs/machines.mdx`, `fp-in-ui.mdx`, `remote-data.mdx` — reference the live linked snippets.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The good-side snippets are sourced from real files via region markers (edit source → regen →
|
||||
snippet updates); a CI drift gate enforces it. Bad-pattern illustrations stay authored.
|
||||
- [x] The state machine + parse demos animate (reduced-motion respected globally); no new dependency.
|
||||
- [x] `npm run ci` green.
|
||||
@@ -0,0 +1,52 @@
|
||||
# WP-40 — PII kernel: branded `Bsn` VO + masked-value atom
|
||||
|
||||
Status: done
|
||||
Phase: 8 — platform/DX/showcase
|
||||
Priority: P2
|
||||
|
||||
## Outcome
|
||||
|
||||
- `shared/kernel/bsn.ts` — `Bsn` branded VO + `parseBsn` with the **elfproef** (11-test) checksum
|
||||
(+spec). Wired into the DigiD login boundary (`digid.adapter`): login now does real BSN
|
||||
validation instead of "any 9 digits" — login hint + e2e BSNs updated to a valid one (`123456782`).
|
||||
- `shared/kernel/pii.ts` — pure `maskBsn`/`maskTail`/`REDACTED` (+spec), the functional core. Moved
|
||||
out of `debug-state/mask.ts`, which keeps only `redactProfile` (it depends on the registratie
|
||||
`BigProfile`, so it can't live in `shared/kernel` — boundary; debug-state is the sanctioned
|
||||
cross-context devtool). `mask.spec` ported to `pii.spec`.
|
||||
- `<app-masked-value>` atom (`shared/ui/masked-value`) + story — masked-by-default value with an
|
||||
optional reveal affordance; **centralises** the `.includes('*')` masked-detection that
|
||||
`behandel-scherm` used to sniff inline (now refactored to the atom). The atom only emits
|
||||
`reveal`; the caller keeps the step-up confirm + audited fetch.
|
||||
- Deviation (flagged): `Session.bsn` stays typed `string` — the brand guards the login ENTRY, but
|
||||
Session crosses a persistence boundary where the restore path deliberately drops the bsn
|
||||
(privacy) and JSON can't carry a brand, so re-typing it would add a `''`/nullable ripple for no
|
||||
real gain. Backend `MaskTail` twin unchanged (still in sync).
|
||||
|
||||
## Why
|
||||
|
||||
Masking is real but ad-hoc: pure helpers live in `debug-state/mask.ts`, the BSN is a bare `string`
|
||||
in `session.ts` (no branded VO / checksum, unlike `BigNummer`/`Postcode`), and masked state is
|
||||
detected by `.includes('*')` sniffing — no reusable atomic-design component. For a register this is
|
||||
AVG art. 9 (BSN = special category) territory; the pieces should be first-class and reusable.
|
||||
|
||||
## Decisions
|
||||
|
||||
- `Bsn` branded value object + `parseBsn` with the **elfproef** (9 digits, weighted mod-11)
|
||||
checksum — parity with the other value objects. Replace the bare `string` in `session.ts`.
|
||||
- Consolidate the pure maskers into `shared/kernel/pii.ts` (functional core): `maskBsn`, `maskTail`,
|
||||
`redactProfile`, … Keep the backend `MaskTail` twin in sync.
|
||||
- `<app-masked-value>` atom (`shared/ui`) — masked by default, optional reveal affordance + a11y;
|
||||
replaces `.includes('*')` sniffing and composes into the behandel-scherm reveal.
|
||||
|
||||
## Files
|
||||
|
||||
- New `src/app/shared/kernel/bsn.ts` (+spec, elfproef cases) and `shared/kernel/pii.ts` (+spec).
|
||||
- `src/app/auth/domain/session.ts` — `bsn: Bsn`.
|
||||
- New `src/app/shared/ui/masked-value/` atom (+ story).
|
||||
- `src/app/shared/ui/debug-state/mask.ts` — re-export/move to the kernel module.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `parseBsn` accepts valid elfproef numbers, rejects bad checksum/length/all-zeros (spec).
|
||||
- [x] `<app-masked-value>` renders masked by default with an optional reveal; behandel-scherm uses it (no more inline sniff).
|
||||
- [x] Pure maskers have specs (no TestBed); backend `MaskTail` parity noted; `npm run ci` green.
|
||||
@@ -0,0 +1,46 @@
|
||||
# WP-41 — Persisted, queryable authz/PII-reveal audit
|
||||
|
||||
Status: done
|
||||
Phase: 8 — platform/DX/showcase
|
||||
Priority: P2
|
||||
Depends on: WP-40
|
||||
|
||||
## Outcome
|
||||
|
||||
New data-minimised EF table `AuthzAuditEntry` (`Data/AuthzAuditStore.cs`, DbSet + key config in
|
||||
`AppDbContext`, migration `AuthzAudit`): `At, Action, Resource, Decision, Role, CorrelationId` —
|
||||
**never** a name/BSN/value. `AuditAuthz` now persists (via `AuthzAuditStore.Record`) alongside its
|
||||
log line, so every authz denial + BIG-nummer reveal/step-up attempt is captured. `GET /admin/audit`
|
||||
(admin-gated by the existing `CasesAdmin`/`cases:manage` — a dedicated `audit:read` cap is a later
|
||||
refinement) returns the trail newest-first (client-side sort — SQLite can't ORDER BY DateTimeOffset).
|
||||
+3 backend tests (deny recorded, reveal recorded, **schema-carries-no-PII** reflection test). Typed
|
||||
client regenerated (`audit()` + `AuthzAuditDto`). No FE consumer yet — a future audit view (WP-42
|
||||
finish) must add `/api/v1/admin/audit` to the `role.interceptor` ROLE_AWARE list or it silently 403s.
|
||||
|
||||
## Why
|
||||
|
||||
The security-relevant events (authz denials via `AuditAuthz`, BIG-nummer reveal, step-up) are
|
||||
**log-only** — never persisted, so there's no queryable trail. The one persisted `AuditEntry` table
|
||||
covers document lifecycle only. PRD-0002 §8 calls for a persisted authorization audit. A register
|
||||
(BIO) wants this queryable, and strictly data-minimised.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Persist authz/reveal/step-up events into a data-minimised EF table extending the existing
|
||||
`AuditEntry`/`AppDbContext` pattern: role, action, resource-ref, decision, correlation-id,
|
||||
timestamp — **never** the value or any PII.
|
||||
- Wire `AuditAuthz` + the reveal endpoint to persist (in addition to the log line).
|
||||
- Optional thin admin read view — reuse `cases:manage` or add an `audit:read` capability.
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/.../Data/` — new audit entity + `AppDbContext` DbSet + EF migration.
|
||||
- `backend/.../Program.cs` — `AuditAuthz` + reveal endpoint persist.
|
||||
- Backend tests: events are persisted; **no PII columns**; correlation-id captured.
|
||||
- (Optional) FE audit read page + adapter/store, gated by the capability.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Denials, reveals, and step-up attempts land as rows with no PII/value fields.
|
||||
- [x] A test asserts the schema carries no name/bsn/value column.
|
||||
- [x] `dotnet test` (132) + `npm run ci` green; api-client drift clean after commit.
|
||||
@@ -0,0 +1,53 @@
|
||||
# WP-42 — Privacy & security showcase page
|
||||
|
||||
Status: done (optional Foundations MDX writeup left as a nice-to-have)
|
||||
|
||||
## Audit view (added after WP-41)
|
||||
|
||||
`/beheer/audit` — an admin page (`beheer/ui/audit.page.ts`) reading the WP-41 `GET /admin/audit`
|
||||
trail through a `beheer` adapter/store (domain `AuditEntry` + trust-boundary parse), rendered as a
|
||||
read-only table (time/action/resource/decision/role/correlation-id), capability-gated on
|
||||
`cases:manage`. Added to `ADMIN_LINKS` (so it shows in the header nav + dashboard "Beheer" section)
|
||||
and to the `role.interceptor` ROLE_AWARE list (else it silently 403s). This closes the audit half.
|
||||
|
||||
Phase: 8 — platform/DX/showcase
|
||||
Priority: P2
|
||||
Depends on: WP-40, WP-41
|
||||
|
||||
## Outcome (mask/parse half — done, on user request ahead of WP-41)
|
||||
|
||||
Added a "6 · PII — maskeren & parsen" section to `/concepts` demonstrating the WP-40 pieces with
|
||||
FP + atomic design, framed for AVG art. 9 / data-minimisation: a live `<app-masked-value>` atom
|
||||
(masked-by-default BSN that reveals on click; note points to the real step-up + audited reveal in
|
||||
behandel-scherm) and a live `parseBsn` elfproef parse mirroring the postcode demo. Both show the
|
||||
real linked source via the WP-39 snippet mechanism (`// #region showcase:parseBsn` in bsn.ts,
|
||||
`showcase:mask` in pii.ts, registered in `gen-snippets.mjs` → `snippets.generated.ts`, drift-gated).
|
||||
No i18n (showcase is Dutch-only teaching text). No behaviour change outside the showcase.
|
||||
|
||||
**Still pending (needs WP-41):** the "log PII / no-PII audit trail" half — visualizing the
|
||||
persisted authz/reveal audit — plus an optional Foundations MDX writeup.
|
||||
|
||||
## Why
|
||||
|
||||
Once the reusable privacy pieces exist (WP-40 masked-value atom + pure maskers, WP-41 persisted
|
||||
no-PII audit), showcase them as a teaching artifact: how to **mask** and **log** PII safely with FP
|
||||
(pure functional core) + atomic design (the masked-value atom), tied to the existing reveal +
|
||||
step-up + capability slice.
|
||||
|
||||
## Decisions
|
||||
|
||||
- A Foundations MDX + a small `showcase` demo (showcase context may read every context).
|
||||
- Teach the principles explicitly: data-minimisation, PII out of logs, masked-by-default +
|
||||
audited reveal, server as authority — framed for a Dutch register (AVG art. 9, BIO).
|
||||
- Reuse WP-39's linked-snippet mechanism so the shown code can't drift.
|
||||
|
||||
## Files
|
||||
|
||||
- New `src/docs/privacy-security.mdx` (Foundations).
|
||||
- New/extended `showcase` demo wiring `<app-masked-value>`, the pure maskers, and the reveal flow.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Page demonstrates mask + no-PII logging with live components and linked (non-drifting) code.
|
||||
- [ ] Explains the register rationale (AVG/BIO/data-minimisation) concisely.
|
||||
- [ ] Storybook a11y green; `npm run ci` green.
|
||||
@@ -0,0 +1,47 @@
|
||||
# WP-43 — Runnable leaf generators
|
||||
|
||||
Status: done (value-object + form-machine; ui-component/bff-endpoint kept as skills)
|
||||
Phase: 8 — platform/DX/showcase
|
||||
Priority: P3
|
||||
|
||||
## Outcome
|
||||
|
||||
Tool = **plop**. `plopfile.mjs` + `plop-templates/` provide two runnable generators for the pure-TS
|
||||
patterns: `npm run gen:value-object` (branded type + `parseX` + spec, mirrors postcode/bsn) and
|
||||
`npm run gen:form-machine` (Draft/Valid/Errors + Editing/Submitting/Submitted/Failed union +
|
||||
`initial`/pure `reduce`/`assertNever` + spec, mirrors change-request.machine). Prompts take
|
||||
context + PascalCase name (positional-arg bypass for scripting); each emits a co-located spec so the
|
||||
"pure logic must have a spec" rule holds by default; a post-action reminds to add the English
|
||||
`<target>` for the generated `$localize` id. Smoke-tested (both generate → lint + dep:check clean),
|
||||
then the demo output removed. Skills (`value-object`, `form-machine`) point at the generator.
|
||||
`plop-templates/` is prettier-ignored (Handlebars, not TS).
|
||||
|
||||
**Scoped out (kept as skills):** `gen:ui-component` (Angular template `{{ }}` collides with
|
||||
Handlebars) and `gen:bff-endpoint` (spans the C# backend + `gen:api` regen) — a generator adds
|
||||
little over the recipe there.
|
||||
|
||||
## Why
|
||||
|
||||
The house patterns (value object, form machine, bff endpoint, ui component) are documented only as
|
||||
agent-executed prose skills; there's no `npm run gen:*` a developer runs to emit the boilerplate.
|
||||
For a template meant to bootstrap real registers, mechanise the highest-churn recipes.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Step 1 — tool fork:** plop (recommended: tiny, readable templates, `npm run gen:*`) vs Angular
|
||||
schematics (native `ng generate`, heavier to author). Decide before building.
|
||||
- Generators emit house-pattern files **with co-located spec/story**, matching current idioms:
|
||||
`gen:value-object`, `gen:form-machine` (page/wizard), `gen:bff-endpoint`, `gen:ui-component`.
|
||||
- Templates become the single source; each matching skill gets a one-line "run `npm run gen:x`, then …".
|
||||
|
||||
## Files
|
||||
|
||||
- New `plopfile.mjs` (or `schematics/`) + template files; `package.json` `gen:*` scripts.
|
||||
- Update the corresponding `.claude/skills/*/SKILL.md` to invoke the generator.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `gen:value-object` + `gen:form-machine` emit files that pass `npm run lint` + `dep:check`
|
||||
with no hand-edits beyond the domain specifics (rule/fields) + the en.xlf target.
|
||||
- [x] Generated units include their co-located spec.
|
||||
- [x] Smoke-tested both; `npm run ci` green.
|
||||
@@ -0,0 +1,54 @@
|
||||
# WP-44 — `gen:context` generator
|
||||
|
||||
Status: done
|
||||
Phase: 8 — platform/DX/showcase
|
||||
Priority: P3
|
||||
Depends on: WP-38, WP-43
|
||||
|
||||
## Outcome
|
||||
|
||||
`npm run gen:context` (plop, extends WP-43's `plopfile.mjs`) prompts for a lowercase context name
|
||||
and emits: `src/app/<ctx>/{domain,application,infrastructure,contracts}/.gitkeep` + a starter
|
||||
`ui/<ctx>.page.ts` (a `PageShellComponent` wrapper — replace with the real first feature slice);
|
||||
the `@<ctx>/*` tsconfig alias; one new key in `.dependency-cruiser.js`'s `CONTEXT_ALLOWED` map; and
|
||||
a lazy, `authGuard`-gated route in `app.routes.ts` inserted before the catch-all.
|
||||
|
||||
**Refactored `.dependency-cruiser.js` to make "one config entry" literally true.** The pre-WP file
|
||||
hand-duplicated each context's forbidden-imports list as a separate `contextRule(name, from,
|
||||
forbidden)` call — adding a context meant editing N existing calls to add it to their forbidden
|
||||
list, not adding one entry. Replaced with a single `CONTEXT_ALLOWED` map (context → contexts it may
|
||||
additionally import) that every rule + the `FEATURES` string is _derived_ from; `showcase` maps to
|
||||
`null` (unrestricted — the one exempt case) and is skipped when generating rules. Verified
|
||||
behavior-preserving: `npm run dep:check` reports the same module/dependency counts before and after,
|
||||
`npm run dep:graph`'s committed output is byte-identical, and a planted cross-context violation
|
||||
(`auth` importing `@herregistratie`, type-only) is still caught under the new `auth-scope` rule name.
|
||||
|
||||
Smoke-tested by generating a real `vergunning` context end-to-end (`dep:check`, `lint`, `build` all
|
||||
green, including the new lazy chunk), then removed the demo output. `.claude/skills/new-context/
|
||||
SKILL.md` now points at the generator as step 1.
|
||||
|
||||
## Why
|
||||
|
||||
Adding a bounded context is currently a manual multi-file edit (folders + tsconfig alias + copied
|
||||
ESLint boundary block + lazy route) — the `new-context` skill's most error-prone steps. Mechanise it.
|
||||
|
||||
## Decisions
|
||||
|
||||
- `gen:context` (same tool as WP-43) emits `{domain,application,infrastructure,ui,contracts}`
|
||||
folders + the `@ctx/*` tsconfig path alias + a lazy route + the boundary entry.
|
||||
- **Simpler because of WP-38:** boundaries are declarative by then, so the generator adds one config
|
||||
entry instead of hand-copying ESLint blocks.
|
||||
- Replaces the manual portion of the `new-context` skill.
|
||||
|
||||
## Files
|
||||
|
||||
- Extend the WP-43 generator setup with a `context` generator + templates.
|
||||
- Wire tsconfig alias + boundary config + route insertion (idempotent edits).
|
||||
- Update `.claude/skills/new-context/SKILL.md`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `npm run gen:context <name>` produces a context that lints clean (boundaries recognised) and
|
||||
routes lazily.
|
||||
- [x] Boundary tool (WP-38) validates the new context's allowed edges.
|
||||
- [x] `npm run ci` green.
|
||||
@@ -0,0 +1,32 @@
|
||||
# WP-45 — `create-frontend` bootstrap generator
|
||||
|
||||
Status: done (ad7ca31)
|
||||
Phase: 8 — platform/DX/showcase
|
||||
Priority: P4
|
||||
Depends on: WP-43, WP-44
|
||||
|
||||
## Why
|
||||
|
||||
`new-ssp` is a manual fork-and-strip recipe. To actually bootstrap a production-ready frontend for
|
||||
a different Dutch register quickly, mechanise it into a runnable script that produces a clean
|
||||
scaffold keeping the enforced architecture + shared building blocks.
|
||||
|
||||
## Decisions
|
||||
|
||||
- A `create-frontend` script (Node/plop-driven) that clones-and-strips per the `new-ssp` recipe:
|
||||
drop the business contexts, prune their routes/aliases/boundary entries, rename `BigRegister.*`,
|
||||
re-point the `--rhc-*` token bridge, re-seed the backend, re-run `gen:api`.
|
||||
- Keep-as-is list from `new-ssp` (shared kernel/ui/layout, eslint/boundary config, check-tokens,
|
||||
CI, nswag, storybook, auth shell, ADRs, CLAUDE.md, the skills).
|
||||
- Reuse WP-44's `gen:context` to seed the first real context of the new portal.
|
||||
|
||||
## Files
|
||||
|
||||
- New `scripts/create-frontend.*` (+ any templates); documented in `.claude/skills/new-ssp/SKILL.md`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Running it yields a portal that builds, lints (boundaries intact), and passes `npm run ci`
|
||||
with no business contexts left over.
|
||||
- [ ] The generated scaffold's `gen:api` + token bridge point at the new backend/theme.
|
||||
- [ ] Documented end-to-end in the `new-ssp` skill.
|
||||
@@ -0,0 +1,46 @@
|
||||
# WP-46 — Vitest coverage (report + thresholds)
|
||||
|
||||
Status: done
|
||||
Phase: 8 — platform/DX/showcase
|
||||
Priority: P1
|
||||
|
||||
## Outcome
|
||||
|
||||
Coverage is first-class in the `@angular/build:unit-test` builder — no separate vitest config
|
||||
needed. Added a `coverage` **configuration** to the `test` target in `angular.json` (`coverage: true`,
|
||||
reporters `text-summary`/`html`/`lcov`, excludes for specs/stories/contracts/generated
|
||||
`api-client.ts`/`main.ts`/`.d.ts`) + `@vitest/coverage-v8` (pinned to vitest's 4.1.9 to avoid the
|
||||
mixed-version warning). `npm run test:coverage` → `ng test --configuration coverage`. **Report-only
|
||||
(no thresholds)** per the ponytail default — visibility first. CI's test step now runs
|
||||
`test:coverage` (a strict superset of `npm test`: fails on a failing test, never on coverage).
|
||||
Baseline at delivery: **~71% statements / 69% branches / 68% functions / 74% lines.** Output dir
|
||||
`coverage/` was already git-ignored. Path to enforce later: add `coverageThresholds` to the config.
|
||||
|
||||
## Why
|
||||
|
||||
Tests run via the `@angular/build:unit-test` (Vitest) builder but report no coverage — there's no
|
||||
visibility into what the house test rules actually cover. A template/showcase should surface this.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Step 1 — verify wiring:** confirm how coverage is enabled with the Angular unit-test builder
|
||||
(`codeCoverage` option and/or a vitest config), add `@vitest/coverage-v8`.
|
||||
- `npm run test:coverage` → text + HTML/lcov report.
|
||||
- **Report-only first** (ponytail: visibility before a blocking gate, like the semgrep job).
|
||||
Threshold focus on the layers the house rules already demand tests for (`domain/**`, pure
|
||||
combinators, `parse*` boundaries, reducers), not a blanket global %; exclude stories, the
|
||||
generated `api-client.ts`, and DTOs/contracts.
|
||||
- Wire into `ci-local.sh`/CI **non-blocking**, with a documented path to flip on enforcement later.
|
||||
- **Open choice:** report-only vs enforce a minimum now.
|
||||
|
||||
## Files
|
||||
|
||||
- `package.json` (`test:coverage` + dep), coverage config (builder option or vitest config).
|
||||
- `scripts/ci-local.sh` / `.github/workflows/ci.yml` — non-blocking coverage step.
|
||||
- `.gitignore` — ignore the coverage output dir.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `npm run test:coverage` produces a report (text-summary + HTML/lcov) with sensible excludes.
|
||||
- [x] Coverage runs in CI without failing the build (report-only).
|
||||
- [x] `npm run ci` green.
|
||||
@@ -0,0 +1,38 @@
|
||||
# WP-47 — Runtime feature flags (catalog-in-code, admin-toggled)
|
||||
|
||||
Status: done
|
||||
Phase: 8 — platform/DX/showcase
|
||||
|
||||
## Why
|
||||
|
||||
Ops needs to turn features on/off at runtime without a deploy. Mirrors the two house templates: the
|
||||
capability spine (server-resolved, FE reads) and the org-template runtime-SQLite config (admin edits
|
||||
at runtime). Per ADR-0004 the **catalog** (which flags exist + defaults) is config-as-code; only the
|
||||
**on/off state** is runtime.
|
||||
|
||||
## Decisions (locked with the user)
|
||||
|
||||
- Catalog in code (typed, build-validated); on/off state in SQLite; admin toggles at runtime.
|
||||
- **FE + backend enforcement** — the FE hides the surface AND the server enforces (a flag can guard
|
||||
a real feature, not just UI).
|
||||
|
||||
## Outcome
|
||||
|
||||
- Backend: `Domain/Features/FeatureFlags.cs` (catalog: one flag `inschrijving-open`, default on) +
|
||||
`Data/FeatureFlagStore.cs` (`FeatureFlagEntity` in SQLite + migration; `All()` merges catalog
|
||||
defaults with overrides, `IsEnabled`, `Set` rejects unknown keys). `GET /flags` (readable, drives
|
||||
FE gating) + `PUT /admin/flags/{key}` (gated by new `flags:manage` cap + `FlagsAdmin`). Enforced
|
||||
end-to-end: `POST /applications` for a `registratie` returns 403 when `inschrijving-open` is off.
|
||||
- FE: `shared/domain/feature-flag.ts` + `feature-flags.adapter.ts` (parse boundary) +
|
||||
`shared/application/feature-flags.store.ts` (root singleton, `enabled(key)` deny-by-default,
|
||||
`set`). Capability `flags:manage` (union + me.adapter + role.interceptor `/api/v1/admin/flags`).
|
||||
The "Inschrijven" nav item + dashboard action hide when the flag is off. Admin toggle page
|
||||
`beheer/ui/feature-flags.page.ts` at `/beheer/functies`, in `ADMIN_LINKS`.
|
||||
- Tests: catalog-unique + endpoint (admin-only toggle, 404 unknown key, close→403 / reopen→201).
|
||||
`/me` cap-list test updated. Backend 136; typed client regenerated.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Admin toggles a flag at runtime; state persists (SQLite) and the whole app reads it.
|
||||
- [x] FE hides the flagged feature AND the backend enforces it (registration close → 403).
|
||||
- [x] `npm run ci` green (dep:check, localized build, backend `dotnet test`, drift clean after commit).
|
||||
@@ -0,0 +1,40 @@
|
||||
# WP-48 — Stamdata deletion protection (referential integrity)
|
||||
|
||||
Status: done
|
||||
Phase: 8 — platform/DX/showcase
|
||||
|
||||
## Why
|
||||
|
||||
Deleting a stamdata row that something relies on (e.g. a `professions.program` a diploma maps
|
||||
through) would silently break behaviour. Stamdata is config-as-code (PR-applied, CI-gated), so the
|
||||
authoritative guard belongs at the build gate; the editor gets a fast-feedback nudge.
|
||||
|
||||
## Decisions (locked with the user)
|
||||
|
||||
- **CI gate (authoritative) + editor warning (fast feedback).**
|
||||
- **Steer temporal rows toward expiring** (set `geldigTot`) over hard delete.
|
||||
|
||||
## Outcome
|
||||
|
||||
- **CI gate:** generalized the dangling-reference test in `StamdataValidationTests` into a declared,
|
||||
extensible reference list (`StamdataRef` records) — "every declared reference into a stamdata key
|
||||
resolves against the currently-valid stamdata." Today one entry: `Diploma.Opleiding →
|
||||
professions.program (valid today)`. Resolvers use the "valid today" view (`Professions.ByProgram`),
|
||||
so removing/renaming a referenced program OR expiring it while current data still references it
|
||||
**fails the PR build**; expiring once nothing current relies on it passes. Adding a future FK is
|
||||
one list entry.
|
||||
- **Editor (fast feedback):** `stamdata-table-editor` now confirms before delete (`@@beheer.removeConfirm`
|
||||
— warns that a referenced row fails CI and, for a dated table, to close validity instead) and, for
|
||||
**temporal** tables, adds a **"Sluiten per vandaag"** action that sets `geldigTot` to today
|
||||
(reusing `CellEdited`) — steering to expire over hard delete. CI stays the authority.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A delete/expire that orphans a declared reference fails the build gate (existing seed passes).
|
||||
- [x] Editor confirms deletes and offers expire (close validity) for temporal tables.
|
||||
- [x] `npm run ci` green (backend `dotnet test`, localized build).
|
||||
|
||||
## Deferred (noted)
|
||||
|
||||
A per-row "referenced" hint in the editor DTO (server-computed usage) — would let the editor warn on
|
||||
the _specific_ referenced rows rather than a generic confirm. Not needed for the authoritative gate.
|
||||
@@ -0,0 +1,59 @@
|
||||
# WP-49 — OpenZaak zaken read seam (slice 1)
|
||||
|
||||
Status: done
|
||||
Phase: 9 — OpenZaak / ZGW integration
|
||||
|
||||
## Why
|
||||
|
||||
The POC serves cases from local SQLite. To grow toward production it must be able to source
|
||||
them from a real ZGW backend (OpenZaak) **without changing the frontend** (BFF-lite, ADR-0001).
|
||||
The backend had no data-access abstraction to swap behind, no outbound HTTP, and no JWT. This is
|
||||
the first thin vertical slice: read-only zaken.
|
||||
|
||||
## Read first
|
||||
|
||||
- [ADR-0005 — OpenZaak behind the BFF](../reference/architecture/0005-openzaak-behind-bff.md)
|
||||
- [openzaak-integration.md](../reference/openzaak-integration.md)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- OpenZaak's anti-corruption layer lives in the **BFF**, never the browser.
|
||||
- Source selected by config (`Zgw:Enabled`, default false) → POC still runs offline.
|
||||
- Each source maps into the **existing** `ApplicationSummaryDto` → no api-client drift, no FE change.
|
||||
- Fresh HS256 JWT **per call** (no refresh flow). Hand-rolled (no new NuGet).
|
||||
- `IZaakSource` is sync (matches the endpoint + local store); OpenZaak source does sync-over-async.
|
||||
|
||||
## Files
|
||||
|
||||
- `Data/IZaakSource.cs`, `Data/LocalZaakSource.cs`
|
||||
- `Zgw/{ZgwOptions,ZgwTokenProvider,ZgwZaakMapper,OpenZaakZaakSource}.cs`
|
||||
- `Program.cs` (DI + resolve `IZaakSource` in `/admin/cases`), `appsettings.json` (`Zgw` section)
|
||||
- tests: `ZgwTokenProviderTests`, `ZgwZaakMapperTests`, `OpenZaakZaakSourceTests`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Extract the cases read into `IZaakSource`; `LocalZaakSource` delegates to `ApplicationStore`.
|
||||
2. Build the `Zgw/` client (options, JWT minter, ZGW→DTO mapper, paginating HTTP source).
|
||||
3. Wire DI by config; refactor `/admin/cases` to resolve `IZaakSource`.
|
||||
4. Unit-test the minter, mapper, and source (fixtures + stub `HttpMessageHandler`).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `/admin/cases` serves identical DTOs via `LocalZaakSource` (default, offline).
|
||||
- [x] `OpenZaakZaakSource` follows pagination, resolves+caches zaaktype labels, sends a Bearer token.
|
||||
- [x] JWT verifies (HS256) with the required claims.
|
||||
- [x] `dotnet test` green (142); `npm run ci` green with **no api-client drift** (FE untouched).
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test`; `npm run ci`; manual: `/beheer/zaken` still lists cases with `Zgw:Enabled=false`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Create-zaak (WP-50), Documenten/DRC (WP-51), Notificaties (WP-52), real inbound OIDC/JWT,
|
||||
OpenZaak in docker-compose.
|
||||
|
||||
## Risks
|
||||
|
||||
Sync-over-async blocks a thread under load if OpenZaak becomes the default → make the read path
|
||||
async then (noted at the call site).
|
||||
@@ -0,0 +1,52 @@
|
||||
# WP-50 — OpenZaak create-zaak (first write slice)
|
||||
|
||||
Status: done (de3bff0)
|
||||
Phase: 9 — OpenZaak / ZGW integration
|
||||
|
||||
## Why
|
||||
|
||||
WP-49 made the cases **read** path source-swappable. The next slice is the first **write**:
|
||||
create a Zaak in OpenZaak when an aanvraag is submitted, still behind the config gate, still
|
||||
without changing the FE contract.
|
||||
|
||||
## Read first
|
||||
|
||||
- [openzaak-integration.md](../reference/openzaak-integration.md) — "How to add the next slice"
|
||||
- [ADR-0005](../reference/architecture/0005-openzaak-behind-bff.md), [ADR-0001](../reference/architecture/0001-bff-lite-decision-dtos.md)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Route the create through the existing submit/mutation seam; keep the FE response DTO identical.
|
||||
- A create needs a `zaaktype` **URL** from Catalogi (OpenZaak validates it by fetching) — map
|
||||
the aanvraag `type` → a configured zaaktype URL.
|
||||
- Follow the create with a `status` + a `rol` (initiator/betrokkene by BSN) as ZGW expects.
|
||||
|
||||
## Files
|
||||
|
||||
- Extend `IZaakSource` (or add a write method) + `OpenZaakZaakSource`; `LocalZaakSource` keeps
|
||||
the current local submit.
|
||||
- `ZgwOptions`: a `type → zaaktype URL` map + `bronorganisatie`/`verantwoordelijkeOrganisatie` (RSIN).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add `CreateZaak` to the source seam; local impl = current submit, OpenZaak impl = POST to ZRC.
|
||||
2. Map aanvraag `type` → zaaktype URL; POST zaak, then status + rol.
|
||||
3. Map the created Zaak back into the existing submit response DTO.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Submitting with `Zgw:Enabled=true` creates a Zaak (+ status + rol) in OpenZaak.
|
||||
- [ ] FE submit response DTO unchanged; no api-client drift.
|
||||
- [ ] Covered by tests (stub handler asserts the POST bodies + type→zaaktype mapping).
|
||||
|
||||
## Verification
|
||||
|
||||
`dotnet test`; against a docker OpenZaak if available.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Documenten (WP-51), Notificaties (WP-52).
|
||||
|
||||
## Risks
|
||||
|
||||
Create needs read scope on Catalogi (type-URL validation) — provision AC scopes accordingly.
|
||||
@@ -0,0 +1,52 @@
|
||||
# WP-51 — OpenZaak Documenten (DRC) upload + link
|
||||
|
||||
Status: done (5807937)
|
||||
Phase: 9 — OpenZaak / ZGW integration
|
||||
|
||||
## Why
|
||||
|
||||
Uploaded documents currently persist as bytes in local SQLite (`DocumentStore`). To be
|
||||
production-ready they must live in OpenZaak's **Documenten API (DRC)** as
|
||||
`enkelvoudiginformatieobjecten`, linked to a Zaak via `zaakinformatieobject` — behind the same
|
||||
config gate, still without a FE contract change.
|
||||
|
||||
## Read first
|
||||
|
||||
- [openzaak-integration.md](../reference/openzaak-integration.md)
|
||||
- WP-49 (the seam pattern), WP-50 (create-zaak, the zaak to link to)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Introduce an `IDocumentSource` sibling of `IZaakSource`; local impl = `DocumentStore`,
|
||||
OpenZaak impl = DRC. FE upload DTOs unchanged.
|
||||
- A document needs an `informatieobjecttype` **URL** from Catalogi (like zaaktype for a zaak).
|
||||
- Upload → returns document URL → `zaakinformatieobject` links it to the zaak URL.
|
||||
|
||||
## Files
|
||||
|
||||
- `Data/IDocumentSource.cs`, `Data/LocalDocumentSource.cs`, `Zgw/OpenZaakDocumentSource.cs`
|
||||
- `ZgwOptions`: `informatieobjecttype` URL(s) + `DrcBaseUrl`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Abstract the upload/read/link paths behind `IDocumentSource`.
|
||||
2. OpenZaak impl: POST `enkelvoudiginformatieobjecten` (content), then POST `zaakinformatieobjecten`.
|
||||
3. Map DRC document metadata back into the existing document DTOs.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] With `Zgw:Enabled=true`, an upload lands in DRC and is linked to its zaak.
|
||||
- [ ] FE upload/list DTOs unchanged; no api-client drift.
|
||||
- [ ] Tests cover the DRC POST bodies + the zaak-link step (stub handler).
|
||||
|
||||
## Verification
|
||||
|
||||
`dotnet test`; against a docker OpenZaak if available.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Notificaties (WP-52), content virus-scanning / blob-storage tuning.
|
||||
|
||||
## Risks
|
||||
|
||||
Large file content over base64/multipart — mind memory; stream if needed.
|
||||
@@ -0,0 +1,67 @@
|
||||
# WP-52 — OpenZaak Notificaties (NRC) live status
|
||||
|
||||
Status: done (`c4dd846` endpoint, tests/docs/config finished this session)
|
||||
Phase: 9 — OpenZaak / ZGW integration
|
||||
|
||||
## Why
|
||||
|
||||
With cases in OpenZaak, case status changes in the backoffice, not in this app. Production
|
||||
"live" status needs the **Notificaties API (NRC)**: subscribe to zaak events and update on
|
||||
webhook, rather than polling. Last slice of the ZGW integration arc.
|
||||
|
||||
## Read first
|
||||
|
||||
- [openzaak-integration.md](../reference/openzaak-integration.md)
|
||||
- WP-49/50/51 (the read/write/document slices this builds on)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- The BFF exposes a webhook endpoint that NRC calls; it validates an Authorization header the
|
||||
BFF issued, then invalidates any cached case data / notifies the FE.
|
||||
- Subscription (`abonnement` on the `zaken` kanaal) is provisioning/config, not runtime code.
|
||||
- FE update mechanism reuses the existing RemoteData reload — no new FE contract.
|
||||
|
||||
## Files
|
||||
|
||||
- A new BFF webhook endpoint (`POST /zgw/notificaties`) + Authorization validation.
|
||||
- `ZgwOptions`: `NrcBaseUrl` + the webhook shared secret.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add the webhook endpoint (auth-checked, no PII logged — reuse the audit seam).
|
||||
2. On a zaak event, invalidate cache / push an update to the FE.
|
||||
3. Document the `abonnement` provisioning (out-of-band, one-time).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A posted NRC event (correct auth) is accepted (204); a bad-auth post is rejected (401).
|
||||
- [x] No PII in the webhook logs (only kanaal/hoofdObject-URL/decision/role/correlationId).
|
||||
- [x] Tests cover auth accept/reject (`NotificatieTests.cs`).
|
||||
|
||||
## Verification
|
||||
|
||||
`dotnet test` (151/151 green, incl. 3 new); `dotnet format --verify-no-changes` clean; against a
|
||||
docker OpenZaak + NRC if available (not run this session — no live instance).
|
||||
|
||||
## Out of scope
|
||||
|
||||
Full event fan-out / real-time push infra beyond a simple cache-invalidation + reload. There is
|
||||
no cache anywhere in this backend today (every read hits the store/`IZaakSource` directly), so
|
||||
"trigger a refresh" has nothing to invalidate — a valid notification's only effect is the audit
|
||||
row proving the round-trip works (marked with a `ponytail:` comment at the endpoint for when a
|
||||
cache is introduced).
|
||||
|
||||
## Risks
|
||||
|
||||
Webhook must be reachable from NRC in prod (network/ingress) — a deployment concern, not code.
|
||||
|
||||
## Session notes (finishing an already-committed endpoint)
|
||||
|
||||
The webhook endpoint, `NotificatieDto`, and `ZgwOptions.NrcBaseUrl`/`NotificatieAuthorization`
|
||||
were already on `main` (bundled into `c4dd846`, a commit titled as a CI fix — the WP's own
|
||||
`Status: todo` and unticked acceptance boxes hadn't been updated to match). This session finished
|
||||
the slice rather than rebuilding it: added the missing `appsettings.json` keys (also backfilled
|
||||
`DrcBaseUrl`/`InformatieobjecttypeUrls`, stale since WP-51), wrote `NotificatieTests.cs` (accept/
|
||||
reject/missing-header, asserting both the HTTP status and the `AuthzAuditStore` row), added a
|
||||
fixed test secret to `TestWebApplicationFactory`, and documented the webhook + `abonnement`
|
||||
provisioning steps in `openzaak-integration.md`.
|
||||
@@ -0,0 +1,140 @@
|
||||
# WP-53 — Inbound identity + citizen-scoping (the ZGW auth seam)
|
||||
|
||||
Status: done
|
||||
Phase: 9 — OpenZaak / ZGW integration
|
||||
|
||||
## Why
|
||||
|
||||
WP-49 made the cases read path swappable, but everything runs as a **stubbed identity**: the
|
||||
principal comes from an `X-Role` header and the "owner" is a single hardcoded BSN. A real
|
||||
OpenZaak integration needs a genuine per-request user in order to (a) fill the ZGW JWT
|
||||
`user_id`/`user_representation` audit claims, and (b) **scope zaken to the logged-in citizen**
|
||||
(you must never return another citizen's cases). This WP threads a real identity through the
|
||||
system **without** building DigiD/OIDC itself — CLAUDE.md keeps real auth out of scope, so the
|
||||
deliverable is the _seam_: a per-request `CallerIdentity` (subject BSN + display name) produced
|
||||
by a **pluggable, stubbed** provider, consumed everywhere the hardcoded owner is used today.
|
||||
Production later swaps the stub for OIDC/DigiD without touching any consumer.
|
||||
|
||||
## Context — current state (read before designing; this is the handoff, no prior chat needed)
|
||||
|
||||
Identity is faked in these exact places — this WP replaces the fakes with one identity flow:
|
||||
|
||||
- **Backend principal**: `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` —
|
||||
`ResolvePrincipal(ctx)` reads the `X-Role` header (drafter/approver/admin). Its own doc
|
||||
comment says _"A real system builds this from verified AD/OIDC claims … everything else in
|
||||
this file carries over unchanged once that swap happens."_ That is the seam to formalize.
|
||||
- **Hardcoded owner/BSN**: `backend/src/BigRegister.Api/Data/DocumentStore.cs` —
|
||||
`public const string DemoOwner = "19012345601";` (the single seeded citizen's BIG-nummer).
|
||||
Grep `DemoOwner` across `Program.cs` + stores — every "whose data is this" decision uses it.
|
||||
- **Owner-scoped stores** already take an `owner` string:
|
||||
`Data/ApplicationStore.cs` (`List(owner)`, `Get(id, owner)`, `CreateConcept(type, owner)`,
|
||||
`Submit(id, owner, …)`) and `Data/DocumentStore.cs`. They are ready to receive a real BSN —
|
||||
today the endpoints pass `DocumentStore.DemoOwner`.
|
||||
- **ZGW JWT user claims** are static: `Zgw/ZgwTokenProvider.cs` `Mint()` reads
|
||||
`ZgwOptions.UserId` / `ZgwOptions.UserRepresentation` (constant strings). These must become
|
||||
**per-request** (the acting citizen), or the ZGW audit trail is wrong.
|
||||
- **The cases read interface** `Data/IZaakSource.cs` has one method, `ListCases(now)`, with **no
|
||||
caller** — it returns the admin cross-owner list. There is no citizen-scoped "my cases" read
|
||||
yet, and `OpenZaakZaakSource` lists ALL zaken (`{ZrcBaseUrl}/zaken`, no filter).
|
||||
- **Correlation middleware** (`Program.cs`, the `app.Use(...)` block setting
|
||||
`X-Correlation-Id`) is the pattern/location to add an identity-resolution middleware next to.
|
||||
- **Frontend** identity is the dev role switch: `?role=drafter|approver|admin` + the `⚙ state`
|
||||
panel + `SessionStore` (`src/app/auth/`), documented in `docs/reference/roles-and-access.md`.
|
||||
The FE already persists a session (localStorage). No FE change is required for the backend
|
||||
seam, but the citizen's BSN must originate from the session, not a constant — note where.
|
||||
|
||||
ZGW detail that drives the scoping query: OpenZaak filters a citizen's zaken via the query
|
||||
param `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=<bsn>` on `GET {ZRC}/zaken`.
|
||||
|
||||
## Read first
|
||||
|
||||
- [openzaak-integration.md](../reference/openzaak-integration.md) — the seam + the "two nested
|
||||
ACLs" section (this WP is about the identity that flows through both).
|
||||
- [ADR-0005 — OpenZaak behind the BFF](../reference/architecture/0005-openzaak-behind-bff.md)
|
||||
("Deferred: real inbound OIDC/JWT auth" — this WP formalizes the seam, not the provider).
|
||||
- [ADR-0002 — user groups & bounded contexts](../reference/architecture/0002-user-groups-and-bounded-contexts.md),
|
||||
[roles-and-access.md](../reference/roles-and-access.md).
|
||||
- `CLAUDE.md` → "Out of scope: Real auth/DigiD" — respect it: build the seam + a stub, not DigiD.
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **Seam, not provider.** Introduce a `CallerIdentity` (subject BSN + display name + role) and an
|
||||
`IIdentityProvider` with a **`StubIdentityProvider`** (reads the existing `X-Role` + a
|
||||
configurable/`X-Subject` BSN, defaulting to the seeded citizen). Production swaps the provider;
|
||||
no consumer changes. Do **not** add DigiD/OIDC.
|
||||
- **One source of "who".** Resolve `CallerIdentity` once per request (middleware, beside the
|
||||
correlation block) and flow it to: `Authz.ResolvePrincipal`, the store `owner` arguments
|
||||
(replace `DocumentStore.DemoOwner` call sites), and `ZgwTokenProvider.Mint(caller)`.
|
||||
- **Citizen-scoped reads are separate from admin reads.** Keep the admin cross-owner list
|
||||
(`cases:manage`) as-is; add a citizen-scoped "my zaken" path that filters by the caller's BSN
|
||||
(ZGW `rol__…__inpBsn`; local store: `List(owner)`).
|
||||
- **Ownership stays server-authoritative.** The BSN comes from the resolved identity, never from
|
||||
a client-supplied body field.
|
||||
|
||||
## Files
|
||||
|
||||
- New: `Domain/Authorization/CallerIdentity.cs`, `Domain/Authorization/IIdentityProvider.cs` +
|
||||
`StubIdentityProvider.cs`; an identity-resolution middleware in `Program.cs`.
|
||||
- Edit: `Domain/Authorization/Authz.cs` (build the principal from `CallerIdentity`),
|
||||
`Zgw/ZgwTokenProvider.cs` (`Mint(CallerIdentity)`), `Zgw/OpenZaakZaakSource.cs` (BSN filter on
|
||||
the citizen read), `Data/IZaakSource.cs` (+ a caller-scoped read), `Program.cs` (replace
|
||||
`DemoOwner` call sites with the resolved BSN; DI-register the provider).
|
||||
- Tests: identity resolution (stub), token carries the per-request user, citizen read filters by
|
||||
BSN (stub handler asserts the query param), admin read still cross-owner.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add `CallerIdentity` + `IIdentityProvider` + `StubIdentityProvider` (X-Role + X-Subject BSN,
|
||||
default = seeded citizen); DI-register; resolve once in middleware into `HttpContext.Items`.
|
||||
2. Route `Authz.ResolvePrincipal` and every `DemoOwner` call site through the resolved identity.
|
||||
3. `ZgwTokenProvider.Mint(caller)` — per-request `user_id`/`user_representation`.
|
||||
4. Add a caller-scoped cases read to `IZaakSource` (+ both impls); `OpenZaakZaakSource` adds the
|
||||
`rol__…__inpBsn` filter; local uses `ApplicationStore.List(owner)`.
|
||||
5. Tests as above; keep the admin list unchanged.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] No `DocumentStore.DemoOwner` reference remains in request handling (grep clean); ownership
|
||||
comes from the resolved identity.
|
||||
- [x] ZGW JWT carries the acting citizen's `user_id`/`user_representation` (test-verified).
|
||||
- [x] A citizen read returns only that BSN's zaken (local + ZGW-stub tests); admin read unchanged.
|
||||
- [x] `dotnet test` green; `npm run ci` green with **no api-client drift** (FE contract intact).
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test` (159/159, incl. 8 new); `dotnet format --verify-no-changes` clean;
|
||||
`npm run ci` green (no api-client drift). Manual: `X-Role`/`X-Subject` still switch identity
|
||||
offline (no header → the seeded citizen, drafter); with `Zgw:Enabled=true` (WP-54 harness) a
|
||||
citizen would see only their zaken via the new `rol__…__inpBsn` filter.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Real DigiD/OIDC/JWT validation (this is the seam + stub only), FE login redesign, multi-tab
|
||||
session sync (CLAUDE.md out-of-scope list).
|
||||
|
||||
## Risks
|
||||
|
||||
- Missing a `DemoOwner` call site → a citizen sees another's data. Mitigated: grep gate (clean)
|
||||
- `ApplicationTests.Applications_are_scoped_to_the_caller_bsn` (two `X-Subject` identities,
|
||||
HTTP end-to-end) proving neither sees the other's cases.
|
||||
- ZGW rol filter param name is exact and version-sensitive; asserted in
|
||||
`OpenZaakZaakSourceTests.ListMyCases_filters_by_the_callers_bsn`.
|
||||
|
||||
## Session notes
|
||||
|
||||
Built as designed — no premise in the Decisions/Context block turned out stale. One
|
||||
implementation choice not spelled out in the WP: `Authz.ResolvePrincipal(HttpContext ctx)` kept
|
||||
its exact signature (now `new(ctx.Caller().Role)` instead of re-reading `X-Role` itself), so
|
||||
none of its ~15 call sites needed touching — "flow it to Authz.ResolvePrincipal" didn't require
|
||||
threading `CallerIdentity` through every endpoint that resolves a `Principal`. `ZgwTokenProvider`
|
||||
grew a `Mint(CallerIdentity)` overload alongside the existing parameterless `Mint()` (kept for
|
||||
calls not tied to one citizen — the admin cross-owner `ListCases`, and Catalogi metadata lookups)
|
||||
rather than replacing it outright, so `ZgwOptions.UserId`/`UserRepresentation` stay meaningful as
|
||||
the BFF's own system identity. `IZaakSource`/`IDocumentSource` gained an explicit `CallerIdentity`
|
||||
parameter on every citizen-scoped method (`ListMyCases`, `CreateZaak`, `Upload`, `LinkToZaak`)
|
||||
rather than resolving it ambiently via `IHttpContextAccessor` — kept it unit-testable without any
|
||||
DI/HttpContext ceremony (see `StubIdentityProviderTests`, the `ZgwTokenProviderTests` addition).
|
||||
`GET /applications` (the citizen's own dashboard list) is now routed through
|
||||
`IZaakSource.ListMyCases` instead of calling `ApplicationStore` directly — closing the exact gap
|
||||
`openzaak-integration.md`'s ACL caveat used to flag for that endpoint; under `Zgw:Enabled=true` it
|
||||
would now source from OpenZaak (BSN-filtered) like `/admin/cases` already did.
|
||||
@@ -0,0 +1,145 @@
|
||||
# WP-54 — Docker OpenZaak integration-test harness
|
||||
|
||||
Status: done
|
||||
Phase: 9 — OpenZaak / ZGW integration
|
||||
|
||||
## Why
|
||||
|
||||
Everything ZGW so far is verified against **fixtures + a stub `HttpMessageHandler`** — nothing
|
||||
exercises a real OpenZaak. That is fine for unit-testing the mapper/JWT/pagination, but it does
|
||||
not prove the BFF actually talks to OpenZaak (auth accepted, real response shapes, real
|
||||
pagination, zaaktype resolution). This WP stands up a **local OpenZaak via docker**, seeds a
|
||||
minimal catalog + a client credential matching `ZgwOptions`, and adds an **opt-in** integration
|
||||
test that points the BFF at it with `Zgw:Enabled=true`. It closes the "no live instance" gap
|
||||
called out in the roadmap and makes WP-50/51/52 developable against something real.
|
||||
|
||||
## Context — current state (read before designing; self-contained handoff)
|
||||
|
||||
- **What exists to test**: `backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs` (lists zaken,
|
||||
follows `{count,next,previous,results}`, resolves zaaktype labels from Catalogi, Bearer auth
|
||||
via `ZgwTokenProvider`), selected when `Zgw:Enabled=true`. Config is the `Zgw` section of
|
||||
`appsettings.json` (`ZrcBaseUrl`, `ZtcBaseUrl`, `ClientId`, `Secret`, `UserId`,
|
||||
`UserRepresentation`) → `Zgw/ZgwOptions.cs`.
|
||||
- **Current tests** (the pattern to extend, not replace): `ZgwZaakMapperTests` (inline JSON),
|
||||
`OpenZaakZaakSourceTests` (stub handler). These stay as fast unit tests; the new integration
|
||||
test is a **separate, opt-in** category so the default `dotnet test` and CI stay fast/offline.
|
||||
- **Existing compose**: repo root `docker-compose.yml` runs FE + BFF only (Swagger at :5000).
|
||||
Do **not** bolt OpenZaak onto it — OpenZaak is heavy (postgres + redis + celery). Use a
|
||||
**separate** compose file so `docker compose up` stays light for everyone else.
|
||||
- **Test project**: `backend/tests/BigRegister.Tests/` (xunit). It uses
|
||||
`Microsoft.AspNetCore.Mvc.Testing` (`TestWebApplicationFactory.cs`) — the factory can be
|
||||
configured to override the `Zgw` config section to point at the compose instance.
|
||||
- **CLAUDE.md / backlog GREEN**: the default local gate and CI must remain runnable without
|
||||
docker/OpenZaak. Anything requiring the harness is explicitly separate (like e2e is today —
|
||||
a distinct job, not chained into `npm run ci`).
|
||||
|
||||
OpenZaak facts that shape the harness (from the ZGW research):
|
||||
|
||||
- OpenZaak is the reference impl of the 5 ZGW APIs; the published
|
||||
`docker-compose.yml` (github.com/open-zaak/open-zaak) brings up **openzaak (web + celery),
|
||||
postgres, redis** and (optionally) Open Notificaties. A one-time **bootstrap** is required:
|
||||
create a superuser, register an **Application** in the Autorisaties API with a **Client
|
||||
ID/Secret + scopes** (`zaken.lezen`, `catalogi.lezen`, …), and import/create a **Catalogus
|
||||
with a zaaktype** (published) so there is something to read.
|
||||
- Auth = the HS256 JWT the BFF already mints; the harness's client credentials must **match**
|
||||
`ZgwOptions.ClientId`/`Secret`.
|
||||
- Reading a zaak needs read scope on **both** Zaken and Catalogi (zaaktype resolution).
|
||||
|
||||
## Read first
|
||||
|
||||
- [openzaak-integration.md](../reference/openzaak-integration.md) (the seam + config keys).
|
||||
- [ADR-0005](../reference/architecture/0005-openzaak-behind-bff.md).
|
||||
- WP-19 (`WP-19-e2e-smoke.md`) — the precedent for a heavy, separate, opt-in test job (mirror
|
||||
its "not chained into the default gate" structure).
|
||||
- Upstream: OpenZaak docker docs (https://open-zaak.readthedocs.io/) + the repo's
|
||||
`docker-compose.yml` and its `import` fixtures for a demo catalogus.
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **Separate compose file** (e.g. `backend/openzaak/docker-compose.openzaak.yml`), never merged
|
||||
into the root compose. `docker compose up` stays FE+BFF only.
|
||||
- **Opt-in test category.** Tag the integration test `[Trait("Category", "Integration")]`;
|
||||
exclude it from the default run (`dotnet test --filter Category!=Integration`) and from the
|
||||
standard CI jobs. Provide a documented command / optional manual CI job to run it.
|
||||
- **Bootstrap is scripted, not manual.** A checked-in setup (compose `import` fixture or a small
|
||||
bootstrap script) creates the client credentials (matching `ZgwOptions`), a catalogus, and one
|
||||
published zaaktype + one zaak — so the test is deterministic and repeatable.
|
||||
- **Provider stays stubbed** (WP-53) — this WP is about the ZGW round-trip, not real user auth.
|
||||
|
||||
## Files
|
||||
|
||||
- New: `backend/openzaak/docker-compose.openzaak.yml` + bootstrap fixture/script + a short
|
||||
`backend/openzaak/README.md` (how to bring it up, credentials, teardown).
|
||||
- New: `backend/tests/BigRegister.Tests/OpenZaakIntegrationTests.cs` (Category=Integration):
|
||||
configure the BFF (`WebApplicationFactory`) with `Zgw:Enabled=true` + the compose URLs/creds,
|
||||
hit `/admin/cases`, assert the seeded zaak comes back mapped.
|
||||
- Edit: `docs/reference/openzaak-integration.md` (add a "Run against real OpenZaak" section);
|
||||
optionally a manual/gated CI job in `.github/workflows/ci.yml` mirroring the e2e job's shape.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add the separate compose file bringing up OpenZaak + postgres + redis; pin image versions.
|
||||
2. Script the bootstrap: superuser, Autorisaties Application (Client ID/Secret = `ZgwOptions`),
|
||||
a catalogus + one published zaaktype + one zaak with a natuurlijk-persoon rol (the seeded BSN).
|
||||
3. Write the opt-in integration test: point the BFF at the compose instance, assert the mapped
|
||||
zaak (id = uuid, type = zaaktype label, status) via `/admin/cases`.
|
||||
4. Document `docker compose -f … up` + the run command; optionally add a manual CI job.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `docker compose -f backend/openzaak/docker-compose.openzaak.yml up` yields a reachable
|
||||
OpenZaak with the seeded catalogus + zaak, and credentials matching `ZgwOptions`.
|
||||
- [x] The Category=Integration test passes against it; the BFF returns the seeded zaak mapped to
|
||||
`ApplicationSummaryDto` through the real HTTP + JWT path.
|
||||
- [x] Default `dotnet test` and `npm run ci` are unaffected (integration test excluded, no docker
|
||||
needed); `docker compose up` (root) is unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
`docker compose -f backend/openzaak/docker-compose.openzaak.yml up -d` →
|
||||
`./backend/openzaak/bootstrap-catalogus.sh` → `dotnet test --filter Category=Integration` →
|
||||
green; then teardown. Actually run (not just planned) during this WP — see Deviations below
|
||||
for what that surfaced.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Documenten/DRC + Notificaties services in the harness (add when WP-51/52 land), a permanent
|
||||
always-on CI job (keep it opt-in/manual — OpenZaak startup is slow), performance testing.
|
||||
|
||||
## Risks
|
||||
|
||||
- OpenZaak startup is slow + resource-heavy → keep it opt-in; a permanent CI job would blow the
|
||||
same runner budget WP's storybook cap just fixed.
|
||||
- ZGW API/version drift vs the pinned image can change response shapes → pin image versions and
|
||||
keep the fixture in the repo.
|
||||
- Bootstrap client scopes must include `catalogi.lezen` or zaaktype resolution 403s — cover in
|
||||
the setup script.
|
||||
|
||||
## Deviations from the original plan
|
||||
|
||||
- **`heeft_alle_autorisaties: true` instead of granular scopes.** The plan called out
|
||||
`zaken.lezen`/`catalogi.lezen` specifically; in practice OpenZaak's scripted config
|
||||
(`vng_api_common_applicaties_config`, upstream's own documented `setup_configuration` YAML
|
||||
mechanism) exposes an all-scopes flag on the one `Applicatie` this harness ever creates. Since
|
||||
that application exists for nothing but this throwaway test instance, granular scopes would
|
||||
add YAML-schema risk for no real least-privilege benefit — took the simpler, equally-scripted
|
||||
option.
|
||||
- **A live run found a real production bug, not just a harness wrinkle**: `ZgwHttpClient.cs`
|
||||
never sent `Content-Crs`/`Accept-Crs` on any ZGW call. Every ZGW write 412s ("Content-Crs
|
||||
header ontbreekt") without it — a real OpenZaak enforces this; the stub `HttpMessageHandler`
|
||||
every prior Zgw test used never modelled header requirements, so nothing from WP-49/50 caught
|
||||
it before now. Fixed in `ZgwHttpClient.cs` alongside the harness (see
|
||||
`docs/reference/openzaak-integration.md`) — this is precisely the class of bug this WP exists
|
||||
to catch.
|
||||
- **Publishing a zaaktype needs more seed data than the plan anticipated**: OpenZaak refuses to
|
||||
publish a zaaktype with fewer than one resultaattype or fewer than two statustypen (begin +
|
||||
eind), and a resultaattype's `selectielijstklasse` must share a `procesType` with the
|
||||
zaaktype's own `selectielijstProcestype` — both cross-checked live against the public VNG
|
||||
selectielijst API (`selectielijst.openzaak.nl`). `bootstrap-catalogus.sh` seeds all of this;
|
||||
see its comments for the exact values used and why.
|
||||
- **No celery/celery-beat/nginx in the harness**, unlike upstream's own compose — trimmed for a
|
||||
faster-booting, single-purpose harness (this test never asserts on notification delivery,
|
||||
which is celery's job). `NOTIFICATIONS_DISABLED=true` is required as a consequence: without a
|
||||
celery worker, `NotificationsConfig` has no client, and OpenZaak's `notify()` hook otherwise
|
||||
raises inside the same DB transaction as the create — turning a missing-worker problem into a
|
||||
500 that rolls back the create it was supposed to just notify about.
|
||||
@@ -0,0 +1,91 @@
|
||||
# WP-55 — Real secrets + TLS for the OpenZaak harness
|
||||
|
||||
Status: done
|
||||
Phase: 10 — OpenZaak production hardening
|
||||
|
||||
## Why
|
||||
|
||||
`backend/openzaak/docker-compose.openzaak.yml` is explicitly a throwaway dev/test harness:
|
||||
`SECRET_KEY: wp-54-local-harness-not-for-prod`, `POSTGRES_HOST_AUTH_METHOD=trust` (no DB
|
||||
password), `IS_HTTPS: 'no'`, `DISABLE_2FA: 'true'`. Before anything else in this phase can
|
||||
be called "production," the instance needs real secrets, real DB auth, and TLS. The BFF
|
||||
side is already fine — `ZgwOptions.cs` binds from `IConfiguration`, so this is a deploy-config
|
||||
change, not application code.
|
||||
|
||||
## Read first
|
||||
|
||||
- [openzaak-integration.md](../reference/openzaak-integration.md)
|
||||
- `backend/openzaak/README.md`
|
||||
- [ADR-0005 — OpenZaak behind the BFF](../reference/architecture/0005-openzaak-behind-bff.md)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Secrets come from the deployment environment (env vars / secrets manager), never
|
||||
checked into compose or appsettings.
|
||||
- TLS termination happens at a reverse proxy/ingress in front of OpenZaak — OpenZaak
|
||||
itself doesn't need built-in cert handling.
|
||||
- The existing dev harness stays as-is for local iteration (WP-54's trimmed rig is
|
||||
intentional and still valuable); this WP adds a production compose/override or an
|
||||
env-driven parameterization of the same file, not a replacement of the dev rig.
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/openzaak/docker-compose.openzaak.yml` (or a new `docker-compose.openzaak.prod.yml` override)
|
||||
- `backend/openzaak/README.md`
|
||||
- `backend/src/BigRegister.Api/appsettings*.json` / `Zgw/ZgwOptions.cs` (confirm only, likely no change)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Parameterize `SECRET_KEY`, DB user/password, and the ZGW JWT secret via env vars;
|
||||
remove hardcoded values from the committed file.
|
||||
2. Switch `POSTGRES_HOST_AUTH_METHOD` from `trust` to password auth, password from env.
|
||||
3. Set `IS_HTTPS: 'yes'`; document the required reverse-proxy/ingress TLS termination.
|
||||
4. Update `backend/openzaak/README.md` with the required env vars and the TLS note.
|
||||
5. Confirm the BFF's JWT secret already comes from config — no code change expected.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] No secret value is hardcoded in any committed compose/config file.
|
||||
- [x] The production compose fails fast (or docs state clearly) when secrets aren't
|
||||
supplied — no silent fallback to a real-looking default.
|
||||
- [x] README documents exactly which env vars must be set and how TLS is terminated.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
The WP's own "Files" section expected the secret to be parameterized directly inside
|
||||
`docker-compose.openzaak.yml`'s environment or a straight env-var override. That covers
|
||||
`SECRET_KEY`/DB password/`IS_HTTPS` fine (compose does key-based environment merging across
|
||||
`-f` files even though the base file writes some blocks as YAML mappings and others as
|
||||
anchors), but the ZGW client secret lives inside `setup_configuration/data.yaml`, a file
|
||||
OpenZaak's own `setup_configuration` management command reads — compose has no mechanism to
|
||||
interpolate env vars _inside_ a mounted file's contents. Solved by templating that one file
|
||||
(`data.prod.yaml.template`, no secret) + a tiny host-side `render-prod-secrets.sh`
|
||||
(`envsubst`, fail-fast via `${VAR:?...}`) that produces a gitignored `data.prod.yaml`, which
|
||||
`docker-compose.openzaak.prod.yml` mounts over the container's `data.yaml` (bind-mounting a
|
||||
single file inside an already bind-mounted read-only directory works fine in Docker/Podman —
|
||||
verified via `docker compose config` with the override applied). No new dependency: `envsubst`
|
||||
is part of `gettext`, already present on this machine.
|
||||
|
||||
Verified for real: `docker compose -f docker-compose.openzaak.yml -f
|
||||
docker-compose.openzaak.prod.yml config` succeeds with all required env vars set and both
|
||||
environment overrides (SECRET_KEY, DB password/auth method) present in the merged output;
|
||||
fails with a clear `${VAR:?...}` error when any is missing. `render-prod-secrets.sh` itself
|
||||
fails fast (tested) when `OPENZAAK_CLIENT_SECRET` etc. are unset, and its rendered
|
||||
`data.prod.yaml` was inspected and matched the template with real values substituted.
|
||||
`cd backend && dotnet test` (WP-54 harness untouched): 159/159 green. The dev harness
|
||||
(`docker-compose.openzaak.yml` alone, `setup_configuration/data.yaml`) is untouched.
|
||||
|
||||
## Verification
|
||||
|
||||
`docker compose -f backend/openzaak/docker-compose.openzaak.yml config` with required env
|
||||
vars set; `cd backend && dotnet test` (WP-54 harness tests unaffected); manual: the local
|
||||
dev harness still works with its dev-only values documented as dev-only.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Catalogus provisioning (WP-56), client scope narrowing (WP-57), notifications (WP-58).
|
||||
|
||||
## Risks
|
||||
|
||||
If TLS/secrets docs lag an actual deploy, someone could ship with dev defaults — mitigate
|
||||
by making the prod compose fail without required env vars rather than silently defaulting.
|
||||
@@ -0,0 +1,105 @@
|
||||
# WP-56 — Idempotent catalogus provisioning
|
||||
|
||||
Status: done
|
||||
Phase: 10 — OpenZaak production hardening
|
||||
|
||||
## Why
|
||||
|
||||
`backend/openzaak/bootstrap-catalogus.sh` seeds catalogus/zaaktype/statustype/roltype/zaak
|
||||
via hand-rolled curl+JWT and is explicitly **not idempotent** (fails on `domein`+`rsin`
|
||||
uniqueness on rerun) — fine for a one-shot WP-54 harness, wrong for an environment that
|
||||
needs to be rebuildable. OpenZaak already ships a documented, scripted alternative — the
|
||||
`setup_configuration` mechanism (already used in the harness for the JWTSecret/Applicatie,
|
||||
see `setup_configuration/data.yaml`) — this WP extends that same mechanism to the catalogus
|
||||
content too.
|
||||
|
||||
## Read first
|
||||
|
||||
- `backend/openzaak/bootstrap-catalogus.sh`
|
||||
- `backend/openzaak/setup_configuration/data.yaml`
|
||||
- `backend/openzaak/docker-compose.openzaak.yml` (`web-init` service)
|
||||
- OpenZaak's own `setup_configuration` / `openzaak_config_cli` docs (upstream)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Move catalogus/zaaktype/statustype/roltype provisioning into `setup_configuration`,
|
||||
run by the existing `web-init` one-shot service, instead of the separate curl script.
|
||||
- Keep `bootstrap-catalogus.sh` only for whatever content `setup_configuration` genuinely
|
||||
can't express (e.g. a demo zaak instance) — confirm what's left at kickoff.
|
||||
- Provisioning must be safe to run against an already-provisioned instance — either
|
||||
genuinely idempotent, or the compose is structured to only run it once per fresh
|
||||
volume (document which, don't leave it ambiguous).
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/openzaak/setup_configuration/data.yaml`
|
||||
- `backend/openzaak/bootstrap-catalogus.sh` (trim to whatever remains)
|
||||
- `backend/openzaak/docker-compose.openzaak.yml`
|
||||
- `backend/openzaak/README.md`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Express the catalogus/zaaktype/statustype/roltype definitions currently created by
|
||||
curl as `setup_configuration` YAML.
|
||||
2. Wire it into the `web-init` command alongside the existing JWTSecret/Applicatie config.
|
||||
3. Trim `bootstrap-catalogus.sh` to only what setup_configuration can't cover, if anything.
|
||||
4. Test: tear down + `docker compose up` twice in a row (fresh volume, then existing
|
||||
volume); confirm no failure on rerun.
|
||||
5. Update the README describing the provisioning flow.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] ~~Catalogus/zaaktype/statustype/roltype provisioning is declarative
|
||||
(`setup_configuration`), not imperative curl.~~ Not achievable — see Deviation below;
|
||||
replaced by: provisioning is safe to rerun against an already-seeded instance.
|
||||
- [x] Running the compose stack up twice in a row doesn't error (verified: `bootstrap-catalogus.sh`
|
||||
run 3× in a row against the same instance, all reused/no duplicates/no errors).
|
||||
- [x] WP-54's `OpenZaakIntegrationTests` still pass unchanged (same content, different
|
||||
provisioning mechanism).
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
Confirmed by reading the `django_setup_configuration` steps actually installed inside the
|
||||
`openzaak/open-zaak:1.29.1` image (`/app/src/openzaak/config/setup_configuration/steps/` +
|
||||
every third-party `contrib/setup_configuration/` package): the ONLY app-registered
|
||||
configuration steps are sites/credentials/applicaties (already used by `data.yaml`) and
|
||||
Selectielijst API config. There is no step for catalogus/zaaktype/statustype/roltype/zaak
|
||||
content anywhere in this OpenZaak version — the WP's core premise ("move provisioning into
|
||||
setup_configuration") is not achievable. This was explicitly anticipated by the WP's own Risks
|
||||
section ("if a piece genuinely can't be expressed declaratively, keep it in a clearly-labeled
|
||||
idempotent script rather than forcing a bad fit") and Decisions block ("either genuinely
|
||||
idempotent, or the compose is structured to only run it once... document which").
|
||||
|
||||
Chose **genuinely idempotent**: `bootstrap-catalogus.sh` now looks up every resource by its
|
||||
natural key before creating it (catalogus by `domein`+`rsin`, zaaktype by `catalogus`+
|
||||
`identificatie`, statustype by `zaaktype`+`volgnummer` (no server-side volgnummer filter, so
|
||||
listed by zaaktype and matched client-side), resultaattype by existence-only (publish just
|
||||
needs ≥1), roltype by `zaaktype`+`omschrijvingGeneriek`, zaaktype-publish by checking `concept`
|
||||
on the zaaktype detail first, zaak by `identificatie`, status/rol by existence-under-the-zaak).
|
||||
Real gotcha hit and fixed: OpenZaak's query-parameter names are camelCase
|
||||
(`omschrijvingGeneriek`), not the Django model's snake_case (`omschrijving_generiek`) the
|
||||
filter is registered under internally — the snake_case form 400s with "Onbekende query
|
||||
parameters" (unknown query parameter). Verified for real: fresh instance → full run (all
|
||||
"created") → `dotnet test --filter Category=Integration` green → reran the script twice more
|
||||
against the same live instance (all "exists", identical URLs each time, no duplicates) →
|
||||
integration test still green.
|
||||
|
||||
Not attempted: moving anything into `setup_configuration/data.yaml` (nothing there to move,
|
||||
per the finding above).
|
||||
|
||||
## Verification
|
||||
|
||||
`docker compose -f backend/openzaak/docker-compose.openzaak.yml up` twice in a row (fresh
|
||||
volume, then existing volume); `cd backend && dotnet test --filter Category=Integration`
|
||||
against the harness.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Secrets/TLS (WP-55, unrelated but sequenced first in the table only by number), client
|
||||
scopes (WP-57), notifications (WP-58).
|
||||
|
||||
## Risks
|
||||
|
||||
OpenZaak's `setup_configuration` coverage for zaaktype/besluittype content may be
|
||||
incomplete upstream — if a piece genuinely can't be expressed declaratively, keep it in a
|
||||
clearly-labeled idempotent script rather than forcing a bad fit.
|
||||
@@ -0,0 +1,104 @@
|
||||
# WP-57 — Least-privilege client scopes
|
||||
|
||||
Status: done
|
||||
Phase: 10 — OpenZaak production hardening
|
||||
|
||||
## Why
|
||||
|
||||
The harness's OpenZaak client is granted `heeft_alle_autorisaties: true` in
|
||||
`setup_configuration/data.yaml` — acceptable for a disposable test rig, wrong for anything
|
||||
closer to production, where the BFF's client should hold only the Autorisaties it actually
|
||||
exercises.
|
||||
|
||||
## Read first
|
||||
|
||||
- `backend/openzaak/setup_configuration/data.yaml`
|
||||
- `backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs` and `OpenZaakDocumentSource.cs`
|
||||
(the actual ZGW endpoints/verbs called)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Scope precisely to what the BFF calls today: zaken (aanmaken, bijwerken, lezen),
|
||||
statussen (aanmaken), rollen (aanmaken), documenten/zaakinformatieobjecten (aanmaken,
|
||||
lezen) — enumerate exactly at kickoff from the client code, don't guess broader.
|
||||
- No wildcard/all-scope grant in any environment beyond the pre-WP-56 disposable dev rig.
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/openzaak/setup_configuration/data.yaml` (Autorisaties block)
|
||||
- `backend/openzaak/README.md`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Grep `OpenZaakZaakSource.cs` and `OpenZaakDocumentSource.cs` for every ZGW
|
||||
endpoint/verb called.
|
||||
2. Replace `heeft_alle_autorisaties: true` with an explicit `autorisaties` list matching
|
||||
exactly that set.
|
||||
3. Re-run the full integration suite against the narrowed client; add any scope a 403
|
||||
surfaces.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Client config has no wildcard/all-scopes grant.
|
||||
- [x] `OpenZaakIntegrationTests` (WP-54) pass unchanged against the narrowed client.
|
||||
|
||||
## What actually happened
|
||||
|
||||
`vng_api_common`'s `ApplicatieConfigurationModel` (the class backing
|
||||
`setup_configuration`'s `vng_api_common_applicaties` step, read from the installed package
|
||||
inside the `openzaak/open-zaak:1.29.1` image) only has fields for
|
||||
`uuid`/`client_ids`/`label`/`heeft_alle_autorisaties` — there is no YAML field for granular
|
||||
`autorisaties` at all. So `data.yaml` now sets `heeft_alle_autorisaties: false` (both the dev
|
||||
harness and the prod template), which leaves `bigregister-test` with **zero** Autorisaties
|
||||
until something else grants them.
|
||||
|
||||
That "something else" can't be the JWT-authenticated Autorisaties REST API — a zero-scope
|
||||
client can't grant itself scope over an API gated by scope (confirmed from
|
||||
`ApplicatieViewSet.required_scopes`: `update`/`partial_update` need
|
||||
`autorisaties.bijwerken`). `bootstrap-catalogus.sh` grants the scopes directly via the ORM
|
||||
instead (`docker compose exec web python manage.py shell`, workdir `/app/src`) — no
|
||||
JWT/REST layer involved, so no circularity. Two grants, both idempotent (delete-then-create):
|
||||
|
||||
- `ztc`: `catalogi.lezen` + `catalogi.schrijven` — granted up front (no zaaktype dependency).
|
||||
Only `catalogi.schrijven` is provisioning-only; the BFF itself only ever reads Catalogi.
|
||||
- `zrc`: `zaken.aanmaken` + `zaken.bijwerken` + `zaken.lezen`, scoped to the one zaaktype
|
||||
(`zaaktype=<ZT-HERREG url>`, `max_vertrouwelijkheidaanduiding=openbaar` — both fields are
|
||||
_required_ by OpenZaak's `AutorisatieValidator` for any `zaken.*` scope) — granted once
|
||||
`zaaktype_url` is known, right after the zaaktype is created/resolved.
|
||||
|
||||
Reading the actual `RolViewSet`/`StatusViewSet`/`ZaakInformatieObjectViewSet`
|
||||
`required_scopes` (not just the scope docstrings, which are aspirational/descriptive) showed
|
||||
the decision text's "statussen (aanmaken), rollen (aanmaken)" don't map to separate OpenZaak
|
||||
scopes — `zaken.aanmaken` alone (OR'd against alternatives) already covers the first status
|
||||
and the initiator rol; there is no `rollen.aanmaken` scope. `documenten`/`zaakinformatieobjecten`
|
||||
scope was **not** granted: `Zgw:InformatieobjecttypeUrls` is empty in `appsettings.json`, so
|
||||
`OpenZaakDocumentSource.Upload` can't function in this harness regardless of scope (throws
|
||||
before any HTTP call) — nothing to scope precisely to yet. Left as a documented follow-up
|
||||
(the script would also need to seed an `informatieobjecttype` to have something concrete to
|
||||
scope `documenten.aanmaken` to).
|
||||
|
||||
Verified for real: `down -v` fresh volume → `up -d` → `bootstrap-catalogus.sh` (all
|
||||
"created", scopes granted, `heeft_alle_autorisaties: False` confirmed via `manage.py shell`)
|
||||
→ `dotnet test --filter Category=Integration` green → reran `bootstrap-catalogus.sh` again
|
||||
under the now-narrowed client (all "exists", scopes re-granted idempotently, no 403s) →
|
||||
confirmed the narrowing is real, not just untested, by DELETEing the seeded zaak with a
|
||||
hand-rolled JWT for this client: 403 `permission_denied` (zaak deletion needs
|
||||
`zaken.verwijderen`/`zaken.geforceerd-bijwerken`, neither granted).
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test --filter Category=Integration` against the harness with the
|
||||
narrowed client.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Rotating/expiring the client credential itself — defer until multi-tenant/production ops
|
||||
actually need it.
|
||||
|
||||
## Risks
|
||||
|
||||
An overlooked scope only surfaces as a runtime 403 against a real instance — mitigated by
|
||||
running the full integration suite, which already exercises every current call path
|
||||
(WP-54).
|
||||
|
||||
Depends on: WP-56 (provisioning mechanism this scopes down).
|
||||
@@ -0,0 +1,128 @@
|
||||
# WP-58 — Real notifications (celery + scripted abonnement)
|
||||
|
||||
Status: done
|
||||
Phase: 10 — OpenZaak production hardening
|
||||
|
||||
## Why
|
||||
|
||||
The WP-54 harness deliberately trims celery/celery-beat/celery-flower and nginx, and sets
|
||||
`NOTIFICATIONS_DISABLED: 'true'` — without a Celery worker, OpenZaak 500s and rolls back on
|
||||
every write to a notified resource. Fine for a fixture-driven integration harness; a real
|
||||
deployment that wants live Notificaties (WP-52's webhook) needs the workers running and the
|
||||
`abonnement` (subscription) actually registered against the BFF's public callback URL —
|
||||
today that registration step is manual.
|
||||
|
||||
## Read first
|
||||
|
||||
- `backend/openzaak/docker-compose.openzaak.yml` (top-of-file ponytail note)
|
||||
- [openzaak-integration.md](../reference/openzaak-integration.md) (Notificaties section)
|
||||
- [WP-52](WP-52-openzaak-notificaties.md)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Add celery + celery-beat as additional compose services (same `openzaak/open-zaak`
|
||||
image, different command), pointed at the same redis broker already in the harness.
|
||||
- Registering the `abonnement` becomes a scripted, idempotent step — not a manual
|
||||
admin-UI action — parameterized by the BFF's real public URL.
|
||||
- Keep the existing WP-54 harness variant (`NOTIFICATIONS_DISABLED: 'true'`) available for
|
||||
fast local iteration where a live webhook round-trip isn't needed; this WP is additive
|
||||
(a "with notifications" profile/override), not a replacement.
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/openzaak/docker-compose.openzaak.yml` (or an override file)
|
||||
- New script/config for `abonnement` registration
|
||||
- `docs/reference/openzaak-integration.md`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add celery/celery-beat services to a notifications-enabled compose profile.
|
||||
2. Flip `NOTIFICATIONS_DISABLED` off for that profile.
|
||||
3. Script the `abonnement` registration (POST to the NRC, pointed at the BFF's
|
||||
`/zgw/notificaties` endpoint from WP-52), idempotent on rerun.
|
||||
4. Verify a real write (e.g. a status change) triggers a live webhook delivery to the BFF.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A notifications-enabled harness profile runs a celery worker and delivers a real
|
||||
notification end-to-end to the BFF's webhook.
|
||||
- [x] Provisioning is a script, re-runnable without erroring on an already-configured target.
|
||||
|
||||
## What actually happened
|
||||
|
||||
The Decisions block assumed OpenZaak itself could be pointed at, celery-wired, and made to
|
||||
deliver to a subscribed `abonnement` — checking the running image (`grep -ril abonnement` inside
|
||||
the `web` container) found nothing: **OpenZaak does not serve the Notificaties API.** It's a
|
||||
separate application (`openzaak/open-notificaties`, its own image/DB/celery/beat stack).
|
||||
Standing one up for real `abonnement`/kanaal-filtered routing would mean ~5 new services (a
|
||||
second Postgres, web, worker, beat, plus the NRC↔AC authorization chain) for a benefit this
|
||||
harness doesn't need — there is exactly one subscriber (this repo's own BFF), never N. Re-scoped
|
||||
before writing any code (confirmed with the user): OpenZaak's own `NotificationsConfig` points
|
||||
straight at the BFF's webhook via a `zgw_consumers.Service` (`auth_type=api_key`) instead — no
|
||||
NRC, no `abonnement`, same delivery proof (a real write → OpenZaak's celery worker → a real HTTP
|
||||
POST → the BFF's audit trail). The two "no `abonnement`" acceptance-criteria words above were
|
||||
edited out for the same reason.
|
||||
|
||||
- `docker-compose.openzaak.notificaties.yml` — an opt-in overlay (not `profiles:`, matching
|
||||
WP-55's prod-override precedent) adding one celery worker (not celery-beat: `send_notification`
|
||||
is a plain async task fired on save, not a scheduled one — beat only matters on a real NRC's
|
||||
polling side) and flipping `NOTIFICATIONS_DISABLED` off. The two changes are inseparable:
|
||||
`NOTIFICATIONS_GUARANTEE_DELIVERY` defaults true, so the moment that flag is false, every write
|
||||
to a notified resource 500s-and-rolls-back unless `NotificationsConfig` already has a client —
|
||||
hence `bootstrap-notificaties.sh` configuring it is not a separate step.
|
||||
- Reaching the BFF from the worker turned out to be the real obstacle, not the Django/celery
|
||||
wiring. `extra_hosts: host.docker.internal:host-gateway` (the plan's first choice) resolves
|
||||
fine but every TCP connect through it timed out — confirmed live: this environment's rootless
|
||||
Podman doesn't route container→host-port traffic that way. Fix: join the overlay's `celery`
|
||||
service to the repo root's own `docker compose up` network (`external: true`, by the
|
||||
`atomic-design-poc_default` name compose derives from the repo directory) and reach the BFF by
|
||||
its container name (`api`) instead — container-to-container, which this exact stack already
|
||||
proved reliable (`celery` already talks to `db`/`redis` that way). One more trap on that path:
|
||||
`docker compose run --name api ...` does **not** register the `api` DNS alias other containers
|
||||
need (only `docker compose up -d api` does) — cost a debugging round-trip before switching to
|
||||
`up -d` (via a temporary, uncommitted `docker-compose.override.yml`) for the live verification.
|
||||
- `bootstrap-notificaties.sh` — `update_or_create` on the `Service`'s fixed slug (idempotent);
|
||||
preflights the BFF's webhook with a synthetic notification body first (204 required) so a
|
||||
misconfigured target fails before touching OpenZaak, not after (a later write would otherwise
|
||||
500-and-rollback with no obvious cause).
|
||||
- `verify-notificatie.sh` — the runnable end-to-end check. First attempt triggered the write via
|
||||
a second `statussen` POST (the "final" status) — 403'd: WP-57's narrowed `zaken.aanmaken` scope
|
||||
permits exactly **one** status per zaak ("Met de 'zaken.aanmaken' scope mag je slechts 1 status
|
||||
zetten"). Switched the trigger to a zaak `PATCH` (`toelichting`), covered by the already-granted
|
||||
`zaken.bijwerken` and trivially repeatable. Second attempt used the _final_ statustype anyway
|
||||
for a different reason and got a 400 ("Zaak has no resultaat") — OpenZaak requires a `resultaat`
|
||||
before the closing status; the `PATCH` sidesteps that precondition entirely too.
|
||||
- Verified for real, twice: `bootstrap-catalogus.sh` (idempotent re-run, all "exists") →
|
||||
`bootstrap-notificaties.sh` (preflight 204, `Service` configured) → `verify-notificatie.sh`
|
||||
(PATCH → polled `/admin/audit` → found the delivered `zgw:notificatie`/`allow` row) → reran
|
||||
both WP-58 scripts again under the same running harness (still idempotent, delivered again).
|
||||
Also confirmed the negative case directly: `POST /zgw/notificaties` with no `Authorization`
|
||||
header, and with a wrong one, both 401 — the shared-secret gate isn't just accepting anything.
|
||||
Backend suite stayed green throughout (159/159, `dotnet test --filter Category!=Integration`).
|
||||
Test infrastructure (the temporary `docker-compose.override.yml`, the manually-created `api`
|
||||
container) was torn down / reconciled back to the pre-session baseline afterward.
|
||||
|
||||
## Verification
|
||||
|
||||
Bring up the notifications-enabled profile (`backend/openzaak/README.md`'s "Notifications-enabled
|
||||
profile" section); run `./bootstrap-catalogus.sh && ./bootstrap-notificaties.sh &&
|
||||
./verify-notificatie.sh`. The last script fails loudly (with celery/worker log diagnostics) if no
|
||||
delivered notification shows up in the BFF's `/admin/audit` within 60s.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Cache invalidation on notification receipt (flagged separately in
|
||||
`openzaak-integration.md` as a `ponytail:` marker, not part of this slice);
|
||||
celery-flower/monitoring UI. A real Notificaties API (NRC) + `abonnement`/kanaal-filtered
|
||||
routing (see "What actually happened") — add one if a later WP needs more than this harness's
|
||||
single subscriber.
|
||||
|
||||
## Risks
|
||||
|
||||
Celery adds real operational surface (another process to keep alive) — scope this WP to
|
||||
"works, documented," not a fully monitored deployment. The direct-to-BFF shortcut means this
|
||||
harness doesn't exercise real `abonnement`/kanaal-filter validation — a production deployment's
|
||||
NRC-based path (documented in `openzaak-integration.md`) is untested by this harness by
|
||||
construction.
|
||||
|
||||
Depends on: WP-56 (provisioning mechanism this extends).
|
||||
@@ -0,0 +1,83 @@
|
||||
# WP-59 — Per-document-type confidentialiteit config
|
||||
|
||||
Status: done
|
||||
Phase: 10 — OpenZaak production hardening
|
||||
|
||||
## Why
|
||||
|
||||
`OpenZaakDocumentSource` hardcodes `vertrouwelijkheidaanduiding` to `"openbaar"` for every
|
||||
uploaded document, regardless of document type. Real BIG-register documents (diploma's, ID
|
||||
scans) plausibly need different confidentiality levels. This repo already has a house
|
||||
pattern for exactly this kind of business-tunable value — stamdata-as-code (ADR-0004) — so
|
||||
this slice is "apply the existing pattern," not invent a new one.
|
||||
|
||||
## Read first
|
||||
|
||||
- [ADR-0004 — Stamdata as code](../reference/architecture/0004-stamdata-as-code.md)
|
||||
- `backend/src/BigRegister.Api/Stamdata/` (an existing table for the shape to imitate)
|
||||
- `backend/src/BigRegister.Api/Zgw/OpenZaakDocumentSource.cs`
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Confidentiality level is keyed by document type (whatever type already distinguishes
|
||||
uploads, e.g. diploma vs. id-bewijs) via a new Stamdata table, using the existing
|
||||
`StamdataTable.Of<T>` mechanism — not a new ad hoc config format.
|
||||
- Default/fallback value stays `"openbaar"` if a document type isn't in the table, to
|
||||
avoid a silent upload failure.
|
||||
|
||||
## Files
|
||||
|
||||
- `Stamdata/` (new table + validation)
|
||||
- `Zgw/OpenZaakDocumentSource.cs`
|
||||
- `StamdataCatalog.cs` (register the new table)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add a `DocumentConfidentialiteit` stamdata table (document type →
|
||||
vertrouwelijkheidaanduiding), validated at build like every other stamdata table
|
||||
(`StamdataValidationTests`).
|
||||
2. Register it in `StamdataCatalog` so it's editable via the existing `/beheer/stamdata`
|
||||
grid.
|
||||
3. `OpenZaakDocumentSource` looks up the level by document type instead of hardcoding
|
||||
`"openbaar"`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Confidentiality level for a real upload varies by document type per the new
|
||||
stamdata table (`identiteit` → `vertrouwelijk`; everything else → `openbaar`).
|
||||
- [x] `StamdataValidationTests` cover the new table (a bad edit fails CI, per ADR-0004).
|
||||
- [x] `/beheer/stamdata` can edit the new table without a code change (existing generic
|
||||
editor — the `StamdataCatalog` registration is the only wiring needed).
|
||||
|
||||
## What actually happened
|
||||
|
||||
Implemented mostly as planned — one gap found and closed: the diff as first written
|
||||
registered `DocumentConfidentialiteit` in `StamdataCatalog` and wired the lookup into
|
||||
`OpenZaakDocumentSource`, plus a positive test (`identiteit` → `vertrouwelijk`) and a
|
||||
fallback test (an unmapped category, `org-logo`, → `openbaar`), but had **no**
|
||||
`StamdataValidationTests` reference-integrity entry for the new table — the second
|
||||
acceptance box was unchecked. Added one: a `StamdataRef` resolving every
|
||||
`documentconfidentialiteit.json` `categoryId` against the real set of document category
|
||||
ids (`DocumentRules.AllCategoriesFor` across `registratie`/`herregistratie`/`org-template`),
|
||||
so a typo'd or stale `categoryId` now fails the build instead of silently never matching
|
||||
(`OpenZaakDocumentSource.ConfidentialiteitFor`'s dictionary lookup would otherwise just
|
||||
fall back to `"openbaar"` forever with no signal). `org-logo` deliberately stays absent
|
||||
from the confidentialiteit table (falls back to `"openbaar"`) and correctly still
|
||||
resolves as a known category — the reference check validates "is this a real category",
|
||||
not "must every category be configured."
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test` (161/161 green, incl. the 2 new `OpenZaakDocumentSourceTests` plus
|
||||
the new `StamdataValidationTests` reference entry); `dotnet format --verify-no-changes` clean.
|
||||
Manual: `/beheer/stamdata` shows and edits the new table; an upload for a mapped document type
|
||||
carries the mapped confidentiality level (test asserted).
|
||||
|
||||
## Out of scope
|
||||
|
||||
Any UI-facing confidentiality display/change on the citizen side (FE keeps rendering
|
||||
decisions, not recomputing them, per ADR-0001).
|
||||
|
||||
## Risks
|
||||
|
||||
None significant — this is a config/data-shape change reusing an established mechanism.
|
||||
@@ -0,0 +1,94 @@
|
||||
# WP-60 — Write-divergence resilience (local + ZGW writes)
|
||||
|
||||
Status: done
|
||||
Phase: 10 — OpenZaak production hardening
|
||||
|
||||
## Why
|
||||
|
||||
A citizen action today does a local `Aanvraag`/`Document` write and a paired ZGW write
|
||||
(create zaak/status/document); these aren't transactional. If the ZGW call fails after the
|
||||
local write succeeds (or vice versa), the two diverge silently —
|
||||
`openzaak-integration.md` flags this explicitly as "acceptable for a demo backend; a
|
||||
production arc needs retry/reconciliation or an outbox." This is the one genuine
|
||||
correctness gap standing between the current integration and something safe to call
|
||||
production.
|
||||
|
||||
## Read first
|
||||
|
||||
- [openzaak-integration.md](../reference/openzaak-integration.md) (the section discussing
|
||||
this gap)
|
||||
- `backend/src/BigRegister.Api/Data/ApplicationStore.cs`,
|
||||
`Zgw/OpenZaakZaakSource.cs` (the two write sides)
|
||||
- [ADR-0005 — OpenZaak behind the BFF](../reference/architecture/0005-openzaak-behind-bff.md)
|
||||
|
||||
## Decisions
|
||||
|
||||
Picked **(b), narrowed further: bounded synchronous retry + flag, no reconcile job.** The
|
||||
`planner` agent's kickoff review found the write side smaller than either option assumed:
|
||||
|
||||
- The only ZGW writes are `OpenZaakZaakSource.CreateZaak` (zaak/status/rol, one POST sequence
|
||||
per submit) and `OpenZaakDocumentSource.Upload`/`LinkToZaak` (DRC + zaakinformatieobject).
|
||||
There is no standalone status-transition write path yet (that's WP-66) — Step 2 below is
|
||||
corrected accordingly.
|
||||
- Every path already does the local write first and never rolls it back on a ZGW failure — "the
|
||||
ZGW half fails, local succeeded" is the only real scenario; the reverse can't happen.
|
||||
- An outbox was rejected: three request-triggered write paths don't justify a persisted queue,
|
||||
and a ZGW call's `CallerIdentity` (needed for the JWT's audit claims, WP-53) would mean PII
|
||||
sitting in a new table — the "generic outbox framework" this WP's own Risks section warns
|
||||
against.
|
||||
- A reconcile job was judged unnecessary for the acceptance criteria: flagging (not silent
|
||||
divergence) is sufficient, and repair is always possible on demand because a zaak's
|
||||
`identificatie` equals the aanvraag's `Referentie` — no reconcile job ships in this WP.
|
||||
|
||||
Shipped: bounded retry (3 attempts, doubling backoff from 200ms) in `ZgwHttpClient` for
|
||||
transport-shaped failures only (429/502/503/504/408 + connection errors/timeouts — deliberately
|
||||
**not** 500, which can follow a partial commit on the non-idempotent `/statussen`/`/rollen`
|
||||
POSTs); `Aanvraag.ZgwError` + a `zgw:divergence` audit row when a ZGW write still fails after
|
||||
retry (`Program.cs`'s submit endpoint, two separate try/catches so a create-zaak failure doesn't
|
||||
also skip the still-local document link); `OpenZaakDocumentSource.Upload` catches and logs
|
||||
without a separate flag column (`DrcUrl == null` already means "not registered in ZGW yet").
|
||||
Full reasoning + rejected sub-options: [openzaak-integration.md](../reference/openzaak-integration.md)'s
|
||||
"Write resilience" section.
|
||||
|
||||
## Files
|
||||
|
||||
`Zgw/ZgwHttpClient.cs` (retry), `Data/ApplicationStore.cs` (`ZgwError` column + migration),
|
||||
`Program.cs` (submit endpoint rewire + `RecordZgwDivergence` + HttpClient timeouts),
|
||||
`Zgw/OpenZaakDocumentSource.cs` (non-throwing upload). No new file for a mechanism — no
|
||||
outbox/background worker shipped (see Decisions).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Design review with the `planner` agent — pick outbox vs. retry+reconcile. Done: retry+flag
|
||||
(see Decisions).
|
||||
2. Implement the chosen mechanism for the create-zaak and document (upload + link) write
|
||||
paths — not "status-transition" as originally scoped here; that path doesn't exist yet
|
||||
(arrives with WP-66).
|
||||
3. Add a test that simulates a ZGW failure mid-write and asserts the system recovers
|
||||
(retries successfully, or is left in a detectably-inconsistent-but-flagged state)
|
||||
rather than silently diverging.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A simulated ZGW failure after a successful local write no longer leaves permanent
|
||||
silent divergence — either it retries to consistency or the divergence is
|
||||
detectable/flagged.
|
||||
- [x] No new synchronous latency added to the happy path beyond what the chosen mechanism
|
||||
requires.
|
||||
|
||||
## Verification
|
||||
|
||||
A new integration test that fails a stubbed ZGW call mid-write and asserts
|
||||
recovery/flagging behavior; `cd backend && dotnet test`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
A general-purpose outbox framework reusable beyond this one write pair (YAGNI unless a
|
||||
second write pair appears — note WP-66 is exactly that second pair, so revisit scope if
|
||||
WP-66 lands first); UI surfacing of reconciliation state (backend-only fix for now).
|
||||
|
||||
## Risks
|
||||
|
||||
Over-building this (a generic outbox/saga framework) for a POC's actual write volume —
|
||||
ladder check at kickoff: does a bounded retry + periodic reconcile job cover it before
|
||||
reaching for an outbox table?
|
||||
@@ -0,0 +1,83 @@
|
||||
# WP-61 — Bootstrap the behandelportal app
|
||||
|
||||
Status: done (behandelportal@6b0c6ce — separate sibling repo, not a commit in this one)
|
||||
Phase: 11 — Behandelportal
|
||||
|
||||
## Why
|
||||
|
||||
ADR-0002 already designed the Behandelaar/backoffice as a separate sibling frontend app,
|
||||
not a folder in this repo. Nothing exists yet — `/beheer/zaken` is confirmed to be only a
|
||||
cross-owner list+delete, no treatment workflow. The `new-ssp` skill exists precisely to
|
||||
bootstrap a new portal from this template; this slice is running that recipe for real,
|
||||
with no business context yet — an empty, correctly-scaffolded shell.
|
||||
|
||||
## Read first
|
||||
|
||||
- `.claude/skills/new-ssp/SKILL.md`
|
||||
- [ADR-0002 — user groups & bounded contexts](../reference/architecture/0002-user-groups-and-bounded-contexts.md)
|
||||
- [ADR-0001 — BFF-lite decision DTOs](../reference/architecture/0001-bff-lite-decision-dtos.md)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Follow `new-ssp`'s own "keep vs. strip" checklist as-is: keep the `shared/ui` kernel,
|
||||
tooling/CI gates, ADRs 0001-0003; strip the four citizen contexts and citizen branding.
|
||||
- The new app talks to the same `BigRegister.Api` backend — no new backend service
|
||||
(confirmed by ADR-0002: contexts integrate through the backend).
|
||||
- Repo layout: **separate sibling repo** at `/home/eho/repos/behandelportal` (fresh clone —
|
||||
`scripts/create-frontend.mjs`'s own header comment says to run it inside a fresh clone, not
|
||||
against this repo's working tree).
|
||||
|
||||
## Resolution (kickoff deviation from the vanilla recipe)
|
||||
|
||||
`create-frontend.mjs` unconditionally renames the backend project regardless of
|
||||
`--skip-backend` (that flag only gates the `gen:api` regen step) — which conflicts with
|
||||
"no new backend service" above. Resolved by running the script as-is
|
||||
(`--name Behandelportal --context behandeling --skip-backend`), then in the new repo:
|
||||
deleting `backend/` entirely, vendoring its `swagger.json` into `api-contract/swagger.json`,
|
||||
and pointing `gen:api` at `nswag run nswag.json` against that vendored doc instead of
|
||||
regenerating from a live backend. Dev workflow is two terminals: `BigRegister.Api` runs from
|
||||
_this_ repo on `:5000`, Behandelportal serves on `:4201` via its own unchanged
|
||||
`proxy.conf.json`.
|
||||
|
||||
Also found and fixed by hand (real bugs in `create-frontend.mjs`, not specific to this WP):
|
||||
`plop context`'s `.dependency-cruiser.js` insertion anchors on the `showcase`
|
||||
`CONTEXT_ALLOWED` entry, which is already stripped by the time `plop context` runs in the
|
||||
same invocation — so a freshly scaffolded context silently gets **no boundary-fence
|
||||
entry**, and `dep:check` passes green with the architecture guarantee simply absent. Not
|
||||
re-fixed in `create-frontend.mjs` itself this pass (out of scope here) — just compensated for by
|
||||
hand in the new repo.
|
||||
|
||||
## Files
|
||||
|
||||
Whatever `new-ssp`'s recipe touches (new app root, `package.json`, shared/ui copy or
|
||||
workspace reference, CI config) — enumerate at kickoff by following the skill.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run the `new-ssp` bootstrap per its own checklist.
|
||||
2. Confirm the known un-genericizable rough edges it flags (`shared/ui/debug-state/`, the
|
||||
`/dashboard` route) are handled per the skill's own guidance (delete / TODO stopgap)
|
||||
rather than re-solved from scratch.
|
||||
3. Land an empty landing/login page only — no `behandeling` context yet (that's WP-64+).
|
||||
4. Get the new app's own CI green.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] New app boots and its own `npm run ci` is green.
|
||||
- [x] No citizen-facing business context (`registratie`, `herregistratie`, `brief`,
|
||||
`showcase`) present.
|
||||
- [x] Points at the same backend (`BigRegister.Api`) as this repo, no new backend stood
|
||||
up.
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run ci` in the new app; manual smoke — app loads to an empty shell page.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Any `behandeling` screens (WP-64/65), identity (WP-62), status lifecycle (WP-63).
|
||||
|
||||
## Risks
|
||||
|
||||
`new-ssp`'s own docs already name its rough edges (`debug-state`, `/dashboard`) — budget
|
||||
time for those rather than being surprised by them.
|
||||
@@ -0,0 +1,96 @@
|
||||
# WP-62 — Backend: medewerker caller identity + authz seam
|
||||
|
||||
Status: done
|
||||
Phase: 11 — Behandelportal
|
||||
|
||||
## Why
|
||||
|
||||
The backend's only identity today is `CallerIdentity` (BSN + display name, from WP-53)
|
||||
modeling a single zorgverlener actor. ADR-0002 requires a second actor kind
|
||||
(medewerker/employee) that authenticates differently (no BSN, has `rollen`) and needs its
|
||||
own capability checks for backoffice calls. This slice adds that identity + authz surface
|
||||
on the backend only — unused by any frontend until WP-64 calls it, matching the same
|
||||
"seam, not provider" discipline WP-53 used for citizen identity (stub, no real employee
|
||||
SSO — out of scope per CLAUDE.md, same as DigiD).
|
||||
|
||||
## Read first
|
||||
|
||||
- [ADR-0002 §3 — Principal union](../reference/architecture/0002-user-groups-and-bounded-contexts.md)
|
||||
- `backend/src/BigRegister.Api/Domain/Authorization/CallerIdentity.cs`,
|
||||
`IIdentityProvider.cs`, `StubIdentityProvider.cs` (WP-53's pattern to extend/mirror)
|
||||
- [WP-53](WP-53-inbound-identity-and-citizen-scoping.md)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Model the two actor kinds as a discriminated union (mirroring ADR-0002 §3:
|
||||
`{ kind: 'zorgverlener'; bsn }` | `{ kind: 'medewerker'; medewerkerId; rollen }`),
|
||||
backend-side, extending `CallerIdentity` rather than introducing a parallel type.
|
||||
- Stub the medewerker identity the same way WP-53 stubbed citizen identity (a
|
||||
header-driven `StubIdentityProvider` variant) — no real employee SSO/eHerkenning.
|
||||
- New capability checks (e.g. `canBeoordelen`) are computed backend-side and exposed only
|
||||
as decision flags, never a permission matrix shipped to a frontend (ADR-0001 discipline,
|
||||
reaffirmed by ADR-0002 §3).
|
||||
|
||||
## Files
|
||||
|
||||
- `Domain/Authorization/CallerIdentity.cs` (extend to the union)
|
||||
- `Domain/Authorization/StubIdentityProvider.cs` (medewerker variant)
|
||||
- `Domain/Authorization/Authz.cs` (medewerker capability checks)
|
||||
- Tests
|
||||
|
||||
## Steps
|
||||
|
||||
1. Extend `CallerIdentity` to the two-actor-kind union.
|
||||
2. Extend the stub identity provider to produce a `medewerker` identity from a
|
||||
header/config, alongside the existing zorgverlener stub.
|
||||
3. Add capability checks a backoffice caller needs (start with `canBeoordelen`; extend as
|
||||
WP-65 needs more).
|
||||
4. Unit tests for both identity kinds and the new capability checks — no consumer exists
|
||||
yet (WP-64+ will call this).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `CallerIdentity` represents both actor kinds without breaking any existing
|
||||
zorgverlener call site (WP-53's tests still green).
|
||||
- [x] A stub medewerker identity resolves from a request header, mirroring the existing
|
||||
citizen stub.
|
||||
- [x] At least one capability flag (`canBeoordelen`) computable for a medewerker
|
||||
identity, unit-tested.
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test` (existing WP-53 tests unaffected + new medewerker tests
|
||||
green) — 182/182 (168 baseline + 14 new). `dotnet format --verify-no-changes` clean.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Any actual backoffice endpoint using this (WP-64+); real employee SSO/eHerkenning.
|
||||
|
||||
## Risks
|
||||
|
||||
If the union is modeled as a bolt-on rather than replacing the flat type, existing
|
||||
zorgverlener call sites could break — mitigated by keeping WP-53's existing tests as a
|
||||
regression gate.
|
||||
|
||||
## Outcome notes
|
||||
|
||||
- **The `Files` list undersold the blast radius.** `CallerIdentity` became `abstract`
|
||||
with two derived records (`ZorgverlenerCaller`, `MedewerkerCaller`), which is a hard
|
||||
compile error at every `new CallerIdentity(...)` and every `.Bsn` read outside
|
||||
`Domain/Authorization/` — 17 `ctx.Caller().Bsn` reads in `Program.cs` alone, plus 6
|
||||
seam signatures (`IDocumentSource.Upload`, `IZaakSource.ListMyCases` and their
|
||||
Local/OpenZaak implementations) narrowed to `ZorgverlenerCaller` where `.Bsn` is used
|
||||
as an ownership key, plus test fixtures in 4 test files.
|
||||
`CallerIdentity.SubjectId` (BSN or medewerkerId) is the trick that kept the
|
||||
token-mint-only call sites (`ZgwTokenProvider.Mint`, `ZgwHttpClient`, `IZaakSource
|
||||
.CreateZaak`, `IDocumentSource.LinkToZaak`) compiling with zero signature changes —
|
||||
they never needed the BSN specifically, just _an_ id for the ZGW audit trail.
|
||||
- **`Role` (`PrincipalRole`, the existing dev-role stand-in) stays on the base record**,
|
||||
not per-variant — it's an orthogonal axis (both actor kinds can be any dev role),
|
||||
which is why `Authz.ResolvePrincipal(ctx) => new(ctx.Caller().Role)` and its ~15
|
||||
call sites needed no changes at all.
|
||||
- **A new extension, `ctx.Zorgverlener()`**, narrows `CallerIdentity` to
|
||||
`ZorgverlenerCaller` or throws — deliberately a 500, not a 403, since no medewerker
|
||||
reaches any SSP endpoint today (nothing sends `X-Medewerker` yet). WP-64 should
|
||||
map this to a 403 once real backoffice traffic exists; flagging it now so it isn't
|
||||
mistaken for an oversight.
|
||||
@@ -0,0 +1,86 @@
|
||||
# WP-63 — Backend: aanvraag status lifecycle as a published DTO
|
||||
|
||||
Status: done
|
||||
Phase: 11 — Behandelportal
|
||||
|
||||
## Why
|
||||
|
||||
The FE currently infers "in behandeling" from a single boolean, `pendingHerregistratie`
|
||||
(`big-profile.store.ts:53`) — explicitly called out in ADR-0002 as "a temporary stand-in
|
||||
for a real, backend-owned status." The full lifecycle (`Ingediend → In behandeling →
|
||||
(Meer info gevraagd ⇄) → Goedgekeurd/Afgewezen`) needs to become a real backend-published
|
||||
value before either frontend can render it meaningfully — the SSP needs it as a richer
|
||||
read (this WP), the behandelportal needs it as the thing it advances (WP-65).
|
||||
|
||||
## Read first
|
||||
|
||||
- [ADR-0002](../reference/architecture/0002-user-groups-and-bounded-contexts.md) (status
|
||||
lifecycle diagram)
|
||||
- `src/app/registratie/application/big-profile.store.ts` (the current boolean)
|
||||
- [ADR-0001 — BFF-lite decision DTOs](../reference/architecture/0001-bff-lite-decision-dtos.md)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Status lives on the existing `Aanvraag`/`ApplicationSummaryDto` aggregate (extend,
|
||||
don't invent a parallel status resource).
|
||||
- The DTO change is additive: the SSP's `pendingHerregistratie` boolean can be derived
|
||||
from the new status field (or kept as a computed convenience) so this ships with zero
|
||||
required FE behavior change — a pure backend + contract widening.
|
||||
- Only the status _value_ is published here; any transition (advancing it) is a separate
|
||||
write endpoint, not part of this slice (that's WP-65's mutation).
|
||||
|
||||
## Files
|
||||
|
||||
- `Data/ApplicationStore.cs` (status field/enum)
|
||||
- `Contracts/Dtos.cs` (extend `ApplicationSummaryDto`/status DTO)
|
||||
- The FE `infrastructure/*.adapter.ts` + `parse*` boundary consuming it
|
||||
- `big-profile.store.ts` (derive the existing boolean from the new field)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Model the full status enum backend-side (`Ingediend`, `InBehandeling`,
|
||||
`MeerInfoGevraagd`, `Goedgekeurd`, `Afgewezen`) on `Aanvraag`.
|
||||
2. Publish it on the existing DTO the SSP already consumes.
|
||||
3. Regenerate the typed client (`npm run gen:api`); update the FE `parse*` boundary to
|
||||
read the new field.
|
||||
4. Point `pendingHerregistratie` (or its replacement) at the new field so the SSP's
|
||||
existing behavior is unchanged, just backed by a real value.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Backend publishes the full status lifecycle value on the existing aanvraag DTO.
|
||||
- [x] `npm run gen:api` leaves no drift; SSP's existing "pending" display is unchanged in
|
||||
behavior, now backed by the real status.
|
||||
- [x] `dotnet test` + `npm run ci` green.
|
||||
|
||||
## Outcome
|
||||
|
||||
Implemented as a pure type-system widening, not a behavior change: `AanvraagStatusTag`
|
||||
(`Ingediend | InBehandeling | MeerInfoGevraagd | Goedgekeurd | Afgewezen`) is a new C# enum
|
||||
backing `Mappers.ToStatusDto`'s existing string literals — `AanvraagStatusDto.Tag` stays a
|
||||
plain string, so the OpenAPI schema (and `npm run gen:api`) don't change at all, satisfying
|
||||
"zero required FE behavior change" trivially. `Ingediend`/`MeerInfoGevraagd` aren't reachable
|
||||
from any code path yet (no behandelaar action exists to produce them) — that's WP-65's
|
||||
transition endpoint, exactly per this WP's own Risks note. The FE `AanvraagStatus` union,
|
||||
`parseAanvraagStatus`, `statusLabel`/`submittedRow`/`detailRows`, `blockActions`, and the
|
||||
dashboard's sort order were all widened to the two new tags so TypeScript's exhaustiveness
|
||||
checking forces every switch to handle them once WP-65 starts emitting them.
|
||||
`big-profile.store.ts`'s `pendingHerregistratie` was deliberately left untouched — it's a
|
||||
pure client-side optimistic UI flag unrelated to any DTO field (not what the WP's "Why"
|
||||
section implied), and the decision text's "or kept as a computed convenience" explicitly
|
||||
allows this.
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test`; `npm run gen:api` (no drift); `npm run ci`; manual: SSP
|
||||
dashboard still shows the same pending/approved states it does today.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Any endpoint that _advances_ the status (WP-65); the behandelportal consuming it (WP-64).
|
||||
|
||||
## Risks
|
||||
|
||||
If the enum doesn't anticipate a state WP-65 needs (e.g. distinguishing who can transition
|
||||
from what), it gets revised there — acceptable, this slice only needs to cover the states
|
||||
already named in ADR-0002's diagram.
|
||||
@@ -0,0 +1,98 @@
|
||||
# WP-64 — Behandelportal: werkvoorraad (queue) screen
|
||||
|
||||
Status: done
|
||||
Phase: 11 — Behandelportal
|
||||
|
||||
## Why
|
||||
|
||||
First real screen in the new app — a read-only list of aanvragen needing treatment (the
|
||||
"werkvoorraad"), gated by the medewerker identity from WP-62 and backed by the real status
|
||||
DTO from WP-63. This is the smallest useful vertical slice of actual case-treatment
|
||||
functionality — usable and demoable on its own, even before any decision can be recorded
|
||||
(WP-65).
|
||||
|
||||
## Read first
|
||||
|
||||
- WP-61/62/63 outcomes
|
||||
- `bff-endpoint` skill (screen-shaped decision DTO recipe)
|
||||
- `src/app/registratie/ui/admin-cases.page.ts` (the existing cross-owner list, for what
|
||||
to avoid repeating — that page is audit/delete, this one is a queue)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- New BFF-lite endpoint (decision-enriched DTO) shaped for a werkvoorraad screen — not a
|
||||
reuse of the existing `/admin/cases` endpoint, which is audit-shaped, not queue-shaped
|
||||
(per CLAUDE.md's per-screen endpoint discipline).
|
||||
- Gated by the `canBeoordelen`-style capability from WP-62, not a new ad hoc role check.
|
||||
- Domain first, then infrastructure, application, UI — per the house `new-feature`
|
||||
recipe.
|
||||
|
||||
## Files
|
||||
|
||||
New backend endpoint + DTO in `BigRegister.Api`; new `behandeling` context in the
|
||||
behandelportal app (domain/infrastructure/application/ui per the house layering).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Backend: new screen-shaped endpoint returning aanvragen needing treatment
|
||||
(status = `InBehandeling`/`Ingediend`), gated by WP-62's capability.
|
||||
2. FE: scaffold the `behandeling` context (domain → infrastructure → application → ui),
|
||||
following `new-feature`.
|
||||
3. UI: a list page (queue), composed from the shared `shared/ui` kernel — no new atoms
|
||||
unless nothing existing fits.
|
||||
4. Storybook story for the new list component/page, a11y-checked.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Werkvoorraad screen lists aanvragen needing treatment for an authenticated
|
||||
medewerker.
|
||||
- [x] `npm run ci` green in the behandelportal app; Storybook story present.
|
||||
- [x] Endpoint follows BFF-lite discipline (decision-enriched, not raw passthrough).
|
||||
|
||||
## Outcome
|
||||
|
||||
`GET /werkvoorraad` reuses the existing `ApplicationSummaryDto`/`IZaakSource.ListCases` —
|
||||
no new DTO — filtered server-side to `Status.Tag is "Ingediend" or "InBehandeling"`. Gated
|
||||
by a new `Werkvoorraad` local-function twin of `CasesAdmin`, but checking
|
||||
`Authz.CanBeoordelen(ctx.Caller())` (the CallerIdentity directly) rather than a
|
||||
role-only `Principal` — a zorgverlener with `X-Role=admin` is still denied (covered by a test).
|
||||
|
||||
One course correction beyond the pre-made decisions: `GET /me` didn't expose any
|
||||
capability a medewerker could hold (`RoleCapabilities` only switches on `PrincipalRole`,
|
||||
which every `MedewerkerCaller` also carries but doesn't determine `CanBeoordelen`). Added
|
||||
one line — `if (Authz.CanBeoordelen(ctx.Caller())) caps.Add("aanvraag:beoordelen")` — and a
|
||||
matching `Capability` union member in `libs/shared`, so the FE page can use the same
|
||||
deny-by-default `AccessStore`/`capabilityGuard` idiom every other gated page uses (avoids a
|
||||
wasted 403 GET and a denial flash), instead of inventing a second gating mechanism.
|
||||
|
||||
FE: the queue item's domain type (`behandeling/domain/werkvoorraad-item.ts`) is
|
||||
deliberately **narrower** than the SSP's full `AanvraagStatus` union — only
|
||||
`Ingediend`/`InBehandeling` — so a case the backend's filter let through with any other
|
||||
tag is a parse error, not a silently-rendered row. Composed entirely from existing
|
||||
`libs/shared/ui` molecules (`ApplicationListComponent` + `ApplicationLinkComponent`, the
|
||||
same ones ssp's dashboard uses) via one new organism, `WerkvoorraadListComponent` — no new
|
||||
atom. The stopgap `behandeling.page.ts`/`BehandelingPage` (WP-61's scaffold placeholder,
|
||||
its own TODO said to replace it) is gone; `/dashboard` now loads `WerkvoorraadPage`
|
||||
directly, and the redundant `/behandeling` route (same placeholder, two paths) was dropped.
|
||||
|
||||
**Correction (found during WP-65):** this WP's Verification line ("manual: log in as a stub
|
||||
medewerker, see the queue populated") could not actually have passed — the behandelportal's
|
||||
login was still WP-61's copy-pasted citizen/BSN DigiD flow, nothing sent `X-Medewerker`, so
|
||||
`WerkvoorraadPage` always rendered its denial alert in a real browser. CI stayed green
|
||||
regardless (none of this WP's tests exercise the browser gate). Fixed in WP-65 with a
|
||||
dev-only `medewerkerInterceptor` — see that WP's Progress notes.
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run ci` in the behandelportal app; `cd backend && dotnet test`; manual: log in as a
|
||||
stub medewerker, see the queue populated from seeded aanvragen.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Viewing a single zaak's detail (WP-65); recording any decision (WP-65).
|
||||
|
||||
## Risks
|
||||
|
||||
None major — this is a read-only composition slice once WP-61-63 exist.
|
||||
|
||||
Depends on: WP-61, WP-62, WP-63.
|
||||
@@ -0,0 +1,186 @@
|
||||
# WP-65 — Behandelportal: zaak detail + beoordeling (decision) screen
|
||||
|
||||
Status: done (65a — detail read — done; 65b — decision write — done)
|
||||
Phase: 11 — Behandelportal
|
||||
|
||||
## Why
|
||||
|
||||
The core case-treatment write path — a medewerker opens one aanvraag's detail (including
|
||||
its documents) and records a decision (goedkeuren/afwijzen/meer info opvragen), advancing
|
||||
the status lifecycle WP-63 published. This is the first genuinely new _write_ capability
|
||||
in the system beyond what the citizen SSP already does to itself.
|
||||
|
||||
## Read first
|
||||
|
||||
- `mutation-command` skill
|
||||
- `form-machine` skill (the decision action is a state-changing form, same idiom as
|
||||
everywhere else in this house)
|
||||
- [WP-63](WP-63-aanvraag-status-lifecycle.md) (the status field being advanced)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- The decision action is modeled as a `*.machine.ts` (Model/Msg/reduce) + a `submit-*`
|
||||
command returning `Result`, per this house's one-idiom-for-forms rule — not a
|
||||
hand-rolled mutable field.
|
||||
- The mutation endpoint is a new BFF-lite write (per `mutation-command` recipe) that
|
||||
transitions the status field from WP-63; it validates the transition is legal
|
||||
server-side (e.g. can't approve an already-approved case) — the backend remains the
|
||||
authority.
|
||||
- Runs against `LocalZaakSource` for this slice; wiring the decision into real OpenZaak is
|
||||
explicitly WP-66, not bundled here — keeps this slice's surface to app-level behavior
|
||||
only.
|
||||
|
||||
## Files
|
||||
|
||||
New mutation endpoint + command in `BigRegister.Api`; `behandeling/ui` detail page +
|
||||
`behandeling/application` decision machine in the behandelportal app.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Backend: mutation endpoint advancing aanvraag status (goedkeuren/afwijzen/meer-info-
|
||||
opvragen), validating the transition.
|
||||
2. FE: zaak-detail page (documents + current status) + a decision form machine + submit
|
||||
command.
|
||||
3. Wire the werkvoorraad list (WP-64) to link into this detail page.
|
||||
4. Storybook stories + a11y for the new detail/decision UI.
|
||||
|
||||
## Progress notes (65a — done)
|
||||
|
||||
This WP's own Risks note offered an explicit escape hatch ("split detail-view (read) from
|
||||
decision-recording (write) into two sessions if it feels too big"). Taken: 65a below is done
|
||||
and committed; 65b (the mutation + decision machine + form) is the remaining half — see its
|
||||
own separate session notes once started. Don't relitigate the split; do relitigate nothing
|
||||
else pre-made here.
|
||||
|
||||
**Blocking gap found and fixed, not in this WP's original scope:** the behandelportal's
|
||||
login was still WP-61's copy-pasted citizen/BSN DigiD flow — nothing sent `X-Medewerker`, so
|
||||
`StubIdentityProvider` always resolved a zorgverlener, `GET /me` never contained
|
||||
`aanvraag:beoordelen`, and WP-64's `WerkvoorraadPage` always rendered its denial alert in a
|
||||
real browser. WP-64's own Verification line ("manual: log in as a stub medewerker, see the
|
||||
queue populated") could not have passed as written — CI stayed green because none of its
|
||||
tests exercise the browser gate. Fixed with a dev-only `medewerkerInterceptor`
|
||||
(`apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts` +
|
||||
`medewerker.ts`), mirroring `@shared/infrastructure/role.ts`'s `?role=` idiom but app-local
|
||||
(`?rollen=`, sticky per tab, default `behandelaar`) — real employee-SSO login stays out of
|
||||
scope (ADR-0002 §3: the two apps' login flows are expected to diverge). Documented in
|
||||
`docs/reference/roles-and-access.md`.
|
||||
|
||||
**Backend (`GET /beoordeling/{id}`):** reads through `IZaakSource.ListCases` (no new seam
|
||||
method — one would force an `OpenZaakZaakSource` get-by-id + mapper, which is WP-66's
|
||||
surface), filters to the requested id, 404s a Concept or unknown id. Documents come from
|
||||
`DocumentStore.ByIds` (new method, mirrors `ByLocalIds`) — `DocumentStore` directly, not
|
||||
`IDocumentSource`, since that seam has no read method and its own header comment already
|
||||
says `DocumentStore` stays the record of truth for preview/download/audit regardless of
|
||||
config. The owner BSN is masked (`MaskTail`, same helper `/brief`'s BIG-nummer masking
|
||||
uses) — WP-64's queue row leaks the full BSN via the same `ApplicationSummaryDto.Owner`
|
||||
field; flagging as a follow-up, not fixed here or ssp's `/admin/cases` page moves too.
|
||||
The gate that was `Werkvoorraad(ctx, action)` is now `Beoordelen(ctx, resource, action)` —
|
||||
one gate for every behandelaar endpoint, `resource` feeding the audit row; the one existing
|
||||
`/werkvoorraad` call site was updated to pass `"werkvoorraad"`.
|
||||
|
||||
**Decision-readiness (`BeoordelingDecisionsDto.canBesluiten`) ships now, not deferred to
|
||||
65b:** `BeoordelingRules.CanDecide(AanvraagStatusTag)` only inspects the aanvraag's current
|
||||
_computed_ status tag (`Ingediend`/`InBehandeling`/`MeerInfoGevraagd` → decidable;
|
||||
`Goedgekeurd`/`Afgewezen` → not) — no persisted "was a decision recorded" field exists yet,
|
||||
so this pure rule needed nothing from 65b's eventual migration to be correct today. 65b adds
|
||||
the mutation, the `Besluit` enum, and the transition-legality check that reuses this same
|
||||
function.
|
||||
|
||||
**FE:** `BeoordelingStatus` (`domain/beoordeling.ts`) is the five-tag union (all of ssp's
|
||||
`AanvraagStatus` minus `Concept`, which 404s) — wider than WP-64's `WerkvoorraadStatus` (two
|
||||
tags). `TYPE_LABELS`/`statusLabel` moved from `werkvoorraad-item-view.ts` into a new
|
||||
`domain/beoordeling-view.ts` (the file owning the wider union) and are re-exported from the
|
||||
old location so no consumer or existing spec needed to change. Detail page composes
|
||||
`<app-data-block>`/`<app-data-row>` (mirrors ssp's `aanvraag-detail.page.ts`) plus one new
|
||||
organism, `beoordeling-documenten` (plain links to the existing, pre-existing-unauthenticated
|
||||
`/uploads/{id}/content` endpoint — not `ApplicationLinkComponent`, whose `to` is a
|
||||
`routerLink`, not an external href). The werkvoorraad list's rows now link to
|
||||
`/aanvraag/:id`, gated by the same `aanvraag:beoordelen` capability. No `MaskedValueComponent`
|
||||
reveal affordance — this screen never reveals the owner, so pulling in that atom's unused
|
||||
reveal machinery would be speculative.
|
||||
|
||||
**Deferred to 65b, deliberately:** the mutation endpoint, the `Aanvraag.BesluitStatus`
|
||||
column + EF migration, `Mappers.ToStatusDto`'s "a recorded decision wins" branch, the
|
||||
`besluit.machine.ts` + `besluit-form` UI, and re-running the werkvoorraad/ssp-dashboard
|
||||
end-to-end smoke this WP's acceptance criteria actually asks for (a decision advancing
|
||||
status, illegal transitions rejected). None of WP-65's acceptance criteria are checked off
|
||||
yet — 65a is infrastructure the decision screen needs, not a slice of the AC itself.
|
||||
|
||||
`npm run ci` green (lint, dep:check ×2, both apps' localized builds, both Storybook builds,
|
||||
test, backend test — 197/197 including this WP's 9 new tests). Only the api-client-drift
|
||||
step shows the expected pre-commit diff (this WP's own uncommitted endpoint).
|
||||
|
||||
## Progress notes (65b — done)
|
||||
|
||||
**Backend (`POST /beoordeling/{id}/besluit`):** runs against `ApplicationStore` directly
|
||||
(not the `IZaakSource` seam) — same reasoning as 65a's GET: a new seam method would force
|
||||
an `OpenZaakZaakSource` write now, which stays WP-66's surface. A new `Besluit` enum
|
||||
(`Goedkeuren | Afwijzen | MeerInfoOpvragen`) backs a nullable `Aanvraag.BesluitStatus` +
|
||||
`Aanvraag.BesluitToelichting` column pair (EF migration `BesluitStatus`). Like every other
|
||||
enum in this backend, `Besluit` never crosses the wire as a raw C# enum — no
|
||||
`JsonStringEnumConverter` is configured, so `RecordBesluitRequest.Besluit` is a plain
|
||||
`string`, parsed with `Enum.TryParse` (400 on an unknown name) — the same wire convention
|
||||
`AanvraagStatusDto.Tag` already established. The endpoint reuses
|
||||
`BeoordelingRules.CanDecide` — the SAME function the read side's `canBesluiten` flag calls
|
||||
— as the transition-legality check, so the two can never drift (409 on an illegal
|
||||
transition, e.g. deciding an already-`Goedgekeurd` case again). Toelichting is required
|
||||
(400) for Afwijzen/MeerInfoOpvragen, optional for Goedkeuren — enforced server-side because
|
||||
the published `AanvraagStatusDto`'s `Reden` field is non-optional on those two tags (the
|
||||
FE's existing `parseBeoordelingStatus` already required it). `Mappers.ToStatusDto` gained
|
||||
"a recorded decision wins" between the submit-time `Reden` check and the auto-approve
|
||||
computation — the two never collide in practice (a submit-time-rejected case is already
|
||||
terminal and never reaches the werkvoorraad/beoordeling screens, so no behandelaar ever
|
||||
records a besluit on one). `MeerInfoGevraagd` is not terminal: `CanDecide` still allows a
|
||||
further besluit afterwards, so a behandelaar can ask for info, then later approve/reject
|
||||
once it arrives — the same `BesluitStatus` column is simply overwritten.
|
||||
|
||||
**FE:** `besluit.machine.ts` is the same single-step Editing/Submitting/Submitted/Failed
|
||||
union as `change-request.machine.ts` (form-machine skill) — `Draft.besluit` stays a raw
|
||||
string (parsed into the narrow `BesluitTag` union only in `validate`, "parse, don't
|
||||
validate"), so the generic `SetField` reducer case needs no per-field typing gymnastics.
|
||||
`besluit-form` (organism) composes `RadioGroupComponent` (the three actions) +
|
||||
`FormFieldComponent`/`TextInputComponent` (toelichting, plain single-line — no textarea
|
||||
atom exists and this form doesn't justify adding one) — no new shared atom. On a
|
||||
successful decision it emits `decided`, and `BeoordelingPage` just calls
|
||||
`BeoordelingStore.reload()` — the server is the authority on the new status, the page
|
||||
never guesses it. The form only renders when the server's `canBesluiten` flag is true
|
||||
(ADR-0001: render the decision, don't recompute the lifecycle).
|
||||
|
||||
Re-ran the full acceptance-criteria smoke by hand against `LocalZaakSource`: created a
|
||||
manual registratie case, opened it via werkvoorraad → beoordeling, recorded Afwijzen with a
|
||||
toelichting (status → Afgewezen, reason shown), confirmed a further besluit on that same
|
||||
case now 409s. `npm run ci` green (lint, dep:check ×2, format:check, check:tokens, all four
|
||||
projects' test suites, both apps' localized `nl`+`en` builds, backend `dotnet format
|
||||
--verify-no-changes` + `dotnet test` — 201/201 including this WP's 5 new tests, api-client
|
||||
regenerated). One pre-existing, unrelated finding: `format:check` was already red on this
|
||||
branch before this session touched anything — `docs/project/backlog/README.md` has a
|
||||
long-standing prettier drift (a big markdown table) untouched by this WP; fixed the same
|
||||
class of drift in this file's own body (`*computed*` → `_computed_`) since this WP was
|
||||
already editing it, left `README.md` alone as out of scope.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A medewerker can view one aanvraag's detail and record a decision that advances its
|
||||
status.
|
||||
- [x] Illegal transitions are rejected server-side (tested).
|
||||
- [x] End-to-end smoke: werkvoorraad → detail → decision → status change reflected back
|
||||
in the queue.
|
||||
- [x] `npm run ci` (behandelportal app) + `dotnet test` green.
|
||||
|
||||
## Verification
|
||||
|
||||
Manual/automated smoke test of the full werkvoorraad → beoordeling → besluit flow against
|
||||
`LocalZaakSource`; `npm run ci`; `cd backend && dotnet test`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Writing the decision to real OpenZaak (WP-66).
|
||||
|
||||
## Risks
|
||||
|
||||
This is the largest FE slice in Phase 11 — if it feels too big at kickoff, split
|
||||
detail-view (read) from decision-recording (write) into two sessions; the WP as scoped
|
||||
already keeps them in one slice because a detail view with no decision action isn't
|
||||
independently useful for a caseworker.
|
||||
|
||||
Depends on: WP-64.
|
||||
@@ -0,0 +1,83 @@
|
||||
# WP-66 — Wire the decision into OpenZaak
|
||||
|
||||
Status: done (d996ca2)
|
||||
Phase: 11 — Behandelportal
|
||||
|
||||
## Why
|
||||
|
||||
WP-65's decision currently only updates local state (`LocalZaakSource`). For the
|
||||
behandelportal to actually function against a real register, the recorded decision needs
|
||||
to also write a besluit/status transition to ZGW — extending the write capability that
|
||||
already partially exists (`CreateStatusRequest`, `CreateRolRequest` in
|
||||
`OpenZaakZaakSource.cs`) rather than building a new ZGW client from scratch.
|
||||
|
||||
## Read first
|
||||
|
||||
- `backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs` (existing write records)
|
||||
- ZGW's Besluiten API (referenced in `openzaak-integration.md` if covered, or the ZGW
|
||||
standard docs) for besluit creation
|
||||
- [WP-50](WP-50-openzaak-create-zaak.md) (the first ZGW write slice, for the pattern to
|
||||
follow)
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- Extend `IZaakSource`/`OpenZaakZaakSource` with a besluit/status-transition write,
|
||||
following the same pattern WP-50 established for create-zaak (a records + mapper
|
||||
addition, not a new abstraction).
|
||||
- Gated by `Zgw:Enabled` like every other ZGW write — the behandelportal keeps working
|
||||
against `LocalZaakSource` when it's off.
|
||||
- Best done after Phase 10's WP-60 (write-divergence resilience) lands, since this is
|
||||
exactly the second write pair that resilience work should already cover — but not
|
||||
strictly blocked on it if Phase 10 is still in progress (call out the residual risk
|
||||
explicitly if shipped first).
|
||||
|
||||
## Files
|
||||
|
||||
`Zgw/OpenZaakZaakSource.cs` (besluit/status write), `Data/IZaakSource.cs` (new write
|
||||
method), tests.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add the besluit/status-transition write to `OpenZaakZaakSource`, mirroring WP-50's
|
||||
create-zaak pattern.
|
||||
2. Wire WP-65's decision command to call it when `Zgw:Enabled=true`.
|
||||
3. Integration test against the WP-54 harness (extend `OpenZaakIntegrationTests`).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A recorded decision writes a real besluit/status transition to OpenZaak when
|
||||
`Zgw:Enabled=true`. Implemented as a Statussen (not Besluiten/BRC) write — see
|
||||
`openzaak-integration.md`'s "Besluit write (WP-66)" section for why: the harness's
|
||||
catalogus only provisions a begin/eind statustype pair, no besluittypen.
|
||||
- [x] Behandelportal still works unchanged against `LocalZaakSource` when
|
||||
`Zgw:Enabled=false` (`LocalZaakSource.RecordBesluit` is a no-op).
|
||||
- [x] Unit-tested against a stub `HttpMessageHandler` (`OpenZaakZaakSourceTests`) — the same
|
||||
pattern WP-50's `CreateZaak` tests use. Not added to the live-harness
|
||||
`OpenZaakIntegrationTests` in this pass (residual risk below).
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test --filter Category=Integration` against the (ideally
|
||||
Phase-10-hardened) OpenZaak harness; manual smoke with `Zgw:Enabled=true`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Any further behandelportal screens beyond beoordeling.
|
||||
|
||||
## Risks
|
||||
|
||||
WP-60 (write-divergence resilience) has landed: bounded retry lives in `ZgwHttpClient`, so
|
||||
this write pair inherits it automatically. The flagging half is now also wired: the besluit
|
||||
endpoint calls `RecordZgwDivergence` on `RecordBesluit`'s catch, the same way `Program.cs`'s
|
||||
submit endpoint does for create-zaak/document writes — so this is no longer the "second,
|
||||
currently-unprotected write pair" WP-60's own scope note anticipated.
|
||||
|
||||
**Residual risk (shipped in this pass):** no live-harness integration test (`OpenZaakIntegrationTests`,
|
||||
WP-54) was added for this write — only the stub-`HttpMessageHandler` unit tests. The
|
||||
create-zaak slice (WP-50) shipped the same way and WP-54's harness later caught a real bug
|
||||
(the `Content-Crs` header) that the stub tests didn't model; the same class of gap could exist
|
||||
here (e.g. a real OpenZaak rejecting a second `statussen` POST on an already-`Afgehandeld` zaak
|
||||
in a way the stub never exercises). Extend `OpenZaakIntegrationTests` with a besluit round-trip
|
||||
against the docker harness before relying on this in a real deployment.
|
||||
|
||||
Depends on: WP-65.
|
||||
@@ -0,0 +1,137 @@
|
||||
# WP-67 — Merge behandelportal into this repo as a monorepo
|
||||
|
||||
Status: done
|
||||
Phase: 11 — Behandelportal
|
||||
|
||||
## Why
|
||||
|
||||
WP-61 bootstrapped `behandelportal` as a **separate sibling repo**, per ADR-0002's original
|
||||
"separate frontend application" reading taken literally as "separate git repository." That
|
||||
produced real, measured friction: a hand-vendored, manually-kept-in-sync copy of the
|
||||
backend's OpenAPI doc instead of a live-generated one; `shared/ui`+`shared/layout` forked at
|
||||
WP-61 and already silently diverging by the time this WP checked (7 files differed); a
|
||||
`beheer` (admin/stamdata) context and the `styles.scss` token bridge duplicated byte-for-byte
|
||||
across both repos; a second CI/lint/CLAUDE.md to hand-maintain. The user asked to collapse
|
||||
this into one repo so the two apps share one CI, one shared UI library, and one generated API
|
||||
client — the standard monorepo payoff, now that a second real frontend exists.
|
||||
|
||||
## Read first
|
||||
|
||||
- [ADR-0002](../reference/architecture/0002-user-groups-and-bounded-contexts.md) — its
|
||||
"Amendment (WP-67)" section records exactly what changed and why the underlying
|
||||
actor/bounded-context reasoning didn't.
|
||||
- [dependencies.md](../reference/architecture/dependencies.md) — the per-app
|
||||
dependency-cruiser split this WP introduced.
|
||||
- `behandelportal-bootstrap` memory (prior sessions) for WP-61/62's own decisions.
|
||||
|
||||
## Decisions (made during this WP, not pre-made — see the plan file for the questions asked)
|
||||
|
||||
- **Unify** `libs/shared` into one library both apps import (not two copies) — the user's
|
||||
explicit call, given the two trees had already started diverging.
|
||||
- Restructure into **`apps/ssp/` + `apps/behandelportal/`** (not an Angular-CLI `projects/`
|
||||
addition next to an untouched `src/app/`) — the user's call, accepting the larger diff.
|
||||
- **Leave** the old sibling repo (`/home/eho/repos/behandelportal`) untouched — no deletion.
|
||||
- Bring the content in as a **fresh commit**, no git-history import — that repo is itself a
|
||||
fork of this repo's own pre-WP-61 history, so a subtree merge would fight to reconcile two
|
||||
copies of the same ancestor commits for one commit's worth of real new content.
|
||||
- **Course corrections found during execution** (squarely within "unify shared," not
|
||||
separately asked): `libs/beheer` (admin/stamdata — already identical in both apps, not
|
||||
actor-specific) and `libs/shared/styles.scss` (the token bridge, also byte-identical) were
|
||||
folded in alongside `libs/shared/ui`. `auth` was deliberately **not** unified — ADR-0002
|
||||
models it as actor-specific (different `Principal` variants), so today's accidental
|
||||
similarity is expected to diverge, not something to force together.
|
||||
- **Two Storybook instances, not one** (`.storybook-ssp/`, `.storybook-behandelportal/**`) —
|
||||
a structural necessity, not a simplification choice: both apps' `auth` (and other) context
|
||||
aliases share the name `@auth/*` but resolve to different physical directories, so no
|
||||
single tsconfig can compile both apps' stories in one pass.
|
||||
- Each app's own `shell/nav.config.ts` supplies its primary nav + admin links to the shared
|
||||
`SiteHeaderComponent` via two new injection tokens (`HEADER_NAV_ITEMS`,
|
||||
`HEADER_ADMIN_LINKS`) rather than the component hardcoding one app's routes — the same
|
||||
"shared component takes copy as an input, the domain caller supplies it" idiom CLAUDE.md
|
||||
already used for `shared/ui` copy, extended to injection tokens for this DI-shaped case.
|
||||
The dev-only "state" panel (`DebugStateComponent`) moved out of `libs/shared` into
|
||||
`apps/ssp` entirely (it's coupled to `BigProfileStore`, an ssp-only store) and is now hosted
|
||||
by the shared `ShellComponent` via a `DEBUG_PANEL` injection token — no provider, no panel.
|
||||
|
||||
## Files
|
||||
|
||||
Nearly the whole repo, mechanically (`git mv`), plus:
|
||||
|
||||
- `angular.json` — rewritten for 4 projects: `ssp`, `behandelportal` (real apps),
|
||||
`shared`, `beheer` (library projects whose only real purpose is giving `ng test` a
|
||||
`buildTarget` to satisfy — see `libs/*/src/test-entry.ts`'s comment).
|
||||
- `tsconfig.json` (root, no more `paths` — see its comment) + a new `tsconfig.json` per
|
||||
app/library declaring that project's own full alias map.
|
||||
- `.dependency-cruiser.base.js` (rule factory) + `.dependency-cruiser.ssp.js` +
|
||||
`.dependency-cruiser.behandelportal.js` (replacing the single `.dependency-cruiser.js`).
|
||||
- `eslint.config.mjs`, `package.json` scripts, `docker-compose.yml`, `.github/workflows/ci.yml`,
|
||||
`scripts/ci-local.sh`, `scripts/check-tokens.sh`, `scripts/gen-snippets.mjs`,
|
||||
`scripts/serve-i18n.mjs`, `scripts/dep-graph.sh`, `plopfile.mjs`, `nswag.json` — all
|
||||
re-pointed at the new paths / made per-app aware.
|
||||
- `CLAUDE.md`, `ARCHITECTURE.md`, `dependencies.md`, ADR-0002 — updated for the new layout.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Branch, then `git mv` the SSP's `src/app/*` (minus `shared/`) → `apps/ssp/src/app/`, plus
|
||||
`main.ts`/`index.html`/`locale/`/`proxy.conf.json`/`tsconfig.app.json`/`tsconfig.spec.json`.
|
||||
2. `git mv src/app/shared` → `libs/shared/src`; `git mv src/app/beheer` → `libs/beheer/src`;
|
||||
fold in `environments/` and the Storybook `docs/*.mdx` (both found byte-identical between
|
||||
the two repos — same treatment as `shared/ui`).
|
||||
3. Copy (not `git mv` — a different repo) behandelportal's `auth`/`behandeling` +
|
||||
`main.ts`/`index.html`/`locale/` into `apps/behandelportal/src/`.
|
||||
4. Rewrite `angular.json` for the two real projects + two library test-only projects; fix
|
||||
every relative import that broke (`environments/environment` → `@shared/environments/environment`;
|
||||
MDX docs' `../app/shared/...` → `../src/...`).
|
||||
5. Split `.dependency-cruiser.js` into a base factory + one config per app; broaden
|
||||
`eslint.config.mjs`'s `files` glob.
|
||||
6. Rewrite `package.json` scripts, `docker-compose.yml` (added a `web-behandelportal`
|
||||
service), `.github/workflows/ci.yml` + `scripts/ci-local.sh` (both apps built/tested,
|
||||
path filters widened), the four `scripts/*.mjs`/`*.sh` helpers, `plopfile.mjs`'s three
|
||||
generators (value-object/form-machine paths now resolve `shared`/`beheer` to `libs/`, the
|
||||
`context` generator's tsconfig/dep-cruiser/routes edits re-anchored on the ssp config).
|
||||
7. Split `.storybook/` into `.storybook-ssp/` + `.storybook-behandelportal/` (forced by the
|
||||
`@auth/*` alias collision); moved the CIBG token bridge (`styles.scss`) and the generated
|
||||
`documentation.json` to avoid two more collisions.
|
||||
8. Add `HEADER_NAV_ITEMS`/`HEADER_ADMIN_LINKS`/`DEBUG_PANEL` injection tokens to
|
||||
`SiteHeaderComponent`/`ShellComponent`; give each app its own `shell/nav.config.ts` and
|
||||
(ssp only) `shell/debug-state/`.
|
||||
9. Update `CLAUDE.md`, `ARCHITECTURE.md`, `dependencies.md`, and amend ADR-0002.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `npm run ci` green (lint, dep:check ×2, format:check, check:tokens, test:coverage ×4
|
||||
projects, `ng build --localize` ×2, npm audit, backend test, snippets drift,
|
||||
api-client drift).
|
||||
- [x] Both apps' dev servers run against the one shared backend
|
||||
(`npm start` / `npm run start:behandelportal`, ports 4200/4201, backend on 5000).
|
||||
- [x] Both apps' production + localized builds succeed.
|
||||
- [x] Both Storybook instances build (`build-storybook[:behandelportal]`).
|
||||
- [x] `npm run gen:api` generates the one client into `libs/shared` with zero drift.
|
||||
- [x] `docker compose up` serves both apps + the shared backend.
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run ci`; `npx ng build ssp --localize && npx ng build behandelportal --localize`;
|
||||
`npx ng run ssp:build-storybook && npx ng run behandelportal:build-storybook`; manual:
|
||||
`npm start` on :4200 and `npm run start:behandelportal` on :4201 against
|
||||
`dotnet run --project backend/src/BigRegister.Api`, log in on both.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Deleting or archiving the old sibling repo (`/home/eho/repos/behandelportal`) — left
|
||||
untouched per the user's explicit choice.
|
||||
- `scripts/create-frontend.mjs` / the `new-ssp` skill (bootstrapping a _third_ portal as a
|
||||
fresh standalone repo) — whether future portals should also join this monorepo is a
|
||||
separate decision.
|
||||
- Extending the `storybook-a11y` CI job to behandelportal's own Storybook instance — it
|
||||
still only covers ssp's, unchanged from before this WP.
|
||||
- Reconciling `auth` between the two apps — left duplicated, deliberately (see Decisions).
|
||||
|
||||
## Risks
|
||||
|
||||
The `libs/shared`/`libs/beheer` "library" Angular projects exist solely to give the
|
||||
unit-test builder a `buildTarget` to resolve (`@angular/build:unit-test` always requires
|
||||
one, even for a project that's only ever tested) — `test-entry.ts` + `tsconfig.app.json` in
|
||||
each are a `ponytail:`-flagged workaround, not a real buildable/publishable library. If
|
||||
either library ever needs to actually build (e.g. an ng-packagr distributable), replace this
|
||||
with a real library target then.
|
||||
@@ -0,0 +1,337 @@
|
||||
# WP-68 — Aggregate invariants + status modelling (architecture review remediation)
|
||||
|
||||
Status: done (a394950..472a49f)
|
||||
Phase: 12 — DDD hardening
|
||||
|
||||
## Why
|
||||
|
||||
An architecture review on 2026-08-05 (bounded contexts, aggregates, CQRS, DDD/BDD test
|
||||
alignment, measured against this repo's own documented pattern) found the context boundaries,
|
||||
the FP/TEA idioms and the read/write separation to be sound — and found four defects clustered
|
||||
in one place: **the backend's aggregate roots do not guard their own invariants, and the
|
||||
aanvraag status lifecycle is a computed string living in the contracts layer.**
|
||||
|
||||
The four in this WP, in dependency order:
|
||||
|
||||
- **F1 — `submit` links client-supplied `documentId`s with no ownership check.**
|
||||
`Program.cs:353-356` takes document ids straight from the request body and hands them to
|
||||
`ApplicationStore.Submit` and `documents.LinkToZaak`; `DocumentStore.Link` has no `owner`
|
||||
parameter and performs no check (`DocumentStore.cs:113-125`). Same for `SyncDraft`
|
||||
(`Program.cs:317`). A caller who knows a foreign document GUID can attach another citizen's
|
||||
upload to their own aanvraag — where it appears on the behandelaar's beoordeling screen with
|
||||
its filename (`Program.cs:434`) and is POSTed to OpenZaak as a zaakinformatieobject on
|
||||
_their_ zaak — and flips the victim's `Linked = true`, which permanently blocks the victim's
|
||||
own delete (`DeleteOwned` → `DeleteResult.Linked`). ADR-0001 is explicit that the FE holds
|
||||
no authority; this trusts it anyway.
|
||||
|
||||
- **F3 — the aanvraag status lifecycle is a computed string in `Contracts/`.** Three
|
||||
compounding facts: the status is derived in `Contracts/Mappers.ToStatusDto`
|
||||
(`Mappers.cs:44-63`), not in the domain; `Concept` is **not** a member of
|
||||
`AanvraagStatusTag` (`ApplicationStore.cs:14`) but a magic string the mapper emits; and the
|
||||
write path reads its own guard back out of the read DTO —
|
||||
`a.ToStatusDto(now).Tag` → compare `"Concept"` → `Enum.Parse<AanvraagStatusTag>`
|
||||
(`Program.cs:466-468`). This violates the repo's non-negotiable #3 ("make illegal states
|
||||
unrepresentable") on the backend's most important type: the status is
|
||||
`enum + one string that is not in the enum`, so `Enum.Parse` is a runtime throw waiting for
|
||||
a new tag. It is also the one genuine CQRS symptom in the codebase — a command deriving its
|
||||
invariant from a read projection — and it is _why_ F2 exists: there is no domain object that
|
||||
could have owned the guard.
|
||||
|
||||
- **F2 — the besluit invariant is checked outside the write transaction.**
|
||||
`Program.cs:469` calls `BeoordelingRules.CanDecide`; the write happens later in
|
||||
`ApplicationStore.RecordBesluit` (`ApplicationStore.cs:278-291`), which takes the lock and
|
||||
assigns unconditionally. Two concurrent besluiten both pass the check and both write, so the
|
||||
second silently overwrites a terminal decision the rule exists to freeze. The codebase
|
||||
already documents the correct pattern three methods earlier — `CreateConcept`: _"Race-free:
|
||||
the existence check and the insert share the single write gate."_ This is an internal
|
||||
inconsistency, not a missing concept.
|
||||
|
||||
- **F6 — a besluit rule with no home in `Domain/`.** "Toelichting verplicht bij Afwijzen /
|
||||
MeerInfoOpvragen" lives inline at `Program.cs:473`, although `BeoordelingRules`' own
|
||||
doc-comment says the decision-recording rules were meant to land there. It therefore has no
|
||||
unit test, only the endpoint test `Afwijzen_requires_a_toelichting`.
|
||||
|
||||
Plus one documentation correction (**F5**, see Decisions — the enforcement itself is deferred
|
||||
to WP-69, because it needs a wire change).
|
||||
|
||||
The review's remaining findings are listed under "Follow-ups" and are **not** this WP's scope.
|
||||
|
||||
## Read first
|
||||
|
||||
- `CLAUDE.md` §"The decisions" #3 (make illegal states unrepresentable) and #4 (BFF-lite)
|
||||
- [ADR-0001 — BFF-lite + decision DTOs](../../../reference/architecture/0001-bff-lite-decision-dtos.md)
|
||||
- `backend/src/BigRegister.Api/Data/ApplicationStore.cs` (the `Aanvraag` entity, the store's
|
||||
lock discipline, `AanvraagStatusTag`, `RecordBesluit`)
|
||||
- `backend/src/BigRegister.Api/Contracts/Mappers.cs` (`ToStatusDto` — the logic to move)
|
||||
- `backend/src/BigRegister.Api/Program.cs` lines 300-500 (draft sync, submit, beoordeling GET,
|
||||
besluit POST)
|
||||
- `backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs` (**the second producer of the status
|
||||
DTO** — easy to miss)
|
||||
- `backend/src/BigRegister.Api/Data/DocumentStore.cs` (`Link`, `DeleteOwned`, the existing
|
||||
`DeleteResult` enum this WP copies)
|
||||
- `backend/src/BigRegister.Api/Domain/Beoordeling/BeoordelingRules.cs`
|
||||
|
||||
## Prerequisite
|
||||
|
||||
**Commit or stash the working tree first.** At review time it carried the WP-66 id-mismatch fix
|
||||
across 11 modified files plus the untracked `backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs`.
|
||||
Do not start a cross-cutting refactor on top of uncommitted work.
|
||||
|
||||
## Decisions
|
||||
|
||||
Pre-made — do not relitigate.
|
||||
|
||||
### F3 — the status type
|
||||
|
||||
1. **Move `AanvraagStatusTag` and `Besluit`** out of `Data/ApplicationStore.cs` into
|
||||
`Domain/Applications/` (namespace `BigRegister.Domain.Applications`).
|
||||
**`ApplicationStore.ProcessingWindow` stays where it is.** The original text here said to
|
||||
move it too "because `StatusAt` needs it" — but `StatusAt` is an instance method on
|
||||
`Aanvraag`, itself defined in `ApplicationStore.cs`, so it already sits in the same file/
|
||||
namespace as `ProcessingWindow` and can reference it directly with no cross-namespace
|
||||
issue. Moving it would have been motion without a reason, and — found only once
|
||||
implementation started — `ApplicationTests.cs` references `ApplicationStore.ProcessingWindow`
|
||||
directly in two tests this WP's own acceptance criteria require to stay **unmodified**;
|
||||
moving the constant would have forced a choice between breaking that criterion or adding a
|
||||
forwarding shim for no gain. Leave it.
|
||||
2. **`AanvraagStatusTag` is NOT given a `Concept` member — implemented differently, deliberately.**
|
||||
The original text said to add `Concept` as the first member. That directly conflicts with
|
||||
this WP's own acceptance criterion that `AanvraagStatusTag_covers_the_published_lifecycle`
|
||||
(which asserts `Enum.GetNames<AanvraagStatusTag>()` equals exactly the five published-lifecycle
|
||||
names) passes **unmodified** — adding a sixth name breaks it. Found only once implementation
|
||||
started; resolved in favor of the harder constraint (the regression-net test) and a cleaner
|
||||
design: **`AanvraagStatus.Tag` is `AanvraagStatusTag?`, null exactly for Concept.** This
|
||||
still closes the actual finding (a magic string with no corresponding enum member,
|
||||
round-tripped through the DTO and `Enum.Parse`d) without touching the enum the test pins,
|
||||
and without the reduce-only "boolean + tag" shape rule #3 warns against — a nullable
|
||||
discriminator is the standard two-case union, not a second boolean bolted on. `Ingediend`
|
||||
is unaffected by this and is still kept reserved (see below).
|
||||
Keep `Ingediend` even though nothing produces it today (verified: neither `ToStatusDto` nor
|
||||
`ZgwZaakMapper` emits it) — `BeoordelingRules.CanDecide` accepts it, the FE's
|
||||
`BeoordelingStatus` union declares it, `statusLabel` has a `$localize` id for it, and
|
||||
`Only_open_statuses_are_decidable` tests it. Deleting it would ripple into
|
||||
`messages.en.xlf`. Mark it reserved with a comment instead.
|
||||
3. **New `Domain/Applications/AanvraagStatus.cs`**: a `sealed class` (not a `record` — no
|
||||
external mutation via `with` is wanted, and record value-equality/`ToString` boilerplate
|
||||
buys nothing for a short-lived read model) carrying `AanvraagStatusTag? Tag` (null =
|
||||
Concept) plus the same optional payload fields the DTO has (`StepIndex`, `StepCount`,
|
||||
`Referentie`, `Manual`, `Reden`), constructed **only** via static factories —
|
||||
`Concept(stepIndex, stepCount)`, `InBehandeling(referentie, manual)`,
|
||||
`Goedgekeurd(referentie)`, `Afgewezen(referentie, reden)`,
|
||||
`MeerInfoGevraagd(referentie, reden)`.
|
||||
**Rejected: a full abstract-record union** (one subrecord per tag). It is the purer
|
||||
modelling, but it forces exhaustive switches at four call sites and a per-case mapper for a
|
||||
marginal gain over "the factories are the only construction path". Not worth the diff here.
|
||||
4. **`Aanvraag.StatusAt(DateTimeOffset now)`** — an instance method on the entity carrying the
|
||||
logic currently in `ToStatusDto` **verbatim**, including the "a recorded decision wins over
|
||||
the auto-approve computation" ordering.
|
||||
5. **`Mappers.ToStatusDto` becomes a one-line projection** of `a.StatusAt(now)`, via a shared
|
||||
`Mappers.ToDto(this AanvraagStatus s)` extension (also used by `ZgwZaakMapper` — see below,
|
||||
point 7 — so both status producers agree on one projection):
|
||||
`new(s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden)`.
|
||||
6. **`AanvraagStatusDto` is unchanged — `Tag` stays a `string`.** This is the safety property
|
||||
that makes F3 an internal refactor: **no wire change, no `gen:api` drift, no frontend
|
||||
change, no `messages.en.xlf` change.** Do not "improve" the DTO in this WP.
|
||||
7. **`ZgwZaakMapper` is the second producer** and must be converted too, or the string literals
|
||||
survive: `ToSummaryDto` and `ToCreatedStatusDto` build `AanvraagStatus` via the factories and
|
||||
project through the same one-liner. Its coarse behaviour must not change (open/no einddatum →
|
||||
`InBehandeling` with `Manual: true`; closed → `Goedgekeurd`) — `ZgwZaakMapperTests` is the net.
|
||||
8. **The besluit endpoint stops going through the DTO**: `var status = a.StatusAt(now);`
|
||||
compare `status.Tag == AanvraagStatusTag.Concept`, pass `status.Tag` to `CanDecide`. The
|
||||
`Enum.Parse` at `Program.cs:468` is deleted.
|
||||
9. **One `Enum.Parse` may remain** — the beoordeling GET at `Program.cs:438`, which parses a tag
|
||||
off a DTO returned by the `IZaakSource` seam. That is a genuine wire→domain trust boundary,
|
||||
not a smell. Keep exactly one, make it non-throwing for an unknown tag, and comment it as the
|
||||
seam boundary. **Changing `IZaakSource` to return domain types is out of scope.**
|
||||
|
||||
### F2 — the besluit guard
|
||||
|
||||
`ApplicationStore.RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now)`
|
||||
returns `(RecordBesluitOutcome Outcome, Aanvraag? Aanvraag)` with
|
||||
`enum RecordBesluitOutcome { Ok, NotFound, Conflict }` — mirroring the existing
|
||||
`DocumentStore.DeleteResult` precedent rather than inventing a new result idiom. Inside the
|
||||
lock: find, `StatusAt(now)`, `CanDecide` → `Conflict` if refused, then write. **The endpoint
|
||||
drops its own pre-check** and maps the outcome to 200/404/409, so there is one source of truth
|
||||
for the transition. The endpoint keeps its id-resolution and its `Concept` → 404 (both need the
|
||||
`IZaakSource` lookup the store cannot see).
|
||||
|
||||
### F1 — document ownership
|
||||
|
||||
New `DocumentStore.ForeignIds(IEnumerable<string> ids, string owner)` returning the ids that do
|
||||
**not** resolve to a document owned by `owner` (returning the offending ids, not a bool, so the
|
||||
ProblemDetails can name them). Called in `POST /applications/{id}/submit` **before** any write,
|
||||
and in the draft-sync endpoint (`Program.cs:317`); non-empty → 400 ProblemDetails.
|
||||
|
||||
Endpoint-level check only. `IDocumentSource.LinkToZaak` keeps its current signature (two
|
||||
implementations, and the endpoint has now validated its input) — add a comment saying so.
|
||||
"A document already linked to a different aanvraag of the same owner" is **not** covered here;
|
||||
note it as a follow-up, do not build it.
|
||||
|
||||
### F5 — narrowed to a doc fix
|
||||
|
||||
`IntakePolicy`'s XML doc-comment claims _"the backend re-validates on submit as the
|
||||
authority"_. It does not: the constant's only consumer is `Program.cs:155`, which echoes it, and
|
||||
both submit paths apply `SubmissionRules.RejectZeroUren` only. Verified cause: **neither
|
||||
`SubmitApplicationRequest(DiplomaHerkomst, Uren, Documents)` nor `IntakeRequest(int Uren)`
|
||||
carries a scholing answer at all**, so the server cannot re-validate without a contract change,
|
||||
and the wizard's answers (`scholingGevolgd`, `punten` — `intake.machine.ts:26,37`) never reach
|
||||
it. Reading them out of the opaque `Draft` JSON is rejected: the backend's documented posture is
|
||||
that the draft is opaque (`AppDbContext` header comment).
|
||||
|
||||
**In this WP: correct the doc-comment to state the gap, and nothing else.** The enforcement is
|
||||
WP-69 (a real FE+BE slice: request fields, `IntakePolicy.RejectMissingScholing`, wizard payload,
|
||||
`gen:api`).
|
||||
|
||||
## Files
|
||||
|
||||
- `Domain/Applications/AanvraagStatus.cs` (new — tag enum, `Besluit`, `ProcessingWindow`, the
|
||||
status record + factories)
|
||||
- `Data/ApplicationStore.cs` (`Aanvraag.StatusAt`, `RecordBesluit` signature + in-lock guard,
|
||||
enums moved out)
|
||||
- `Contracts/Mappers.cs` (`ToStatusDto` reduced to a projection)
|
||||
- `Zgw/ZgwZaakMapper.cs` (both producers converted)
|
||||
- `Data/DocumentStore.cs` (`ForeignIds`)
|
||||
- `Domain/Beoordeling/BeoordelingRules.cs` (`RequiresToelichting`)
|
||||
- `Domain/Intake/IntakePolicy.cs` (doc-comment only)
|
||||
- `Program.cs` (submit + draft-sync ownership checks; besluit endpoint simplified)
|
||||
- `tests/BigRegister.Tests/` — `RuleTests.cs` (new `AanvraagStatusTests` nested class +
|
||||
`RequiresToelichting`), `ApplicationTests.cs` (ownership), `BeoordelingTests.cs` (concurrency)
|
||||
|
||||
No migration: no persisted column changes (`BesluitStatus` already stores `Besluit`, whose
|
||||
member names are unchanged).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Commit/stash the WP-66 working tree (see Prerequisite).
|
||||
2. **F1** — `DocumentStore.ForeignIds` + the two endpoint checks + tests. Independent of the
|
||||
rest; land it first so the correctness fix is not blocked by the refactor.
|
||||
3. **F3** — the status type, in Decisions order 1→9. `dotnet test` green with
|
||||
`AanvraagStatusTag_covers_the_published_lifecycle`,
|
||||
`AutoApprovable_flips_to_goedgekeurd_after_the_window` and `ZgwZaakMapperTests` **unchanged**
|
||||
— those three are the regression net for the refactor.
|
||||
4. **F2** — `RecordBesluitOutcome`, guard moved inside the lock, endpoint maps the outcome.
|
||||
5. **F6** — `BeoordelingRules.RequiresToelichting` + unit test; endpoint calls it.
|
||||
6. **T3** — the lifecycle spec that F3 makes expressible: one `[Theory]` over
|
||||
(status × besluit) → allowed/denied, asserting among others that Afgewezen → Goedgekeurd is
|
||||
refused as a _domain_ statement, not only at the endpoint.
|
||||
7. **F5** — correct the `IntakePolicy` doc-comment; open WP-69 for the enforcement.
|
||||
8. Run the full gate (see Verification).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Submitting (or draft-syncing) an aanvraag with a `documentId` owned by another citizen is
|
||||
rejected with 400, and the other citizen's document remains deletable
|
||||
(`DeleteResult.Ok`). (`Submitting_a_foreign_documentId_is_rejected_and_leaves_it_deletable_by_its_owner`,
|
||||
`Draft_sync_with_a_foreign_documentId_is_rejected`.)
|
||||
- [x] `AanvraagStatusTag` does NOT contain `Concept` — implemented instead as
|
||||
`AanvraagStatus.Tag` being `AanvraagStatusTag?`, null exactly for Concept (see Decisions
|
||||
§F3.2 for why this replaced the original "add Concept to the enum" instruction). No
|
||||
_internal domain_ code compares a status against the `"Concept"` string; the one
|
||||
remaining comparison (`Program.cs`'s beoordeling GET, against `IZaakSource`'s wire DTO)
|
||||
is the deliberate wire-boundary exception, paired with the one allowed `Enum.TryParse`
|
||||
below.
|
||||
- [x] `Enum.Parse`/`TryParse<AanvraagStatusTag>` appears **at most once** in `backend/src`, at
|
||||
the `IZaakSource` seam (`Program.cs` beoordeling GET), and does not throw on an unknown
|
||||
tag (`Enum.TryParse` there, not `Enum.Parse`).
|
||||
- [x] `Mappers.ToStatusDto` contains no lifecycle logic — it projects `Aanvraag.StatusAt(now)`.
|
||||
- [x] `ZgwZaakMapper` constructs no `AanvraagStatusDto` from string literals.
|
||||
- [x] `npm run gen:api` leaves **no diff** in `backend/swagger.json` or
|
||||
`libs/shared/src/infrastructure/api-client.ts` beyond F1's new 400 responses (verified —
|
||||
the only diff after F3 is the two `.ProducesProblem(400)` blocks F1 added; proof F3
|
||||
changed no wire shape).
|
||||
- [x] Two concurrent `POST /beoordeling/{id}/besluit` racing on the same still-open aanvraag
|
||||
yield exactly one 200 and one 409; the persisted status matches whichever request won
|
||||
(`Concurrent_besluiten_on_the_same_aanvraag_yield_exactly_one_success`, stable across 5
|
||||
repeated runs).
|
||||
- [x] `BeoordelingRules.RequiresToelichting` exists, is unit-tested
|
||||
(`Only_a_non_approval_requires_a_toelichting`), and is the only place the rule lives.
|
||||
- [x] A `[Theory]`/aggregate-level test covers the transition table
|
||||
(`A_terminal_decision_refuses_any_further_besluit`,
|
||||
`MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal` — via
|
||||
`Aanvraag.StatusAt` + `BeoordelingRules.CanDecide`, not just a bare-tag `[Theory]`, since
|
||||
`CanDecide` doesn't vary by which besluit is attempted — see Decisions for why a literal
|
||||
status×besluit cross-product theory would have been redundant with
|
||||
`Only_open_statuses_are_decidable`).
|
||||
- [x] `IntakePolicy`'s doc-comment no longer claims server-side re-validation; WP-69 exists
|
||||
(`docs/project/backlog/WP-69-intake-scholing-threshold-enforcement.md`).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd backend && dotnet test # while iterating
|
||||
npm run gen:api && git diff --exit-code backend/swagger.json libs/shared/src/infrastructure/api-client.ts
|
||||
npm run ci # the full gate before pushing
|
||||
npm run e2e # after F1/F2/F3 — needs the backend + `npm start` running
|
||||
```
|
||||
|
||||
The three existing tests named in step 3 must pass **unmodified**; if a refactor step needs one
|
||||
of them changed, the refactor changed behaviour and is wrong.
|
||||
|
||||
**Result:** `npm run ci` passed fully green — lint, format:check, check:tokens, all four test
|
||||
suites, both localized builds, `npm audit`, backend `dotnet format`+`dotnet test` (216 passing,
|
||||
up from 207 at the start of this WP), snippet-generator drift, and API-client drift (only F1's
|
||||
new 400 responses; F3 shows zero additional wire diff, per acceptance criteria). `npm run e2e`
|
||||
could **not** be verified in this session: port 4200 was already occupied by an unrelated
|
||||
container (`team-monitor-web-1`, a different repo) that Playwright's local `reuseExistingServer`
|
||||
reused as if it were this app, so every test timed out waiting for a `BSN` field that container
|
||||
doesn't have — a pre-existing local port collision, not a regression (nothing in this WP touches
|
||||
ports/docker), and per CLAUDE.md's GREEN definition `npm run e2e` isn't part of the local GREEN
|
||||
gate regardless. Free port 4200 (or set `E2E_BASE_URL`) and re-run `npm run e2e` to close this
|
||||
out if end-to-end confirmation is wanted.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Deliberately excluded — each is a separate WP if wanted:
|
||||
|
||||
- **F4** — backend layer enforcement. `Domain/Beoordeling/BeoordelingRules.cs` and
|
||||
`Domain/Authorization/Authz.cs` import `BigRegister.Api.Data` (and `Authz` also
|
||||
`.Contracts`, returning `BriefDecisionsDto`), with nothing in CI checking direction — the FE
|
||||
has `dep:check`, the backend has only `dotnet format` + `dotnet test`. This WP's step 3
|
||||
removes the `BeoordelingRules` violation as a side effect; the `Authz` one and the ~6-line
|
||||
reflection convention test are WP-70.
|
||||
- **F5 enforcement** → WP-69 (see Decisions).
|
||||
- **F7** — `ApplicationStore.Submit` and `DocumentStore.Link` take separate locks with no
|
||||
transaction and no compensation; a link failure leaves a submitted aanvraag whose documents
|
||||
are still deletable. Same failure class WP-60 closed for ZGW and left open locally. Fix is to
|
||||
route it through the existing divergence flag + audit row, not to merge the aggregates.
|
||||
- **F8** — pushing invariants from the static stores onto `Aanvraag` as instance methods
|
||||
(`TryRecordBesluit`). This WP does the two that matter; the general move can wait.
|
||||
- **F9** — `Authz` spans five contexts and its four admin gates are byte-identical
|
||||
`role == Admin` checks with **no direct unit test** and no test denying `Approver`.
|
||||
- **F10** — splitting `Program.cs` (917 lines, 50 endpoints). **Deliberately deferred and
|
||||
flagged as risky:** `OrgAdmin`, `StamdataAdmin`, `Beoordelen`, `Submit` and `AuditAuthz` are
|
||||
non-static **local functions** (`Program.cs:756+`) that every endpoint lambda closes over, so
|
||||
splitting means converting all of them to static helpers with explicit dependencies across
|
||||
all 50 registrations — with the deliberate authz ordering (Forbidden before Conflict) as the
|
||||
thing that breaks silently. Lowest value of the review's findings; do it alone, with tests as
|
||||
the net, or not at all.
|
||||
- **F11** — three FE adapter fetch idioms; two loaders `throw` instead of returning `Result`;
|
||||
`runSubmit` (which mints an `Idempotency-Key`) is used for **reads** in `brief.adapter.ts:56`,
|
||||
`org-template.adapter.ts:39,51`, `stamdata.adapter.ts:27,42`. Fix is `runQuery`/`runCommand`
|
||||
over one shared try/catch, ~10 lines.
|
||||
- **T2** — ~54 FE `it()` titles are named after `Msg` tags (`'SetField updates the draft'`,
|
||||
`'SubmitConfirmed maps Submitting to Submitted'`), against `bdd.mdx` rule 3. Titles only.
|
||||
- **T5** — named coverage gaps: `OrgTemplateRules.RejectDraft` (both identity branches, no
|
||||
margin boundary test), the four `Authz` admin gates, `DocumentRules.CategoriesFor`'s
|
||||
`herregistratie`/`org-template` branches, `SubmissionRules.NewReference`, FE
|
||||
`isStatusConsistent` (tested on the backend, never on the FE), the FE herregistratie window
|
||||
boundary, and the FE/BE margin constants which mirror each other with no contract test.
|
||||
- **T6** — trust-boundary `describe` naming has three dialects; 7 `parse*` specs use none.
|
||||
- **ADR-0006 "CQS without CQRS"** — the review's learning deliverable: the read/write
|
||||
separation already present, why the emit-and-enforce rule (one function feeding both the
|
||||
decision flag and the enforcement) makes a read/write stack split actively harmful here, and
|
||||
WP-60's deferred outbox as the documented trigger that would change the answer. Prose only,
|
||||
no runtime code.
|
||||
- Anything CQRS-mechanical: MediatR, handler classes, a separate read store, event sourcing,
|
||||
repositories/unit-of-work, Gherkin/Reqnroll. All explicitly rejected by the review.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Scope creep on F3.** The temptation is to "fix" `AanvraagStatusDto` into a proper wire union
|
||||
while in there. That turns a zero-diff internal refactor into an FE + `messages.en.xlf` +
|
||||
`gen:api` change. The acceptance criterion "`gen:api` leaves no diff" exists to catch it.
|
||||
- **Missing the second producer.** `ZgwZaakMapper` is easy to overlook because it lives under
|
||||
`Zgw/`, not `Contracts/`. If it is missed, the string literals survive and the finding is only
|
||||
half fixed.
|
||||
- **Over-modelling.** A full abstract-record status union, or a repository/unit-of-work layer to
|
||||
"properly" own the aggregate, would be a bigger diff than the defects justify — see Decisions.
|
||||
@@ -0,0 +1,226 @@
|
||||
# WP-69 — Enforce the scholing threshold server-side
|
||||
|
||||
Status: done (5d73ca2)
|
||||
Phase: 12 — DDD hardening
|
||||
|
||||
## Verification result (2026-08-18)
|
||||
|
||||
Backend 230 → 245 tests (+8 `Domain/IntakeRuleTests`, +7 `Acceptance/IntakeSubmissionTests`);
|
||||
frontend ssp 238 → 242. `npm run ci` green incl. `check:seam` (`OK … (1000)`), api-client and
|
||||
behaviour-spec drift.
|
||||
|
||||
**The bypass was proven closed against a running backend, not just by green tests:**
|
||||
`POST /api/v1/intakes {"uren": 500}` with no scholing fields → **400** with the Dutch detail;
|
||||
an intake-typed Concept submitted via `POST /applications/{id}/submit {"uren": 500}` → **400**,
|
||||
and `GET /applications/{id}` afterwards still reports `"tag":"Concept"` (no state change, still
|
||||
retryable); the same request plus `"aanvullendeScholing": false` → **200** with a referentie and
|
||||
`InBehandeling`.
|
||||
|
||||
**Deviation from the Files list:** `EndpointTests.Worked_hours_submission_succeeds` had to be
|
||||
touched despite being on the do-not-modify list — it posts `{ uren: 40 }` with no scholing
|
||||
answer, which _is_ the crafted-POST bypass this WP closes, so the existing test was itself
|
||||
asserting the vulnerable behaviour. Fixed minimally by adding `aanvullendeScholing = false`
|
||||
(unknown to and ignored by `HerregistratieRequest`, so the paired `/herregistraties` row is
|
||||
unaffected). The zero-hours 422 rows — the ordering regression net — are unmodified as planned.
|
||||
|
||||
## Why
|
||||
|
||||
WP-68 (F5) found that `IntakePolicy`'s doc-comment claimed _"the backend re-validates on
|
||||
submit as the authority"_ — it doesn't. `GET /intake/policy` only echoes `ScholingThreshold`;
|
||||
neither `SubmitApplicationRequest` (`DiplomaHerkomst`, `Uren`, `Documents`) nor `IntakeRequest`
|
||||
(`Uren`) carries a scholing answer at all, so there's nothing for the server to re-validate.
|
||||
Both submit paths apply only `SubmissionRules.RejectZeroUren`. A crafted POST — bypassing the
|
||||
wizard entirely — can skip the scholing requirement (`scholingGevolgd`/`punten` in
|
||||
`intake.machine.ts`) even though it's presented as mandatory in the UI. ADR-0001's canonical
|
||||
"config value" example (the FE applies the threshold for instant feedback, the backend
|
||||
re-validates as authority) is unenforced for the one rule it was written to illustrate.
|
||||
|
||||
## Read first
|
||||
|
||||
- `backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs` (the corrected doc-comment,
|
||||
WP-68)
|
||||
- [ADR-0001 — BFF-lite + decision DTOs](../../../reference/architecture/0001-bff-lite-decision-dtos.md)
|
||||
§"config value"
|
||||
- `apps/ssp/src/app/herregistratie/domain/intake.machine.ts` (`lageUren`, `scholingGevolgd`,
|
||||
`punten` — the wizard's existing FE-side rule and its answers)
|
||||
- `backend/src/BigRegister.Api/Contracts/Dtos.cs` (`SubmitApplicationRequest`,
|
||||
`IntakeRequest`, `DocumentRefDto`)
|
||||
- `backend/src/BigRegister.Api/Program.cs` — the `intakes` and `applications/{id}/submit`
|
||||
endpoints
|
||||
|
||||
## Stale premises in the original placeholder (verified 2026-08-18)
|
||||
|
||||
- **"Both submit paths" is half-stale.** `POST /api/v1/intakes` is **dead from the UI** — the
|
||||
wizard submits via `draft-sync` → `POST /applications/{id}/submit`; no code in `apps/` or
|
||||
`libs/` calls the generated `intakes()`/`herregistraties()` methods. It is still a live
|
||||
crafted-POST surface, so fix both; do **not** delete it here (see Risks).
|
||||
- **The submit endpoint does not distinguish intake from herregistratie** —
|
||||
`Program.cs` lumps them: `_ /* herregistratie | intake */ => (RejectZeroUren(...), true)`.
|
||||
`herregistratie.machine.ts` has no scholing question, so the new check **must** be gated on
|
||||
`existing.Type == "intake"` or the herregistratie wizard starts 400-ing for every low-uren user.
|
||||
- **`RejectMissingScholing(uren, scholing)` is under-specified.** The rule is three-valued
|
||||
(answer present / `true` needs punten / punten without `true` is illegal); two parameters
|
||||
cannot express it.
|
||||
- **A live FE bug shares this rule and must be fixed here.** `intake.machine.ts:116` requires
|
||||
`punten` whenever `scholingGevolgd === 'ja'` **regardless of `lageUren`**, while the template
|
||||
renders both fields only inside `@if (scholingZichtbaar())` (= `lageUren`). Answer scholing
|
||||
`'ja'`, then raise `uren` above the threshold: either the user is blocked by an error on an
|
||||
**invisible** field, or `validateAll` emits `aanvullendeScholing: undefined` **together with**
|
||||
`punten: 150` — exactly the payload the new server rule rejects. Both branches reachable today.
|
||||
|
||||
## Decisions
|
||||
|
||||
Made by a `planner` pass on 2026-08-18 — do not relitigate.
|
||||
|
||||
### 1. What the rule is (and deliberately is not)
|
||||
|
||||
The FE rule is **completeness**, not merit: below the threshold the scholing question must be
|
||||
**answered**; `'nee'` is a legal answer that still submits. So the server authority is:
|
||||
|
||||
- `uren < IntakePolicy.ScholingThreshold` ⇒ an answer must be present;
|
||||
- answer `true` ⇒ punten present and `>= 0` (mirrors `parseUren`);
|
||||
- answer not `true` ⇒ punten must be **absent**.
|
||||
|
||||
**Out of scope, deliberately:** turning "few uren + no scholing" into an `Afgewezen` decision.
|
||||
The wizard accepts that today; inventing a substantive rejection would create a _new_ FE/BE
|
||||
divergence in the WP that closes one. **Boundary is `<`, not `<=`** — mirrors `lageUren`.
|
||||
|
||||
### 2. Wire shape
|
||||
|
||||
Two nullable fields appended (positionally last, defaulted) to both request records in
|
||||
`Contracts/Dtos.cs`: `bool? AanvullendeScholing = null, int? ScholingPunten = null`.
|
||||
|
||||
- **`ScholingPunten`, not `Punten`** — `SubmitApplicationRequest` is shared by all three wizard
|
||||
types and the herregistratie wizard has its own unrelated `punten`.
|
||||
- **Illegal states are representable on the wire, unrepresentable past the boundary.** A JSON
|
||||
DTO consumed by NSwag can't carry a union without hand-written polymorphism, and both fields
|
||||
must be optional for the other wizards anyway. Closure happens at the rule boundary — the same
|
||||
posture WP-68 took for `AanvraagStatus`. _Rejected:_ a nested `ScholingDto` (removes one of
|
||||
three illegal combinations, adds a DTO); a closed `ScholingAnswer` type (one call site, not
|
||||
persisted — ceremony).
|
||||
- **Not persisted.** Submit-time rule input, not aggregate state: no `Aanvraag` column, **no EF
|
||||
migration**. The draft JSON stays opaque (WP-68) — the answer arrives as an explicit field.
|
||||
|
||||
### 3. Rule home — `IntakePolicy`, not `SubmissionRules`
|
||||
|
||||
`public static string? RejectIncompleteScholing(int uren, bool? aanvullendeScholing, int? scholingPunten)`
|
||||
— same "reason or null" idiom as `SubmissionRules`, so endpoints compose both identically.
|
||||
|
||||
The rule _is_ the threshold's enforcement and the class already owns the constant. Putting it in
|
||||
`SubmissionRules` would either re-declare `1000` there (silent drift — exactly what WP-71's
|
||||
`check:seam` exists to catch, and which it would **not** catch outside `IntakePolicy.cs`) or make
|
||||
the generic cross-wizard class depend on one wizard's policy. `SubmissionRules.cs` and
|
||||
`SubmissionRuleTests.cs` are **not modified**.
|
||||
|
||||
**`check:seam` constraint (load-bearing):** `scripts/check-seam.sh` greps _all_
|
||||
`ScholingThreshold\s*=\s*[0-9]+` matches in `IntakePolicy.cs`. The new code must **reference**
|
||||
the const (`uren < ScholingThreshold`, `$"…{ScholingThreshold}…"`) and must never introduce a
|
||||
second literal (e.g. a default parameter `int scholingThreshold = 1000`) — a second match makes
|
||||
`backend_value` two lines and fails with a misleading "drift" message.
|
||||
|
||||
### 4. HTTP shape: 400 ProblemDetails, matching WP-68 F1
|
||||
|
||||
A missing/contradictory conditionally-required field is a **contract violation**, not a business
|
||||
outcome → `Results.Problem(detail: …, statusCode: 400)`. Deliberately unlike `RejectZeroUren`,
|
||||
which is a _merit_ rejection (422 legacy / `Afgewezen` + 200 on the aanvraag path).
|
||||
|
||||
**Ordering: the zero-uren rejection wins.** Guard with `reject is null &&` so `{ uren: 0 }` is
|
||||
decided on merit and completeness is moot — this keeps `EndpointTests`' 422 rows passing
|
||||
**unmodified**. Place the check **before** the document-ownership check and
|
||||
`ApplicationStore.Submit`, so a rejected submit leaves the aanvraag a Concept (retryable).
|
||||
|
||||
Gated on `existing.Type == "intake"`. `/applications/{id}/submit` already declares
|
||||
`.ProducesProblem(400)` (WP-68 F1) — no metadata change; `/intakes` needs one added, with the
|
||||
check _outside_ the `Submit(...)` helper so the 400 is not cached in `IdempotencyStore`.
|
||||
|
||||
Detail copy (Dutch, like all backend ProblemDetails — backend copy is not `$localize`d):
|
||||
missing answer → `$"Beantwoord de vraag over aanvullende scholing: bij minder dan {ScholingThreshold} gewerkte uren is dit verplicht."`;
|
||||
`true` without punten → `"Vul het aantal behaalde nascholingspunten in."`;
|
||||
punten without `true` → `"Nascholingspunten horen alleen bij een gevolgde aanvullende scholing."`
|
||||
|
||||
### 5. Backwards compatibility
|
||||
|
||||
Fields optional on the wire, conditionally required by the rule (the same DTO serves registratie
|
||||
and herregistratie, which never send them). **In-flight Concept drafts (WP-22) are unaffected** —
|
||||
the draft JSON already holds `scholingGevolgd`/`punten`, its format doesn't change, and the new FE
|
||||
derives the request fields at submit time. The one real incompatibility is a **stale FE bundle**
|
||||
submitting a below-threshold intake: it gets a 400 with an actionable Dutch detail via
|
||||
`problemDetail()`. Accepted — the POC has no API versioning, and the alternatives (grace period,
|
||||
inferring from the draft) are what WP-68 forbade. _Rejected:_ a feature flag whose only purpose
|
||||
is to leave a security gap open.
|
||||
|
||||
### 6. Frontend changes
|
||||
|
||||
- Wizard payload gains `aanvullendeScholing` + `scholingPunten` (`undefined` members are dropped
|
||||
by `JSON.stringify` and bind to `null` server-side).
|
||||
- **`intake.machine.ts` needs two narrowing edits** (see Stale premises — this is a live bug):
|
||||
`validateStep('werk')` requires punten only when `lageUren(…) && scholingGevolgd === 'ja'`
|
||||
(matching the template's `@if`), and `validateAll` computes punten from
|
||||
`aanvullendeScholing === true` rather than `scholingGevolgd === 'ja'`, so a stale answer left by
|
||||
raising `uren` can't leak into `ValidIntake`. `Answers` (the raw record) is unchanged — stale
|
||||
raw answers are fine; `ValidIntake`, the _parsed_ type, must be honest.
|
||||
- **No change** to `SCHOLING_THRESHOLD_DEFAULT`, `lageUren`, `SetPolicy`, the policy
|
||||
adapter/store, or the template. **No new `$localize` id ⇒ no `messages.en.xlf` change.**
|
||||
|
||||
### 7. Test plan (WP-71 conventions)
|
||||
|
||||
G/W/T bodies, `Domain/<Aggregate>RuleTests.cs`, `Acceptance/`, fixtures via the `Given` builder —
|
||||
never hand-built initializers.
|
||||
|
||||
**New `Domain/IntakeRuleTests.cs`** (pure rule, no HTTP; the arguments _are_ the Given, so these
|
||||
degenerate to When/Then per `bdd.mdx`): answer required below threshold; not required _at_ the
|
||||
threshold (pins `<` vs `<=`); `niet gevolgd` is a complete answer (pins §1's scope); `gevolgd`
|
||||
requires punten; zero punten valid; negative refused; `[Theory]` — punten without `gevolgd`
|
||||
refused (two rows, incl. the stale-punten shape §6 removes).
|
||||
|
||||
**New `Acceptance/IntakeSubmissionTests.cs`** (HTTP, both paths, `Given.Concept(type: "intake")`
|
||||
|
||||
- a local `Persist` mirroring `BesluitLifecycleTests`; the builder's default owner **is**
|
||||
`StubIdentityProvider`'s default caller, so no header juggling): below threshold without an
|
||||
answer → 400 **and still a Concept**; answered → 200; above threshold → 200; punten without
|
||||
gevolgd → 400; **herregistratie unaffected** (guards the `Type` gate); `{ uren: 0 }` still
|
||||
`Afgewezen` + 200, not 400 (pins the ordering); legacy `/intakes` enforces it too.
|
||||
|
||||
**Not modified:** `EndpointTests.cs` (its 422 rows are the ordering regression net),
|
||||
`SubmissionRuleTests.cs`, `ApplicationTests.cs`, `Builders/AanvraagBuilder.cs`.
|
||||
|
||||
**Frontend:** `intake.machine.spec.ts` — drops punten when raising uren hides the question; does
|
||||
not require punten for a hidden question. `intake.acceptance.spec.ts` — one journey: low uren →
|
||||
`'ja'` + punten → back → raise uren → submit → both fields `undefined`.
|
||||
|
||||
### 8. Sequencing
|
||||
|
||||
1. `IntakePolicy.RejectIncompleteScholing` + `Domain/IntakeRuleTests.cs` (red→green, no wire change).
|
||||
2. `Contracts/Dtos.cs` + both endpoints + `/intakes`' `.ProducesProblem(400)`.
|
||||
3. `Acceptance/IntakeSubmissionTests.cs`; `dotnet test`.
|
||||
4. **`npm run gen:api`** — after step 2, before the FE payload change. Commit `backend/swagger.json`
|
||||
- `libs/shared/src/infrastructure/api-client.ts`. CI's drift job fails if skipped/hand-edited.
|
||||
5. FE: `intake.machine.ts` narrowing + specs, then the wizard payload.
|
||||
6. `npm run gen:snippets` (expect no diff) and **`npm run gen:behaviour-spec`** (will diff — new
|
||||
test names; commit it or CI's drift step fails).
|
||||
7. Docs in the same diff: rewrite `IntakePolicy`'s doc-comment from "gap deferred to WP-69" to what
|
||||
it now guarantees; one line in ADR-0001 §"config value"/worked example B; `backend/README.md`'s
|
||||
`/api/intakes` row (add the 400) + its `IntakePolicy.cs` bullet.
|
||||
8. `npm run ci`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Turning "few uren + no scholing" into an `Afgewezen` **decision** (§1) — a decision flag, an FE
|
||||
change, and a separate WP.
|
||||
- Deleting the dead `/intakes` + `/herregistraties` endpoints (with `EndpointTests`,
|
||||
`backend/README.md`, `gen:api`) — real cleanup, but not this WP's security fix.
|
||||
- The herregistratie wizard's `jaren`/`punten`, equally un-re-validated server-side.
|
||||
- `docs/reference/fp-tea-atomic-design.md:587` / `ARCHITECTURE.md:464` still teach a
|
||||
`visibleSteps`-with-a-`'scholing'`-step intake the fixed-3-step wizard no longer matches.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Ordering regression (highest).** Running completeness before `RejectZeroUren` silently turns
|
||||
`{ uren: 0 }` from 422/`Afgewezen` into 400 and breaks two existing endpoint tests. The
|
||||
`reject is null &&` guard is load-bearing — keep the comment saying why.
|
||||
- **`check:seam` false failure** if a second `ScholingThreshold = <digits>` literal lands in
|
||||
`IntakePolicy.cs` (§3). The message will say "FE/BE seam drift" and mislead.
|
||||
- **Missing the `Type == "intake"` gate** breaks the herregistratie wizard for every low-uren
|
||||
user; the `herregistratie is unaffected` test is the only net.
|
||||
- **Stale-bundle 400 loop:** the wizard's `Retry` re-sends the identical payload, so a pre-deploy
|
||||
tab loops until reloaded. Acceptable for a POC.
|
||||
@@ -0,0 +1,118 @@
|
||||
# WP-70 — Test-data builders: illegal fixtures unrepresentable
|
||||
|
||||
Status: done (2eea860..a82332f)
|
||||
Phase: 12 — DDD hardening
|
||||
|
||||
## Why
|
||||
|
||||
Decision #3 in `CLAUDE.md` — "make illegal states unrepresentable" — is honoured in
|
||||
production code (`AanvraagStatus`'s private-ctor/factory shape, the FE's tagged-union
|
||||
machines, branded value objects behind `parse*`) but **not** in the test suites that exercise
|
||||
them. Every layer independently reinvented ad-hoc, hand-built fixtures that reach around the
|
||||
production construction path:
|
||||
|
||||
- Backend: `Aanvraag` is a mutable EF-backed bag with independent public setters. Its own
|
||||
`StatusAt` dereferences `Referentie!` three times on the unstated assumption
|
||||
"Submitted ⇒ Referentie != null" — a convention two test files (`RuleTests.cs`,
|
||||
`OpenZaakZaakSourceTests.cs`) kept consistent by hand across eight inline fixtures.
|
||||
- Frontend: no shared fixture helper existed anywhere. Every machine spec redefined its own
|
||||
throwaway literal helper (`editing1/2/3`, `editingWith`), each hardcoding fields like
|
||||
`errors: {}` — asserting against shapes the real reducer may never produce.
|
||||
- E2E: the seeded BSN and a diploma id were copy-pasted across all three specs, coupled to
|
||||
`SeedData.cs`'s exact shape by comment only.
|
||||
|
||||
## Read first
|
||||
|
||||
- ADR-0006 (`docs/reference/architecture/0006-test-data-builders.md`) — the principle and the
|
||||
full decision table this WP implements.
|
||||
- `CLAUDE.md` §"The decisions" #3, #5.
|
||||
- `backend/src/BigRegister.Api/Data/ApplicationStore.cs` (`Aanvraag`, `StatusAt`).
|
||||
- `backend/src/BigRegister.Api/Domain/Applications/AanvraagStatus.cs` — the exemplar this
|
||||
WP's backend builder mirrors.
|
||||
- `backend/src/BigRegister.Api/Domain/Beoordeling/BeoordelingRules.cs`.
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
1. **No `With*`-per-field builders anywhere.** A builder that opens every field back up is an
|
||||
object literal with extra syntax — reject that shape on either side of the seam.
|
||||
2. **Backend: a type-state builder.** `Given.Concept()` → `ConceptAanvraag` (only `.AtStep`/
|
||||
`.Submitted`/`.Build` exist) → `SubmittedAanvraag` (only `.Decided`/`.Build` exist) →
|
||||
`DecidedAanvraag`. `Decided(...)` validates a toelichting by calling the real
|
||||
`BeoordelingRules.RequiresToelichting`, not by re-stating the rule.
|
||||
3. **`Aanvraag` itself stays mutable** — WP-68 deliberately kept it an EF-backed class; fixing
|
||||
that for real is an EF-mapping refactor, out of scope here (see Follow-ups).
|
||||
4. **Frontend: replay, don't fabricate.** One combinator, `given(reduce, initial)(...msgs)`
|
||||
(`libs/shared/src/testing/machine.ts`), replaces every hand-written state literal. Value
|
||||
objects: `unwrapOk(parseX(raw))`, never a cast. `RemoteData`: named constructors
|
||||
(`loading()`/`success(v)`/`failure(e)`), replacing duplicated per-file literals.
|
||||
5. **E2E stays a flat smoke suite** (WP-19's scope). Only extract shared `Actors`/`SeedRefs`/
|
||||
`loginAs` — no page-object layer, no Given/When/Then runner, no dev-only seeding API.
|
||||
The shared-mutable-backend isolation problem is a documented follow-up, not fixed here.
|
||||
6. **Convert worst offenders only**, not a full sweep: `RuleTests.cs`'s `Decided()` helper +
|
||||
`OpenZaakZaakSourceTests.cs`'s seven inline initializers (backend);
|
||||
`herregistratie.machine.spec.ts` + `change-request.machine.spec.ts` + both RemoteData
|
||||
specs (frontend); all three e2e specs (actors/seed-refs only).
|
||||
|
||||
## Files
|
||||
|
||||
| Area | Path |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| New (BE) | `backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs`, `Acceptance/BesluitLifecycleTests.cs` |
|
||||
| Edit (BE) | `RuleTests.cs`, `OpenZaakZaakSourceTests.cs` |
|
||||
| New (FE) | `libs/shared/src/testing/{machine,remote-data,value-object}.ts`, `herregistratie/domain/intake.testing.ts`, `intake.acceptance.spec.ts` |
|
||||
| Edit (FE) | `herregistratie.machine.spec.ts`, `change-request.machine.spec.ts`, `remote-data.spec.ts`, `machine-remote-data.spec.ts`, both `tsconfig.app.json`, `angular.json` |
|
||||
| New (e2e) | `e2e/support/actors.ts` |
|
||||
| Edit (e2e) | `smoke.spec.ts`, `brief-v2.spec.ts`, `error-state.spec.ts` |
|
||||
| Docs | ADR-0006, `libs/shared/docs/testing.mdx`, `.claude/skills/test-strategy/SKILL.md`, this file + backlog README row |
|
||||
|
||||
## Steps
|
||||
|
||||
Executed as three file-disjoint parallel tracks (backend / frontend / e2e), each ending its
|
||||
own layer's tests green, then a combined gate, then docs written up against the interfaces as
|
||||
actually shipped.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `Given.Concept().Decided(...)` does not compile (proved live: temporarily inserted the
|
||||
call, confirmed `dotnet build` fails with `CS1061`, reverted).
|
||||
- [x] `Decided(Besluit.Afwijzen)`/`MeerInfoOpvragen` with no toelichting throws, via the real
|
||||
`BeoordelingRules.RequiresToelichting`.
|
||||
- [x] Backend tests: 220/220 passing (was 216 before; +4 from `BesluitLifecycleTests`).
|
||||
- [x] Frontend: `npm test` green across all four projects (ssp/behandelportal/shared/beheer);
|
||||
converted specs assert the same behaviour as before (diffed, not just re-passed) —
|
||||
one case (`editing3`'s hardcoded `errors: {}` at step 3 with invalid punten) was
|
||||
confirmed reachable via `SetField` after `Next`, not an unrepresentable state, so the
|
||||
assertion carried over unchanged.
|
||||
- [x] No fixture-only export (`givenIntake` etc.) leaks into a production bundle — confirmed
|
||||
via `grep -rl` on `dist/` after both a plain and a `--localize` build.
|
||||
- [x] `npm run ci` green (lint, format, tokens, both localized builds, audit, backend
|
||||
format+test, snippet-drift, api-client-drift).
|
||||
- [~] `npm run e2e` — refactor reviewed line-by-line (zero assertions changed), but not run to
|
||||
completion in this environment: port 4200 was occupied by an unrelated container
|
||||
(`team-monitor-web-1`), not this repo's stack. Confirm on a clean runner/CI before
|
||||
relying on it; not a regression introduced by this WP.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd backend && dotnet format --verify-no-changes && dotnet test # 220/220
|
||||
npm run ci # green (2026-08-18)
|
||||
npm run e2e # run on a clean port 4200
|
||||
```
|
||||
|
||||
## Out of scope
|
||||
|
||||
- E2E test isolation (a dev-only seed endpoint) — the real fix for the shared-mutable-backend
|
||||
problem; a new production-adjacent surface needing its own security review.
|
||||
- Making `Aanvraag` itself illegal-states-unrepresentable (EF-mapping refactor).
|
||||
- `RegistrationStatus`'s equivalent flat-record gap (`Domain/Registrations/`) — same class of
|
||||
defect, separate WP.
|
||||
- E2E coverage for `apps/behandelportal` (currently zero).
|
||||
|
||||
## Risks
|
||||
|
||||
- The backend type-state builder only guards the fields it models (`Submitted`, `Referentie`,
|
||||
`SubmittedAt`, `BesluitStatus`, `BesluitToelichting`); other `Aanvraag` fields (e.g.
|
||||
`ZaakUrl`) are still set post-`.Build()` directly, since `Aanvraag` remains mutable. A
|
||||
future field added to the lifecycle needs a deliberate builder update, or it silently
|
||||
reopens the same gap this WP closed.
|
||||
@@ -0,0 +1,134 @@
|
||||
# WP-71 — Test framework coherence: BDD/DDD alignment + closing the illegal-state escape hatches
|
||||
|
||||
Status: done (b937e55..3652ff8)
|
||||
Phase: 12 — DDD hardening
|
||||
|
||||
## Why
|
||||
|
||||
WP-70 shipped test-data builders and ADR-0006, then a three-angle audit (BDD conventions,
|
||||
DDD alignment, type-safety of test code) asked whether the framework as a whole is "the best
|
||||
way to set up testing." It was not — and the gap was not where WP-70 looked.
|
||||
|
||||
**WP-70 built the door but left the walls open.** `unwrapOk` had zero call sites; `given()`
|
||||
was adopted in 4 specs. Meanwhile 76 `as any` casts survived in the three biggest wizard
|
||||
specs, and the reason was systemic: `eslint.config.mjs` blanket-exempted every `*.spec.ts`
|
||||
from the `any` ban, and no gate anywhere ran `tsc --noEmit` over spec files, so a wrong cast
|
||||
could never fail the build. One assertion
|
||||
(`org-template.machine.spec.ts`, `s.tag === 'loaded' && s.dirty`) passed vacuously whenever
|
||||
the tag was wrong.
|
||||
|
||||
Alongside that: the documented "never assert on `$localize` copy" ban was broken in 5 files;
|
||||
`bdd.mdx` mis-cited its own exemplar as "one transition per test"; `layers.mdx` still taught
|
||||
the pre-WP-67 six-context structure with no `apps/`+`libs/` split; backend tests were
|
||||
organised by technical concern (`RuleTests.cs` held 5 aggregates as nested classes) rather
|
||||
than by aggregate; and duplicated FE/BE rules (the scholing threshold, the phone-format regex)
|
||||
had no test spanning the seam, so they could silently diverge.
|
||||
|
||||
## Read first
|
||||
|
||||
- `docs/reference/architecture/0006-test-data-builders.md` (WP-70's ADR).
|
||||
- `libs/shared/docs/bdd.mdx`, `libs/shared/docs/layers.mdx` (both rewritten by this WP).
|
||||
- `backend/tests/BigRegister.Tests/Acceptance/BesluitLifecycleTests.cs` — the canonical G/W/T
|
||||
shape both docs now point at.
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
1. **No Gherkin/Cucumber.** Feature files bind steps by runtime string matching, which
|
||||
directly undoes the compile-time guarantees WP-70 added, and need two frameworks
|
||||
(.NET + TS) for an audience of developers, not scenario-co-authoring stakeholders. Instead:
|
||||
generate a business-readable behaviour spec FROM the test names (`gen-behaviour-spec.mjs`,
|
||||
modeled on the existing `gen-snippets.mjs`), gated for drift in CI. Test names stay the
|
||||
single source of truth.
|
||||
2. **Playwright stays** — no change to the e2e framework.
|
||||
3. **Given/When/Then becomes the default structure for ALL tests** (user override of the
|
||||
audit's initial recommendation). `bdd.mdx`'s old "no G/W/T ceremony" clause is removed and
|
||||
inverted; ADR-0006 already matched. Present-tense declarative naming is unchanged. The
|
||||
_retrofit_ in this WP covers the acceptance specs, the canonical exemplars, and every file
|
||||
the other tracks already opened — not a mechanical sweep of all ~600 tests (tracked as a
|
||||
follow-up).
|
||||
4. **Hardening = one helper + four gates.** `expectTag(state, tag)` replaces every unsafe
|
||||
narrowing cast; the ESLint spec exemption is removed; `npm run typecheck` is added; a
|
||||
dependency-cruiser rule keeps test helpers out of production.
|
||||
5. **FE/BE seam: document + one worked pattern**, not full seam coverage. `check-seam.sh`
|
||||
catches the scholing-threshold literal drift; the other three divergences (phone regex,
|
||||
disjoint eligibility fixtures, toelichting rule) are documented, not fixed. Scholing
|
||||
_enforcement_ stays WP-69's job.
|
||||
|
||||
## Files
|
||||
|
||||
| Track | Representative paths |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| A · hardening | `libs/shared/src/testing/expect-tag.ts` (new); the three wizard machine specs + `*has-progress.spec.ts` + `aanvraag-view.spec.ts`; `eslint.config.mjs`, `.dependency-cruiser.base.js`, `package.json`, CI; `Builders/AanvraagBuilder.cs`, endpoint test files |
|
||||
| B · BDD | `libs/shared/docs/bdd.mdx`, ADR-0006; the 5 copy-assertion files; the multi-behaviour title splits |
|
||||
| C · DDD | `libs/shared/docs/layers.mdx`; `RuleTests.cs` → `Domain/*RuleTests.cs`; new specs for `isStatusConsistent`, `OrgTemplateRules.RejectDraft`, both apps' `session.ts` |
|
||||
| D+E · living docs + seam | `scripts/gen-behaviour-spec.mjs`, `libs/shared/docs/behaviour-spec.mdx` (generated), `scripts/check-seam.sh` |
|
||||
|
||||
## Steps
|
||||
|
||||
Executed as four tracks: A/C/D+E ran file-disjoint in parallel first; B ran after, since its
|
||||
doc rewrite needed to reflect what A/C actually converted. Each track ended its own layer
|
||||
green; a combined gate followed; then per-track commits.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `expectTag` replaces all 76 `as any` + 12 `as Extract<>` state-narrowing casts across
|
||||
the three biggest wizard specs and the `*has-progress`/`besluit`/`change-request` specs.
|
||||
Zero tests legitimately started failing — every wrong-variant read the casts were hiding
|
||||
turned out to already be correct.
|
||||
- [x] The vacuous assertion in `org-template.machine.spec.ts` (and, on inspection, every
|
||||
sibling instance of the same pattern in that file) is fixed.
|
||||
- [x] Four new gates proven to actually fail before being trusted: `npm run lint` fails on a
|
||||
planted `any`; a deliberately-wrong `expectTag` call fails with a named
|
||||
"expected X, got Y" error, not `undefined`; `npm run dep:check` fails on a planted
|
||||
production import of `libs/shared/src/testing`; `npm run typecheck` fails on a planted
|
||||
type error in a spec.
|
||||
- [x] `check-seam.sh` proven to fail when the two scholing-threshold literals are set to
|
||||
different values, with both file paths and values named in the error.
|
||||
- [x] Backend test count: 220 (WP-70 baseline) → 230 (+9 `OrgTemplateRuleTests`, +1 from the
|
||||
`Unknown_id_404s_and_zorgverlener_is_forbidden` split). File count in
|
||||
`RuleTests.cs`'s place: 0 (deleted) → 7 files under `Domain/`, same total test count
|
||||
moved (plus the new file).
|
||||
- [x] Frontend test count grew only from legitimate title splits (no assertion dropped) and
|
||||
the new session/isStatusConsistent specs — before/after counts reported per file by the
|
||||
owning track.
|
||||
- [x] `layers.mdx` reflects the actual WP-67 monorepo structure (`apps/`+`libs/`,
|
||||
dependency-cruiser as the real enforcement mechanism, not ESLint).
|
||||
- [x] `bdd.mdx`'s "one transition per test" citation of `registratie-wizard.machine.spec.ts`
|
||||
is true again (the cited test was split).
|
||||
- [x] Every one of the 5 documented `$localize`-copy-assertion violations is fixed or
|
||||
explicitly justified as the doc's own escape hatch (a `reden` free-text passthrough with
|
||||
no backing tag — `aanvraag-view.spec.ts`/`beoordeling-view.spec.ts` — left alone with an
|
||||
inline comment explaining why, rather than forcing a fake enum into production code).
|
||||
- [x] `npm run ci` green (lint, typecheck, dep:check, format, check:tokens, check:seam, all
|
||||
four test projects, both localized builds, audit, backend format+test, snippet drift,
|
||||
behaviour-spec drift, api-client drift). `npm run build-storybook` green (the new/edited
|
||||
MDX pages build without error).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
npm run typecheck && npm run lint && npm run dep:check && npm run check:seam
|
||||
npm run ci # green (2026-08-18)
|
||||
npm run build-storybook # green
|
||||
cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration" # 230/230
|
||||
```
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Universal Given/When/Then sweep across all ~600 tests (staged instead — retrofit covers the
|
||||
files this WP already touched; the rest adopt it as they're next edited).
|
||||
- Scholing threshold _enforcement_ — WP-69 owns it.
|
||||
- Reconciling the phone-format regex divergence in production code (documented, not fixed;
|
||||
verified not a live bug — the FE normalises `+31`→`0` before the wire).
|
||||
- Making `Aanvraag` genuinely immutable (EF refactor, inherited from WP-70).
|
||||
- E2E test isolation via a dev-only seed endpoint (inherited from WP-70).
|
||||
- `RegistrationStatus`'s flat-record gap on the backend (inherited from WP-70).
|
||||
|
||||
## Risks
|
||||
|
||||
- `expectTag`'s runtime throw only fires when a spec actually calls it with the wrong tag —
|
||||
it does not retroactively audit every state a reducer can reach. A future variant added to
|
||||
a union still needs its own test coverage; the helper only makes existing coverage honest.
|
||||
- `check-seam.sh` covers exactly one FE/BE literal pair (the scholing threshold). The other
|
||||
three documented divergences (phone regex, eligibility fixtures, toelichting) have no
|
||||
automated guard — a future edit to either side can still silently diverge undetected.
|
||||
@@ -0,0 +1,56 @@
|
||||
# WP-72 — Delete the dead legacy submit endpoints
|
||||
|
||||
Status: done (6bc00a9)
|
||||
Phase: 12 — DDD hardening
|
||||
|
||||
## Why
|
||||
|
||||
`POST /api/v1/intakes` and `POST /api/v1/herregistraties` were dead from the UI — the wizard
|
||||
submits through `POST /applications/{id}/submit`, and nothing in `apps/` or `libs/` called the
|
||||
generated `intakes()`/`herregistraties()` client methods. They were also strictly **less
|
||||
capable** than the endpoint that replaced them: they minted a bare reference and wrote no
|
||||
`Aanvraag`, made no ZGW/OpenZaak call, and performed no document-ownership check.
|
||||
|
||||
WP-69 hardened `/intakes` with a 400 last session. Deleting the surface is the stronger fix;
|
||||
WP-69's `/applications/{id}/submit` enforcement — the path the wizard actually uses — is
|
||||
untouched.
|
||||
|
||||
## Decisions (pre-made)
|
||||
|
||||
1. Delete both routes together. Their two `EndpointTests` are `[Theory]`s parameterised across
|
||||
_both_ routes, so deleting one would leave an `InlineData` row 404-ing.
|
||||
2. **Keep** the shared `Submit(...)` helper, `ReferentieResponse`, `SubmissionRules.NewReference`
|
||||
and the whole `IdempotencyStore` path — `/registrations` and `/change-requests` still use
|
||||
them, and `IdempotencyTests` covers the latter.
|
||||
3. This WP owns the wire artifacts; no other track runs `gen:api`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Both routes return **404** against a live backend (verified by curl, not by inference).
|
||||
- [x] Zero references remain in `libs/shared/src/infrastructure/api-client.ts`.
|
||||
- [x] `gen:api` diff is **pure deletion** — 124 lines out of `swagger.json`, 109 out of the API
|
||||
client, zero additions.
|
||||
- [x] Backend tests 245 → 240, exactly the 5 deleted cases (2 `[Theory]`s × 2 rows + 1 `[Fact]`).
|
||||
- [x] `Submit(...)` and the idempotency path survive with their live callers intact.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration"
|
||||
npm run ci
|
||||
curl -X POST http://localhost:5000/api/v1/intakes -d '{"uren":500}' # 404
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
Committed together with WP-73 (`6bc00a9`): both edit `Program.cs`, and splitting them would
|
||||
have produced a commit that does not build. The two were run in separate execution waves to
|
||||
avoid a concurrent `dotnet build` collision — but since neither committed independently, the
|
||||
file-level entanglement remained at integration time. Worth remembering when planning future
|
||||
parallel backend tracks: **separate waves do not produce separate commits.**
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- `docs/reference/fp-tea-atomic-design.md:587` / `ARCHITECTURE.md:464` still teach a
|
||||
`visibleSteps`-with-a-`'scholing'`-step intake the fixed-3-step wizard no longer matches
|
||||
(inherited from WP-69).
|
||||
@@ -0,0 +1,90 @@
|
||||
# WP-73 — `RegistrationStatus` and `Aanvraag` as closed unions
|
||||
|
||||
Status: done (6bc00a9)
|
||||
Phase: 12 — DDD hardening
|
||||
|
||||
## Why
|
||||
|
||||
Two backend domain types still allowed illegal states, against `CLAUDE.md`'s non-negotiable #3.
|
||||
|
||||
`RegistrationStatus` was a flat record whose **own doc-comment** admitted only `Geregistreerd`
|
||||
should carry a herregistratie deadline — and noted the frontend modelled it correctly as a
|
||||
discriminated union while the backend did not. It also made `reden` nullable on all three
|
||||
variants where the FE requires it on two.
|
||||
|
||||
`Aanvraag` was a mutable EF class with 14 public setters. Its `StatusAt` carried **five
|
||||
`Referentie!` null-forgiving derefs** plus a `SubmittedAt!.Value` — the compiler saying out loud
|
||||
that "Submitted ⇒ Referentie != null" was convention, not type. WP-68 left it mutable
|
||||
deliberately; WP-70/71 bought most of the safety with a test-only builder, which was itself a
|
||||
hand-rolled prototype of the union this WP builds for real.
|
||||
|
||||
## Decisions (pre-made)
|
||||
|
||||
1. **Full union, not private setters.** The cheaper option (flip 14 setters to `private set`,
|
||||
3 files, no migration) was rejected in favour of the honest modelling.
|
||||
2. `RegistrationStatus` → abstract record + three sealed variants behind a private base ctor.
|
||||
Chosen over WP-68's static-factory shape (`AanvraagStatus`) because with only 4 read sites the
|
||||
abstract record is affordable and makes **reading** safe too, not just construction.
|
||||
3. `Aanvraag` → `Concept | Submitted | Decided` (with `Decided` further split into
|
||||
`Goedgekeurd | Afgewezen | MeerInfoGevraagd`), the EF row demoted to `AanvraagEntity` behind
|
||||
a two-way mapper.
|
||||
4. The `(Owner, Type)` "at most one unsubmitted aanvraag" rule is an **aggregate-set** invariant —
|
||||
it cannot live on the entity and stays procedural in `CreateConcept` under the lock. Stated in
|
||||
code so nobody tries to move it.
|
||||
5. No migration, no schema change, no wire change.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] **Illegal construction is a compile error, proven not assumed.** Each was attempted, the
|
||||
compiler error recorded, then reverted:
|
||||
|
||||
| Attempted illegal state | Compiler error |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `Decided` with no referentie | `CS9035: Required member 'Aanvraag.Decided.Referentie' must be set` |
|
||||
| `Geschorst` with a HerregistratieDatum | `CS1739: The best overload for 'Geschorst' does not have a parameter named 'HerregistratieDatum'` |
|
||||
| `Afwijzen` with no toelichting | `CS9035: Required member 'Aanvraag.Decided.Afgewezen.Toelichting' must be set` |
|
||||
|
||||
- [x] All five `Referentie!` derefs and the `SubmittedAt!.Value` are **gone**, not suppressed.
|
||||
`IZaakSource.CreateZaak` narrows to `Aanvraag.Submitted`, removing the same class of deref
|
||||
in both `LocalZaakSource` and `OpenZaakZaakSource`.
|
||||
- [x] `reden` is now required on `Geschorst`/`Doorgehaald`, matching the FE union.
|
||||
- [x] `HerregistratieRule.IsStatusConsistent` deleted as dead code — the type now guarantees what
|
||||
it checked, and its test **could no longer construct the illegal state it existed to
|
||||
catch**. That failure to compile is the proof the refactor worked.
|
||||
- [x] Backend 242 → 241, exactly that one deleted test. No other count change.
|
||||
- [x] `RegistrationStatusDto` and the application DTOs byte-identical — confirmed by diffing a
|
||||
live backend's `/swagger/v1/swagger.json` against the checked-in copy. No `gen:api`.
|
||||
|
||||
## The `Draft` decision (made explicitly)
|
||||
|
||||
`ApplicationStore`'s doc-comment claimed `Draft` was "Concept only" (`Draft != null ⇒ !Submitted`),
|
||||
but `Submit` never cleared it — so the invariant was **violated in production**. Resolved in
|
||||
favour of the code matching the comment: `Submitted`/`Decided` simply have no `Draft` property,
|
||||
so submitting drops it. Verified nothing reads a submitted aanvraag's draft — `draft-sync.ts`'s
|
||||
`applyResume` is the only consumer of `ApplicationDetailDto.Draft` and only ever resumes an
|
||||
unsubmitted wizard.
|
||||
|
||||
## Deviations
|
||||
|
||||
- **`Aanvraag` (EF row) renamed to `AanvraagEntity`.** The domain union needed the bare name to
|
||||
match `RegistrationStatus`/`AanvraagStatus` conventions; keeping both would make every file
|
||||
importing both namespaces ambiguous (`CS0104`). The table name is unaffected — EF derives it
|
||||
from the `Applications` `DbSet` property, not the CLR type.
|
||||
- **Step invariant loosened** from `0 <= StepIndex < StepCount` to `<=`: `CreateConcept` produces
|
||||
`(0, 0)` before the wizard's first draft sync, which the strict form would reject at creation.
|
||||
- `AanvraagBuilder.Decided(...)` now delegates to the real union constructors, dropping its own
|
||||
hand-rolled toelichting guard; a one-line wrapper keeps the `.Decided(...).Build()` chain
|
||||
source-compatible for existing call sites.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd backend && dotnet format --verify-no-changes && dotnet test BigRegister.slnx --filter "Category!=Integration"
|
||||
npm run ci
|
||||
```
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Making `Besluit` flow through the generated client as an enum rather than a `string` would
|
||||
remove that FE/BE seam entirely rather than guarding it (WP-75 added the guard) — but it is a
|
||||
wire change.
|
||||
@@ -0,0 +1,86 @@
|
||||
# WP-74 — E2E isolation without a new backend endpoint
|
||||
|
||||
Status: done (42f7bd6)
|
||||
Phase: 12 — DDD hardening
|
||||
|
||||
## Why
|
||||
|
||||
The three Playwright specs shared one mutable backend and admitted it in their own comments
|
||||
("Restart the backend between CI runs — a second run would see a leftover Concept"). A crashed
|
||||
mid-wizard run poisoned every subsequent run via `CreateConcept`'s 409, and both mutating specs
|
||||
acted as the same identity (`DocumentStore.DemoOwner`), so any new state-touching spec would
|
||||
collide immediately.
|
||||
|
||||
## Decisions (pre-made)
|
||||
|
||||
**WP-70 recorded the fix as a dev-only seed endpoint. That premise was wrong**, and exploration
|
||||
established why:
|
||||
|
||||
- The DB path already routes through `IConfiguration` (`Program.cs`,
|
||||
`Db.ConnectionString = GetConnectionString("AppDb") ?? …`), so `ConnectionStrings__AppDb` as an
|
||||
env var gives a throwaway DB with **zero backend change** — the same trick
|
||||
`TestWebApplicationFactory` already uses per-test.
|
||||
- `StubIdentityProvider` **already honours** an `X-Subject` header; the only gap was that no FE
|
||||
interceptor sent one.
|
||||
- The backend has **no `IsDevelopment()` gate anywhere** (grep: zero hits), so a seed endpoint
|
||||
would have had to invent the codebase's first environment gate — a new security posture for no
|
||||
gain.
|
||||
|
||||
So: throwaway DB + a dev-only `X-Subject` interceptor. No new endpoint, no environment gate.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] **`npm run e2e` passes twice back-to-back with no backend restart** — the actual acceptance
|
||||
test, and the thing that failed before this WP.
|
||||
- [x] **`X-Subject` observed on a real request** reaching the backend (`X-Subject: 111222333` on
|
||||
`GET /api/v1/uploads/categories`), not merely wired.
|
||||
- [x] Each new BSN elfproef-verified by script against the weights `[9,8,7,6,5,4,3,2,-1]`.
|
||||
- [x] No backend change, no new endpoint, no `IsDevelopment()` gate.
|
||||
- [x] Committed port config still defaults to 4200 (verification used an override).
|
||||
|
||||
## Notes on the two caveats
|
||||
|
||||
- **`reuseExistingServer` stays on.** Flipping it to `false` would hard-fail `npm run e2e` for
|
||||
anyone already running the docker stack on 4200/5000 — a real local-workflow regression. The
|
||||
consequence (the throwaway DB only applies when Playwright itself spawns the backend; always
|
||||
true in CI) is documented in a comment on the `webServer` entry.
|
||||
- **Unique DB filename per invocation**, with `global-setup.ts` sweeping only _prior_ runs'
|
||||
leftovers. A fixed name unlinked mid-run is only safe if SQLite's pool never reopens by path
|
||||
afterwards; under `fullyParallel` that risks silently recreating an empty, unmigrated DB.
|
||||
|
||||
## Deviation: interceptors alone were not enough
|
||||
|
||||
Two hand-written call sites bypass Angular's interceptor chain (as `CLAUDE.md` documents) and
|
||||
needed `X-Subject` stamped explicitly:
|
||||
|
||||
- `libs/shared/src/upload/upload.adapter.ts`'s raw XHR upload — without this every uploaded
|
||||
document landed under `DemoOwner`, breaking submit for any other identity.
|
||||
- `apps/ssp/.../letter-preview.adapter.ts`'s preview fetch (plus `cache: 'no-store'`, correct
|
||||
regardless since the endpoint sends no `Cache-Control`).
|
||||
|
||||
## Known gap (a real backend bug, not caused by this WP)
|
||||
|
||||
Under any BSN other than `DemoOwner`, `GET /brief/preview` returns a **sent** letter still
|
||||
carrying the draft watermark — while `curl` against the same backend at the same instant returns
|
||||
the correct frozen archive. Client caching was ruled out (`no-store`, then cache-busting query
|
||||
strings), the dev proxy was ruled out, and it reproduced across two BSNs and never for
|
||||
`DemoOwner`. This points at a staleness/race in `BriefStore`'s SQLite read path.
|
||||
|
||||
`brief-v2.spec.ts` therefore keeps the shared `zorgverlener` identity — it still gains
|
||||
throwaway-DB repeatability, just not per-spec identity isolation. `actors.ts` reserves a
|
||||
`briefOpsteller` actor for whoever fixes the backend. **Tracked as a follow-up below.**
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
npm run e2e # twice consecutively, no backend restart
|
||||
npm run lint && npm run typecheck && npm test && npm run build
|
||||
```
|
||||
|
||||
Note: port 4200 was held by an unrelated container on the dev machine, so verification ran with
|
||||
`E2E_BASE_URL` pointed at an alternate port. The committed default is unchanged.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- **`/brief/preview` staleness for non-`DemoOwner` identities** (above) — the blocker for giving
|
||||
`brief-v2.spec.ts` its own identity.
|
||||
@@ -0,0 +1,65 @@
|
||||
# WP-75 — Close the remaining FE/BE seams
|
||||
|
||||
Status: done (6fa27d1)
|
||||
Phase: 12 — DDD hardening
|
||||
|
||||
## Why
|
||||
|
||||
WP-71 added `scripts/check-seam.sh` guarding one literal pair (the scholing threshold) and
|
||||
documented three further FE/BE duplications that nothing tested across the seam. This closes
|
||||
them — two by deletion, one by a guard, one by an actual fix.
|
||||
|
||||
## Decisions (pre-made)
|
||||
|
||||
1. **Dead reference impls get deleted, and `CLAUDE.md` is amended.** This overturns the
|
||||
documented policy that server-owned rules "stay in `domain/*.policy.ts` as reference impl +
|
||||
unit test". That policy is precisely what kept dead code alive. Blast radius is small:
|
||||
`registration.policy.ts` is the only `*.policy.ts` in the repo.
|
||||
2. Guard the `Besluit` tag list by **extending** `check-seam.sh`, not adding a second script.
|
||||
3. The phone seam gets a **contract test**, not a grep check — see below.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `isHerregistratieEligible` deleted (uncalled; dead by its own doc-comment) along with
|
||||
`isStatusConsistent` (also uncalled — WP-71 had added a spec for it the session before).
|
||||
The three live exports (`statusLabel`, `statusColor`, `herregistratieDeadline`) stay, and
|
||||
`herregistratieDeadline` gained direct coverage it previously only had transitively.
|
||||
- [x] `CLAUDE.md` amended: server-owned rules live **only** on the server; the FE may mirror a
|
||||
server-supplied _value_ (a threshold, a bound) for instant feedback, but never
|
||||
reimplements the _algorithm_. ADR-0001's matching claim aligned.
|
||||
- [x] `check-seam.sh` guards the `Besluit` tag list, **proven to fail** when a fourth member is
|
||||
added to the C# enum only, naming both files and both lists. Anchored on the full
|
||||
declaration so it avoids the "greps all matches" trap WP-69 documented.
|
||||
- [x] Phone contract test added and green; backend stripping fixed.
|
||||
|
||||
## The phone divergence was real, not latent
|
||||
|
||||
WP-71 recorded this as latent because the Angular app normalises before sending — true of _that_
|
||||
path. The contract test proved the two sides genuinely disagreed: the backend returned **422**
|
||||
for `+31612345678` and `(06) 12345678`, both of which the FE's own `parseTelefoonnummer`
|
||||
accepts. Any non-Angular client, crafted POST, or future FE change would have hit it.
|
||||
|
||||
`SubmissionRules.RejectPhoneChange` now strips exactly what the FE strips (`[\s\-()]`, then a
|
||||
leading `+31` → `0`) before applying the shared `^0\d{9}$`. The FE value object was not touched —
|
||||
it is the more permissive and correct side.
|
||||
|
||||
**Why a contract test rather than a grep check:** both sides carry the identical `^0\d{9}$`
|
||||
literal, so a drift check would have compared them, found them equal, and reported all clear.
|
||||
The divergence was in the _normalisation before_ the regex — invisible to text comparison. Worth
|
||||
remembering when choosing between the two guard styles: grep checks catch drifting **constants**,
|
||||
contract tests catch drifting **behaviour**.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
npm run check:seam # both checks OK
|
||||
npm run ci
|
||||
cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration"
|
||||
```
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Making `Besluit` flow through the generated client as an enum rather than a `string` would
|
||||
remove that seam entirely rather than guarding it — a wire change, so not done here.
|
||||
- The herregistratie-eligibility seam is closed by deletion; if a FE mirror is ever reintroduced,
|
||||
the disjoint-fixture problem returns and would need a contract test, not a grep check.
|
||||
Reference in New Issue
Block a user