test: split multi-assertion specs into single-behavior tests
One behavior per test across FE machine/store specs and backend endpoint tests, so a failure names exactly what broke. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Showcase roadmap — superseded
|
||||
|
||||
**This roadmap is superseded by [`docs/backlog/`](backlog/README.md)** (2026-07-02).
|
||||
|
||||
The backlog absorbs and corrects this document: its Storybook-as-curriculum track became
|
||||
WP-14/15 (+ per-invariant MDX pages in WP-05/07/08/13/17), its enforcement track became
|
||||
WP-01…04, and several of its claims had gone stale (value-object specs exist; more than
|
||||
one component lacked a story). Two backlogs would drift — this one is closed.
|
||||
@@ -0,0 +1,114 @@
|
||||
# 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/SHOWCASE-ROADMAP.md`**.
|
||||
|
||||
## Session protocol
|
||||
|
||||
- **Switch to Opus first** (`/model opus`) before tackling any WP.
|
||||
- **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.
|
||||
- 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 | todo |
|
||||
| [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 | todo |
|
||||
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo |
|
||||
|
||||
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.
|
||||
|
||||
## 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/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/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/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/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/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/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/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/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/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/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/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,190 @@
|
||||
# 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/architecture/0002-user-groups-and-bounded-contexts.md` (the `Principal`
|
||||
union, identity-vs-authorization split — see the deviation noted below)
|
||||
- `docs/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,131 @@
|
||||
# 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`, start backend
|
||||
(`dotnet run --project backend/src/BigRegister.Api &`), `npm start &` (or `ng
|
||||
serve` backgrounded), wait-on both ports, `npm run e2e`. `timeout-minutes: 15`
|
||||
per the hardened workflow convention already in `ci.yml`.
|
||||
|
||||
## 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
|
||||
|
||||
- **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/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/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/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/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/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/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,93 @@
|
||||
# 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**: `past`/`future: Brief[]` in
|
||||
`BriefStore` (cap 50; push on `edit()`; clear `future` on a new edit); restore
|
||||
dispatches the **existing `Seed` Msg** — zero machine changes — then `scheduleSave()`.
|
||||
- **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,66 @@
|
||||
# WP-28 — Brief v2 demo polish (scenarios, e2e, docs)
|
||||
|
||||
Status: todo
|
||||
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/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/prd/0003-brief-v2-demo-script.md` (new)
|
||||
- `e2e/brief-v2.spec.ts` (new)
|
||||
- story gap-fill where WP-24..27 left holes
|
||||
- `docs/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
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] Full GREEN; backlog README statuses correct; CLAUDE.md mentions
|
||||
`?role=admin` and `/brief/huisstijl`.
|
||||
|
||||
## 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,219 @@
|
||||
# PRD 0001 — "Mijn aanvragen": running wizards, application status & document preview
|
||||
|
||||
Status: Implemented · Date: 2026-07-01 · Context: SSP / Zorgverlener (see ADR-0002)
|
||||
|
||||
> Cross-references: **ADR-0001** (BFF-lite endpoints + decision DTOs) and **ADR-0002** (user groups as
|
||||
> actors; the `Concept → In behandeling → Goedgekeurd/Afgewezen` aanvraag lifecycle). This PRD
|
||||
> _materializes_ that lifecycle as a backend-owned aggregate — still entirely within the Zorgverlener
|
||||
> self-service context; the Behandelaar/backoffice app that advances manual cases stays a separate,
|
||||
> unbuilt context.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
A logged-in Zorgverlener cannot see what they have started or submitted, and cannot review what they
|
||||
uploaded. Concretely, today:
|
||||
|
||||
- Wizard drafts live in **per-wizard `sessionStorage`** (`registratie-v2`, `intake-v3`); the
|
||||
**herregistratie wizard has no persistence at all**. Nothing enumerates "my in-progress applications."
|
||||
- There is **no application list** — only a single optimistic boolean, `pendingHerregistratie`, in
|
||||
`src/app/registratie/application/big-profile.store.ts:53`, surfaced as one dashboard alert.
|
||||
- Uploaded documents **cannot be previewed or downloaded**. The backend `DocumentStore`
|
||||
(`backend/src/BigRegister.Api/Data/DocumentStore.cs`) deliberately stores **metadata only, no bytes**,
|
||||
and there is **no GET-content endpoint**.
|
||||
- A **manual diploma is hard-rejected**: `SubmissionRules.RejectRegistratie("handmatig")` returns a
|
||||
422 (`backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs`). The product wants such a
|
||||
submission to _succeed_ and sit in a pending (manual-review) state instead.
|
||||
|
||||
## 2. Goals
|
||||
|
||||
1. Show **all running (concept) applications** as dynamic blocks at the **top of the dashboard**, so the
|
||||
user immediately sees what they initiated, with **Verder gaan** (resume) and **Annuleren** (cancel →
|
||||
start fresh).
|
||||
2. On **re-opening** a concept wizard, let the user **preview/download** the documents they already
|
||||
uploaded.
|
||||
3. Show **submitted-but-unprocessed** applications in a **pending** state on the dashboard.
|
||||
4. Provide **two registratie flows** that both submit successfully (never disallow submission):
|
||||
- **Auto-approved** (`diplomaHerkomst = 'duo'`) → resolves to **Goedgekeurd**.
|
||||
- **Manual backoffice** (`diplomaHerkomst = 'handmatig'`) → stays **In behandeling** (pending).
|
||||
5. Make the backend the **system of record** for applications (concept + submitted), per the chosen
|
||||
architecture.
|
||||
|
||||
## 3. Non-goals / Out of scope (POC)
|
||||
|
||||
- The **Behandeling/backoffice application** that advances manual aanvragen (ADR-0002). Manual cases
|
||||
stay pending forever in this build.
|
||||
- Real auth / multi-user — a single `DemoOwner` owns everything (matches the faked DigiD session).
|
||||
- Real blob storage, virus scanning, retention — document **bytes are held in-memory** and reset when
|
||||
the backend restarts.
|
||||
- **Withdrawing** a submitted aanvraag ("intrekken") — cancel applies to concepts only.
|
||||
- Document preview for the **dev upload simulation** (`?scenario=upload-slow|upload-fail`) — that path
|
||||
returns a fake `demo-*` documentId with no bytes; preview requires a real upload.
|
||||
|
||||
## 4. Personas
|
||||
|
||||
Single actor: the **Zorgverlener** (healthcare professional, DigiD/BSN, self-service). Per ADR-0002,
|
||||
"who may advance a manual application" is the **Behandelaar**, an actor in a _separate_ backoffice
|
||||
context that is not part of this app.
|
||||
|
||||
## 5. Domain model — the `Aanvraag` aggregate (backend-owned)
|
||||
|
||||
The backend gains an `Aanvraag` (application) aggregate — the system of record the dashboard reads.
|
||||
|
||||
| Field | Type | Notes |
|
||||
| ------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `id` | string (uuid) | client-visible handle; used in the resume deep link |
|
||||
| `type` | `registratie` \| `herregistratie` \| `intake` | which wizard |
|
||||
| `status` | discriminated union (below) | computed on read for auto-approval |
|
||||
| `draft` | opaque JSON | the wizard's persisted machine snapshot (Concept only) |
|
||||
| `stepIndex`, `stepCount` | int | for the "Stap X van Y" progress on the block |
|
||||
| `documentIds` | string[] | documents linked to this aanvraag |
|
||||
| `referentie` | string? | set on submit (e.g. `BIG-2026-456789`) |
|
||||
| `owner` | string | `DemoOwner` |
|
||||
| `autoApprovable` | bool | set at submit: `diplomaHerkomst === 'duo'` (registratie); other types auto |
|
||||
| `createdAt` / `updatedAt` / `submittedAt?` | timestamps | |
|
||||
|
||||
### Status lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Concept: create (first wizard step)
|
||||
Concept --> Concept: draft sync (per step)
|
||||
Concept --> [*]: Annuleren (cancel, delete)
|
||||
Concept --> InBehandeling: submit (accepted)
|
||||
Concept --> Afgewezen: submit invalid (e.g. 0 uren)
|
||||
InBehandeling --> Goedgekeurd: auto (duo) after processing window Δ
|
||||
InBehandeling --> InBehandeling: manual (handmatig) — awaits backoffice (not built)
|
||||
Goedgekeurd --> [*]
|
||||
Afgewezen --> [*]
|
||||
```
|
||||
|
||||
FE-mirrored discriminated union (illegal states unrepresentable, same reflex as `RemoteData`):
|
||||
|
||||
```ts
|
||||
type AanvraagStatus =
|
||||
| { tag: 'Concept'; stepIndex: number; stepCount: number }
|
||||
| { tag: 'InBehandeling'; referentie: string; manual: boolean } // manual=true → "wordt beoordeeld"
|
||||
| { tag: 'Goedgekeurd'; referentie: string }
|
||||
| { tag: 'Afgewezen'; referentie: string; reden: string };
|
||||
```
|
||||
|
||||
### Deterministic auto-approval (no background timer)
|
||||
|
||||
Auto-approval is **computed on read**: for an `autoApprovable` aanvraag, if
|
||||
`now > submittedAt + PROCESSING_WINDOW` (≈8s) the status reports **Goedgekeurd**, else **In behandeling**.
|
||||
Manual (`autoApprovable === false`) aanvragen never auto-advance. This yields a visible
|
||||
pending→approved transition for the auto flow and a persistent pending for the manual flow, with no
|
||||
timers or background jobs — purely a function of stored timestamps.
|
||||
|
||||
## 6. UX
|
||||
|
||||
### Dashboard — new "Mijn aanvragen" section (top, above "Wat moet ik regelen")
|
||||
|
||||
Renders `ApplicationsStore.applications()` through `<app-async>` as a list of **blocks**, sorted:
|
||||
Concept → In behandeling → resolved. Empty list → the section is hidden.
|
||||
|
||||
Per-status block (new `aanvraag-block` component; **which** actions/badge a block shows comes from a
|
||||
pure `blockActions(status)`):
|
||||
|
||||
| Status | Badge | Body | Actions |
|
||||
| ------------------ | ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------ |
|
||||
| **Concept** | grey "Concept" | wizard type + "Stap X van Y" | **Verder gaan** (resume), **Annuleren** (cancel) |
|
||||
| **In behandeling** | amber "In behandeling" | referentie + ingediend-datum; manual → "wordt handmatig beoordeeld in de backoffice" | view/preview documents |
|
||||
| **Goedgekeurd** | green "Goedgekeurd" | referentie | — |
|
||||
| **Afgewezen** | red "Afgewezen" | referentie + reden | — |
|
||||
|
||||
- **Verder gaan** deep-links to the wizard with `?aanvraag=<id>`; the wizard loads the draft from the
|
||||
backend and seeds its machine.
|
||||
- **Annuleren** confirms, then `DELETE /applications/{id}` (removes the aanvraag and its unlinked
|
||||
documents); the block disappears and the user can start a new one from the existing action cards.
|
||||
|
||||
### Document preview/download
|
||||
|
||||
`document-chip` (completed uploads) gains a **"Voorbeeld / Download"** affordance linking to
|
||||
`GET /api/v1/uploads/{documentId}/content` — opens inline for `application/pdf` and images, downloads
|
||||
otherwise. Available both inside the wizard (Concept) and from an In-behandeling block's document view.
|
||||
|
||||
## 7. Backend design (ASP.NET Core, in-memory — extends existing patterns)
|
||||
|
||||
- **`ApplicationStore`** (new, mirrors `DocumentStore`): in-memory dict + lock; create/get/list/
|
||||
upsert-draft/delete; `Submit()` transition; status computed on read (auto-approval window).
|
||||
- **Endpoints** (`Program.cs`, `/api/v1`):
|
||||
- `GET /applications` → `List<ApplicationSummaryDto>` for the owner.
|
||||
- `GET /applications/{id}` → `ApplicationDetailDto` (includes `draft` to resume).
|
||||
- `POST /applications` → create Concept (returns id); `PUT /applications/{id}` → draft sync (idempotent).
|
||||
- `DELETE /applications/{id}` → cancel Concept (cascades to unlinked documents).
|
||||
- `POST /applications/{id}/submit` → runs `SubmissionRules`, sets `autoApprovable`, transitions to
|
||||
In behandeling (or Afgewezen), links documents, returns `{ referentie, status }`.
|
||||
- **`SubmissionRules` change**: `handmatig` no longer 422s — it yields `autoApprovable = false`
|
||||
(manual/pending). Keep genuine validation rejects (0 uren → Afgewezen).
|
||||
- **`DocumentStore` change**: store `byte[] Content` + `string ContentType`; add
|
||||
`GET /uploads/{documentId}/content` returning bytes (Content-Disposition inline for pdf/image,
|
||||
attachment otherwise). The multipart `POST /uploads` now captures bytes + content-type.
|
||||
- **Tests** (`dotnet test`): lifecycle (concept→submit→auto/manual/afgewezen), auto-approve-on-read
|
||||
window boundary, content endpoint returns bytes+type, cancel cascades to unlinked docs only.
|
||||
|
||||
## 8. Frontend design (Angular — TEA + atomic + BFF-lite)
|
||||
|
||||
- **Contracts** (`contracts/`): `ApplicationSummaryDto`, `ApplicationDetailDto`, `AanvraagStatusDto`.
|
||||
- **`applications.adapter.ts`** (`infrastructure/`, the only new network surface): `httpResource` for
|
||||
the list + commands (create/sync/delete/submit), with a hand-written `parseApplication*` boundary
|
||||
(DTO→domain), per ADR-0001. Upload adapter gains a `contentUrl(documentId)` helper.
|
||||
- **`ApplicationsStore`** (`application/`, `providedIn: 'root'`): `applications` RemoteData list rendered
|
||||
via `<app-async>`; optimistic begin/confirm/rollback around cancel + submit; `reload()` after
|
||||
mutations — same pattern as `BigProfileStore`. Dashboard polls/reloads to reflect auto-approval.
|
||||
- **Wizards**: replace `sessionStorage` persistence with a **debounced backend draft-sync** command on
|
||||
each step change; open via `?aanvraag=<id>` (load detail → seed the machine). Apply the same to the
|
||||
herregistratie wizard (it gains persistence). The registratie machine already carries
|
||||
`diplomaHerkomst`, which flows into the submit payload to set `autoApprovable`.
|
||||
- **Domain**: FE `AanvraagStatus` union + pure `blockActions(status)` (badge + allowed actions);
|
||||
co-located `*.spec.ts`.
|
||||
- **UI (atomic, mostly composition)**: new `aanvraag-block`; new "Mijn aanvragen" dashboard section;
|
||||
`document-chip` preview/download affordance. Reuse existing atoms (`card`, `button`, `alert`,
|
||||
`heading`, status-icon/badge). All user-facing copy via `$localize`; shared/English components stay
|
||||
language-agnostic (props with localizable defaults). Stories for each block status + chip preview
|
||||
(a11y addon on).
|
||||
|
||||
## 9. Delivery phases
|
||||
|
||||
Each phase must leave every gate green (`npm run lint`, `npm test`, `npm run build`,
|
||||
`cd backend && dotnet test`). Build the **registratie vertical slice first**; herregistratie/intake
|
||||
blocks are the same pattern copied.
|
||||
|
||||
- **A — Backend Aanvraag store + endpoints + lifecycle** (+ auto-approve-on-read) + tests.
|
||||
- **B — Backend document bytes + `GET /uploads/{id}/content`** + tests.
|
||||
- **C — Contracts + adapters** (applications parse boundary; upload content URL).
|
||||
- **D — `ApplicationsStore` + wizard backend draft-sync + resume-by-link** (retire sessionStorage keys).
|
||||
- **E — FE `AanvraagStatus` union + `blockActions` + registratie two-flow wiring + herregistratie persistence.**
|
||||
- **F — Dashboard "Mijn aanvragen" blocks + `document-chip` preview affordance** + stories.
|
||||
|
||||
## 10. Testing / verification
|
||||
|
||||
Unit (co-located): `blockActions`, `AanvraagStatus` transitions, `parseApplication*` boundary; backend
|
||||
`ApplicationStore` lifecycle + auto-approve window + content endpoint + cancel cascade. UI via Storybook
|
||||
stories. End-to-end demo:
|
||||
|
||||
1. `docker compose up` (Swagger at `:5000/swagger`) or `npm start` + backend; log in.
|
||||
2. Start each wizard partway → blocks appear at the top of the dashboard (Concept, "Stap X van Y").
|
||||
3. Reopen a Concept → previously uploaded documents **preview/download** (real upload, not `?scenario=upload-*`).
|
||||
4. **Annuleren** a Concept → block disappears; start a fresh one.
|
||||
5. Registratie with **DUO** diploma → submit → block shows **In behandeling**, then **Goedgekeurd**
|
||||
after the processing window (on dashboard reload).
|
||||
6. Registratie with **handmatig** diploma → submit (not blocked) → block stays **In behandeling**
|
||||
("wordt handmatig beoordeeld").
|
||||
|
||||
## 11. Risks & notes
|
||||
|
||||
- **Retiring sessionStorage** changes existing wizard behavior; ensure resume-by-link and draft-sync
|
||||
cover the previous "reload keeps progress" guarantee. Bump/remove the old `registratie-v2` / `intake-v3`
|
||||
keys (no migration — ponytail).
|
||||
- **Chatty sync**: debounce the per-step `PUT /applications/{id}`; keep it optimistic so typing stays snappy.
|
||||
- **Dev simulation** uploads have no bytes → hide the preview affordance when the documentId is a
|
||||
`demo-*` sentinel (or when content 404s).
|
||||
- **In-memory reset**: backend restart clears all aanvragen/documents — acceptable for the POC; call it
|
||||
out so testers aren't surprised.
|
||||
|
||||
ponytail: the only genuinely new pieces are the backend `Aanvraag` store, document byte storage, and
|
||||
the `aanvraag-block` component. Everything else reuses existing store/adapter/`<app-async>`/atom
|
||||
patterns. Don't build the backoffice, real storage, or withdrawal — YAGNI until asked.
|
||||
@@ -0,0 +1,247 @@
|
||||
# PRD 0002 — Attribute-Based Access Control (ABAC) in the UI
|
||||
|
||||
Status: Proposed · Date: 2026-07-02 · Context: SSP / backoffice actors (see ADR-0002)
|
||||
|
||||
> Cross-references: **ADR-0001** (BFF-lite endpoints + decision DTOs), **ADR-0002** (user groups as
|
||||
> actors; identity vs authorization), and **PRD-0001** (the `Aanvraag` lifecycle those decisions gate).
|
||||
> This PRD _materializes_ ADR-0002's authorization half: the AD server authenticates and supplies
|
||||
> **coarse roles**; the app layers a **fine-grained, app-owned** access model on top, resolved by the
|
||||
> backend and rendered — never decided — by the UI.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The AD (Active Directory) server is the identity provider: it authenticates the user and returns
|
||||
**coarse, role-based attributes** — group memberships that map to a handful of roles. That is all AD
|
||||
owns. The product needs access controls that are **finer than a role** and that AD does **not**
|
||||
administer:
|
||||
|
||||
- **Capability gating** — one role, many buttons: some users in a role may approve letters, reveal a
|
||||
BSN, or advance a manual application; others may not.
|
||||
- **Data-scoping** — the same role sees _different rows_: only their own region / office / caseload.
|
||||
- **Field / PII-level** — restrict _which fields_ (notably the BSN and other special-category personal
|
||||
data under GDPR/AVG art. 9) a user may see or edit, independently of their role.
|
||||
- **Segregation-of-duty / step-up** — combinations and conditions: approver ≠ drafter, four-eyes,
|
||||
recent MFA, time-boxed break-glass.
|
||||
|
||||
Today the codebase has none of this, and what stands in for a "role" is not a security control at all:
|
||||
|
||||
- `Session` (`src/app/auth/domain/session.ts:2-9`) carries only `bsn` + `naam` — **no roles, claims,
|
||||
or attributes**. `SessionStore` (`src/app/auth/application/session.store.ts:32`) is
|
||||
`providedIn:'root'`.
|
||||
- The only "role" is a **dev-only, unverified** query param: `currentRole()` reads
|
||||
`?role=drafter|approver` from the URL (`src/app/shared/infrastructure/role.ts`), stamped onto brief
|
||||
requests as an `X-Role` header by a dev-only interceptor (`src/app/shared/infrastructure/role.interceptor.ts`,
|
||||
registered only under `isDevMode()` in `src/app/app.config.ts:22`). `X-Admin: true` is the parallel
|
||||
admin stand-in.
|
||||
- One route guard exists — `authGuard` (`src/app/auth/auth.guard.ts:6-10`) — a pure _authentication_
|
||||
check. There is **no** role/permission guard, and **no** `can` / `hasRole` / `isAuthorized` helper
|
||||
anywhere.
|
||||
- The backend is **fully open**: `backend/src/BigRegister.Api/Program.cs` has no authentication or
|
||||
authorization middleware, no `[Authorize]`, and never reads `HttpContext.User`. Identity is faked via
|
||||
a single `DemoOwner` id (`DocumentStore.cs:26`) plus the client-asserted `X-Role` / `X-Admin`
|
||||
headers. The brief's two-person rule _is_ enforced (`BriefStore.Review`, `backend/.../Data/BriefStore.cs:113-123`:
|
||||
`if (actingId == e.DrafterId) return Forbidden`) — but against the **unverified** `X-Role` header, so
|
||||
any caller can assert `X-Role: approver`.
|
||||
|
||||
The building block we need already exists in one place: the **decision-flag seam**. The backend
|
||||
computes `(bool, reason)` and embeds it in a screen DTO — `HerregistratieDecisionsDto` inside
|
||||
`DashboardViewDto` (`backend/src/BigRegister.Api/Contracts/Dtos.cs:25-27`), computed by
|
||||
`HerregistratieRule.Evaluate` (`backend/.../Domain/Registrations/HerregistratieRule.cs:16-27`). This
|
||||
PRD extends that same seam from _business_ decisions to _authorization_ decisions.
|
||||
|
||||
## 2. Goals
|
||||
|
||||
1. Support all four control types above — **capability gating, data-scoping, field/PII-level, and
|
||||
step-up/SoD** — as one coherent model.
|
||||
2. **Backend is the authority** for every access decision (per ADR-0001). The UI _mirrors_ decisions
|
||||
for UX; it never computes them.
|
||||
3. **AD roles are the base; the app owns a fine-grained overlay.** The two merge **server-side** into a
|
||||
single `Principal`; capabilities are resolved server-side.
|
||||
4. **Deny-by-default.** Absence of a decision means denied — in the guard, the template, and the
|
||||
endpoint.
|
||||
5. **Privacy by design (data minimization).** The FE receives only the decisions it needs to render —
|
||||
resolved capability flags, already-scoped rows, redacted PII — never the policy matrix, raw AD group
|
||||
dumps, or other users' attributes.
|
||||
6. **Auditable.** Every authorization decision that matters (denials, PII reveals, step-up,
|
||||
break-glass) is logged server-side against the acting principal.
|
||||
|
||||
## 3. Non-goals / Out of scope (POC)
|
||||
|
||||
- **Real AD / OIDC / SAML integration.** The AD roles remain _simulated_; how claims actually arrive
|
||||
(token, header, SSO) is a wiring concern for later, isolated to `infrastructure/` + the backend
|
||||
authn middleware.
|
||||
- **A general policy engine (OPA/Cedar/XACML).** We express access as named **capabilities** computed
|
||||
in plain code, not a rules DSL. Add an engine only if the capability set outgrows hand-written rules.
|
||||
- **An admin UI for the overlay.** The app-owned overlay is seeded/hardcoded in this build; who
|
||||
administers it is a separate backoffice concern (ADR-0002).
|
||||
- **A real MFA provider / real break-glass workflow.** Step-up is modelled (an environment attribute +
|
||||
precondition) but satisfied by a stub in the POC.
|
||||
- The **unverified `X-Role` / `X-Admin` header seam stays** as the POC's identity stub — but it is
|
||||
explicitly relabelled in code and docs as **"dev stub — NOT a security boundary."** Production
|
||||
replaces it with a verified principal (§7).
|
||||
|
||||
## 4. Personas & attributes
|
||||
|
||||
Actors (per ADR-0002): the **Zorgverlener** (self-service, DigiD/BSN) and one or more **backoffice**
|
||||
actors (Behandelaar, Beoordelaar). ABAC is what lets these — and finer distinctions _within_ a role —
|
||||
diverge without a folder-per-role explosion.
|
||||
|
||||
An access decision is a function of four attribute sets:
|
||||
|
||||
| Attribute set | Source | Examples |
|
||||
| --------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Subject** | AD roles **+ app overlay + derived context** | AD: `beoordelaar`, `behandelaar`. Overlay: `mag-bsn-inzien`, `mag-brief-goedkeuren`. Derived: own BIG-registration, own region/office |
|
||||
| **Resource** | the domain entity | owner id, region, sensitivity class (contains BSN / art. 9 data), status |
|
||||
| **Action** | the request | `view`, `edit`, `approve`, `reveal-bsn`, `beoordelen` |
|
||||
| **Environment** | the request context | MFA/assurance level, time-of-day, break-glass flag |
|
||||
|
||||
> AD owns only the first column's first row (coarse roles). Everything else is the app's overlay and
|
||||
> the entity's own attributes — the reason a role alone is too blunt.
|
||||
|
||||
## 5. Access model — four mechanisms, each server-authoritative
|
||||
|
||||
Every mechanism follows one rule: **the backend decides and enforces; the UI renders the decision.**
|
||||
|
||||
### 5a. Capability gating (feature/UI)
|
||||
|
||||
The atomic unit is a **named capability** — a stable, namespaced string, e.g. `brief:approve`,
|
||||
`aanvraag:beoordelen`, `registratie:reveal-bsn`. The backend resolves the subject's capabilities for a
|
||||
given resource+environment and ships them as **decision flags on the screen DTO** — exactly the
|
||||
`HerregistratieDecisionsDto` pattern, extended:
|
||||
|
||||
```csharp
|
||||
// contracts: capability flags travel with the screen they gate (data-minimized: only this screen's)
|
||||
public sealed record BriefDecisionsDto(bool CanApprove, bool CanReject, bool CanRevealBsn,
|
||||
bool RequiresStepUp, string? DeniedReason);
|
||||
```
|
||||
|
||||
The UI reads the flag and shows/hides. It **never** re-derives the flag from roles. (Contrast today's
|
||||
`BriefStore.editable`, `src/app/brief/application/brief.store.ts:34-37`, which computes the gate FE-side
|
||||
from `currentRole()` — this PRD moves that authority to the server flag.)
|
||||
|
||||
### 5b. Data-scoping (row-level)
|
||||
|
||||
The server **filters rows by the subject's scope attributes at the source** — a Beoordelaar for region
|
||||
_Noord_ receives only _Noord_ aanvragen. The FE never receives out-of-scope records and so cannot leak
|
||||
them (no client-side "fetch all, hide some"). Scope is a subject attribute (overlay/derived), applied
|
||||
in the query, not a UI filter.
|
||||
|
||||
### 5c. Field / PII-level
|
||||
|
||||
Sensitive fields are **redacted or omitted server-side** when the capability is absent. The BSN is the
|
||||
canonical case (art. 9 / special-category data):
|
||||
|
||||
- Default DTO carries a **masked** BSN (`******601`) or omits it entirely.
|
||||
- A `canRevealBsn` flag gates an explicit reveal action; reveal requires **step-up** (§5d) and is
|
||||
**audited** (§8).
|
||||
|
||||
> **Implementation note.** No **BSN** actually travels the wire in this POC (the BSN lives only in
|
||||
> the faked login and is never persisted). The sensitive identifier the backend *does* serve is the
|
||||
> **BIG-nummer** on the backoffice case screen (`CaseContextDto`), so the delivered field-level reveal
|
||||
> is realized there (`canRevealBigNummer`, `POST /brief/reveal-bignummer`) — the on-the-wire
|
||||
> equivalent of this BSN illustration.
|
||||
|
||||
Precedent already in the code: the client persists **only `naam`, never the BSN**, to `sessionStorage`
|
||||
(`src/app/auth/application/session.store.ts:40-47`) — this PRD generalizes that instinct to every PII
|
||||
field, enforced server-side.
|
||||
|
||||
### 5d. Step-up / segregation-of-duty
|
||||
|
||||
Expressed as **preconditions on a capability**, evaluated server-side:
|
||||
|
||||
- **SoD (four-eyes)** — already real for the brief: approve/reject require `actingId != drafterId`
|
||||
(`BriefStore.Review`, `backend/.../Data/BriefStore.cs:113-123`). Generalize to a reusable
|
||||
precondition, and enforce it against a **verified** principal instead of the `X-Role` header.
|
||||
- **Step-up (assurance)** — a capability may require a minimum MFA/assurance level or recent
|
||||
re-authentication (e.g. `registratie:reveal-bsn`). The DTO surfaces `requiresStepUp`; the UI prompts;
|
||||
the server re-checks the environment attribute before permitting.
|
||||
- **Break-glass** — an explicit, **time-boxed, heavily-audited** override attribute for emergency
|
||||
access. Modelled here, stubbed in the POC.
|
||||
|
||||
## 6. Frontend design ("in the UI")
|
||||
|
||||
The FE's job is to **mirror** server decisions cleanly and deny-by-default. It reuses existing patterns
|
||||
— no new libraries.
|
||||
|
||||
- **`Session` → `Principal`** (`src/app/auth/domain/`, per ADR-0002): the authenticated identity gains
|
||||
`roles: readonly Role[]` (from AD) and a resolved `capabilities: ReadonlySet<Capability>`. `Capability`
|
||||
is a branded/union string type in `shared/`. The FE treats capabilities as **opaque, server-resolved
|
||||
facts** — it stores them, it does not compute them from roles.
|
||||
- **`AccessStore`** (`src/app/shared/application/access.store.ts`, `providedIn:'root'`, built on the
|
||||
Elm `store.ts` + signals like `SessionStore`): holds the `Principal`. Two feeds:
|
||||
- **Global capabilities** (nav/menu visibility) from a small **`GET /me`** endpoint, loaded once at
|
||||
login as `RemoteData`.
|
||||
- **Screen capabilities** read from each screen's decision DTO (§5a) — no extra round-trip.
|
||||
- **`can(capability): boolean`** — a signal-friendly helper on `AccessStore`; unknown/absent capability
|
||||
⇒ `false` (deny-by-default).
|
||||
- **`capabilityGuard(cap): CanActivateFn`** — a factory guard extending the `authGuard` shape
|
||||
(`src/app/auth/auth.guard.ts`): authenticated **and** `access.can(cap)` ⇒ allow, else redirect /
|
||||
403 page. Wired in `app.routes.ts` alongside `authGuard`.
|
||||
- **Template gating** — declarative `@if (access.can('brief:approve')) { … }`. A `*appCan` structural
|
||||
directive is **optional** and only worth adding if the `@if` repeats enough to hurt (YAGNI until then).
|
||||
- **Retire the dev role hack** — `currentRole()` / `X-Role` (`src/app/shared/infrastructure/role.ts`,
|
||||
`role.interceptor.ts`) is replaced by Principal-derived capabilities. A dev **role/scenario toggle**
|
||||
may stay as a POC affordance, but behind the same `Principal` seam (it sets simulated capabilities),
|
||||
never read directly by feature code.
|
||||
|
||||
> **Non-negotiable:** none of the above is a security boundary. A user who forges `can()` in the
|
||||
> browser changes only what they _see_; every gated route, action, and field is independently enforced
|
||||
> by the backend (§7).
|
||||
|
||||
## 7. Backend design
|
||||
|
||||
Extends ADR-0001's decision-DTO pattern; closes the "fully open" gap.
|
||||
|
||||
- **Authenticate, then build a `Principal` server-side.** Replace the unverified `X-Role`/`X-Admin`
|
||||
headers with a verified principal derived from the AD claims (stubbed in the POC, real
|
||||
authn middleware later). Merge **AD roles + the app-owned overlay** into one `Principal` here — the
|
||||
FE never sees the merge.
|
||||
- **Resolve + enforce capabilities** in a single shared authorization helper (`Authz.Can(principal,
|
||||
action, resource, env)`), used **on every endpoint** — not merely to _emit_ flags but to _gate_ the
|
||||
operation. Forbidden ⇒ 403 (reuse the existing `Outcome.Forbidden → 403` mapping,
|
||||
`backend/.../Program.cs:330-335`). Emitting a flag and forgetting to enforce it is the classic
|
||||
broken-object-level-authorization bug; the helper makes emit and enforce the same code path.
|
||||
- **Publish decisions as DTO fields** on the screen DTOs (§5a) — the FE's only source of truth for what
|
||||
to render.
|
||||
- **Scope + redact at the source** (§5b, §5c): apply the scope filter in the query and redact PII in
|
||||
the mapper, so out-of-scope / unauthorized data never enters a response body.
|
||||
|
||||
## 8. Privacy & audit (the security-expert layer)
|
||||
|
||||
- **Data-minimized DTOs.** Ship resolved decisions + only-visible, already-scoped data. Never the
|
||||
permission matrix, AD group lists, or other subjects' attributes. Smaller payload = smaller attack
|
||||
surface and a smaller GDPR footprint.
|
||||
- **Server-side PII redaction** as the default; reveal is the exception, gated + stepped-up + logged.
|
||||
- **Audit log** of authorization-relevant events — denials, PII reveals, approvals/rejections,
|
||||
step-up, break-glass — recording acting principal, action, resource, decision, and timestamp. (An
|
||||
`Actor`/audit-entry seam is already noted in ADR-0002.)
|
||||
- **Avoid resource-existence enumeration.** For resources the subject may not even know exist, prefer
|
||||
**404 over 403** so the response doesn't confirm existence; use 403 only where existence is already
|
||||
known to the caller. Document the choice per endpoint.
|
||||
- **Break-glass is time-boxed and alerting** — every use raises an audit event and expires
|
||||
automatically.
|
||||
|
||||
## 9. Phasing
|
||||
|
||||
- **P1 — Capability spine.** `Principal` (roles + capabilities); `AccessStore` + `can()`;
|
||||
`capabilityGuard`; `GET /me`; capability flags on screen DTOs; enforce server-side via `Authz.Can`.
|
||||
Convert the brief drafter/approver gate from `currentRole()` to a real `brief:approve` capability
|
||||
(verified principal, keep the SoD `approver != drafter` check).
|
||||
- **P2 — Data + field.** Row-level scoping on list endpoints; server-side PII redaction + `canRevealBsn`.
|
||||
- _Field-level reveal delivered_ (WP-18 follow-up): the backoffice case screen ships the
|
||||
**BIG-nummer** masked (`Authz.CanRevealBigNummer` + `BriefDecisionsDto.CanRevealBigNummer`),
|
||||
revealed by the step-up-gated, audited `POST /brief/reveal-bignummer`. Realized on the
|
||||
BIG-nummer, **not the BSN** — see the §5c note. Row-level scoping (§5b) still unbuilt.
|
||||
- **P3 — Step-up & audit.** MFA/assurance preconditions, break-glass, and the authorization audit log.
|
||||
- _Audit log delivered (lite)_: `AuditAuthz` logs reveal attempts (allow/deny) and
|
||||
org-admin denials, no PII (§8). Step-up is stubbed as the `X-Step-Up` header (§5d); the
|
||||
`capabilityGuard` is wired onto the admin route (§6). MFA and break-glass still unbuilt.
|
||||
|
||||
## 10. Cross-references
|
||||
|
||||
- **ADR-0001** — BFF-lite endpoints + decision DTOs (the seam this PRD reuses for authz).
|
||||
- **ADR-0002** — identity vs authorization; `Principal` union; authz enforced backend-side, published
|
||||
as decision DTOs.
|
||||
- **PRD-0001** — the `Aanvraag` lifecycle whose actions (`beoordelen`, advance) these capabilities gate.
|
||||
Reference in New Issue
Block a user