Compare commits

..

42 Commits

Author SHA1 Message Date
f6c837f281 feat(fp): WP-26 — admin org-template editor
Some checks failed
CI / frontend (push) Failing after 57s
CI / storybook-a11y (push) Successful in 4m27s
CI / backend (push) Successful in 1m15s
CI / codeql (csharp) (push) Failing after 1m42s
CI / codeql (javascript-typescript) (push) Failing after 1m19s
CI / api-client-drift (push) Successful in 1m40s
CI / e2e (push) Failing after 3h11m33s
Edit the letter's org identity in place on the same canvas the drafter composes
on (editableRegions='template'): letterhead/signature/footer become inline
controls, content a read-only sample. Margins (bounded), logo upload (reuses the
shared upload transport + single-upload), version history + rollback, proefbrief,
and publish-with-impact-confirmation. House form-machine idiom
(org-template.machine.ts) + root store with debounced save. Capability-gated
(orgtemplate:edit) with a deny-by-default alert; route /brief/huisstijl.

Backend + generated client were already in place (WP-23). Also fixes a
pre-existing red check:tokens (WP-24 canvas hex fallbacks) and threads the
published logo through to the drafter's canvas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:22:55 +02:00
1bb9383344 feat(fp): WP-25 — server-rendered letter HTML preview
Adds LetterHtml.Render, a pure composer mirroring the FE letter canvas'
class vocabulary, behind two ExcludeFromDescription()'d endpoints
(GET /brief/preview, GET /admin/org-template/{subOrgId}/preview).
Auto-resolvable placeholders pull from seed/case data; unresolved
manual ones render as "[NOG IN TE VULLEN: label]". A sent brief
archives its composed HTML (BriefEntity.ArchivedHtml) so a later
org-template republish never changes it. FE gets a hand-written fetch
adapter (text/html, not JSON) and a "Voorbeeld" button that opens the
preview in a new tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 12:56:36 +02:00
c07a33ee3e feat(fp): WP-24 — letter canvas (edit on the letter)
Some checks failed
CI / frontend (push) Failing after 59s
CI / storybook-a11y (push) Successful in 4m22s
CI / backend (push) Successful in 1m18s
CI / codeql (csharp) (push) Failing after 1m47s
CI / codeql (javascript-typescript) (push) Failing after 1m20s
CI / api-client-drift (push) Successful in 1m42s
CI / e2e (push) Failing after 3h8m54s
One letter surface for every role: LetterCanvasComponent renders the
org template's letterhead/signature/footer around the case-type
sections, with editableRegions content|template|none. public/letter.css
is the FE⇄BE rendering contract (WP-25 inlines it verbatim).
letter-preview deleted — its read-only rendering absorbed into 'none'
mode. brief.machine.ts byte-identical; orgTemplate parses at the
adapter boundary and lives beside the machine in BriefStore.

Also fixes passage-picker multi-select (checkboxes all shared
id="undefined", so labels only toggled the first box) and keeps the
±page-break marks from drawing through canvas content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:47:51 +02:00
5a610c10f0 feat(fp): WP-23 — org-template backend + admin role
Second template axis (org identity: letterhead, footer, signature,
margins) server-side: OrgTemplateStore with JSON version history,
publish/rollback, sent-brief version pinning, admin role + capability,
5 admin endpoints, org-logo upload category. FE seam widened only
(Role/Capability unions, interceptor); WP-24/26 consume it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:17:05 +02:00
44eb2d2186 chore(deps): update npm packages within declared ranges; reformat for prettier 3.9.4
Some checks failed
CI / frontend (push) Successful in 1m46s
CI / storybook-a11y (push) Successful in 4m23s
CI / backend (push) Successful in 1m14s
CI / codeql (csharp) (push) Has been cancelled
CI / codeql (javascript-typescript) (push) Has been cancelled
CI / api-client-drift (push) Has been cancelled
CI / e2e (push) Has been cancelled
npm update brought every package to the latest version its existing package.json
range allows (Angular tooling 22.0.2/22.0.4 -> 22.0.5, prettier 3.8.4 -> 3.9.4,
typescript-eslint 8.62.0 -> 8.62.1); package.json itself needed no range changes.

Auditing actual deprecation warnings (not just outdated versions) found nothing
further to fix: @angular/platform-browser-dynamic and @angular-devkit/build-angular
are deprecated by Angular but still required peer dependencies of the latest
published @storybook/angular (10.4.6 — peer range still `>=18.0.0 < 22.0.0`,
already why .npmrc sets legacy-peer-deps); jest-process-manager/expect-playwright
are transitive-only through @storybook/test-runner's latest stable (0.24.4). No
newer version of either Storybook package exists yet that drops them. The
remaining npm audit advisory (@babel/core, low severity) is the same
already-documented, deliberately-left issue in README.md (fixing it downgrades
Angular). Left package.json's overrides untouched.

The prettier bump alone changed formatting opinions on files this session didn't
otherwise touch (a stale markdown italics marker, a few object-literal wrap
points) — reformatted everything so `format:check` (part of CI) doesn't regress.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 10:29:36 +02:00
556f2f47bf feat(fp): WP-22 — durable persistence (SQLite/EF Core)
Applications, documents (+ audit log) and the brief move off static in-memory
Dictionaries onto a real SQLite file via EF Core, so demo data survives a
process restart or `docker compose restart api` for the first time. The three
stores (ApplicationStore/DocumentStore/BriefStore) keep their exact public
signatures and static-class shape — no DI, no async ripple into Program.cs's
minimal-API handlers — each method just opens a short-lived AppDbContext via
Db.Create() under the same lock it already had. Opaque nested shapes (a
wizard's draft snapshot, a brief's sections/placeholders/status) are stored as
JSON text columns rather than redesigned into relational tables, matching the
existing "don't interpret it" posture.

Found two things the WP's own text got wrong, corrected in
docs/backlog/WP-22-durable-persistence.md's Deviations section: SeedData never
seeded these three stores (only the read-only BRP/DUO-mimicking GETs, which
stay in-memory) so there's no seed step; and no new docker-compose volume is
needed since the existing bind mount already covers the SQLite file — verified
against this environment's real podman-backed compose stack, not just by
reading the file.

Also: pinned SQLitePCLRaw.bundle_e_sqlite3 to 3.0.3 (EF Core Sqlite's own
transitive default bundles a pre-3.50.2 SQLite with a known high-severity
memory-corruption advisory); found and fixed a real xUnit test race where
concurrent test-class hosts stomped a shared static connection-string field,
fixed by disabling cross-class test parallelization rather than adding DI the
stores don't otherwise need.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 10:19:23 +02:00
40dbcb2606 feat(fp): WP-21 — resilience seams (correlation-id, idempotency, retry)
Correlation id becomes real ASP.NET Core middleware instead of a per-endpoint
read: every request gets one (client-supplied or generated), it's echoed as
an X-Correlation-Id response header, and pushed into the logging scope so
every log line for that request carries it — not just the Submit helper's,
verified against LogBrief which never threads it explicitly.

Idempotency-Key moves from per-HTTP-attempt (defeating its own purpose) to
per-logical-submit: runSubmit mints one key and threads it through a small
bridge (withIdempotencyKey/currentIdempotencyKey) since the NSwag-generated
client has no per-call header hook. Backend gains an IdempotencyStore that
short-circuits a replayed key to the first call's result instead of minting
a second reference — scoped to the Submit-helper endpoints per the WP's own
decision.

GET requests now retry transient failures (rxjs retry({count:2, delay:500}));
writes never auto-retry. Proven with a fake-HttpClient spec
(api-client.provider.spec.ts) rather than a manual network-tab check — the
WP's suggested `?scenario=error` check turned out not to exercise a real
network call at all (the interceptor throws before calling next()), so the
automated test is the actual proof.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 20:03:41 +02:00
e276629107 feat(fp): WP-20 — second locale proof (nl/en build seam)
angular.json gains an i18n block (sourceLocale nl, en translation file) and
an `en` build/serve configuration with i18nMissingTranslation: "error" so a
new $localize string without an English unit fails the build, not silently
falls back. CI now runs `ng build --localize` to build both locales every
run. Verified end-to-end, not just "the build succeeded": the nl bundle
ships "Inloggen met DigiD", the en bundle ships "Log in with DigiD".

Incidental: prettier/compodoc regen noise in docs/wcag-checklist.md,
src/docs/a11y.mdx, documentation.json from the same working session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 18:16:11 +02:00
26c2c5acd0 feat(fp): WP-19 — Playwright e2e smoke against the real FE+backend
Adds a happy-path spec (login → dashboard → registratie wizard, including
a real identity-document upload → real submit) and a degraded-path spec
(?scenario=error → <app-async> error slot → retry), both driving the real
app against the real .NET backend, plus a CI job that boots both.

Writing the retry spec surfaced a real bug: AsyncComponent's retry() only
reloads a [resource]-fed instance, so every real page (all [data]-fed via
a store's RemoteData) had a silently no-op retry button. Added a
retryClicked output and wired it on the dashboard's two async blocks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 10:13:40 +02:00
e272869f00 feat(fp): WP-17 — app-level a11y: route focus, template lint, WCAG checklist
Adds route-change focus management (new page's h1, afterNextRender) plus
scroll-position restoration wired once in app.config.ts; angular-eslint's
templateAccessibility bundle linting every inline template via
processInlineTemplates (verified firing with a planted violation, one real
hit fixed in rich-text-editor); docs/wcag-checklist.md and Foundations/
Accessibility MDX tying the four a11y layers (axe, lint, play tests,
manual checklist) together. The checklist pass already earned its keep —
it found a real 320px overflow in aanvraag-block's warning alert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 08:26:01 +02:00
f3de30b72c feat(fp): WP-16 — component a11y: description wiring + alert role
Wires text-input's aria-describedby to the form-field description div
(the BSN hint was rendered but never announced), pins desc-before-error
ordering, and switches alert to role=alert for errors vs role=status
for info/ok/warning. Composition contract enforced by story play tests
(form-field+text-input, alert per variant) run in the WP-01 CI gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 08:15:24 +02:00
85c805b8bd docs(backlog): backfill WP-15 commit hash
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 23:38:07 +02:00
0cfb01f12c feat(fp): WP-15 — missing stories: shell + brief components
Add the 7 stories CLAUDE.md's testing rule ("UI is exercised via Storybook
stories") was missing: shared/layout/shell (Design System/Templates/Shell)
and all six previously-unstoried brief components (passage-picker,
rejection-comments, diagnostics-panel, letter-block, letter-preview,
letter-section — Domein/Brief/*), each with a default state plus the
meaningful variants (locked/editable, findings/clean, show/entry, etc).
Every *.component.ts in the repo now has a co-located story; *.page.ts
files stay unstoried, matching the existing norm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 23:37:58 +02:00
ac1f0b6aeb docs(backlog): backfill WP-14 commit hash
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 23:32:26 +02:00
8b19fad558 feat(fp): WP-14 — Storybook taxonomy reorg + Layers MDX
Retitle all 49 stories into a sidebar that makes the DDD seam visible:
Foundations (curriculum) -> Design System (Atoms/Molecules/Organisms/
Templates/Devtools, everything in shared/ui + shared/layout) -> Domein
(Registratie/Herregistratie/Auth/Brief, everything in a context's ui/).
Pin the order via storySort. Add layers.mdx explaining the split and
linking the enforcing eslint rules; document the story-title convention
in CLAUDE.md. Fix a stale "status banner" reference in atomic-design.mdx
left over from WP-13's upload-status-banner deletion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 23:32:19 +02:00
cbf697b8fa docs(backlog): backfill WP-10/WP-13 commit hashes
Some checks failed
CI / storybook-a11y (push) Successful in 4m2s
CI / backend (push) Successful in 1m2s
CI / codeql (csharp) (push) Failing after 1m40s
CI / frontend (push) Failing after 54s
CI / codeql (javascript-typescript) (push) Failing after 1m23s
CI / api-client-drift (push) Successful in 1m30s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:42:26 +02:00
9d58f597ea feat(fp): WP-13 — CIBG-gap register + hygiene + MDX
Mark every hand-rolled shared/ui surface with a `// CIBG-GAP EXTENSION:`
comment + `cibgGap` story parameter (skeleton, spinner, rich-text-editor,
wizard-shell's error summary, application-link's non-navigating row,
debug-state, status-badge, card, placeholder-chip) so deviations from the
CIBG design system are auditable. Add the register MDX
(Foundations/CIBG Gap Register), cross-linked from ADR-0003. Delete the
near-identity upload-status-banner wrapper; its one consumer now uses
<app-alert> directly (a story added to keep the info-banner state covered).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:42:13 +02:00
69880efd38 feat(fp): WP-10 — CIBG button fidelity
Fix button atom's dead .btn-outline-primary → .btn-secondary; add 'ghost'
variant (.btn-ghost, CIBG-documented). RTE toolbar drops invented
.btn-outline-secondary/.btn-sm for .btn-ghost. file-input already used the
correct vendored .btn-upload pattern from the earlier CIBG UI fidelity
pass — no change needed there (documented as a deviation).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:34:28 +02:00
8078c499cb feat(fp): WP-09 — pure-logic closure: dates + missing command specs
Consolidate four hand-rolled nl-NL date formatters (tasks.ts, aanvraag-
block, letter-preview, aanvraag-view -- one more than the WP found) into
one shared/kernel/datum.ts::formatDatumNl, spec-pinned and empty-safe.
Add the two missing command specs CLAUDE.md's testing rule calls for:
draft-sync.spec.ts (debounce coalescing + trailing-call + submit Result
shape, via fake timers) and submit-change-request.spec.ts. Remove the
unused RemoteData.map3 (updating the three docs that mentioned it); the
variant input on confirmation.component.ts was already gone. Documents
both stale-WP-text corrections in the backlog file.

This closes out backlog Phase 1 (FP/DDD core, WP-05..09).
2026-07-03 22:02:50 +02:00
0d623f90e8 feat(fp): WP-08 — one store idiom + machine naming + TEA MDX
Rename change-request.machine.ts's bare State/Msg to ChangeRequestState/
ChangeRequestMsg (the last machine not context-prefixed), document the
createStore-is-the-idiom + naming convention in CLAUDE.md §3, and add the
Foundations/State Machines (TEA) curriculum page. The wizard pages already
wired createStore (confirmed by reading each and by git log) -- the WP's
"hand-wired signal(model)" premise was stale; recorded as a deviation.
2026-07-03 21:50:53 +02:00
e3cd908f4f feat(fp): WP-07 — brief on the shared idioms + RemoteData MDX
Collapse brief.store's busy signal + nullable lastError into one Idle |
Busy | Failed union (saveState gets matching tag-object style), and route
brief.page's load through RemoteData + <app-async> instead of a hand-rolled
@switch, via a BriefStore.remoteData projection of the machine's existing
loading/failed tags -- the machine keeps owning the letter's own status
lifecycle untouched. New brief.store.spec.ts covers the Busy->Idle/Failed
transitions; new Foundations/RemoteData & Async MDX page documents the
pattern and the WP-06 typed-loaded-slot fallback. Deviation from the
original plan recorded in the WP file.
2026-07-03 21:39:29 +02:00
199cbe1f8c feat(fp): WP-06 — kill $any() in templates (18x)
Make AsyncLoadedDirective generic with a static ngTemplateContextGuard for
AsyncComponent's own internal typing. That can't propagate to consumer
`<ng-template appAsyncLoaded let-p>` sites though -- Angular only infers a
structural directive's type parameter from an input bound on that same
node, not from a sibling input on the parent component -- so the ~9
root-cause consumers (dashboard, registration-detail, aanvraag-detail,
registratie-wizard) instead unwrap the RemoteData Success value via a
typed computed() and narrow it locally with `@if (x(); as p)`. The
remaining union-narrowing casts (registration-summary, showcase concepts
page) are replaced with a stable @let binding and a direct resource read,
respectively. Documented as a deviation in WP-06's backlog file.
2026-07-03 21:27:01 +02:00
34d34512b3 feat(fp): WP-05 — parse-don't-validate closure + MDX
Close the three remaining unvalidated `as <DomainType>` casts at the wire
boundary (intake-policy, big-register aantekening type, brief passage scope),
each replaced by a Result-returning parser with a rejection-case spec, plus
the Foundations/Parse, don't validate curriculum page.
2026-07-03 21:02:15 +02:00
5d6a78d4ec docs(backlog): record WP-18 commit hash
Some checks failed
CI / frontend (push) Successful in 1m22s
CI / backend (push) Successful in 1m3s
CI / api-client-drift (push) Successful in 1m36s
CI / storybook-a11y (push) Successful in 4m4s
CI / codeql (csharp) (push) Failing after 1m45s
CI / codeql (javascript-typescript) (push) Failing after 1m25s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 20:32:03 +02:00
7ec13d8b59 feat(brief): WP-18 — ABAC capability spine (PRD-0002 phase P1)
Replace the FE-computed authorization anti-pattern in BriefStore.editable
(derived from the unverified X-Role header) with server-computed decision
flags, mirroring the existing HerregistratieDecisionsDto pattern:

- Backend: Authz.cs is the single authorization helper — the SAME check
  (Authz.CanActOn) both gates BriefStore.Review's mutations and computes
  the BriefDecisionsDto flags shipped on every brief response, so emit
  and enforce can never drift. New GET /me returns coarse, role-derived
  capabilities (PRD-0002 SS6).
- Every brief endpoint (including send, previously ungated on HttpContext)
  now returns a fresh BriefViewDto so decisions never go stale after a
  mutation.
- FE: brief.store.ts reads canEdit/canApprove/canReject/canSend off the
  loaded decisions instead of computing them from currentRole(); the
  brief.machine carries decisions through every status transition.
- New shared/domain/capability.ts + shared/application/access.store.ts +
  shared/infrastructure/me.adapter.ts: the general capability-spine
  infrastructure (AccessStore.can(), capabilityGuard) for future routes.

Deviates from the original WP-18 draft by NOT renaming auth/domain's
Session to a Principal union — ADR-0002 explicitly defers that refactor
until a second actor exists, and the brief workflow's drafter/approver
identity turned out to be a separate axis from the SSP login session
entirely. See docs/backlog/WP-18-abac-capability-spine.md for the full
as-built record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 20:31:53 +02:00
cbb8ae548c docs(backlog): add WP-18..22 (productie-volwassenheid phase)
Some checks failed
CI / storybook-a11y (push) Successful in 4m12s
CI / backend (push) Successful in 1m6s
CI / api-client-drift (push) Successful in 1m37s
CI / frontend (push) Successful in 1m31s
CI / codeql (csharp) (push) Failing after 1m51s
CI / codeql (javascript-typescript) (push) Failing after 1m24s
Gap analysis found the POC's designed-but-unbuilt strategic gaps: ABAC
authorization (ADR-0002/PRD-0002 phase P1), no e2e coverage, unproven
i18n second-locale seam, thin resilience seams (correlation-id,
idempotency, retry), and in-memory-only persistence. Each WP is grounded
in the current code (file paths + line numbers), not just the analysis.

Also corrects PRD-0001's stale 'Proposed' status header — the Mijn
aanvragen vertical is fully built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 20:07:05 +02:00
bf920696ac ci: harden workflow + add security scanning and format gates
Some checks failed
CI / storybook-a11y (push) Successful in 4m3s
CI / backend (push) Successful in 1m1s
CI / codeql (csharp) (push) Failing after 39m18s
CI / codeql (javascript-typescript) (push) Failing after 1m22s
CI / frontend (push) Successful in 1m25s
CI / api-client-drift (push) Successful in 1m34s
- permissions: contents:read (least privilege), concurrency cancel,
  scope push to main+tags (was: every branch, double-running with PRs),
  per-job timeout-minutes.
- security: npm audit --omit=dev, CodeQL SAST (TS + C#), Dependabot
  (npm/nuget/actions).
- format: npm run format:check + dotnet format --verify-no-changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:39:31 +02:00
1137f59f7b style: format backend with dotnet format
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:39:31 +02:00
e82309786d style: format frontend, docs and skills with prettier; add .prettierignore
One-time prettier --write so the new format:check CI gate starts green.
.prettierignore excludes generated (api-client.ts, documentation.json),
vendored (public/cibg-huisstijl), and backend (dotnet format owns it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:39:31 +02:00
546097434d fix(ci): point backend test at BigRegister.slnx (solution renamed from .sln)
All checks were successful
CI / frontend (push) Successful in 1m12s
CI / storybook-a11y (push) Successful in 4m5s
CI / backend (push) Successful in 43s
CI / api-client-drift (push) Successful in 1m31s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:43:44 +02:00
4ba0a020f3 docs: regenerate Compodoc documentation.json
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:36:26 +02:00
922f9ec8cf docs: README + Storybook mdx describe the CIBG Huisstijl setup
Replace stale @rijkshuisstijl-community package/theming claims with the
vendored CIBG Huisstijl + token-bridge reality (ADR-0003); system-font
stack instead of Fira Sans; embed the now-existing document-upload story
in atomic-design.mdx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:44:00 +02:00
cf44bda0ce docs(skills): extract house recipes as Claude Code skills for SSP templating
Some checks failed
CI / frontend (push) Successful in 2m16s
CI / storybook-a11y (push) Successful in 4m6s
CI / backend (push) Failing after 48s
CI / api-client-drift (push) Successful in 1m32s
8 template-generic skills in .claude/skills/ (new-feature, new-context,
value-object, form-machine, bff-endpoint, mutation-command, ui-component,
new-ssp), condensed from CLAUDE.md/ARCHITECTURE/fp-tea/ADRs and pointing at
this repo's worked examples. CLAUDE.md gains a pointer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:10:38 +02:00
0f360c5939 fix(cibg): datablock row separators + filled Bestand-upload button
- Datablock entries had no separators: CIBG ships the border on dt/dd with a
  :last-of-type reset, but the one-row-per-<div> grouping (for axe) made every
  dt/dd a last-of-type, stripping them all. Carry the separator on the row
  (:host:not(:last-of-type)) in the CIBG line colour (#f1f5f9 = cool-grey-200).
- Upload "Bestand toevoegen" button read as a pale outline: btn-outline-primary
  forced a transparent background over .btn-upload's accent fill. Use
  btn-primary btn-upload (solid #01689b, white text, folder glyph) and drop the
  redundant centred background-image folder.

GREEN: lint, tokens, 183 tests, build, 137 axe stories. Verified via Storybook
screenshots (datablock separators, filled blue upload button).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 08:46:06 +02:00
05314afd98 docs(backlog): WP-11 + WP-12 done; note WP-13 upload correction
The CIBG UI fidelity pass completed WP-11 (aanvragen/application-link) and WP-12
(Datablock), and reworked the upload suite to wrap vendored CIBG classes rather
than mark it as a gap (WP-13's assumption corrected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:56:18 +02:00
ccc0184342 feat(aanvragen): richer rows (purpose + status) linking to a case-detail page
- Aanvragen rows now show what the aanvraag is for (purpose subtitle) and an
  explicit status label (In behandeling / Goedgekeurd / Afgewezen) alongside the
  reference + submit date, via an expanded aanvraag-view (purposeLabel,
  statusLabel, referentie, detailRows) + spec.
- Rows link to a new /aanvraag/:id case-detail page, so the CIBG chevron shows
  and each aanvraag opens as a (stub) case — it lists soort/waarvoor/status/
  referentie/ingediend in a Datablock, with a note that full handling is future.

GREEN: lint, tokens, 183 tests, build, 137 axe stories. Verified visually
(dashboard aanvragen rows, upload drop-zone, datablock) via Storybook screenshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:55:21 +02:00
b5c5d30a65 feat(cibg): adopt the vendored CIBG Bestand-upload component
The upload suite now wraps the vendored CIBG file-upload classes instead of
hand-rolling from tokens:
- file-input becomes a .file-picker-drop-area with drag-and-drop + a real
  .btn-upload button and a visually-hidden, focusable <input>; renders an
  always-visible instruction (allowed types + max size) linked to the input via
  aria-describedby (pattern requirement).
- The file list is a semantic ul.file-list; single-upload is an
  li[app-single-upload] .file-container (native <li> child) with .actions
  (retry/.icon-remove) + progress; document-chip renders the .file block
  (status glyph, .file-name link, .file-meta size/status).
- Category validation moves ABOVE the block as .upload-validation > .feedback.
- Instruction text uses the default (not subtle) foreground for WCAG AA contrast
  on the grey drop-area.

DocumentUpload's public inputs/outputs are unchanged — both wizards keep working.
GREEN: lint, tokens, 181 tests, build, 137 axe stories.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:47:25 +02:00
98fd7e4bcd feat(cibg): WP-11 — render "Mijn aanvragen" as the CIBG Aanvragen component
- application-link switches to a `li[app-application-link]` attribute selector
  (native <li> child of the <ul> — axe-clean list) and drops the invented,
  dead `.application` / `.application-title` classes for the real vendored
  `.dashboard-block.applications li a` chain (h3.h3 / .subtitle / .status / .cta).
  Content stacks in a flex column; a non-navigating row mirrors the card surface
  from tokens. Re-enables a11y on the application-link/list stories.
- Dashboard "Mijn aanvragen" now renders through app-application-list +
  <li app-application-link> rows (was a keuzelijst), mapped by a new pure
  submittedRow() view helper (+ spec). Concepts stay the resumable melding.
- aanvraag-block is now concept-only (submitted mapping moved to aanvraag-view).

WP-11 grep gate clean. GREEN: lint, tokens, 181 tests, build, 136 axe stories.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:34:21 +02:00
82fc3c493d feat(cibg): WP-12 — CIBG Datablock for application data
Adopt the vendored CIBG Datablock (.data-block / .block-wrapper) as the way to
show application data:
- New app-data-block molecule (grey surface + white panel + projected rows,
  optional heading, stacked variant, aria-label) + stories.
- data-row switches to a `div[app-data-row]` attribute selector so the <dl>'s
  direct child is a native <div> (HTML5.1 dl > div > dt+dd). This makes the
  definition list axe-clean — a bare custom element between <dl> and its dt/dd
  trips axe's definition-list rule regardless of display:contents, a defect the
  dashboard shipped live. Re-enables a11y on the data-row / review-section /
  registration-summary stories (previously disabled pending this rework).
- review-section folds onto app-data-block (drops its hand-carried classes).
- registration-summary + dashboard "Persoonsgegevens (BRP)" drop app-card and
  render as datablocks; both wizards' review rows + the beroep row convert to
  the div selector.

GREEN: lint, check:tokens, 178 tests, build, build-storybook, 136 axe stories.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:25:57 +02:00
947d5fa90a docs(backlog): record WP-04 commit hash
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:01:49 +02:00
035e785c95 feat(boundaries): WP-04 — ui ↛ infrastructure + showcase sanction
Move the two wizard lookups behind application-layer facades so ui/ no longer
injects infrastructure adapters directly:
- RegistratieLookupStore (BRP address + DUO diplomas): owns the resources,
  runs the trust-boundary parse, exposes adresStatus/prefillAdres/duoLookup.
- IntakePolicyStore (scholing threshold): owns the policy resource, exposes
  the derived threshold.

Add the lint rule ui/ + layout/ ↛ **/infrastructure/** (@typescript-eslint
variant so it composes with the base direction rules; stories/specs exempted
as test scaffolding). Add the documented showcase sanction (may read every
context). Fix the docs' inventory: 6 contexts / 5 layers, +brief, +contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:01:42 +02:00
3a5c8f157a fix(wizard): don't animate silent ?aanvraag stamp — Firefox double-click on Next
The inschrijven/herregistratie wizards needed two clicks on "Volgende" to advance
in Firefox (and Zen). Root cause: picking a field (e.g. correspondentie) triggers
draft-sync's debounced create, which router.navigate()s to stamp ?aanvraag=<id>
into the URL. withViewTransitions() animated that same-route navigation, and for
the transition's duration Firefox's ::view-transition overlay swallows pointer
events (confirmed: elementFromPoint over the button returns the overlay, not the
button). Chrome sets pointer-events:none on the overlay so clicks pass through —
hence Firefox-only.

Fix: skip the view transition for same-route navigations (compare leaf routeConfig)
so the silent id-stamp doesn't animate; genuine page-to-page transitions still fade.

Verified in Firefox: page navs still animate (skipped:0), the ?aanvraag stamp is
skipped, and Post → single Next advances step 1 → 2. GREEN + a11y.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:51:11 +02:00
330 changed files with 36086 additions and 10263 deletions

View File

@@ -0,0 +1,58 @@
---
name: bff-endpoint
description: Add a screen-shaped read — backend endpoint returning a decision-enriched DTO, regenerated typed client, contracts DTO, adapter with parse boundary, application store, <app-async> in the page. Use for any new data a page needs.
---
# BFF-lite read endpoint (decision DTO)
One screen = one endpoint returning everything that screen needs, **decisions
included**. The FE renders the server's decisions; it never recomputes business
rules (ADR-0001). Per rule choose: **decision flag** (server computes the boolean)
or **config value** (server sends the threshold, FE applies it for instant feedback,
server re-validates as authority).
## Steps
1. **Backend** (`backend/src/BigRegister.Api/`): minimal-API endpoint under `/api/v1`,
wire DTO in `Contracts/`, rule in `Domain/` with a test in
`backend/tests/BigRegister.Tests/`. Rejections are ProblemDetails (RFC 7807, 422).
2. **Regenerate the client**: `npm run gen:api` — commits `backend/swagger.json` +
`src/app/shared/infrastructure/api-client.ts` (CI has a drift check; never edit the generated file).
3. **DTO** in `<context>/contracts/<name>.dto.ts` — plain interfaces/string-literal
unions. Contracts import **nothing** (lint-enforced): no Angular, no aliases, no relative imports.
4. **Adapter** in `<context>/infrastructure/<name>.adapter.ts` — the only place HTTP lives:
```ts
@Injectable({ providedIn: 'root' })
export class DashboardViewAdapter {
private client = inject(ApiClient);
viewResource() { return resource({ loader: () => this.client.dashboardView() }); }
}
// The trust boundary: validate the untrusted shape AND map DTO → domain.
export function parseDashboardView(json: unknown): Result<string, DashboardView> { … }
```
The mapping may read as an identity copy today — the point is the **types** differ,
so the compiler forces a real mapping the moment the wire diverges.
5. **Application store** exposes the resource as `RemoteData` (`fromResource`,
combine sources with `map`/`map2`/`andThen` from `@shared/application/remote-data`).
UI never imports infrastructure (lint-enforced).
6. **Page** renders through `<app-async>` (`shared/ui/async`) — one of
loading/empty/error/loaded by construction. Check all four states with the
dev-only `?scenario=slow|loading|empty|error` toggle.
## Worked example (trace one name through all layers)
`dashboard-view`: `backend/src/BigRegister.Api` endpoint →
`src/app/registratie/contracts/dashboard-view.dto.ts` →
`src/app/registratie/infrastructure/dashboard-view.adapter.ts` (+ spec on `parseDashboardView`) →
`src/app/registratie/application/big-profile.store.ts` → dashboard page.
## Verify
```bash
cd backend && dotnet test && cd ..
npm run gen:api && git diff --exit-code src/app/shared/infrastructure/api-client.ts
npm test && npm run lint && npm run build
```

View File

@@ -0,0 +1,84 @@
---
name: form-machine
description: Add a form or wizard as an Elm-style state machine (Model/Msg/pure reduce) — the one idiom for anything with validation or submission, one step or many. Use instead of hand-rolled mutable fields + ad-hoc error signals.
---
# Form machine (Model / Msg / reduce)
If you're about to add a second boolean to track state, stop — model a discriminated
union. Fields exist only in the states that need them, so illegal states are
unrepresentable.
## Skeleton
`<context>/domain/<name>.machine.ts` (pure TS, no Angular imports):
```ts
import { Result, assertNever } from '@shared/kernel/fp';
export interface Draft {
postcode: string;
uren: string;
} // raw strings as typed
export type StepErrors = Partial<Record<keyof Draft, string>>;
export interface Valid {
postcode: Postcode;
uren: Uren;
} // branded, proven valid
export type State =
| { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
export type Msg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Next' }
| { tag: 'Back' }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Seed'; state: State }; // mount any state (stories, resume)
export const initial: State = { tag: 'Editing', step: 1, draft: emptyDraft, errors: {} };
export function reduce(s: State, m: Msg): State {
switch (m.tag) {
/* … pure transitions only … */
default:
return assertNever(m); // exhaustiveness enforced
}
}
export function validate(draft: Draft): Result<StepErrors, Valid> {
/* calls value-object parsers */
}
```
Rules:
- **Reducer stays pure.** HTTP lives in a command that dispatches `SubmitConfirmed` / `SubmitFailed` (see **mutation-command** skill).
- **Derive, don't store**: anything computable from answers is a pure function (`visibleSteps(answers)`), never a stored field.
- Server-owned thresholds arrive as config values; keep only an offline fallback constant (see `SCHOLING_THRESHOLD_DEFAULT` in the intake machine).
- Co-located `.machine.spec.ts` is **required**: drive `reduce` with messages, assert states. No TestBed.
## Wiring in the UI
The organism holds `createStore(initial, reduce)` (`@shared/application/store`) as a
field initializer, derives view state via `computed` + `whenTag(state, 'Editing')`,
and renders into `<app-wizard-shell>` (`shared/layout/wizard-shell`) — status, steps,
errors, and primary/back/retry outputs map 1:1 onto the machine.
## Worked examples
- `src/app/herregistratie/domain/herregistratie.machine.ts` — canonical multi-step + submit lifecycle.
- `src/app/herregistratie/domain/intake.machine.ts` — progressive disclosure, derive-don't-store, server-owned threshold.
- `src/app/herregistratie/ui/` — the wizard organism + page composition.
## Verify
```bash
npm test && npm run lint && npm run build
```

View File

@@ -0,0 +1,60 @@
---
name: mutation-command
description: Add a write/submit operation — infrastructure adapter method plus an application command factory returning Result, keeping the reducer pure. Use for any POST/PUT/DELETE a form or action triggers.
---
# Mutation command
Reducer = "what the new state is"; command = "go do it, then say what happened".
The UI holds an application command, never the network client (lint-enforced).
## Skeleton
**Adapter method** (`<context>/infrastructure/<name>.adapter.ts`) — takes the
machine's `Valid` type, returns the server's answer, no parse (server is authority):
```ts
@Injectable({ providedIn: 'root' })
export class ChangeRequestAdapter {
private client = inject(ApiClient);
async changeRequest(data: Valid): Promise<string> {
/* → server reference */
}
}
```
**Command factory** (`<context>/application/submit-<name>.ts`) — binds the adapter in
an injection context; `runSubmit` is the one try/catch + ProblemDetails mapping:
```ts
export function createSubmitChangeRequest() {
const adapter = inject(ChangeRequestAdapter);
return (data: Valid): Promise<Result<string, string>> =>
runSubmit(() => adapter.changeRequest(data), SUBMIT_FAILED);
}
```
**Caller** (organism, field initializer — same shape as `createStore`): on the
machine's `Submitting` state, run the command, then dispatch
`SubmitConfirmed` / `SubmitFailed { error }`. Never throw into the template.
## Optimistic updates on shared state
When a root store's view must reflect the write before the server confirms:
`begin*` (flip pending) → `confirm*` (clear + `resource.reload()`) / `rollback*`
(undo). See `src/app/registratie/application/big-profile.store.ts`.
## Worked examples
- `src/app/registratie/infrastructure/change-request.adapter.ts`
- `src/app/registratie/application/submit-change-request.ts`
- `src/app/registratie/application/draft-sync.ts``createDraftSync(...)` bundles
draft persistence + submit for wizard flows; prefer it when the form has a draft.
- `src/app/shared/application/submit.ts``runSubmit`, `SUBMIT_FAILED`.
## Verify
```bash
npm test && npm run lint && npm run build
# exercise the failure path in the browser: ?scenario=error on the submitting page
```

View File

@@ -0,0 +1,51 @@
---
name: new-context
description: Scaffold a new DDD bounded context (folders, path alias, eslint boundary rules, lazy route). Use when adding a new business capability that doesn't belong in an existing context.
---
# New bounded context
A context is a business **capability**, not a user group — a user group is an actor
that may span contexts (ADR-0002). Check first whether the capability belongs in an
existing context; new contexts are rare.
Naming: domain contexts are **Dutch** (`registratie`, `herregistratie`); only
shared/reusable code is English. The context name is the ubiquitous language term.
## Steps
1. **Folders**`src/app/<ctx>/{domain,application,infrastructure,ui}` (`contracts/`
only once it gets a wire seam). Empty layers can wait; don't scaffold placeholders.
2. **Path alias** — add `"@<ctx>/*": ["src/app/<ctx>/*"]` to `tsconfig.json` `paths`.
Aliases are direction statements; always import cross-context via the alias.
3. **eslint boundaries** (`eslint.config.mjs`) — dependencies point inward and
toward `shared` only. Copy the existing per-context block (the `brief` block is
the minimal leaf-context example) and:
- add a block for `src/app/<ctx>/**/*.ts` banning imports from every context it
may **not** depend on;
- add `@<ctx>/*` to the ban lists of `shared/**` and every context that must not
depend on the new one (grep the config for `@brief/*` to find all lists);
- the generic blocks (`domain/**` framework-free, `contracts/**` import-nothing,
ApiClient confinement, `ui/**` never imports `infrastructure`) match by glob
and cover the new context automatically.
4. **Route** — lazy child under the persistent shell in `app.routes.ts`:
```ts
{ path: '<ctx>', canActivate: [authGuard],
loadComponent: () => import('@<ctx>/ui/<ctx>.page').then(m => m.CtxPage) }
```
5. Build the first feature slice with the **new-feature** skill.
## Worked example
`src/app/brief/` — an independent leaf context (depends only on shared): see its
folder layout and its eslint block in `eslint.config.mjs`.
## Verify
```bash
npm run lint && npm run build
# prove the fence works: add a forbidden import (e.g. new ctx → @herregistratie/*),
# confirm lint fails, remove it.
```

View File

@@ -0,0 +1,46 @@
---
name: new-feature
description: Add a feature (screen, flow, capability) to this Angular SSP following the house pipeline — domain first, then infrastructure, application, UI last. Use whenever building anything user-facing that has business rules or data.
---
# New feature (the pipeline)
Every feature is built inward-out. Never start with the component.
## Steps
1. **Domain** (`<context>/domain/`, pure TS, no Angular imports — lint-enforced)
- Data types + pure rules. Forms/wizards get a `*.machine.ts` → use the **form-machine** skill.
- Raw input that must be valid → branded value object → use the **value-object** skill.
- Co-locate a `.spec.ts`; test the pure functions directly, no TestBed.
- Server-owned rules stay here only as reference impl + test, marked server-owned; the FE renders the server's decision (ADR-0001).
2. **Infrastructure** (`<context>/infrastructure/`, the only layer that touches HTTP)
- Read → screen-shaped endpoint + adapter → use the **bff-endpoint** skill.
- Write → command adapter returning `Result` → use the **mutation-command** skill.
3. **Application** (`<context>/application/`)
- Shared cross-page state → `providedIn: 'root'` store exposing `RemoteData` signals.
- Page-local flow state → `createStore(initial, reduce)` from `@shared/application/store`, held in the component.
- Side effects live in commands (`submit-*.ts`), never in the reducer.
4. **UI last** (`<context>/ui/`)
- A page is **composition of existing blocks**`<app-page-shell>`, `<app-async>`, atoms/molecules from `shared/ui`. Adding a new building block is the exception → **ui-component** skill.
- Templates dispatch messages, never mutate. Async data always renders through `<app-async>`.
- All copy via `$localize` with stable `@@context.key` ids.
- Route: lazy `loadComponent` under the `ShellComponent` parent in `app.routes.ts`, `canActivate: [authGuard]` when protected.
## Worked example
The intake wizard slice, end to end:
- `src/app/herregistratie/domain/intake.machine.ts` (+ spec)
- `src/app/herregistratie/infrastructure/`
- `src/app/herregistratie/ui/` (wizard component + page)
## Verify (GREEN gate)
```bash
npm run lint && npm run check:tokens && npm test && npm run build
cd backend && dotnet test # if the backend changed
```

View File

@@ -0,0 +1,55 @@
---
name: new-ssp
description: Bootstrap a new self-service portal from this repo as a template — what to keep, strip, rename, and re-seed. Use when starting a new SSP for a different domain/register.
---
# New SSP from this template
The template's value is the **enforced architecture** (layer fences, token gate,
a11y gate, API-drift gate) and the shared building blocks — not the BIG-register
business content. Keep the machinery, replace the domain.
## Keep as-is
- `src/app/shared/` — kernel (`fp.ts`), application (`remote-data`, `store`,
`submit`), ui atoms/molecules, layout templates, upload subtree.
- Tooling: `eslint.config.mjs`, `scripts/check-tokens.sh`, `.github/workflows/ci.yml`,
`nswag.json`, `.storybook/`, `proxy.conf.json`, `.npmrc` (`legacy-peer-deps`
and never `npm audit fix --force`, it downgrades Angular).
- `src/app/auth/` (fake auth shell) and `src/app/shared/infrastructure/scenario.interceptor.ts` (dev-only).
- `docs/architecture/` ADRs 00010003 — the decisions still apply; amend, don't delete.
- `CLAUDE.md`, `docs/ARCHITECTURE.md`, `docs/fp-tea-atomic-design.md` — update names/examples as contexts change.
- `.claude/skills/` — these recipes are the point of the template.
## Strip / replace
- Business contexts `registratie/`, `herregistratie/`, `brief/`, and `showcase/`:
delete or keep one slice temporarily as the worked example while building the
first real context (**new-context** + **new-feature** skills). If deleted, update
the worked-example paths in these skills to the new flagship context.
- `app.routes.ts` routes and `tsconfig.json` aliases for removed contexts, plus
their eslint blocks in `eslint.config.mjs`.
- Backend: keep the skeleton (`Program.cs` minimal-API style, ProblemDetails 422,
`X-Correlation-Id` audit line, `/api/v1` versioning, `Contracts/`/`Domain/`/`Data/`
split, test project) — replace `Data/SeedData.cs`, `Domain/*` rules, and
`Contracts/*` DTOs with the new domain's. Rename the solution/projects from
`BigRegister.*` (also update `package.json` `gen:api` and `ci.yml` paths).
- Regenerate the seam: `npm run gen:api` (commits `backend/swagger.json` +
`src/app/shared/infrastructure/api-client.ts`).
- Branding: `public/cibg-huisstijl/` + the token bridge in `src/styles.scss` — for a
different house style, swap the vendored CSS and re-point the `--rhc-*` bridge
(ADR-0003 pattern: bridge, don't rewrite tokens).
- `docs/backlog/` WPs, PRDs, and memory-specific docs — new portal, new backlog
(keep `docs/backlog/README.md`'s WP process/template if you like the workflow).
## Verify — the GREEN gate must pass at every step
```bash
npm run lint && npm run check:tokens && npm test && npm run build
npm run build-storybook && npm run test-storybook:ci
cd backend && dotnet test && cd ..
npm run gen:api && git diff --exit-code backend/swagger.json src/app/shared/infrastructure/api-client.ts
```
Strip incrementally and keep this green — the fences are only worth having if they
never go red.

View File

@@ -0,0 +1,53 @@
---
name: ui-component
description: Add a shared UI building block (atom, molecule, organism) with its Storybook story. Use only after confirming no existing block in shared/ui fits — new blocks are the exception, composition is the default.
---
# UI component (atom / molecule / organism)
First: check `shared/ui/` and `shared/layout/` — a new page should be composition of
existing blocks. Only add a block when nothing fits.
## Rules
- **Folder = atomic layer**: `shared/ui/` atoms → molecules → organisms;
`shared/layout/` templates. Each level only uses levels below.
- Standalone component, **English name** (shared = language-agnostic), signal
`input()`s only, `inject()` over constructor DI.
- **Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2) classes** (`btn`,
`form-control`, `card`, …) — you own a small typed `input()` API, the design system
owns the visuals. No CIBG class for it? Hand-roll a small surface from the token
bridge (ADR-0003, e.g. the `alert` atom).
- **Tokens only** — `var(--rhc-*)` / `var(--app-*)`, never hardcoded colors
(`npm run check:tokens` fails the build; escape hatch: `token-ok` marker + reason).
- **No hardcoded Dutch** in shared components — expose copy as `input()`s with
`$localize` defaults; the domain caller supplies the text (see `shared/ui/async`).
- Components with content-projected slots export a spread constant so callers import
one thing: `export const ASYNC = [AsyncComponent, AsyncLoadedDirective, …] as const;`
## Story (required — this is the component's test)
Co-located `<name>.stories.ts`, title prefixed with the layer:
```ts
const meta: Meta<ButtonComponent> = { title: 'Atoms/Button', component: ButtonComponent };
export const Primary: StoryObj<ButtonComponent> = { args: { variant: 'primary' } };
```
One named export per meaningful state. CI runs axe on every story
(`test-storybook:ci`) — a11y failures break the build. Machines/wizards mount
specific states via the `Seed` message. No heavy component specs; Storybook is the
UI test surface.
## Worked examples
- Atom: `src/app/shared/ui/button/` — typed variant API over `btn` classes.
- Molecule: `src/app/shared/ui/async/` — slot directives, localizable input defaults, spread constant.
- Template: `src/app/shared/layout/wizard-shell/` — the canonical wizard outline.
## Verify
```bash
npm run lint && npm run check:tokens && npm run build
npm run storybook # eyeball the story; CI will run axe
```

View File

@@ -0,0 +1,56 @@
---
name: value-object
description: Add a validated input type (postcode, email, hours, id number, …) as a branded value object with a parser — "parse, don't validate". Use whenever a form field or API value has format rules.
---
# Value object (parse, don't validate)
Raw input becomes a branded type only via a parser returning `Result`. Once you hold
the type, never re-check it. Never model validity as a boolean flag next to a string.
## Skeleton
`<context>/domain/value-objects/<name>.ts` (pure TS, no Angular):
```ts
import { Brand, Result, ok, err } from '@shared/kernel/fp';
export type Postcode = Brand<string, 'Postcode'>;
export function parsePostcode(raw: string): Result<string, Postcode> {
const t = raw.trim().toUpperCase();
if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) {
return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`);
}
// The parser also normalises — callers always hold the canonical form.
return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode);
}
```
Rules:
- The `as Brand` cast appears **only** inside the parser — the type is mintable nowhere else.
- Error message is user-facing → `$localize` with a stable `@@validation.<name>` id.
- Trim/normalise before testing; return the cleaned value.
- This is **format** feedback only. The server re-validates as authority (ADR-0001) — never encode business rules (existence, eligibility) here.
## Spec (required)
Co-located `<name>.spec.ts`: happy path, normalisation, each rejection case. Call the
parser directly — no TestBed.
## Wiring into a form
The machine's `validate(draft)` calls the parsers and collects errors into
`StepErrors`; the `Valid` type holds the branded values (see **form-machine** skill).
## Worked examples
`src/app/registratie/domain/value-objects/``postcode.ts`, `email.ts`, `uren.ts`,
`big-nummer.ts`, each with a co-located spec.
## Verify
```bash
npm test && npm run lint
```

17
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: nuget
directory: /backend
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly

View File

@@ -2,11 +2,23 @@ name: CI
on:
push:
branches: [main]
tags: ['v*']
pull_request:
# Least privilege by default; the CodeQL job widens its own scope locally.
permissions:
contents: read
# A newer push to the same ref cancels the in-flight run.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
frontend:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -15,13 +27,20 @@ jobs:
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run format:check
- run: npm run check:tokens
- run: npm test
- run: npm run build
# --localize builds every configured locale (nl + en, angular.json's i18n
# block) in one pass; i18nMissingTranslation:"error" (angular.json) fails
# this step if messages.en.xlf is missing a unit the source (WP-20) gains.
- run: npx ng build --localize
# The shipped bundle must stay clean; dev-only advisories are excluded.
- run: npm audit --omit=dev
storybook-a11y:
# Axe runs against every story in the static build; a violation fails the build.
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -35,16 +54,66 @@ jobs:
backend:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- run: dotnet test backend/BigRegister.sln
- run: dotnet format backend/BigRegister.slnx --verify-no-changes
- run: dotnet test backend/BigRegister.slnx
e2e:
# Smoke-level Playwright run against the REAL FE+backend (WP-19) — a fresh
# runner checkout per run, so there's no bigregister.db (WP-22, gitignored)
# left over from a prior run to leak state in; the backend creates + migrates
# an empty one on this boot, same as a fresh clone always has.
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: dotnet run --project backend/src/BigRegister.Api --urls http://localhost:5000 &
- run: npx ng serve --proxy-config proxy.conf.json &
- run: npx wait-on http://localhost:5000/swagger http://localhost:4200
- run: npm run e2e
codeql:
# Static analysis (SAST) for both sides; results appear under the Security tab.
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
security-events: write
contents: read
actions: read
strategy:
fail-fast: false
matrix:
language: [javascript-typescript, csharp]
steps:
- uses: actions/checkout@v4
- if: matrix.language == 'csharp'
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- uses: github/codeql-action/autobuild@v3
- uses: github/codeql-action/analyze@v3
api-client-drift:
# The committed typed client must match the backend OpenAPI doc.
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4

6
.gitignore vendored
View File

@@ -45,3 +45,9 @@ Thumbs.db
*storybook.log
storybook-static
# Playwright e2e
/test-results
/playwright-report
/blob-report
/playwright/.cache

18
.prettierignore Normal file
View File

@@ -0,0 +1,18 @@
# Build output & caches
dist/
storybook-static/
coverage/
.angular/
# Lockfile
package-lock.json
# Generated — owned by their generators, not prettier
documentation.json
src/app/shared/infrastructure/api-client.ts
# Vendored design system (CIBG Huisstijl)
public/cibg-huisstijl/
# Backend is formatted by `dotnet format`, not prettier
backend/

View File

@@ -1,18 +1,11 @@
import type { StorybookConfig } from '@storybook/angular';
const config: StorybookConfig = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
"@storybook/addon-a11y",
"@storybook/addon-docs",
"@storybook/addon-onboarding"
],
"framework": "@storybook/angular",
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: ['@storybook/addon-a11y', '@storybook/addon-docs', '@storybook/addon-onboarding'],
framework: '@storybook/angular',
// Serve the vendored CIBG package so preview-head.html can <link> its CSS (and its
// relative font/icon/image url()s resolve) — mirrors index.html for the real app.
"staticDirs": ["../public"]
staticDirs: ['../public'],
};
export default config;
export default config;

View File

@@ -1,3 +1,5 @@
<!-- CIBG Huisstijl (customized Bootstrap 5.2), served from the vendored public/ staticDir.
Mirrors src/index.html so stories match the app. System font per styles.scss. -->
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
<!-- Letter-rendering contract (WP-24), mirrors index.html. -->
<link rel="stylesheet" href="letter.css" />

View File

@@ -11,9 +11,7 @@ if (typeof document !== 'undefined') document.body.classList.add('brand--cibg');
const preview: Preview = {
// CIBG/Bootstrap styles bare elements — no theme wrapper class needed; just pad.
// The token bridge lives in styles.scss (loaded via the app build target).
decorators: [
componentWrapperDecorator((story) => `<div style="padding:1.5rem">${story}</div>`),
],
decorators: [componentWrapperDecorator((story) => `<div style="padding:1.5rem">${story}</div>`)],
parameters: {
layout: 'padded',
// Accessibility (axe) via @storybook/addon-a11y. Runs WCAG 2.0/2.1 A+AA rule
@@ -27,6 +25,18 @@ const preview: Preview = {
date: /Date$/i,
},
},
// Sidebar tells the DDD seam: reusable design system, then domain contexts.
// See src/docs/layers.mdx.
options: {
storySort: {
order: [
'Foundations',
'Design System',
['Atoms', 'Molecules', 'Organisms', 'Templates', 'Devtools'],
'Domein',
],
},
},
},
};

View File

@@ -9,8 +9,10 @@ update this file.
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
view their registration, apply for re-registration). Angular 22, standalone,
signals. Auth is faked; **data and business rules are served by a minimal ASP.NET
Core backend** (`backend/`, see its README — in-memory seeded, no DB) and consumed
through an NSwag-generated typed client. The FE renders the backend's decisions.
Core backend** (`backend/`, see its README) and consumed through an NSwag-generated
typed client. The FE renders the backend's decisions. Reference data mimicking
BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief
persist to a SQLite file via EF Core (WP-22) — `docs/backlog/WP-22-durable-persistence.md`.
## Commands
@@ -34,7 +36,8 @@ advisories are pinned via `package.json` `overrides`; the shipped bundle audits
### 1. DDD: contexts then layers, dependencies point inward
`src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`,
`herregistratie`, `showcase` (teaching page, not a feature).
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching
page, not a feature; **sanctioned** to read every context — nothing imports it).
| Layer | Job | Angular allowed? |
| ----------------- | ----------------------------------------- | -------------------------------- |
@@ -45,9 +48,11 @@ advisories are pinned via `package.json` `overrides`; the shipped bundle audits
| `ui/` | how it looks (components, pages) | yes |
**Dependencies only point inward**: `ui → application → domain`; everyone may use
`shared`; never the reverse. Cross-context only `herregistratie → registratie → shared`,
`auth → shared`. Imports use aliases as direction statements: `@shared/* @auth/*
@registratie/* @herregistratie/*`. `domain/` imports nothing from Angular.
`shared`; never the reverse. `ui`/`layout` never import `infrastructure` directly
(reach data through an application store/command) — lint-enforced. Cross-context only
`herregistratieregistratie → shared`, `auth → shared`, `brief → shared`. Imports use
aliases as direction statements: `@shared/* @auth/* @registratie/* @herregistratie/*
@brief/*`. `domain/` imports nothing from Angular.
### 2. Atomic design: folder = layer
@@ -65,14 +70,21 @@ Default reflex — **if you're about to add a second/third boolean to track stat
model a discriminated union instead.** Three tools, all in `shared/application`:
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
Combine sources with `map`/`map2`/`map3`/`andThen` (Failure > Loading > Success).
Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success).
Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four
templates, mutually exclusive by construction. Default loading spinner/skeleton
is delay-gated (~250ms) so fast connections don't flash.
- **Elm-style store** (`store.ts``createStore(initial, reduce)`) — all state in
one Model; change only by `dispatch(msg)`**pure** `reduce(model, msg)`. Models
are tagged unions (see `herregistratie.machine.ts`, `intake.machine.ts`). Templates
send messages, never mutate.
send messages, never mutate. **`createStore` is the one wiring idiom** — a page
never hand-rolls `signal(model)` + a local `dispatch()`. Naming: a top-level
machine's State/Msg types are context-prefixed (`ChangeRequestState`,
`ChangeRequestMsg`), never bare `State`/`Msg`; a top-level machine exports
`initial` + `reduce`. A **composable sub-machine** embedded inside a parent
model keeps prefixed _value_ exports instead (`initialUpload`/`reduceUpload`,
see `upload.machine.ts`) — prefixing there avoids alias noise at the
composition site.
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
branded type only via a parser returning `Result` (`registratie/domain/value-objects/`:
`Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it.
@@ -110,8 +122,12 @@ but the FE doesn't call them.
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
Test the pure function directly — no Angular TestBed for domain. UI is exercised via
Storybook stories (`*.stories.ts` co-located, titled `Layer/Name`, a11y addon on),
not heavy component tests.
Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests.
**Story titles mirror the sidebar's Design System/Domein split** (see
`src/docs/layers.mdx`): a `shared/ui`/`shared/layout` component is titled
`Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`; a component in a
context's `ui/` is titled `Domein/<Context>/<Name>` — full stop, regardless of which
atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
## Conventions
@@ -135,6 +151,11 @@ not heavy component tests.
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
same shape as the wizards, whether it's one step or many. Don't hand-roll mutable
fields + ad-hoc error signals.
- **Dates: `DatePipe` in templates, `formatDatumNl` in pure TS.** A template formats a
date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe
(a domain function, a `$localize` string) uses the one hand-written
`formatDatumNl` (`shared/kernel/datum.ts`). Never a third hand-rolled
`toLocaleDateString` call.
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
[authGuard]` on protected routes (`app.routes.ts`).
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under
@@ -158,6 +179,10 @@ Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter
union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in `<app-async>`,
dispatch messages). Worked example: the intake wizard (`herregistratie/`).
The recipes are also invocable skills in `.claude/skills/`: `new-feature`,
`new-context`, `value-object`, `form-machine`, `bff-endpoint`, `mutation-command`,
`ui-component`, `new-ssp` (bootstrap a new portal from this template).
## Out of scope (POC, don't build unprompted)
Real auth/DigiD, NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark),

113
README.md
View File

@@ -2,14 +2,15 @@
A small Angular app that shows how **atomic design** makes a frontend cheap to build,
reuse and extend. The domain is the **BIG-register** self-service portal (the Dutch
register of healthcare professionals, run by CIBG). It looks like an NL Design System
app, branded **Rijkshuisstijl**, and demonstrates a robust **async-state pattern** where
the UI can never reach an inconsistent state.
register of healthcare professionals, run by CIBG). It is styled with the **CIBG
Huisstijl** design system (a customized Bootstrap 5.2 build, vendored — see ADR-0003),
and demonstrates a robust **async-state pattern** where the UI can never reach an
inconsistent state.
> Demo / POC — **no real login** (DigiD is faked) and synthetic seed data. The
> business rules and data *are* served by a real **ASP.NET Core backend**
> business rules and data _are_ served by a real **ASP.NET Core backend**
> (`backend/`) consumed through a generated typed client, so the BFF + DDD design
> is demonstrable, not hand-waved. Free **Fira Sans** stands in for the licensed
> is demonstrable, not hand-waved. A system-font stack stands in for the licensed
> Rijksoverheid font and a text wordmark for the logo.
---
@@ -30,6 +31,7 @@ cd backend && dotnet run --project src/BigRegister.Api # API → http://localh
npm run storybook # component library, organized by atomic layer
npm run gen:api # regenerate the typed API client from the backend OpenAPI doc
npm run e2e # Playwright smoke tests against the running app + backend (both must be up)
```
Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie → Intake**.
@@ -47,32 +49,32 @@ eligibility, thresholds); see **[backend/README.md](backend/README.md)**.
Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state:
| URL | What you see |
|-----|--------------|
| `/dashboard` | real data (fast) |
| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data |
| `/dashboard?scenario=loading` | the loading state, held open |
| `/dashboard?scenario=empty` | "geen gegevens" empty state |
| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) |
| URL | What you see |
| ----------------------------- | -------------------------------------------- |
| `/dashboard` | real data (fast) |
| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data |
| `/dashboard?scenario=loading` | the loading state, held open |
| `/dashboard?scenario=empty` | "geen gegevens" empty state |
| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) |
---
## How atomic design works here (folder = layer)
Atomic design organizes UI into five layers, each built from the one below. In this repo
the folder structure *is* the hierarchy (`src/app/`):
the folder structure _is_ the hierarchy (`src/app/`):
| Layer | What it is | Examples here |
|-------|-----------|---------------|
| **atoms/** | smallest building blocks; wrap one design-system element | `button`, `text-input`, `heading`, `link`, `alert`, `status-badge`, `spinner`, `skeleton` |
| **molecules/** | a few atoms combined into a unit | `form-field` (label + input + error), `data-row`, `async` (state wrapper) |
| **organisms/** | larger, self-contained sections | `site-header`, `site-footer`, `login-form`, `registration-summary`, `registration-table`, `change-request-form` |
| **templates/** | page skeletons that define layout; content is projected in | `page-layout` (header/content/footer chrome), `page-shell` (back-link + heading + intro + content) |
| **pages/** | a template filled with real data | `login`, `dashboard`, `registration-detail`, `herregistratie` |
| Layer | What it is | Examples here |
| -------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| **atoms/** | smallest building blocks; wrap one design-system element | `button`, `text-input`, `heading`, `link`, `alert`, `status-badge`, `spinner`, `skeleton` |
| **molecules/** | a few atoms combined into a unit | `form-field` (label + input + error), `data-row`, `async` (state wrapper) |
| **organisms/** | larger, self-contained sections | `site-header`, `site-footer`, `login-form`, `registration-summary`, `registration-table`, `change-request-form` |
| **templates/** | page skeletons that define layout; content is projected in | `page-layout` (header/content/footer chrome), `page-shell` (back-link + heading + intro + content) |
| **pages/** | a template filled with real data | `login`, `dashboard`, `registration-detail`, `herregistratie` |
Each atom is a thin Angular standalone component that applies the Utrecht/Rijkshuisstijl
CSS classes — so the design system does the visual work and we only own a small, typed
component API.
Each atom is a thin Angular standalone component that applies CIBG Huisstijl
(Bootstrap 5.2) CSS classes (`btn`, `form-control`, `card`, …) — so the design system
does the visual work and we only own a small, typed component API.
---
@@ -80,14 +82,14 @@ component API.
**1. Reuse — the same blocks appear everywhere.**
| Component | Appears in |
|-----------|-----------|
| `button` | login, change-request, herregistratie, async retry, Storybook |
| `form-field` + `text-input` | login form *and* change-request *and* herregistratie |
| `status-badge` | dashboard summary, detail summary |
| `page-shell` / `page-layout` | all four pages |
| `site-header` / `site-footer` | every page |
| `async` + `skeleton` | dashboard, detail |
| Component | Appears in |
| ----------------------------- | ------------------------------------------------------------- |
| `button` | login, change-request, herregistratie, async retry, Storybook |
| `form-field` + `text-input` | login form _and_ change-request _and_ herregistratie |
| `status-badge` | dashboard summary, detail summary |
| `page-shell` / `page-layout` | all four pages |
| `site-header` / `site-footer` | every page |
| `async` + `skeleton` | dashboard, detail |
Change a component once and every screen that uses it updates.
@@ -102,10 +104,14 @@ were all reused. That's the payoff: new screens cost almost nothing.
Every page used to repeat its own back-link + heading + intro markup. `page-shell`
captures that once; pages now read like `<app-page-shell heading="…" backLink="…">…`.
**4. Theming is one import.**
The look comes from `@rijkshuisstijl-community/design-tokens`. `src/styles.scss` imports
the `lintblauw` palette and applies `rhc-theme lintblauw` on `<body>`. Swap the palette
import to re-theme the whole app — no component changes.
**4. Theming is one stylesheet + a token bridge.**
The look comes from **CIBG Huisstijl**, vendored under `public/cibg-huisstijl/` and
loaded via a `<link>` in `index.html`; `body.brand--cibg` activates CIBG's
robijn/lintblauw palette. `src/styles.scss` is a **token bridge** mapping the app's
semantic `--rhc-*` token vocabulary onto CIBG/`--bs-*` values, so components keep
referencing tokens — swap the vendored CSS and re-point the bridge to re-theme the
whole app, no component changes (ADR-0003). `npm run check:tokens` fails the build
on any hardcoded colour outside that bridge.
---
@@ -120,13 +126,13 @@ decisions the FE renders rather than recomputes (see ADR-0001).
The molecule **`<app-async>`** turns those signals into UI. It renders **exactly one** of
four slots, chosen by a single `computed` — so loading, empty, error and loaded are
mutually exclusive *by construction*. You cannot render data and an error at the same
mutually exclusive _by construction_. You cannot render data and an error at the same
time, or show stale content during a hard failure: those states are unrepresentable.
```html
<app-async [resource]="reg" [isEmpty]="regEmpty">
<ng-template appAsyncLoaded let-r> <app-registration-summary [reg]="r" /> </ng-template>
<ng-template appAsyncLoading> <app-skeleton [count]="6" /> </ng-template>
<ng-template appAsyncLoaded let-r> <app-registration-summary [reg]="r" /> </ng-template>
<ng-template appAsyncLoading> <app-skeleton [count]="6" /> </ng-template>
<!-- appAsyncEmpty / appAsyncError are optional → sensible defaults -->
</app-async>
```
@@ -134,7 +140,7 @@ time, or show stale content during a hard failure: those states are unrepresenta
- **Loaded** — your content, with the value.
- **Loading** — your skeleton, or a default **delayed spinner** (only appears after
~250ms, so fast connections never flash a spinner; slow ones get feedback). Skeletons
are also delay-gated. → *handles slow vs fast connections.*
are also delay-gated. → _handles slow vs fast connections._
- **Empty** — your message, or a default "Geen gegevens gevonden" (driven by an
`isEmpty` predicate).
- **Error** — your template, or a default alert + a **retry** button that calls
@@ -158,14 +164,24 @@ degrade to an instant navigation.
- Angular 22 (standalone components, signals, `httpResource`, view transitions,
control flow `@if/@for`).
- Styling: `@rijkshuisstijl-community/{design-tokens,components-css}` (Utrecht + RHC CSS,
pre-themed Rijkshuisstijl) — imported in `src/styles.scss`, no hand-written theme.
- Data: ASP.NET Core backend (`backend/`, in-memory seeded) exposed via an OpenAPI
contract; the FE consumes an **NSwag-generated** typed client (`npm run gen:api`).
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
- Data: ASP.NET Core backend (`backend/`, EF Core/SQLite-persisted; BRP/DUO
reference data stays in-memory-seeded) exposed via an OpenAPI contract; the FE
consumes an **NSwag-generated** typed client (`npm run gen:api`).
The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
**dev-only** — it is not wired into production builds.
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
Angular 22; the builder runs fine (build verified).
- **i18n**: every user-facing string is `$localize`-wrapped with a stable `@@id`
(source locale `nl`). `npx ng build --localize` (CI runs this) builds both `nl` and
`en` — a genuine second-locale build, not just an unexercised claim — into
`dist/atomic-design-poc/browser/{nl,en}/`; `ng serve --configuration=en` serves the
English build locally. `src/locale/messages.en.xlf` is real (if demo-quality)
English, not machine-untranslated placeholders; `angular.json`'s
`i18nMissingTranslation: "error"` fails the build if a new `$localize` string ships
without a translation. `npm run extract-i18n` regenerates the `nl` reference file.
### Dependency security
@@ -178,5 +194,12 @@ Babel 8 (a breaking change across the Storybook/Babel chain) and is deliberately
We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21.
### Deliberately out of scope (POC)
Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, i18n,
NgRx, licensed Rijkshuisstijl fonts/logo. (The backend itself *is* implemented.)
Real auth/DigiD, real BRP/DUO upstreams, a production-grade database (Postgres/SQL
Server — SQLite persists applications/documents/the brief + a real audit table,
see `backend/README.md`, WP-22), NgRx, licensed RO/Rijks fonts + logo (system-font
stack; text wordmark). (The backend itself _is_ implemented.) i18n's build seam is
proven (see above) but
production-quality translation, a runtime locale switcher, and RTL/pluralization
edge cases are not — the `en` file is demo-quality, and locale is a build-time
choice, not a switch in the running app.

View File

@@ -17,6 +17,14 @@
"root": "",
"sourceRoot": "src",
"prefix": "app",
"i18n": {
"sourceLocale": "nl",
"locales": {
"en": {
"translation": "src/locale/messages.en.xlf"
}
}
},
"architect": {
"build": {
"builder": "@angular/build:application",
@@ -31,7 +39,8 @@
}
],
"styles": ["src/styles.scss"],
"polyfills": ["@angular/localize/init"]
"polyfills": ["@angular/localize/init"],
"i18nMissingTranslation": "error"
},
"configurations": {
"production": {
@@ -59,6 +68,9 @@
"optimization": false,
"extractLicenses": false,
"sourceMap": true
},
"en": {
"localize": ["en"]
}
},
"defaultConfiguration": "production"
@@ -71,6 +83,9 @@
},
"development": {
"buildTarget": "atomic-design-poc:build:development"
},
"en": {
"buildTarget": "atomic-design-poc:build:development,en"
}
},
"defaultConfiguration": "development"

5
backend/.gitignore vendored
View File

@@ -1,2 +1,7 @@
bin/
obj/
# WP-22: runtime SQLite file (+ WAL sidecars) — ship the migration, not the data.
bigregister.db
bigregister.db-shm
bigregister.db-wal

View File

@@ -4,8 +4,18 @@ The backend that hosts the **business rules** for the BIG-register portal. The
frontend renders the decisions this service computes; it does not recompute them
(BFF-lite + decision DTOs — see `../docs/architecture/0001-bff-lite-decision-dtos.md`).
No database, no real BRP/DUO: data is in-memory and seeded (`Data/SeedData.cs`),
but the endpoints, DTOs, status codes and error envelope are production-shaped.
No real BRP/DUO: the reference data they'd return (registration, person, diplomas,
notes — `Data/SeedData.cs`) is in-memory and seeded, but the endpoints, DTOs,
status codes and error envelope are production-shaped.
**Applications, documents and the brief persist** to a SQLite file
(`src/BigRegister.Api/bigregister.db`, EF Core-backed — `Data/AppDbContext.cs`,
`Data/Db.cs`) created and migrated on first run; restarting the process (or
`docker compose restart api` — the existing `./backend:/src` bind mount already
covers it, see `docker-compose.yml`) does **not** lose data. Delete the file to
reset demo data back to empty, the same state a fresh clone starts from. This is
a deliberate, right-sized choice for a POC (SQLite, no external DB service) — see
`docs/backlog/WP-22-durable-persistence.md`.
## Run

View File

@@ -8,6 +8,13 @@
"swagger"
],
"rollForward": false
},
"dotnet-ef": {
"version": "10.0.9",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@@ -7,6 +7,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<!-- Pin over EF Core Sqlite's own transitive default (2.1.11): that version
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 SQLite. -->
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>

View File

@@ -147,7 +147,47 @@ public sealed record BriefDto(
IReadOnlyList<LetterSectionDto> Sections,
BriefStatusDto Status, string DrafterId);
public sealed record BriefViewDto(BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages);
// Decision flags for the CURRENT acting principal + this brief's live status
// (PRD-0002 phase P1) — the FE renders these, it never recomputes them.
public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanReject, bool CanSend);
// The brief's screen DTO also carries the org template it renders with (WP-23):
// the sub-org's current PUBLISHED version — or, once sent, the version pinned at
// send time (sent letters are immutable; a republish never re-renders them).
public sealed record BriefViewDto(
BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages, BriefDecisionsDto Decisions,
OrgTemplateDto OrgTemplate);
public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
public sealed record RejectBriefRequest(string Comments);
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks.
public sealed record MeDto(IReadOnlyList<string> Capabilities);
// --- Organization templates (WP-23, Brief v2 PRD §3) ---
// The second template axis: appearance/identity per sub-organization (letterhead,
// footer, signature, margins). Orthogonal to the case-type template (sections +
// placeholders); the two only meet at render time.
public sealed record MarginsDto(int TopMm, int RightMm, int BottomMm, int LeftMm);
// Version: 0 = a draft (work in progress), n>0 = the published snapshot it is.
public sealed record OrgTemplateDto(
string SubOrgId, string OrgName, string ReturnAddress, string? LogoDocumentId,
string FooterContact, string FooterLegal,
string SignatureName, string SignatureRole, string SignatureClosing,
MarginsDto Margins, int Version = 0);
public sealed record OrgTemplateVersionDto(int Version, string PublishedAt, OrgTemplateDto Template);
// Screen DTO for the admin editor: the editable draft, what's live, the append-only
// history, and the publish-impact count ("dit raakt N nog niet verzonden brieven").
public sealed record OrgTemplateAdminViewDto(
OrgTemplateDto Draft, int PublishedVersion,
IReadOnlyList<OrgTemplateVersionDto> History, int UnsentBriefs);
public sealed record SubOrgSummaryDto(string SubOrgId, string OrgName, int PublishedVersion);
public sealed record SaveOrgTemplateRequest(OrgTemplateDto Draft);
public sealed record PublishOrgTemplateResponse(int Version, int AffectedUnsentBriefs);

View File

@@ -9,53 +9,53 @@ namespace BigRegister.Api.Contracts;
/// <summary>Domain → wire DTO mapping (the anti-corruption boundary, server side).</summary>
public static class Mappers
{
private static string D(DateOnly d) => d.ToString("yyyy-MM-dd");
private static string D(DateOnly d) => d.ToString("yyyy-MM-dd");
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new(
Tag: s.Tag.ToString(),
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
Reden: s.Reden,
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new(
Tag: s.Tag.ToString(),
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
Reden: s.Reden,
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
public static RegistrationDto ToDto(this Registration r) => new(
r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto());
public static RegistrationDto ToDto(this Registration r) => new(
r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto());
public static AdresDto ToDto(this Adres a) => new(a.Straat, a.Postcode, a.Woonplaats);
public static AdresDto ToDto(this Adres a) => new(a.Straat, a.Postcode, a.Woonplaats);
public static PersonDto ToDto(this Person p) => new(p.Naam, D(p.Geboortedatum), p.Adres.ToDto());
public static PersonDto ToDto(this Person p) => new(p.Naam, D(p.Geboortedatum), p.Adres.ToDto());
public static PolicyQuestionDto ToDto(this PolicyQuestion q) => new(
q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst");
public static PolicyQuestionDto ToDto(this PolicyQuestion q) => new(
q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst");
public static DuoDiplomaDto ToDto(this Diploma d) => new(
d.Id, d.Naam, d.Instelling, d.Jaar,
DiplomaRules.ProfessionFor(d),
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList());
public static DuoDiplomaDto ToDto(this Diploma d) => new(
d.Id, d.Naam, d.Instelling, d.Jaar,
DiplomaRules.ProfessionFor(d),
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList());
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
// Goedgekeurd once past the processing window, else In behandeling; a manual case
// stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable
// by passing different `now` values without waiting for the wall clock.
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
{
if (!a.Submitted)
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
if (a.Reden is not null)
return new("Afgewezen", Referentie: a.Referentie, Reden: a.Reden);
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
return new("Goedgekeurd", Referentie: a.Referentie);
return new("InBehandeling", Referentie: a.Referentie, Manual: !a.AutoApprovable);
}
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
// Goedgekeurd once past the processing window, else In behandeling; a manual case
// stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable
// by passing different `now` values without waiting for the wall clock.
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
{
if (!a.Submitted)
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
if (a.Reden is not null)
return new("Afgewezen", Referentie: a.Referentie, Reden: a.Reden);
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
return new("Goedgekeurd", Referentie: a.Referentie);
return new("InBehandeling", Referentie: a.Referentie, Manual: !a.AutoApprovable);
}
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
}

View File

@@ -0,0 +1,71 @@
using System.Text.Json;
using BigRegister.Api.Contracts;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace BigRegister.Api.Data;
/// <summary>
/// EF Core/SQLite persistence for the three stores that used to be static
/// in-memory dictionaries (WP-22): <see cref="Aanvraag"/>, <see cref="StoredDocument"/>
/// + <see cref="AuditEntry"/>, and <see cref="BriefEntity"/>. Opaque nested shapes
/// (a wizard's draft snapshot, a brief's sections/placeholders/status) are stored as
/// JSON text columns rather than redesigned into relational tables — the backend
/// already treats them as opaque (see BriefStore's own header comment), so a JSON
/// column matches that "don't interpret it" posture with zero schema redesign,
/// per this WP's own decision to relocate shapes, not redesign them.
/// </summary>
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<StoredDocument> Documents => Set<StoredDocument>();
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
public DbSet<Aanvraag> Applications => Set<Aanvraag>();
public DbSet<BriefEntity> Briefs => Set<BriefEntity>();
public DbSet<OrgTemplateEntity> OrgTemplates => Set<OrgTemplateEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<StoredDocument>().HasKey(d => d.DocumentId);
modelBuilder.Entity<AuditEntry>(e =>
{
e.HasKey(a => a.Id);
e.Property(a => a.Id).ValueGeneratedOnAdd();
});
modelBuilder.Entity<Aanvraag>(e =>
{
e.HasKey(a => a.Id);
e.Property(a => a.Draft).HasConversion(DraftConverter);
e.Property(a => a.DocumentIds).HasConversion(Json<List<string>>());
});
modelBuilder.Entity<BriefEntity>(e =>
{
e.HasKey(b => b.BriefId);
e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (GetOrCreate's invariant)
e.Property(b => b.Placeholders).HasConversion(Json<IReadOnlyList<PlaceholderDefDto>>());
e.Property(b => b.Sections).HasConversion(Json<List<LetterSectionDto>>());
e.Property(b => b.Status).HasConversion(Json<BriefStatusDto>());
});
modelBuilder.Entity<OrgTemplateEntity>(e =>
{
e.HasKey(t => t.SubOrgId);
e.Property(t => t.Draft).HasConversion(Json<OrgTemplateDto>());
e.Property(t => t.History).HasConversion(Json<List<OrgTemplateVersionDto>>());
});
}
// A wizard's draft is an opaque JsonElement snapshot — stored as its raw JSON
// text. `.Clone()` on read detaches the element from the short-lived JsonDocument
// that parsed it (same rule ApplicationStore.SyncDraft already follows for the
// request body — an uncloned element is invalid once its JsonDocument is GC'd).
private static readonly ValueConverter<JsonElement?, string?> DraftConverter = new(
v => v.HasValue ? v.Value.GetRawText() : null,
v => string.IsNullOrEmpty(v) ? (JsonElement?)null : JsonDocument.Parse(v).RootElement.Clone());
private static ValueConverter<T, string> Json<T>() => new(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => JsonSerializer.Deserialize<T>(v, (JsonSerializerOptions?)null)!);
}

View File

@@ -12,99 +12,122 @@ namespace BigRegister.Api.Data;
/// </summary>
public sealed class Aanvraag
{
public required string Id { get; init; }
public required string Type { get; init; } // registratie | herregistratie | intake
public required string Owner { get; init; }
public JsonElement? Draft { get; set; } // opaque wizard machine snapshot (Concept only)
public int StepIndex { get; set; }
public int StepCount { get; set; }
public List<string> DocumentIds { get; set; } = new();
public string? Referentie { get; set; } // set on submit
public bool AutoApprovable { get; set; } // set on submit: duo (registratie) / other types
public string? Reden { get; set; } // set on submit when rejected → Afgewezen
public bool Submitted { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
public required string Id { get; init; }
public required string Type { get; init; } // registratie | herregistratie | intake
public required string Owner { get; init; }
public JsonElement? Draft { get; set; } // opaque wizard machine snapshot (Concept only)
public int StepIndex { get; set; }
public int StepCount { get; set; }
public List<string> DocumentIds { get; set; } = new();
public string? Referentie { get; set; } // set on submit
public bool AutoApprovable { get; set; } // set on submit: duo (registratie) / other types
public string? Reden { get; set; } // set on submit when rejected → Afgewezen
public bool Submitted { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
}
/// <summary>
/// In-memory application store (no DB), mirrors <see cref="DocumentStore"/>.
/// ponytail: one global lock — fine for a single-process demo; swap for per-key
/// locks if it ever serves load.
/// EF Core/SQLite-backed application store (WP-22 — was a static Dictionary),
/// mirrors <see cref="DocumentStore"/>. ponytail: one global lock — SQLite
/// tolerates only one writer at a time anyway, and this was already a single
/// coarse gate before the DB existed.
/// </summary>
public static class ApplicationStore
{
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
private static readonly Dictionary<string, Aanvraag> _apps = new();
private static readonly object _gate = new();
private static readonly object _gate = new();
public static Aanvraag Create(string type, string owner)
public static Aanvraag Create(string type, string owner)
{
var now = DateTimeOffset.UtcNow;
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
lock (_gate)
{
var now = DateTimeOffset.UtcNow;
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
lock (_gate) _apps[a.Id] = a;
return a;
using var db = Db.Create();
db.Applications.Add(a);
db.SaveChanges();
}
return a;
}
public static Aanvraag? Get(string id, string owner)
public static Aanvraag? Get(string id, string owner)
{
lock (_gate)
{
lock (_gate) return _apps.TryGetValue(id, out var a) && a.Owner == owner ? a : null;
using var db = Db.Create();
var a = db.Applications.Find(id);
return a is not null && a.Owner == owner ? a : null;
}
}
public static IReadOnlyList<Aanvraag> List(string owner)
public static IReadOnlyList<Aanvraag> List(string owner)
{
lock (_gate)
{
lock (_gate) return _apps.Values.Where(a => a.Owner == owner).ToList();
using var db = Db.Create();
return db.Applications.Where(a => a.Owner == owner).ToList();
}
}
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
{
lock (_gate)
{
lock (_gate)
{
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false;
a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
a.StepIndex = stepIndex;
a.StepCount = stepCount;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
a.UpdatedAt = DateTimeOffset.UtcNow;
return true;
}
using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null || a.Owner != owner || a.Submitted) return false;
a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
a.StepIndex = stepIndex;
a.StepCount = stepCount;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
a.UpdatedAt = DateTimeOffset.UtcNow;
db.SaveChanges();
return true;
}
}
/// Cancel a Concept: remove it and delete its (unlinked) documents. Linked docs
/// (belonging to a submitted aanvraag) are left untouched by DocumentStore.
public static bool Delete(string id, string owner)
/// Cancel a Concept: remove it and delete its (unlinked) documents. Linked docs
/// (belonging to a submitted aanvraag) are left untouched by DocumentStore.
public static bool Delete(string id, string owner)
{
List<string> docs;
lock (_gate)
{
List<string> docs;
lock (_gate)
{
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner) return false;
docs = a.DocumentIds.ToList();
_apps.Remove(id);
}
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
return true;
using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null || a.Owner != owner) return false;
docs = a.DocumentIds.ToList();
db.Applications.Remove(a);
db.SaveChanges();
}
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
return true;
}
/// Submit transition. reject != null → Afgewezen; else accepted (In behandeling,
/// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null
/// if the aanvraag is gone or already submitted (idempotency guard).
public static Aanvraag? Submit(string id, string owner, string? reject, bool autoApprovable, IReadOnlyList<string>? documentIds)
/// Submit transition. reject != null → Afgewezen; else accepted (In behandeling,
/// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null
/// if the aanvraag is gone or already submitted (idempotency guard).
public static Aanvraag? Submit(string id, string owner, string? reject, bool autoApprovable, IReadOnlyList<string>? documentIds)
{
lock (_gate)
{
lock (_gate)
{
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return null;
a.Submitted = true;
a.SubmittedAt = DateTimeOffset.UtcNow;
a.UpdatedAt = a.SubmittedAt.Value;
a.Referentie = SubmissionRules.NewReference();
a.AutoApprovable = autoApprovable;
a.Reden = reject;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
return a;
}
using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null || a.Owner != owner || a.Submitted) return null;
a.Submitted = true;
a.SubmittedAt = DateTimeOffset.UtcNow;
a.UpdatedAt = a.SubmittedAt.Value;
a.Referentie = SubmissionRules.NewReference();
a.AutoApprovable = autoApprovable;
a.Reden = reject;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
db.SaveChanges();
return a;
}
}
}

View File

@@ -1,144 +1,190 @@
using BigRegister.Api.Contracts;
using BigRegister.Domain.Authorization;
using BigRegister.Domain.Letters;
using Microsoft.EntityFrameworkCore;
namespace BigRegister.Api.Data;
/// <summary>
/// The letter (brief) — one demo brief per owner, created from a template on first
/// read. In-memory, mirrors <see cref="ApplicationStore"/>. The status machine and
/// its guards live here (the server is authoritative for transitions); the FE mirrors
/// them in its pure reducer for UX. Rich-text content is stored opaquely as DTOs — the
/// stub does not interpret it (no server-side placeholder linting in this slice).
/// read. EF Core/SQLite-backed (WP-22 — was a static Dictionary), mirrors
/// <see cref="ApplicationStore"/>. The status machine and its guards live here (the
/// server is authoritative for transitions); the FE mirrors them in its pure reducer
/// for UX. Rich-text content is stored opaquely as DTOs — the stub does not
/// interpret it (no server-side placeholder linting in this slice).
/// </summary>
public sealed class BriefEntity
{
public required string BriefId { get; init; }
public required string Owner { get; init; }
public required string Beroep { get; init; }
public required string TemplateId { get; init; }
public required string DrafterId { get; init; }
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
public List<LetterSectionDto> Sections { get; set; } = new();
public BriefStatusDto Status { get; set; } = new("draft");
public required string BriefId { get; init; }
public required string Owner { get; init; }
public required string Beroep { get; init; }
public required string TemplateId { get; init; }
public required string DrafterId { get; init; }
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
public List<LetterSectionDto> Sections { get; set; } = new();
public BriefStatusDto Status { get; set; } = new("draft");
/// Which sub-organization's org template themes this letter (WP-23).
public string SubOrgId { get; set; } = OrgTemplateSeed.Registers;
/// Pinned at send: sent letters are immutable, a republish never re-themes them.
public int? SentOrgTemplateVersion { get; set; }
/// The composed HTML archived at send (WP-25) — from here on the preview endpoint
/// serves this verbatim, so a later org-template republish never re-renders it.
public string? ArchivedHtml { get; set; }
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
}
/// <summary>ponytail: one global lock, same as before this WP — SQLite tolerates
/// only one writer at a time anyway, and this was already a single coarse gate.</summary>
public static class BriefStore
{
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
public const string DrafterId = "demo-drafter";
public const string ApproverId = "demo-approver";
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
public const string DrafterId = "demo-drafter";
public const string ApproverId = "demo-approver";
public const string AdminId = "demo-admin";
public enum Outcome { Ok, Forbidden, Conflict }
public enum Outcome { Ok, Forbidden, Conflict }
private static readonly Dictionary<string, BriefEntity> _byOwner = new();
private static readonly object _gate = new();
private static readonly object _gate = new();
public static BriefEntity GetOrCreate(string owner)
public static BriefEntity GetOrCreate(string owner)
{
lock (_gate)
{
lock (_gate)
{
if (_byOwner.TryGetValue(owner, out var existing)) return existing;
var created = BriefSeed.NewBrief(owner);
_byOwner[owner] = created;
return created;
}
using var db = Db.Create();
var existing = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (existing is not null) return existing;
var created = BriefSeed.NewBrief(owner);
db.Briefs.Add(created);
db.SaveChanges();
return created;
}
}
/// Save draft content. Drafter-only; only editable in draft/rejected; a rejected
/// letter reopens to draft on edit (mirrors the FE reducer).
public static (Outcome, BriefEntity?) Save(string owner, IReadOnlyList<LetterSectionDto> sections, bool isDrafter)
/// Save draft content. Drafter-only; only editable in draft/rejected; a rejected
/// letter reopens to draft on edit (mirrors the FE reducer).
public static (Outcome, BriefEntity?) Save(string owner, IReadOnlyList<LetterSectionDto> sections, bool isDrafter)
{
lock (_gate)
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
e.Sections = sections.ToList();
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
return (Outcome.Ok, e);
}
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
e.Sections = sections.ToList();
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
db.SaveChanges();
return (Outcome.Ok, e);
}
}
public static (Outcome, BriefEntity?) Submit(string owner, bool isDrafter, string at)
public static (Outcome, BriefEntity?) Submit(string owner, bool isDrafter, string at)
{
lock (_gate)
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
return (Outcome.Ok, e);
}
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
db.SaveChanges();
return (Outcome.Ok, e);
}
}
public static (Outcome, BriefEntity?) Approve(string owner, string actingId, string at) =>
Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, ApprovedAt: at));
public static (Outcome, BriefEntity?) Approve(string owner, Principal principal, string at) =>
Review(owner, principal, BriefAction.Approve, () =>
new BriefStatusDto("approved", ApprovedBy: Authz.ActingId(principal), ApprovedAt: at));
public static (Outcome, BriefEntity?) Reject(string owner, string actingId, string comments, string at) =>
Review(owner, actingId, e => new BriefStatusDto("rejected", RejectedBy: actingId, RejectedAt: at, Comments: comments));
public static (Outcome, BriefEntity?) Reject(string owner, Principal principal, string comments, string at) =>
Review(owner, principal, BriefAction.Reject, () =>
new BriefStatusDto("rejected", RejectedBy: Authz.ActingId(principal), RejectedAt: at, Comments: comments));
public static (Outcome, BriefEntity?) Send(string owner, string at)
public static (Outcome, BriefEntity?) Send(string owner, string at)
{
lock (_gate)
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("sent", SentAt: at);
return (Outcome.Ok, e);
}
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("sent", SentAt: at);
// Pin the org-template version the letter was sent with (WP-23): from here on
// its appearance is frozen — republishing the template touches unsent briefs only.
e.SentOrgTemplateVersion = OrgTemplateStore.PublishedVersionOf(e.SubOrgId);
// Archive the composed HTML at this exact instant (WP-25): the preview endpoint
// serves this verbatim once sent, so a later republish never re-renders it.
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.SentOrgTemplateVersion);
e.ArchivedHtml = LetterHtml.Render(e, template, at, watermark: false);
db.SaveChanges();
return (Outcome.Ok, e);
}
}
/// Test seam: clear the demo brief between tests.
public static void Reset()
/// Test seam: clear the demo brief between tests.
public static void Reset()
{
lock (_gate)
{
lock (_gate) _byOwner.Clear();
using var db = Db.Create();
db.Briefs.ExecuteDelete();
}
}
/// Demo affordance: drop the owner's brief and create a fresh one. No guards — a
/// "start over" for the showcase, not a real workflow transition.
public static BriefEntity ResetAndCreate(string owner)
/// Demo affordance: drop the owner's brief and create a fresh one. No guards — a
/// "start over" for the showcase, not a real workflow transition.
public static BriefEntity ResetAndCreate(string owner)
{
lock (_gate)
{
lock (_gate)
{
_byOwner.Remove(owner);
var created = BriefSeed.NewBrief(owner);
_byOwner[owner] = created;
return created;
}
using var db = Db.Create();
db.Briefs.Where(e => e.Owner == owner).ExecuteDelete();
var created = BriefSeed.NewBrief(owner);
db.Briefs.Add(created);
db.SaveChanges();
return created;
}
}
// Approve/reject share the guard: must be submitted, and the approver must differ
// from the drafter (a drafter cannot approve their own letter).
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next)
// Approve/reject share the guard: must be submitted, and the approver must differ
// from the drafter (a drafter cannot approve their own letter). The SoD check is
// Authz.CanActOn — the SAME check the screen DTO's decision flags use — checked
// BEFORE the status guard so Forbidden vs Conflict ordering matches the old
// inline check exactly.
private static (Outcome, BriefEntity?) Review(string owner, Principal principal, BriefAction action, Func<BriefStatusDto> next)
{
lock (_gate)
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (actingId == e.DrafterId) return (Outcome.Forbidden, null);
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
e.Status = next(e);
return (Outcome.Ok, e);
}
using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
e.Status = next();
db.SaveChanges();
return (Outcome.Ok, e);
}
}
private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
}
/// <summary>Seeded template (sections + placeholder fields) and passage library.</summary>
public static class BriefSeed
{
public const string TemplateId = "besluit-arts";
public const string Beroep = "arts";
public const string TemplateId = "besluit-arts";
public const string Beroep = "arts";
private static RichTextNodeDto T(string t) => new("text", Text: t);
private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
private static RichTextBlockDto Block(params RichTextNodeDto[] nodes) => new(new[] { new ParagraphDto(nodes) });
private static RichTextNodeDto T(string t) => new("text", Text: t);
private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
private static RichTextBlockDto Block(params RichTextNodeDto[] nodes) => new(new[] { new ParagraphDto(nodes) });
// The template's valid placeholder fields. Two are flagged to exercise the linter:
// a deprecated field and one not fillable for this beroep.
private static readonly IReadOnlyList<PlaceholderDefDto> Placeholders = new[]
{
// The template's valid placeholder fields. Two are flagged to exercise the linter:
// a deprecated field and one not fillable for this beroep.
private static readonly IReadOnlyList<PlaceholderDefDto> Placeholders = new[]
{
new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true),
new PlaceholderDefDto("datum", "Datum", true),
new PlaceholderDefDto("big_nummer", "BIG-nummer", true),
@@ -147,18 +193,18 @@ public static class BriefSeed
new PlaceholderDefDto("specialisme_code", "Specialismecode", true, Fillable: false),
};
public static BriefEntity NewBrief(string owner) => new()
{
BriefId = Guid.NewGuid().ToString(),
Owner = owner,
Beroep = Beroep,
TemplateId = TemplateId,
DrafterId = BriefStore.DrafterId,
Placeholders = Placeholders,
// aanhef + slot are predefined, locked template text (prefilled); the drafter
// composes only the unlocked `kern`. Save trusts the FE not to mutate locked
// sections (FE-authoritative for this POC — the FE reducer also refuses it).
Sections = new()
public static BriefEntity NewBrief(string owner) => new()
{
BriefId = Guid.NewGuid().ToString(),
Owner = owner,
Beroep = Beroep,
TemplateId = TemplateId,
DrafterId = BriefStore.DrafterId,
Placeholders = Placeholders,
// aanhef + slot are predefined, locked template text (prefilled); the drafter
// composes only the unlocked `kern`. Save trusts the FE not to mutate locked
// sections (FE-authoritative for this POC — the FE reducer also refuses it).
Sections = new()
{
new("aanhef", "Aanhef", true, new List<LetterBlockDto>
{
@@ -170,13 +216,13 @@ public static class BriefSeed
new("freeText", "slot-1", Block(T("Met vriendelijke groet,"))),
}, Locked: true),
},
Status = new BriefStatusDto("draft"),
};
Status = new BriefStatusDto("draft"),
};
/// Global passages plus those scoped to the given beroep.
public static IReadOnlyList<LibraryPassageDto> PassagesFor(string beroep)
{
var all = new List<LibraryPassageDto>
/// Global passages plus those scoped to the given beroep.
public static IReadOnlyList<LibraryPassageDto> PassagesFor(string beroep)
{
var all = new List<LibraryPassageDto>
{
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1),
@@ -193,6 +239,6 @@ public static class BriefSeed
new("p-slot-code", "global", "slot", "Specialismecode (niet invulbaar)",
Block(T("Specialisme: "), P("specialisme_code"), T(".")), 1),
};
return all.Where(p => p.Scope == "global" || p.Beroep == beroep).ToList();
}
return all.Where(p => p.Scope == "global" || p.Beroep == beroep).ToList();
}
}

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace BigRegister.Api.Data;
/// <summary>
/// Factory for short-lived <see cref="AppDbContext"/> instances. The three stores
/// (ApplicationStore/DocumentStore/BriefStore) are static classes — that shape
/// predates WP-22 and this WP keeps it — so they can't take a constructor-injected
/// DbContext; each store method opens one here, uses it, and disposes it under its
/// own lock instead.
/// </summary>
public static class Db
{
public static string ConnectionString { get; set; } = "Data Source=bigregister.db";
public static AppDbContext Create() =>
new(new DbContextOptionsBuilder<AppDbContext>().UseSqlite(ConnectionString).Options);
}
/// <summary>Lets `dotnet ef migrations add` construct a context at design time
/// without booting the full app (no DI registration exists for AppDbContext —
/// see Db.Create above for why). Auto-discovered by EF Core's tooling.</summary>
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args) => Db.Create();
}

View File

@@ -1,8 +1,8 @@
namespace BigRegister.Api.Data;
/// <summary>
/// Stored document: metadata + bytes. The demo holds bytes IN-MEMORY (reset on
/// restart) purely so re-opened wizards can preview/download what was uploaded — a
/// Stored document: metadata + bytes. The demo persists bytes in the SQLite file
/// (WP-22) purely so a re-opened wizard can preview/download what was uploaded — a
/// real backend persists them to blob storage keyed by DocumentId. Bytes are never
/// serialized into a JSON response; only the dedicated content endpoint streams them.
/// </summary>
@@ -10,93 +10,137 @@ public sealed record StoredDocument(
string DocumentId, string LocalId, string CategoryId, string WizardId,
string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt)
{
public bool Linked { get; set; }
public bool Linked { get; set; }
}
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor);
/// <summary>Id is EF Core's auto-increment key — not part of the positional
/// constructor, so every existing `new AuditEntry(at, action, ...)` call site
/// keeps working unchanged; EF Core assigns it on insert.</summary>
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor)
{
public long Id { get; init; }
}
/// <summary>
/// In-memory document store + audit log (no DB). ponytail: one global lock — fine
/// for a single-process demo store; swap for per-key locks if it ever serves load.
/// The audit log holds metadata only (never file content or other PII).
/// EF Core/SQLite-backed document store + audit log (WP-22 — was a static
/// Dictionary). ponytail: one global lock, same as before — SQLite tolerates only
/// one writer at a time anyway, and this process already serialized all access
/// through a single gate, so it now doubles as a coarse single-writer guard for
/// the DB file. The audit log holds metadata only (never file content or other PII).
/// </summary>
public static class DocumentStore
{
/// The single seeded user (the demo has no real auth; ownership = this id).
public const string DemoOwner = "19012345601";
/// The single seeded user (the demo has no real auth; ownership = this id).
public const string DemoOwner = "19012345601";
private static readonly Dictionary<string, StoredDocument> _docs = new();
private static readonly List<AuditEntry> _audit = new();
private static readonly object _gate = new();
private static readonly object _gate = new();
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner)
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner)
{
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow);
lock (_gate)
{
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow);
lock (_gate) _docs[doc.DocumentId] = doc;
Audit("upload", doc.DocumentId, categoryId, owner);
return doc;
using var db = Db.Create();
db.Documents.Add(doc);
db.SaveChanges();
}
Audit("upload", doc.DocumentId, categoryId, owner);
return doc;
}
public static StoredDocument? Get(string documentId)
public static StoredDocument? Get(string documentId)
{
lock (_gate)
{
lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null;
using var db = Db.Create();
return db.Documents.Find(documentId);
}
}
/// Status for the poll-on-return pattern: a known localId is "complete" (it
/// arrived), an unknown one is still in flight / never started.
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
/// Status for the poll-on-return pattern: a known localId is "complete" (it
/// arrived), an unknown one is still in flight / never started.
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
{
var set = localIds.ToHashSet();
lock (_gate)
{
var set = localIds.ToHashSet();
lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList();
using var db = Db.Create();
return db.Documents.Where(d => set.Contains(d.LocalId)).ToList();
}
}
/// Mark digital documents as linked to a finalised submission (blocks user delete).
public static void Link(IEnumerable<string> documentIds)
/// Mark digital documents as linked to a finalised submission (blocks user delete).
public static void Link(IEnumerable<string> documentIds)
{
lock (_gate)
{
lock (_gate)
foreach (var id in documentIds)
if (_docs.TryGetValue(id, out var d)) d.Linked = true;
using var db = Db.Create();
foreach (var id in documentIds)
{
var d = db.Documents.Find(id);
if (d is not null) d.Linked = true;
}
db.SaveChanges();
}
}
public enum DeleteResult { Ok, NotFound, Linked }
public enum DeleteResult { Ok, NotFound, Linked }
/// User delete: owner-scoped; blocked once linked to a finalised submission.
public static DeleteResult DeleteOwned(string documentId, string owner)
/// User delete: owner-scoped; blocked once linked to a finalised submission.
public static DeleteResult DeleteOwned(string documentId, string owner)
{
string categoryId;
lock (_gate)
{
string categoryId;
lock (_gate)
{
if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound;
if (d.Linked) return DeleteResult.Linked;
categoryId = d.CategoryId;
_docs.Remove(documentId);
}
Audit("delete-user", documentId, categoryId, owner);
return DeleteResult.Ok;
using var db = Db.Create();
var d = db.Documents.Find(documentId);
if (d is null || d.Owner != owner) return DeleteResult.NotFound;
if (d.Linked) return DeleteResult.Linked;
categoryId = d.CategoryId;
db.Documents.Remove(d);
db.SaveChanges();
}
Audit("delete-user", documentId, categoryId, owner);
return DeleteResult.Ok;
}
/// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for
/// review so a caseworker is notified. Returns false if the document is unknown.
public static bool AdminDelete(string documentId, string actor)
/// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for
/// review so a caseworker is notified. Returns false if the document is unknown.
public static bool AdminDelete(string documentId, string actor)
{
string categoryId;
lock (_gate)
{
string categoryId;
lock (_gate)
{
if (!_docs.TryGetValue(documentId, out var d)) return false;
categoryId = d.CategoryId;
_docs.Remove(documentId);
}
Audit("delete-admin", documentId, categoryId, actor);
return true;
using var db = Db.Create();
var d = db.Documents.Find(documentId);
if (d is null) return false;
categoryId = d.CategoryId;
db.Documents.Remove(d);
db.SaveChanges();
}
Audit("delete-admin", documentId, categoryId, actor);
return true;
}
public static void Audit(string action, string documentId, string categoryId, string actor)
public static void Audit(string action, string documentId, string categoryId, string actor)
{
lock (_gate)
{
lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
using var db = Db.Create();
db.AuditEntries.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
db.SaveChanges();
}
}
public static IReadOnlyList<AuditEntry> AuditLog
public static IReadOnlyList<AuditEntry> AuditLog
{
get
{
get { lock (_gate) return _audit.ToList(); }
lock (_gate)
{
using var db = Db.Create();
return db.AuditEntries.ToList();
}
}
}
}

View File

@@ -0,0 +1,25 @@
namespace BigRegister.Api.Data;
/// <summary>
/// In-memory idempotency-key ledger for the submit endpoints (Program.cs's `Submit`
/// helper): a replayed `Idempotency-Key` returns the exact result computed the first
/// time, instead of re-running the submission and minting a new reference.
/// ponytail: one global lock + no TTL/eviction — fine for a single-process demo;
/// swap for a TTL'd cache before this ever serves real traffic (an unbounded
/// dictionary keyed on client-supplied strings is a memory leak at scale).
/// </summary>
public static class IdempotencyStore
{
private static readonly Dictionary<string, IResult> _results = new();
private static readonly object _gate = new();
public static bool TryGet(string key, out IResult? result)
{
lock (_gate) return _results.TryGetValue(key, out result);
}
public static void Set(string key, IResult result)
{
lock (_gate) _results[key] = result;
}
}

View File

@@ -0,0 +1,195 @@
// <auto-generated />
using System;
using BigRegister.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260704190822_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DocumentIds")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Draft")
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Reden")
.HasColumnType("TEXT");
b.Property<string>("Referentie")
.HasColumnType("TEXT");
b.Property<int>("StepCount")
.HasColumnType("INTEGER");
b.Property<int>("StepIndex")
.HasColumnType("INTEGER");
b.Property<bool>("Submitted")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
});
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DocumentId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrafterId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Placeholders")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sections")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TemplateId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BriefId");
b.HasIndex("Owner")
.IsUnique();
b.ToTable("Briefs");
});
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
{
b.Property<string>("DocumentId")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Linked")
.HasColumnType("INTEGER");
b.Property<string>("LocalId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("WizardId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DocumentId");
b.ToTable("Documents");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,117 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Applications",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Type = table.Column<string>(type: "TEXT", nullable: false),
Owner = table.Column<string>(type: "TEXT", nullable: false),
Draft = table.Column<string>(type: "TEXT", nullable: true),
StepIndex = table.Column<int>(type: "INTEGER", nullable: false),
StepCount = table.Column<int>(type: "INTEGER", nullable: false),
DocumentIds = table.Column<string>(type: "TEXT", nullable: false),
Referentie = table.Column<string>(type: "TEXT", nullable: true),
AutoApprovable = table.Column<bool>(type: "INTEGER", nullable: false),
Reden = table.Column<string>(type: "TEXT", nullable: true),
Submitted = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
SubmittedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Applications", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AuditEntries",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
At = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Action = table.Column<string>(type: "TEXT", nullable: false),
DocumentId = table.Column<string>(type: "TEXT", nullable: false),
CategoryId = table.Column<string>(type: "TEXT", nullable: false),
Actor = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AuditEntries", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Briefs",
columns: table => new
{
BriefId = table.Column<string>(type: "TEXT", nullable: false),
Owner = table.Column<string>(type: "TEXT", nullable: false),
Beroep = table.Column<string>(type: "TEXT", nullable: false),
TemplateId = table.Column<string>(type: "TEXT", nullable: false),
DrafterId = table.Column<string>(type: "TEXT", nullable: false),
Placeholders = table.Column<string>(type: "TEXT", nullable: false),
Sections = table.Column<string>(type: "TEXT", nullable: false),
Status = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Briefs", x => x.BriefId);
});
migrationBuilder.CreateTable(
name: "Documents",
columns: table => new
{
DocumentId = table.Column<string>(type: "TEXT", nullable: false),
LocalId = table.Column<string>(type: "TEXT", nullable: false),
CategoryId = table.Column<string>(type: "TEXT", nullable: false),
WizardId = table.Column<string>(type: "TEXT", nullable: false),
FileName = table.Column<string>(type: "TEXT", nullable: false),
SizeBytes = table.Column<long>(type: "INTEGER", nullable: false),
ContentType = table.Column<string>(type: "TEXT", nullable: false),
Content = table.Column<byte[]>(type: "BLOB", nullable: false),
Owner = table.Column<string>(type: "TEXT", nullable: false),
UploadedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Linked = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Documents", x => x.DocumentId);
});
migrationBuilder.CreateIndex(
name: "IX_Briefs_Owner",
table: "Briefs",
column: "Owner",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Applications");
migrationBuilder.DropTable(
name: "AuditEntries");
migrationBuilder.DropTable(
name: "Briefs");
migrationBuilder.DropTable(
name: "Documents");
}
}
}

View File

@@ -0,0 +1,223 @@
// <auto-generated />
using System;
using BigRegister.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260705085857_OrgTemplates")]
partial class OrgTemplates
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DocumentIds")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Draft")
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Reden")
.HasColumnType("TEXT");
b.Property<string>("Referentie")
.HasColumnType("TEXT");
b.Property<int>("StepCount")
.HasColumnType("INTEGER");
b.Property<int>("StepIndex")
.HasColumnType("INTEGER");
b.Property<bool>("Submitted")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
});
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DocumentId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrafterId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Placeholders")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sections")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("SentOrgTemplateVersion")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SubOrgId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TemplateId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BriefId");
b.HasIndex("Owner")
.IsUnique();
b.ToTable("Briefs");
});
modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
{
b.Property<string>("SubOrgId")
.HasColumnType("TEXT");
b.Property<string>("Draft")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("History")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PublishedVersion")
.HasColumnType("INTEGER");
b.HasKey("SubOrgId");
b.ToTable("OrgTemplates");
});
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
{
b.Property<string>("DocumentId")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Linked")
.HasColumnType("INTEGER");
b.Property<string>("LocalId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("WizardId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DocumentId");
b.ToTable("Documents");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
/// <inheritdoc />
public partial class OrgTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "SentOrgTemplateVersion",
table: "Briefs",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SubOrgId",
table: "Briefs",
type: "TEXT",
nullable: false,
// Briefs from before this migration adopt the seeded default sub-org.
defaultValue: "cibg-registers");
migrationBuilder.CreateTable(
name: "OrgTemplates",
columns: table => new
{
SubOrgId = table.Column<string>(type: "TEXT", nullable: false),
Draft = table.Column<string>(type: "TEXT", nullable: false),
PublishedVersion = table.Column<int>(type: "INTEGER", nullable: false),
History = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OrgTemplates", x => x.SubOrgId);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OrgTemplates");
migrationBuilder.DropColumn(
name: "SentOrgTemplateVersion",
table: "Briefs");
migrationBuilder.DropColumn(
name: "SubOrgId",
table: "Briefs");
}
}
}

View File

@@ -0,0 +1,226 @@
// <auto-generated />
using System;
using BigRegister.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260705101743_ArchivedHtml")]
partial class ArchivedHtml
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DocumentIds")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Draft")
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Reden")
.HasColumnType("TEXT");
b.Property<string>("Referentie")
.HasColumnType("TEXT");
b.Property<int>("StepCount")
.HasColumnType("INTEGER");
b.Property<int>("StepIndex")
.HasColumnType("INTEGER");
b.Property<bool>("Submitted")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
});
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DocumentId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("ArchivedHtml")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrafterId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Placeholders")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sections")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("SentOrgTemplateVersion")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SubOrgId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TemplateId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BriefId");
b.HasIndex("Owner")
.IsUnique();
b.ToTable("Briefs");
});
modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
{
b.Property<string>("SubOrgId")
.HasColumnType("TEXT");
b.Property<string>("Draft")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("History")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PublishedVersion")
.HasColumnType("INTEGER");
b.HasKey("SubOrgId");
b.ToTable("OrgTemplates");
});
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
{
b.Property<string>("DocumentId")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Linked")
.HasColumnType("INTEGER");
b.Property<string>("LocalId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("WizardId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DocumentId");
b.ToTable("Documents");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
/// <inheritdoc />
public partial class ArchivedHtml : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ArchivedHtml",
table: "Briefs",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ArchivedHtml",
table: "Briefs");
}
}
}

View File

@@ -0,0 +1,223 @@
// <auto-generated />
using System;
using BigRegister.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DocumentIds")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Draft")
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Reden")
.HasColumnType("TEXT");
b.Property<string>("Referentie")
.HasColumnType("TEXT");
b.Property<int>("StepCount")
.HasColumnType("INTEGER");
b.Property<int>("StepIndex")
.HasColumnType("INTEGER");
b.Property<bool>("Submitted")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
});
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DocumentId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("ArchivedHtml")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrafterId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Placeholders")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sections")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("SentOrgTemplateVersion")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SubOrgId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TemplateId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BriefId");
b.HasIndex("Owner")
.IsUnique();
b.ToTable("Briefs");
});
modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
{
b.Property<string>("SubOrgId")
.HasColumnType("TEXT");
b.Property<string>("Draft")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("History")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PublishedVersion")
.HasColumnType("INTEGER");
b.HasKey("SubOrgId");
b.ToTable("OrgTemplates");
});
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
{
b.Property<string>("DocumentId")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Linked")
.HasColumnType("INTEGER");
b.Property<string>("LocalId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("WizardId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DocumentId");
b.ToTable("Documents");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,204 @@
using BigRegister.Api.Contracts;
namespace BigRegister.Api.Data;
/// <summary>
/// Organization template per sub-organization (WP-23, Brief v2 PRD §3): one row per
/// sub-org. `Draft` is the work-in-progress payload (Version 0), `History` the
/// append-only list of published snapshots, `PublishedVersion` points into it.
/// Rollback copies an old snapshot back into the draft — it never rewrites history.
/// Mirrors <see cref="BriefStore"/>: static class, short-lived context per call,
/// nested DTO shapes stored as JSON text columns (WP-22 posture).
/// </summary>
public sealed class OrgTemplateEntity
{
public required string SubOrgId { get; init; }
public OrgTemplateDto Draft { get; set; } = null!;
public int PublishedVersion { get; set; }
public List<OrgTemplateVersionDto> History { get; set; } = new();
}
/// <summary>ponytail: one global lock, same shape as BriefStore — SQLite tolerates
/// only one writer at a time anyway.</summary>
public static class OrgTemplateStore
{
private static readonly object _gate = new();
public static IReadOnlyList<SubOrgSummaryDto> List()
{
lock (_gate)
{
using var db = Seeded();
return db.OrgTemplates.AsEnumerable()
.Select(e => new SubOrgSummaryDto(e.SubOrgId, Published(e).OrgName, e.PublishedVersion))
.OrderBy(s => s.SubOrgId)
.ToList();
}
}
public static OrgTemplateAdminViewDto? AdminView(string subOrgId)
{
lock (_gate)
{
using var db = Seeded();
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
return e is null ? null : ToAdminView(db, e);
}
}
/// Save the draft. Caller validates first (OrgTemplateRules); null = unknown sub-org.
public static OrgTemplateAdminViewDto? SaveDraft(string subOrgId, OrgTemplateDto draft)
{
lock (_gate)
{
using var db = Seeded();
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
if (e is null) return null;
// Normalize identity fields the client can't be trusted with: the row key and
// the draft marker (Version 0) always win over whatever was posted.
e.Draft = draft with { SubOrgId = subOrgId, Version = 0 };
db.SaveChanges();
return ToAdminView(db, e);
}
}
/// Draft → published: append a snapshot to history, advance the pointer. Returns
/// the new version + how many unsent briefs of this sub-org it re-themes.
public static PublishOrgTemplateResponse? Publish(string subOrgId, string at)
{
lock (_gate)
{
using var db = Seeded();
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
if (e is null) return null;
var version = e.PublishedVersion + 1;
// Reassign (not mutate) the list: the JSON ValueConverter has no ValueComparer,
// so EF only sees the change when the property reference changes.
e.History = new List<OrgTemplateVersionDto>(e.History)
{
new(version, at, e.Draft with { Version = version }),
};
e.PublishedVersion = version;
db.SaveChanges();
return new PublishOrgTemplateResponse(version, CountUnsent(db, subOrgId));
}
}
/// Rollback = copy an old published snapshot into the draft (admin republishes to
/// make it live). History stays append-only. Null = unknown sub-org or version.
public static OrgTemplateAdminViewDto? Rollback(string subOrgId, int version)
{
lock (_gate)
{
using var db = Seeded();
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
var old = e?.History.FirstOrDefault(h => h.Version == version);
if (e is null || old is null) return null;
e.Draft = old.Template with { Version = 0 };
db.SaveChanges();
return ToAdminView(db, e);
}
}
/// The template a brief renders with: the pinned version once sent (immutable
/// letters), else the sub-org's current published version.
public static OrgTemplateDto TemplateForBrief(string subOrgId, int? pinnedVersion)
{
lock (_gate)
{
using var db = Seeded();
// ponytail: briefs from before this WP carry an empty SubOrgId — fall back to
// the first seeded sub-org instead of failing the whole screen.
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId)
?? db.OrgTemplates.First(t => t.SubOrgId == OrgTemplateSeed.Registers);
var pinned = pinnedVersion is { } v ? e.History.FirstOrDefault(h => h.Version == v)?.Template : null;
return pinned ?? Published(e);
}
}
public static int PublishedVersionOf(string subOrgId)
{
lock (_gate)
{
using var db = Seeded();
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId)
?? db.OrgTemplates.First(t => t.SubOrgId == OrgTemplateSeed.Registers);
return e.PublishedVersion;
}
}
/// Test seam: drop all rows so the next call re-seeds.
public static void Reset()
{
lock (_gate)
{
using var db = Db.Create();
db.OrgTemplates.RemoveRange(db.OrgTemplates);
db.SaveChanges();
}
}
private static OrgTemplateDto Published(OrgTemplateEntity e) =>
e.History.First(h => h.Version == e.PublishedVersion).Template;
private static OrgTemplateAdminViewDto ToAdminView(AppDbContext db, OrgTemplateEntity e) =>
new(e.Draft, e.PublishedVersion, e.History, CountUnsent(db, e.SubOrgId));
// Status is a JSON column (not translatable to SQL) — count in memory; the demo
// holds a handful of briefs at most.
private static int CountUnsent(AppDbContext db, string subOrgId) =>
db.Briefs.AsEnumerable().Count(b => b.SubOrgId == subOrgId && b.Status.Tag != "sent");
private static AppDbContext Seeded()
{
var db = Db.Create();
if (!db.OrgTemplates.Any())
{
db.OrgTemplates.AddRange(OrgTemplateSeed.All());
db.SaveChanges();
}
return db;
}
}
/// <summary>
/// Two seeded sub-organizations so the admin editor can demonstrate isolation
/// (editing one never touches the other). Values come from the fictitious sample
/// artifact `voorbeeldbrief-inschrijving.pdf`; each starts with draft == published v1.
/// </summary>
public static class OrgTemplateSeed
{
public const string Registers = "cibg-registers";
public const string Vakbekwaamheid = "cibg-vakbekwaamheid";
// Deterministic seed timestamp (stable golden files, stable demos).
private const string SeededAt = "2026-07-01T00:00:00.0000000+00:00";
public static IEnumerable<OrgTemplateEntity> All() => new[]
{
Entity(new OrgTemplateDto(
Registers, "BIG-register",
"Retouradres: Postbus 00000, 2500 AA Den Haag",
LogoDocumentId: null,
"BIG-register · Postbus 00000, 2500 AA Den Haag · 070 000 00 00 · info@voorbeeld.example",
"Dit is een gegenereerd voorbeelddocument uit de register-reference PoC. Alle gegevens zijn fictief.",
"A. de Vries", "Hoofd Registratie, BIG-register", "Met vriendelijke groet,",
new MarginsDto(25, 20, 20, 25))),
Entity(new OrgTemplateDto(
Vakbekwaamheid, "CIBG Vakbekwaamheid",
"Retouradres: Postbus 11111, 2500 BB Den Haag",
LogoDocumentId: null,
"CIBG Vakbekwaamheid · Postbus 11111, 2500 BB Den Haag · 070 111 11 11 · vakbekwaamheid@voorbeeld.example",
"Dit is een gegenereerd voorbeelddocument uit de register-reference PoC. Alle gegevens zijn fictief.",
"M. Bakker", "Hoofd Vakbekwaamheid, CIBG", "Met vriendelijke groet,",
new MarginsDto(25, 20, 20, 25))),
};
private static OrgTemplateEntity Entity(OrgTemplateDto v1) => new()
{
SubOrgId = v1.SubOrgId,
Draft = v1 with { Version = 0 },
PublishedVersion = 1,
History = new() { new OrgTemplateVersionDto(1, SeededAt, v1 with { Version = 1 }) },
};
}

View File

@@ -10,31 +10,31 @@ namespace BigRegister.Api.Data;
/// </summary>
public static class SeedData
{
public static readonly Registration Registration = new(
BigNummer: "19012345601",
Naam: "Dr. A. (Anna) de Vries",
Beroep: "Arts",
Registratiedatum: new DateOnly(2012, 9, 1),
Geboortedatum: new DateOnly(1985, 3, 14),
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
public static readonly Registration Registration = new(
BigNummer: "19012345601",
Naam: "Dr. A. (Anna) de Vries",
Beroep: "Arts",
Registratiedatum: new DateOnly(2012, 9, 1),
Geboortedatum: new DateOnly(1985, 3, 14),
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
public static readonly Person Person = new(
Naam: "Dr. A. (Anna) de Vries",
Geboortedatum: new DateOnly(1985, 3, 14),
Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag"));
public static readonly Person Person = new(
Naam: "Dr. A. (Anna) de Vries",
Geboortedatum: new DateOnly(1985, 3, 14),
Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag"));
/// <summary>The address BRP returns for the seeded citizen.</summary>
public static readonly Adres BrpAddress = Person.Adres;
/// <summary>The address BRP returns for the seeded citizen.</summary>
public static readonly Adres BrpAddress = Person.Adres;
public static readonly IReadOnlyList<Diploma> Diplomas = new[]
{
public static readonly IReadOnlyList<Diploma> Diplomas = new[]
{
new Diploma("d1", "Geneeskunde", "Universiteit Leiden", 2011, "geneeskunde", Engelstalig: false),
new Diploma("d2", "Medicine (MBChB)", "University of Edinburgh", 2013, "geneeskunde", Engelstalig: true),
new Diploma("d3", "HBO-Verpleegkunde", "Hogeschool Utrecht", 2016, "verpleegkunde", Engelstalig: false),
};
public static readonly IReadOnlyList<(string Type, string Omschrijving, string Datum)> Notes = new[]
{
public static readonly IReadOnlyList<(string Type, string Omschrijving, string Datum)> Notes = new[]
{
("Specialisme", "Huisartsgeneeskunde", "2016-04-12"),
("Aantekening", "Erkend opleider huisartsgeneeskunde", "2019-01-08"),
("Specialisme", "Spoedeisende hulp (kaderopleiding)", "2021-06-30"),

View File

@@ -0,0 +1,75 @@
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
namespace BigRegister.Domain.Authorization;
public enum PrincipalRole { Drafter, Approver, Admin }
/// <summary>
/// The acting identity for this request. dev stub — NOT a security boundary: resolved
/// from the client-asserted X-Role header (mirrors the FE's `?role=` toggle). A real
/// system builds this from verified AD/OIDC claims (PRD-0002 §3, §7); everything else
/// in this file — the capability model, the single Authz.Can check both emitting and
/// enforcing — carries over unchanged once that swap happens.
/// </summary>
public sealed record Principal(PrincipalRole Role);
public enum BriefAction { Approve, Reject, Send }
/// <summary>
/// Single source of truth for brief authorization (PRD-0002 phase P1). The SAME
/// check both computes the decision flags shipped on the screen DTO (emit) and
/// gates the mutation endpoints (enforce) — so the two can never drift, closing the
/// classic broken-object-level-authorization gap (PRD-0002 §7).
/// </summary>
public static class Authz
{
public static Principal ResolvePrincipal(HttpContext ctx) => new(ctx.Request.Headers["X-Role"].ToString() switch
{
"approver" => PrincipalRole.Approver,
"admin" => PrincipalRole.Admin,
_ => PrincipalRole.Drafter,
});
public static string ActingId(Principal principal) => principal.Role switch
{
PrincipalRole.Approver => BriefStore.ApproverId,
PrincipalRole.Admin => BriefStore.AdminId,
_ => BriefStore.DrafterId,
};
/// Coarse, resource-independent capabilities for `GET /me` (nav/menu-level — NOT
/// tied to any specific brief's live status; contrast Decisions below).
public static IReadOnlyList<string> RoleCapabilities(Principal principal) => principal.Role switch
{
PrincipalRole.Approver => new[] { "brief:approve", "brief:reject", "brief:send" },
PrincipalRole.Admin => new[] { "orgtemplate:edit" },
_ => Array.Empty<string>(),
};
/// Role + four-eyes (SoD) check only — no status. This is the exact check
/// BriefStore.Review enforces before its status guard; kept separate from
/// Decisions() below so enforcement ORDER (Forbidden before Conflict) matches
/// today's behavior exactly. The explicit Approver condition keeps the new Admin
/// role out of the review flow (WP-23) — SoD alone would have let it through.
public static bool CanActOn(BriefAction action, Principal principal, string drafterId) => action switch
{
BriefAction.Approve or BriefAction.Reject =>
principal.Role == PrincipalRole.Approver && ActingId(principal) != drafterId,
BriefAction.Send => true, // sending is a mechanical dispatch step, not role-gated today
_ => false,
};
/// Org-template management (WP-23): admin-only, resource-independent — templates
/// have no per-resource state to weigh, so role IS the whole decision here.
public static bool CanManageOrgTemplates(Principal principal) => principal.Role == PrincipalRole.Admin;
/// Resource-aware decision for the screen DTO: "would this action succeed right
/// now" — role/SoD AND the brief's current status. This is what the UI renders;
/// it never re-derives these booleans itself.
public static BriefDecisionsDto Decisions(Principal principal, string status, string drafterId) => new(
CanEdit: principal.Role == PrincipalRole.Drafter && status is "draft" or "rejected",
CanApprove: CanActOn(BriefAction.Approve, principal, drafterId) && status == "submitted",
CanReject: CanActOn(BriefAction.Reject, principal, drafterId) && status == "submitted",
CanSend: CanActOn(BriefAction.Send, principal, drafterId) && status == "approved");
}

View File

@@ -2,8 +2,8 @@ namespace BigRegister.Domain.Diplomas;
public enum QuestionType
{
JaNee,
Tekst,
JaNee,
Tekst,
}
public sealed record PolicyQuestion(string Id, string Vraag, QuestionType Type);

View File

@@ -8,61 +8,61 @@ namespace BigRegister.Domain.Diplomas;
/// </summary>
public static class DiplomaRules
{
// RULE: study program → BIG profession.
private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase)
{
["geneeskunde"] = "Arts",
["verpleegkunde"] = "Verpleegkundige",
["fysiotherapie"] = "Fysiotherapeut",
["farmacie"] = "Apotheker",
["tandheelkunde"] = "Tandarts",
};
// RULE: study program → BIG profession.
private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase)
{
["geneeskunde"] = "Arts",
["verpleegkunde"] = "Verpleegkundige",
["fysiotherapie"] = "Fysiotherapeut",
["farmacie"] = "Apotheker",
["tandheelkunde"] = "Tandarts",
};
public static string ProfessionFor(Diploma d) =>
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
public static string ProfessionFor(Diploma d) =>
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
public static IReadOnlyList<string> ManualProfessions() =>
ProfessionByProgram.Values.Distinct().ToList();
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
public static IReadOnlyList<string> ManualProfessions() =>
ProfessionByProgram.Values.Distinct().ToList();
// --- Policy questions (geldigheidsvragen) ---
// --- Policy questions (geldigheidsvragen) ---
private static readonly PolicyQuestion NlTaalEngelstalig = new(
"nl-taalvaardigheid",
"Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
QuestionType.JaNee);
private static readonly PolicyQuestion NlTaalEngelstalig = new(
"nl-taalvaardigheid",
"Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
QuestionType.JaNee);
private static readonly PolicyQuestion NlTaalManual = new(
"nl-taalvaardigheid",
"Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
QuestionType.JaNee);
private static readonly PolicyQuestion NlTaalManual = new(
"nl-taalvaardigheid",
"Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
QuestionType.JaNee);
private static readonly PolicyQuestion DiplomaErkend = new(
"diploma-erkend",
"Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?",
QuestionType.JaNee);
private static readonly PolicyQuestion DiplomaErkend = new(
"diploma-erkend",
"Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?",
QuestionType.JaNee);
private static readonly PolicyQuestion Toelichting = new(
"toelichting",
"Geef een korte toelichting op uw diploma en opleiding.",
QuestionType.Tekst);
private static readonly PolicyQuestion Toelichting = new(
"toelichting",
"Geef een korte toelichting op uw diploma en opleiding.",
QuestionType.Tekst);
/// <summary>
/// RULE: an English-language diploma requires proof of Dutch proficiency (B2).
/// Add a question here to apply it to a (set of) diploma(s) — a single backend
/// change, no frontend change.
/// </summary>
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
{
var questions = new List<PolicyQuestion>();
if (d.Engelstalig)
questions.Add(NlTaalEngelstalig);
return questions;
}
/// <summary>
/// RULE: an English-language diploma requires proof of Dutch proficiency (B2).
/// Add a question here to apply it to a (set of) diploma(s) — a single backend
/// change, no frontend change.
/// </summary>
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
{
var questions = new List<PolicyQuestion>();
if (d.Engelstalig)
questions.Add(NlTaalEngelstalig);
return questions;
}
/// <summary>
/// RULE: a manual diploma is unverified, so the strictest (maximal) set applies.
/// </summary>
public static IReadOnlyList<PolicyQuestion> ManualQuestions() =>
new[] { NlTaalManual, DiplomaErkend, Toelichting };
/// <summary>
/// RULE: a manual diploma is unverified, so the strictest (maximal) set applies.
/// </summary>
public static IReadOnlyList<PolicyQuestion> ManualQuestions() =>
new[] { NlTaalManual, DiplomaErkend, Toelichting };
}

View File

@@ -17,67 +17,75 @@ public sealed record DocumentCategory(
public static class DocumentRules
{
private static readonly string[] Pdf = { "application/pdf" };
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
private static readonly string[] Pdf = { "application/pdf" };
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
private static readonly string[] Image = { "image/jpeg", "image/png" };
private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
private static readonly DocumentCategory Identiteit = new("identiteit", "Identiteitsbewijs",
"Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true);
private static readonly DocumentCategory Taalvaardigheid = new("taalvaardigheid", "Bewijs Nederlandse taalvaardigheid",
"Upload een bewijs van uw Nederlandse taalvaardigheid op het vereiste niveau (B2).", true, PdfImage, 10, false, true);
private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
private static readonly DocumentCategory Identiteit = new("identiteit", "Identiteitsbewijs",
"Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true);
private static readonly DocumentCategory Taalvaardigheid = new("taalvaardigheid", "Bewijs Nederlandse taalvaardigheid",
"Upload een bewijs van uw Nederlandse taalvaardigheid op het vereiste niveau (B2).", true, PdfImage, 10, false, true);
/// <summary>
/// The MAXIMAL set of categories a wizard can ever ask for. Used to validate an
/// upload POST (any real category must resolve) — <see cref="CategoriesFor"/>
/// decides which subset is actually presented for a given set of answers.
/// </summary>
public static IReadOnlyList<DocumentCategory> AllCategoriesFor(string wizardId) => wizardId switch
/// <summary>
/// The MAXIMAL set of categories a wizard can ever ask for. Used to validate an
/// upload POST (any real category must resolve) — <see cref="CategoriesFor"/>
/// decides which subset is actually presented for a given set of answers.
/// </summary>
public static IReadOnlyList<DocumentCategory> AllCategoriesFor(string wizardId) => wizardId switch
{
"registratie" => new[] { Diploma, Identiteit, Taalvaardigheid },
"herregistratie" => new[]
{
"registratie" => new[] { Diploma, Identiteit, Taalvaardigheid },
"herregistratie" => new[]
{
new DocumentCategory("werkervaring", "Bewijs van werkervaring",
"Upload bewijs van uw gewerkte uren (bijv. een werkgeversverklaring).", true, Pdf, 10, true, true),
new DocumentCategory("nascholing", "Nascholingscertificaten",
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
},
_ => Array.Empty<DocumentCategory>(),
};
/// <summary>
/// The categories to PRESENT for a wizard, given the answers that affect required
/// documents. RULES (registratie): a diploma upload is required ONLY for a manually
/// entered diploma (a DUO diploma is verified digitally, and nothing is required
/// before a diploma is chosen); proof of Dutch taalvaardigheid is required only when
/// the applicant confirms ("ja") they meet the language requirement. Answer-agnostic
/// wizards get their full set.
/// </summary>
public static IReadOnlyList<DocumentCategory> CategoriesFor(
string wizardId, string? diplomaHerkomst = null, string? taalvaardigheid = null)
// WP-23: the admin's org-template logo rides the same upload machinery as the
// wizard documents — one category under its own "wizard" id.
"org-template" => new[]
{
if (wizardId != "registratie") return AllCategoriesFor(wizardId);
var result = new List<DocumentCategory>();
if (diplomaHerkomst == "handmatig") result.Add(Diploma);
result.Add(Identiteit);
if (taalvaardigheid == "ja") result.Add(Taalvaardigheid);
return result;
}
new DocumentCategory("org-logo", "Organisatielogo",
"Upload het logo van de organisatie (PNG of JPG).", false, Image, 1, false, false),
},
_ => Array.Empty<DocumentCategory>(),
};
public static DocumentCategory? Find(string wizardId, string categoryId) =>
AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
/// <summary>
/// The categories to PRESENT for a wizard, given the answers that affect required
/// documents. RULES (registratie): a diploma upload is required ONLY for a manually
/// entered diploma (a DUO diploma is verified digitally, and nothing is required
/// before a diploma is chosen); proof of Dutch taalvaardigheid is required only when
/// the applicant confirms ("ja") they meet the language requirement. Answer-agnostic
/// wizards get their full set.
/// </summary>
public static IReadOnlyList<DocumentCategory> CategoriesFor(
string wizardId, string? diplomaHerkomst = null, string? taalvaardigheid = null)
{
if (wizardId != "registratie") return AllCategoriesFor(wizardId);
var result = new List<DocumentCategory>();
if (diplomaHerkomst == "handmatig") result.Add(Diploma);
result.Add(Identiteit);
if (taalvaardigheid == "ja") result.Add(Taalvaardigheid);
return result;
}
/// <summary>
/// Authoritative upload validation (the client check is UX-only). Returns a
/// rejection reason, or null when the file is acceptable.
/// </summary>
public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes)
{
if (category is null) return "Onbekende documentcategorie.";
if (!category.AcceptedTypes.Contains(contentType))
return $"Bestandstype niet toegestaan voor {category.Label}.";
if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024)
return $"Bestand is groter dan {category.MaxSizeMb} MB.";
return null;
}
public static DocumentCategory? Find(string wizardId, string categoryId) =>
AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
/// <summary>
/// Authoritative upload validation (the client check is UX-only). Returns a
/// rejection reason, or null when the file is acceptable.
/// </summary>
public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes)
{
if (category is null) return "Onbekende documentcategorie.";
if (!category.AcceptedTypes.Contains(contentType))
return $"Bestandstype niet toegestaan voor {category.Label}.";
if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024)
return $"Bestand is groter dan {category.MaxSizeMb} MB.";
return null;
}
}

View File

@@ -7,5 +7,5 @@ namespace BigRegister.Domain.Intake;
/// </summary>
public static class IntakePolicy
{
public const int ScholingThreshold = 1000;
public const int ScholingThreshold = 1000;
}

View File

@@ -0,0 +1,191 @@
using System.Globalization;
using System.Text;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
namespace BigRegister.Domain.Letters;
/// <summary>
/// Server-rendered letter HTML (WP-25) — the archived, "what is sent" artifact.
/// Mirrors the FE letter canvas' class vocabulary exactly (<c>public/letter.css</c>,
/// the FE⇄BE contract; LetterHtmlTests' class-parity test is the fence against drift).
///
/// Unlike the canvas, placeholders resolve to real text rather than a live editor
/// widget: an auto-resolvable key pulls from seed/case data (there is no per-brief
/// resolved value stored anywhere else in the domain — see BriefEntity's own "does
/// not interpret it" posture), an unresolved manual key renders literally as
/// "[NOG IN TE VULLEN: label]" (PRD Brief v2 §8) — the preview works despite this,
/// only send blocks on it (FE-authoritative linting).
///
/// ponytail: HTML today, a headless-Chromium PDF render slots in behind this same
/// route if the POC ever needs real PDF bytes — see the preview endpoints.
/// </summary>
public static class LetterHtml
{
private static readonly string Css = File.ReadAllText(FindLetterCss());
public static string Render(BriefEntity brief, OrgTemplateDto template, string at, bool watermark)
{
var defs = brief.Placeholders.ToDictionary(p => p.Key);
var sb = new StringBuilder();
sb.Append("<!doctype html><html lang=\"nl\"><head><meta charset=\"utf-8\">");
sb.Append("<title>").Append(Enc(brief.BriefId)).Append("</title>");
sb.Append("<style>").Append(Css).Append(ExtraCss).Append("</style>");
sb.Append("</head><body>");
sb.Append("<div class=\"letter\" style=\"").Append(MarginStyle(template.Margins)).Append("\">");
// --- letterhead ---
sb.Append("<div class=\"letter__letterhead\">");
if (template.LogoDocumentId is { } logoId && DocumentStore.Get(logoId) is { } logo)
{
sb.Append("<img class=\"org-logo\" alt=\"\" src=\"data:").Append(logo.ContentType)
.Append(";base64,").Append(Convert.ToBase64String(logo.Content)).Append("\">");
}
sb.Append("<p class=\"org-wordmark\">").Append(Enc(template.OrgName)).Append("</p>");
sb.Append("<address class=\"return-address\">").Append(EncLines(template.ReturnAddress)).Append("</address>");
sb.Append("<address class=\"address-window\">").Append(EncLines(RecipientPlaceholder)).Append("</address>");
sb.Append("<dl class=\"reference\">");
sb.Append("<div><dt>Ons kenmerk</dt><dd>").Append(Enc(brief.BriefId)).Append("</dd></div>");
sb.Append("<div><dt>Datum</dt><dd>").Append(Enc(FormatDatumNl(at))).Append("</dd></div>");
sb.Append("</dl></div>");
// --- body: the case-type template's sections ---
sb.Append("<div class=\"letter__body\">");
foreach (var section in brief.Sections)
{
sb.Append("<section><h3>").Append(Enc(section.Title)).Append("</h3>");
foreach (var block in section.Blocks)
RenderParagraphs(sb, block.Content.Paragraphs, defs);
sb.Append("</section>");
}
sb.Append("</div>");
// --- signature ---
sb.Append("<div class=\"letter__signature\">");
sb.Append("<p>").Append(Enc(template.SignatureClosing)).Append("</p>");
sb.Append("<p class=\"signature-name\">").Append(Enc(template.SignatureName)).Append("</p>");
sb.Append("<p>").Append(Enc(template.SignatureRole)).Append("</p>");
sb.Append("</div>");
// --- footer ---
sb.Append("<div class=\"letter__footer\">");
sb.Append("<div class=\"footer-contact\">").Append(EncLines(template.FooterContact)).Append("</div>");
sb.Append("<div class=\"footer-legal\">").Append(Enc(template.FooterLegal)).Append("</div>");
sb.Append("</div>");
if (watermark) sb.Append("<div class=\"preview-watermark\" aria-hidden=\"true\">VOORBEELD</div>");
sb.Append("</div></body></html>");
return sb.ToString();
}
/// Exposed so LetterHtmlTests can assert every `letter`-prefixed class this
/// renderer emits also exists in the shared contract file — the fence against drift.
public static string StyleSheet => Css;
// No recipient address is tracked anywhere in this POC's brief domain (BRP lookup
// is out of scope here) — the canvas shows the same static placeholder text.
private const string RecipientPlaceholder = "Adres van de geadresseerde\n(wordt ingevuld bij verzending)";
private static void RenderParagraphs(
StringBuilder sb, IReadOnlyList<ParagraphDto> paragraphs, IReadOnlyDictionary<string, PlaceholderDefDto> defs)
{
string? openList = null;
foreach (var para in paragraphs)
{
if (para.List != openList)
{
if (openList is not null) sb.Append(openList == "bullet" ? "</ul>" : "</ol>");
if (para.List is not null) sb.Append(para.List == "bullet" ? "<ul>" : "<ol>");
openList = para.List;
}
sb.Append(openList is null ? "<p>" : "<li>");
foreach (var node in para.Nodes) RenderNode(sb, node, defs);
sb.Append(openList is null ? "</p>" : "</li>");
}
if (openList is not null) sb.Append(openList == "bullet" ? "</ul>" : "</ol>");
}
private static void RenderNode(
StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary<string, PlaceholderDefDto> defs)
{
switch (node.Type)
{
case "text":
sb.Append(Enc(node.Text ?? ""));
break;
case "lineBreak":
sb.Append("<br>");
break;
case "placeholder":
var key = node.Key ?? "";
var def = defs.GetValueOrDefault(key);
var label = def?.Label ?? key;
sb.Append(def is { AutoResolvable: true } ? Enc(ResolveAuto(key, label)) : Enc($"[NOG IN TE VULLEN: {label}]"));
break;
}
}
// The only place a placeholder key gets a real value: seed/case data for the
// single demo applicant (SeedData.Registration — no per-brief resolved value is
// ever stored, see the class doc above). Falls back to the label itself for any
// other auto-resolvable key, mirroring the FE canvas' own `sampleFor` fallback.
private static string ResolveAuto(string key, string label) => key switch
{
"naam_zorgverlener" => SeedData.Registration.Naam,
"big_nummer" => SeedData.Registration.BigNummer,
"datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o")),
_ => label,
};
private static readonly CultureInfo Nl = CultureInfo.GetCultureInfo("nl-NL");
private static string FormatDatumNl(string at) => DateTimeOffset.Parse(at).ToString("d MMMM yyyy", Nl);
private static string MarginStyle(MarginsDto m) =>
$"--letter-margin-top:{m.TopMm}mm;--letter-margin-right:{m.RightMm}mm;" +
$"--letter-margin-bottom:{m.BottomMm}mm;--letter-margin-left:{m.LeftMm}mm;";
private static string Enc(string s) => System.Net.WebUtility.HtmlEncode(s);
private static string EncLines(string s) => Enc(s).Replace("\n", "<br>");
// Walks up from the running assembly's own directory (NOT the process cwd, which
// varies by how `dotnet run`/docker/tests invoke it — see docs/backlog/WP-25) until
// it finds `public/letter.css`. docker-compose.yml bind-mounts `./public` under the
// api container's `/src` for exactly this walk to resolve there too.
private static string FindLetterCss()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "public", "letter.css");
if (File.Exists(candidate)) return candidate;
}
throw new FileNotFoundException(
$"public/letter.css not found by walking up from {AppContext.BaseDirectory} " +
"— check the docker bind mount or build output location.");
}
// Backend-only concerns absent from the FE canvas (no live preview toggle for
// either): kept out of the shared contract file, not "letter"-prefixed so the
// class-parity test's scope doesn't need to widen for them.
private const string ExtraCss = """
.preview-watermark {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 72pt;
font-weight: 700;
color: rgb(200 30 30 / 0.18);
transform: rotate(-30deg);
pointer-events: none;
z-index: 3;
}
.org-logo {
display: block;
max-height: 18mm;
margin-block-end: 4mm;
}
""";
}

View File

@@ -0,0 +1,28 @@
using BigRegister.Api.Contracts;
namespace BigRegister.Domain.Letters;
/// <summary>
/// SERVER-OWNED org-template validation (Brief v2 PRD §5): bounded margins so an
/// admin cannot break the letter geometry, and the identity fields a letter cannot
/// render without. The FE mirrors the bounds for instant feedback (config values on
/// the wire if ever needed); this is the authority.
/// </summary>
public static class OrgTemplateRules
{
public const int MarginMinMm = 10;
public const int MarginMaxMm = 50;
/// Returns a rejection reason, or null when the draft is acceptable.
public static string? RejectDraft(OrgTemplateDto draft)
{
if (string.IsNullOrWhiteSpace(draft.OrgName)) return "Organisatienaam is verplicht.";
if (string.IsNullOrWhiteSpace(draft.SignatureName)) return "Naam van de ondertekenaar is verplicht.";
var m = draft.Margins;
var outOfBounds = new[] { m.TopMm, m.RightMm, m.BottomMm, m.LeftMm }
.Any(v => v is < MarginMinMm or > MarginMaxMm);
return outOfBounds
? $"Marges moeten tussen {MarginMinMm} en {MarginMaxMm} mm liggen."
: null;
}
}

View File

@@ -8,25 +8,25 @@ namespace BigRegister.Domain.Registrations;
/// </summary>
public static class HerregistratieRule
{
public const int WindowMonths = 12;
public const int WindowMonths = 12;
public static DateOnly? Deadline(Registration reg) =>
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
public static DateOnly? Deadline(Registration reg) =>
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
public static (bool Eligible, string? Reason) Evaluate(
Registration reg, DateOnly today, int windowMonths = WindowMonths)
{
var deadline = Deadline(reg);
if (deadline is null)
return (false, "Geen actieve registratie.");
public static (bool Eligible, string? Reason) Evaluate(
Registration reg, DateOnly today, int windowMonths = WindowMonths)
{
var deadline = Deadline(reg);
if (deadline is null)
return (false, "Geen actieve registratie.");
var windowStart = deadline.Value.AddMonths(-windowMonths);
return today >= windowStart
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
}
var windowStart = deadline.Value.AddMonths(-windowMonths);
return today >= windowStart
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
}
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
public static bool IsStatusConsistent(RegistrationStatus s) =>
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null;
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
public static bool IsStatusConsistent(RegistrationStatus s) =>
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null;
}

View File

@@ -3,9 +3,9 @@ namespace BigRegister.Domain.Registrations;
/// <summary>The three states a BIG registration can be in.</summary>
public enum StatusTag
{
Geregistreerd,
Geschorst,
Doorgehaald,
Geregistreerd,
Geschorst,
Doorgehaald,
}
/// <summary>

View File

@@ -9,29 +9,29 @@ namespace BigRegister.Domain.Submissions;
/// </summary>
public static class SubmissionRules
{
// RULE: a manually entered diploma cannot be auto-verified.
public static string? RejectRegistratie(string diplomaHerkomst) =>
diplomaHerkomst == "handmatig"
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
: null;
// RULE: a manually entered diploma cannot be auto-verified.
public static string? RejectRegistratie(string diplomaHerkomst) =>
diplomaHerkomst == "handmatig"
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
: null;
// RULE: an application reporting zero worked hours is rejected.
public static string? RejectZeroUren(int uren) =>
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
// RULE: an application reporting zero worked hours is rejected.
public static string? RejectZeroUren(int uren) =>
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
private static readonly Regex PostcodePattern =
new(@"^[1-9]\d{3}\s?[A-Z]{2}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex PostcodePattern =
new(@"^[1-9]\d{3}\s?[A-Z]{2}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// RULE: a change request needs a street and a well-formed Dutch postcode. The
// server re-validates format authoritatively (the FE check is UX-only).
public static string? RejectChangeRequest(string straat, string postcode)
{
if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in.";
if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB.";
return null;
}
// RULE: a change request needs a street and a well-formed Dutch postcode. The
// server re-validates format authoritatively (the FE check is UX-only).
public static string? RejectChangeRequest(string straat, string postcode)
{
if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in.";
if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB.";
return null;
}
public static string NewReference() =>
// ponytail: random reference is fine for a demo; a real system reserves it transactionally.
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000);
public static string NewReference() =>
// ponytail: random reference is fine for a demo; a real system reserves it transactionally.
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000);
}

View File

@@ -2,11 +2,15 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.Intake;
using BigRegister.Domain.Letters;
using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Console;
var builder = WebApplication.CreateBuilder(args);
@@ -16,16 +20,49 @@ builder.Services.AddSwaggerGen(c =>
builder.Services.AddProblemDetails();
builder.Services.ConfigureHttpJsonOptions(o =>
{
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
// So the correlation-id scope pushed by the middleware below actually shows up in
// the console, not just in memory for a formatter that never renders it.
builder.Logging.AddSimpleConsole(o => o.IncludeScopes = true);
const string SpaCors = "spa";
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));
// WP-22: the three stores (Applications/Documents/Briefs — Data/*.cs) are static
// classes that open their own short-lived AppDbContext per call (see Db.Create),
// not DI-injected, so there's no builder.Services.AddDbContext here. Configuring
// the connection string still goes through IConfiguration so tests/deployments can
// override it (ConnectionStrings:AppDb) without touching this file.
Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString;
var app = builder.Build();
// Migrate on every startup, seed nothing (WP-22): unlike SeedData's read-only
// reference fixtures (registration/diplomas/notes — untouched by this WP, still
// static in-memory), Applications/Documents/Briefs never had seed data — they
// started empty and accumulated through normal use before this WP too. A fresh
// SQLite file just starts empty again, same as the old in-memory dictionaries did.
using (var db = Db.Create())
db.Database.Migrate();
// Every request gets a correlation id (client-supplied X-Correlation-Id if present,
// else generated), pushed into the logging scope for every log line the request
// produces (not just the Submit helper's) and echoed back as a response header for
// support/debugging correlation. Runs first so nothing downstream logs without it.
app.Use(async (ctx, next) =>
{
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
? v.ToString()
: Guid.NewGuid().ToString();
ctx.Items["CorrelationId"] = cid;
ctx.Response.Headers["X-Correlation-Id"] = cid;
using (app.Logger.BeginScope("CorrelationId:{CorrelationId}", cid))
await next(ctx);
});
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors(SpaCors);
@@ -42,10 +79,10 @@ var api = app.MapGroup("/api/v1");
api.MapGet("/dashboard-view", () =>
{
var reg = SeedData.Registration;
var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
new HerregistratieDecisionsDto(eligible, reason));
var reg = SeedData.Registration;
var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
new HerregistratieDecisionsDto(eligible, reason));
});
api.MapGet("/notes", () =>
@@ -96,21 +133,21 @@ api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, str
// and size authoritatively; stores metadata only (no file bytes / PII held).
api.MapPost("/uploads", async (HttpRequest request) =>
{
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
var form = await request.ReadFormAsync();
var file = form.Files.GetFile("file");
string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
if (file is null || categoryId == "" || localId == "" || wizardId == "")
return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
var form = await request.ReadFormAsync();
var file = form.Files.GetFile("file");
string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
if (file is null || categoryId == "" || localId == "" || wizardId == "")
return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
var category = DocumentRules.Find(wizardId, categoryId);
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
var category = DocumentRules.Find(wizardId, categoryId);
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner);
return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId));
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner);
return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId));
})
.ExcludeFromDescription();
@@ -118,10 +155,10 @@ api.MapPost("/uploads", async (HttpRequest request) =>
// for pdf/image (browser renders it), attachment otherwise (download).
api.MapGet("/uploads/{documentId}/content", (string documentId) =>
{
var doc = DocumentStore.Get(documentId);
if (doc is null) return Results.NotFound();
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
var doc = DocumentStore.Get(documentId);
if (doc is null) return Results.NotFound();
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
})
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
@@ -129,23 +166,23 @@ api.MapGet("/uploads/{documentId}/content", (string documentId) =>
// Poll-on-return: which of these client localIds have arrived at the BFF.
api.MapGet("/uploads/status", (string? localIds) =>
{
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
var results = ids.Select(id => found.TryGetValue(id, out var d)
? new UploadStatusItemDto(id, "complete", d.DocumentId)
: new UploadStatusItemDto(id, "unknown", null)).ToList();
return new UploadStatusDto(results);
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
var results = ids.Select(id => found.TryGetValue(id, out var d)
? new UploadStatusItemDto(id, "complete", d.DocumentId)
: new UploadStatusItemDto(id, "unknown", null)).ToList();
return new UploadStatusDto(results);
});
// User delete: owner-scoped; 409 once linked to a finalised submission.
api.MapDelete("/uploads/{documentId}", (string documentId) =>
DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch
{
DocumentStore.DeleteResult.Ok => Results.NoContent(),
DocumentStore.DeleteResult.Linked => Results.Problem(
detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.",
statusCode: StatusCodes.Status409Conflict),
_ => Results.NotFound(),
DocumentStore.DeleteResult.Ok => Results.NoContent(),
DocumentStore.DeleteResult.Linked => Results.Problem(
detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.",
statusCode: StatusCodes.Status409Conflict),
_ => Results.NotFound(),
})
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status409Conflict)
@@ -164,10 +201,10 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx
api.MapGet("/applications", () =>
{
var now = DateTimeOffset.UtcNow;
return ApplicationStore.List(DocumentStore.DemoOwner)
.OrderByDescending(a => a.UpdatedAt)
.Select(a => a.ToSummaryDto(now)).ToList();
var now = DateTimeOffset.UtcNow;
return ApplicationStore.List(DocumentStore.DemoOwner)
.OrderByDescending(a => a.UpdatedAt)
.Select(a => a.ToSummaryDto(now)).ToList();
});
api.MapGet("/applications/{id}", (string id) =>
@@ -179,8 +216,8 @@ api.MapGet("/applications/{id}", (string id) =>
api.MapPost("/applications", (CreateApplicationRequest req) =>
{
var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner);
return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow));
var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner);
return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow));
})
.Produces<ApplicationDetailDto>(StatusCodes.Status201Created);
@@ -195,12 +232,12 @@ api.MapPut("/applications/{id}", (string id, DraftSyncRequest req) =>
// be withdrawn (out of scope — no "intrekken").
api.MapDelete("/applications/{id}", (string id) =>
{
var a = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (a is null) return Results.NotFound();
if (a.Submitted)
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
ApplicationStore.Delete(id, DocumentStore.DemoOwner);
return Results.NoContent();
var a = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (a is null) return Results.NotFound();
if (a.Submitted)
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
ApplicationStore.Delete(id, DocumentStore.DemoOwner);
return Results.NoContent();
})
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status409Conflict)
@@ -210,128 +247,218 @@ api.MapDelete("/applications/{id}", (string id) =>
// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case.
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) =>
{
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (existing is null) return Results.NotFound();
if (existing.Submitted)
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (existing is null) return Results.NotFound();
if (existing.Submitted)
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);
// Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves.
(string? reject, bool autoApprovable) = existing.Type switch
{
"registratie" => (null, req.DiplomaHerkomst == "duo"),
_ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
};
// Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves.
(string? reject, bool autoApprovable) = existing.Type switch
{
"registratie" => (null, req.DiplomaHerkomst == "duo"),
_ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
};
var docs = req.Documents;
if (docs is not null)
DocumentStore.Link(docs.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
var documentIds = docs?.Where(d => d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
var docs = req.Documents;
if (docs is not null)
DocumentStore.Link(docs.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
var documentIds = docs?.Where(d => d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds);
if (submitted is null) return Results.Conflict();
var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds);
if (submitted is null) return Results.Conflict();
app.Logger.LogInformation(
"aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow)));
app.Logger.LogInformation(
"aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow)));
})
.Produces<SubmitApplicationResponse>()
.ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound);
// --- Brief (letter composition). One demo brief per owner; the server owns the
// status machine + role rules. Role is a dev-only stand-in via X-Role (mirrors the
// X-Admin seam and the FE ?role= toggle) — no real identities in this POC. ---
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
// tied to a specific brief's live status — see BriefDecisionsDto for that).
api.MapGet("/me", (HttpContext ctx) => new MeDto(Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx))))
.Produces<MeDto>();
api.MapGet("/brief", () =>
// --- Brief (letter composition). One demo brief per owner; the server owns the
// status machine + authorization (Authz, PRD-0002 phase P1). Principal is a
// dev-only stand-in via X-Role (mirrors the X-Admin seam and the FE ?role=
// toggle) — no real identities in this POC. ---
api.MapGet("/brief", (HttpContext ctx) =>
{
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
return ToView(ctx, e);
})
.Produces<BriefViewDto>();
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{
var (_, isDrafter) = BriefRole(ctx);
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
return BriefResult(ctx, BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
})
.Produces<BriefDto>()
.Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/submit", (HttpContext ctx) =>
{
var (_, isDrafter) = BriefRole(ctx);
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
LogBrief("submit", r);
return BriefResult(r, "Alleen de opsteller mag indienen.");
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
LogBrief("submit", r);
return BriefResult(ctx, r, "Alleen de opsteller mag indienen.");
})
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
.Produces<BriefDto>()
.Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/approve", (HttpContext ctx) =>
{
var (acting, _) = BriefRole(ctx);
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
LogBrief("approve", r);
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
var r = BriefStore.Approve(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), Now());
LogBrief("approve", r);
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
})
.Produces<BriefDto>()
.Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
{
var (acting, _) = BriefRole(ctx);
var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now());
LogBrief("reject", r);
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
var r = BriefStore.Reject(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), req.Comments, Now());
LogBrief("reject", r);
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
})
.Produces<BriefDto>()
.Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/send", () =>
api.MapPost("/brief/send", (HttpContext ctx) =>
{
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity
// port); the backend only guards the approved→sent transition.
var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
LogBrief("send", r);
return BriefResult(r, "Versturen kan niet in deze status.");
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity
// port); the backend only guards the approved→sent transition (not role-gated
// today — see Authz.CanActOn(Send, …), a mechanical dispatch step).
var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
LogBrief("send", r);
return BriefResult(ctx, r, "Versturen kan niet in deze status.");
})
.Produces<BriefDto>()
.Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/reset", () =>
// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the
// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch →
// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent
// letters serve their frozen archive; anything else renders live with a watermark.
api.MapGet("/brief/preview", (HttpContext ctx) =>
{
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
return Results.Content(archived, "text/html");
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html");
})
.ExcludeFromDescription();
// Proefbrief: the admin's unpublished draft template rendered over a fixture
// brief, so the appearance can be checked before publishing touches real letters.
api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
{
var view = OrgTemplateStore.AdminView(subOrgId);
if (view is null) return Results.NotFound();
var fixture = BriefSeed.NewBrief("proefbrief");
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
}))
.ExcludeFromDescription();
api.MapPost("/brief/reset", (HttpContext ctx) =>
{
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
return ToView(ctx, e);
})
.WithName("briefReset")
.Produces<BriefViewDto>();
// --- Organization templates (WP-23): the second template axis — appearance and
// identity per sub-organization. Admin-only (X-Role: admin, the same dev-stub seam
// as drafter/approver); the same Authz check gates every endpoint and feeds the
// `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. ---
api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () =>
Results.Ok(OrgTemplateStore.List())))
.WithName("orgTemplates")
.Produces<List<SubOrgSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
OrgTemplateStore.AdminView(subOrgId) is { } view ? Results.Ok(view) : Results.NotFound()))
.WithName("orgTemplateGET")
.Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRequest req, HttpContext ctx) => OrgAdmin(ctx, () =>
{
var reject = OrgTemplateRules.RejectDraft(req.Draft);
if (reject is not null) return Results.Problem(detail: reject, statusCode: StatusCodes.Status400BadRequest);
return OrgTemplateStore.SaveDraft(subOrgId, req.Draft) is { } view ? Results.Ok(view) : Results.NotFound();
}))
.WithName("orgTemplatePUT")
.Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
{
var r = OrgTemplateStore.Publish(subOrgId, Now());
if (r is not null)
app.Logger.LogInformation("orgtemplate publish subOrg={SubOrg} version={Version} affected={Affected}",
subOrgId, r.Version, r.AffectedUnsentBriefs);
return r is not null ? Results.Ok(r) : Results.NotFound();
}))
.WithName("orgTemplatePublish")
.Produces<PublishOrgTemplateResponse>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string subOrgId, int version, HttpContext ctx) => OrgAdmin(ctx, () =>
OrgTemplateStore.Rollback(subOrgId, version) is { } view ? Results.Ok(view) : Results.NotFound()))
.WithName("orgTemplateRollback")
.Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
app.Run();
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
// One gate for every org-template endpoint — the enforce twin of the
// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source).
static IResult OrgAdmin(HttpContext ctx, Func<IResult> action) =>
Authz.CanManageOrgTemplates(Authz.ResolvePrincipal(ctx))
? action()
: Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.",
statusCode: StatusCodes.Status403Forbidden);
static string Now() => DateTimeOffset.UtcNow.ToString("o");
// Dev-only role stand-in: X-Role: approver acts as the approver identity, anything
// else (default) acts as the drafter.
static (string acting, bool isDrafter) BriefRole(HttpContext ctx)
{
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
}
BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
e.ToDto(),
BriefSeed.PassagesFor(e.Beroep),
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId),
// Sent letters render with the version pinned at send; everything else follows
// the sub-org's current published template (WP-23 immutability invariant).
OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null));
IResult BriefResult((BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
// Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift.
IResult BriefResult(HttpContext ctx, (BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
{
BriefStore.Outcome.Ok => Results.Ok(r.entity!.ToDto()),
BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden),
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
BriefStore.Outcome.Ok => Results.Ok(ToView(ctx, r.entity!)),
BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden),
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
};
void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) =>
@@ -340,33 +467,48 @@ void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r
// Audit + outcome for a submit, with NO personal data: only kind, outcome,
// generated reference and the caller's correlation id (the observability seam — a
// real system ships this to structured logging / an audit store).
// real system ships this to structured logging / an audit store). A repeated
// Idempotency-Key short-circuits to the first call's result — see IdempotencyStore
// — so a retried submit dedupes instead of minting a second reference.
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
{
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
? v.ToString()
: "none";
var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
? k.ToString()
: null;
if (reject is not null)
{
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
}
if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached))
{
app.Logger.LogInformation("submit kind={Kind} outcome=replayed correlationId={Cid}", kind, cid);
return cached!;
}
IResult result;
if (reject is not null)
{
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
result = Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
}
else
{
if (documents is not null)
{
// Link digital documents (blocks later user delete) and record post-delivery
// intent so a caseworker knows to expect the physical document.
DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
foreach (var d in documents.Where(d => d.Channel == "post"))
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
// Link digital documents (blocks later user delete) and record post-delivery
// intent so a caseworker knows to expect the physical document.
DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
foreach (var d in documents.Where(d => d.Channel == "post"))
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
}
var reference = SubmissionRules.NewReference();
app.Logger.LogInformation(
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
kind, reference, cid, DateTimeOffset.UtcNow);
return Results.Ok(new ReferentieResponse(reference));
result = Results.Ok(new ReferentieResponse(reference));
}
if (idemKey is not null) IdempotencyStore.Set(idemKey, result);
return result;
}
// Exposed so the integration tests can spin up the app with WebApplicationFactory.

View File

@@ -641,6 +641,25 @@
}
}
},
"/api/v1/me": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MeDto"
}
}
}
}
}
}
},
"/api/v1/brief": {
"get": {
"tags": [
@@ -679,7 +698,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BriefDto"
"$ref": "#/components/schemas/BriefViewDto"
}
}
}
@@ -719,7 +738,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BriefDto"
"$ref": "#/components/schemas/BriefViewDto"
}
}
}
@@ -758,7 +777,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BriefDto"
"$ref": "#/components/schemas/BriefViewDto"
}
}
}
@@ -807,7 +826,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BriefDto"
"$ref": "#/components/schemas/BriefViewDto"
}
}
}
@@ -846,7 +865,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BriefDto"
"$ref": "#/components/schemas/BriefViewDto"
}
}
}
@@ -883,6 +902,238 @@
}
}
}
},
"/api/v1/admin/org-templates": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"operationId": "orgTemplates",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SubOrgSummaryDto"
}
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/admin/org-template/{subOrgId}": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"operationId": "orgTemplateGET",
"parameters": [
{
"name": "subOrgId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OrgTemplateAdminViewDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "Not Found"
}
}
},
"put": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"operationId": "orgTemplatePUT",
"parameters": [
{
"name": "subOrgId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SaveOrgTemplateRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OrgTemplateAdminViewDto"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "Not Found"
}
}
}
},
"/api/v1/admin/org-template/{subOrgId}/publish": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"operationId": "orgTemplatePublish",
"parameters": [
{
"name": "subOrgId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublishOrgTemplateResponse"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "Not Found"
}
}
}
},
"/api/v1/admin/org-template/{subOrgId}/rollback/{version}": {
"post": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"operationId": "orgTemplateRollback",
"parameters": [
{
"name": "subOrgId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "version",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OrgTemplateAdminViewDto"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "Not Found"
}
}
}
}
},
"components": {
@@ -1030,6 +1281,24 @@
},
"additionalProperties": false
},
"BriefDecisionsDto": {
"type": "object",
"properties": {
"canEdit": {
"type": "boolean"
},
"canApprove": {
"type": "boolean"
},
"canReject": {
"type": "boolean"
},
"canSend": {
"type": "boolean"
}
},
"additionalProperties": false
},
"BriefDto": {
"type": "object",
"properties": {
@@ -1123,6 +1392,12 @@
"$ref": "#/components/schemas/LibraryPassageDto"
},
"nullable": true
},
"decisions": {
"$ref": "#/components/schemas/BriefDecisionsDto"
},
"orgTemplate": {
"$ref": "#/components/schemas/OrgTemplateDto"
}
},
"additionalProperties": false
@@ -1469,6 +1744,131 @@
},
"additionalProperties": false
},
"MarginsDto": {
"type": "object",
"properties": {
"topMm": {
"type": "integer",
"format": "int32"
},
"rightMm": {
"type": "integer",
"format": "int32"
},
"bottomMm": {
"type": "integer",
"format": "int32"
},
"leftMm": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
},
"MeDto": {
"type": "object",
"properties": {
"capabilities": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
}
},
"additionalProperties": false
},
"OrgTemplateAdminViewDto": {
"type": "object",
"properties": {
"draft": {
"$ref": "#/components/schemas/OrgTemplateDto"
},
"publishedVersion": {
"type": "integer",
"format": "int32"
},
"history": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OrgTemplateVersionDto"
},
"nullable": true
},
"unsentBriefs": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
},
"OrgTemplateDto": {
"type": "object",
"properties": {
"subOrgId": {
"type": "string",
"nullable": true
},
"orgName": {
"type": "string",
"nullable": true
},
"returnAddress": {
"type": "string",
"nullable": true
},
"logoDocumentId": {
"type": "string",
"nullable": true
},
"footerContact": {
"type": "string",
"nullable": true
},
"footerLegal": {
"type": "string",
"nullable": true
},
"signatureName": {
"type": "string",
"nullable": true
},
"signatureRole": {
"type": "string",
"nullable": true
},
"signatureClosing": {
"type": "string",
"nullable": true
},
"margins": {
"$ref": "#/components/schemas/MarginsDto"
},
"version": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
},
"OrgTemplateVersionDto": {
"type": "object",
"properties": {
"version": {
"type": "integer",
"format": "int32"
},
"publishedAt": {
"type": "string",
"nullable": true
},
"template": {
"$ref": "#/components/schemas/OrgTemplateDto"
}
},
"additionalProperties": false
},
"ParagraphDto": {
"type": "object",
"properties": {
@@ -1573,6 +1973,20 @@
},
"additionalProperties": { }
},
"PublishOrgTemplateResponse": {
"type": "object",
"properties": {
"version": {
"type": "integer",
"format": "int32"
},
"affectedUnsentBriefs": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
},
"ReferentieResponse": {
"type": "object",
"properties": {
@@ -1716,6 +2130,33 @@
},
"additionalProperties": false
},
"SaveOrgTemplateRequest": {
"type": "object",
"properties": {
"draft": {
"$ref": "#/components/schemas/OrgTemplateDto"
}
},
"additionalProperties": false
},
"SubOrgSummaryDto": {
"type": "object",
"properties": {
"subOrgId": {
"type": "string",
"nullable": true
},
"orgName": {
"type": "string",
"nullable": true
},
"publishedVersion": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
},
"SubmitApplicationRequest": {
"type": "object",
"properties": {

View File

@@ -6,131 +6,137 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
public class ApplicationTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private readonly HttpClient _client = factory.CreateClient();
private async Task<ApplicationDetailDto> Create(string type = "registratie")
{
var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type });
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
return (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
}
private async Task<ApplicationDetailDto> Create(string type = "registratie")
{
var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type });
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
return (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
}
private Task<List<ApplicationSummaryDto>?> List() =>
_client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/applications");
private Task<List<ApplicationSummaryDto>?> List() =>
_client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/applications");
// --- Lifecycle over HTTP ---
// --- Lifecycle over HTTP ---
[Fact]
public async Task Create_then_list_shows_a_concept_with_step_progress()
{
var a = await Create();
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 });
[Fact]
public async Task Create_then_list_shows_a_concept_with_step_progress()
{
var a = await Create();
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 });
var mine = (await List())!.Single(x => x.Id == a.Id);
Assert.Equal("Concept", mine.Status.Tag);
Assert.Equal(1, mine.Status.StepIndex);
Assert.Equal(4, mine.Status.StepCount);
}
var mine = (await List())!.Single(x => x.Id == a.Id);
Assert.Equal("Concept", mine.Status.Tag);
Assert.Equal(1, mine.Status.StepIndex);
Assert.Equal(4, mine.Status.StepCount);
}
[Fact]
public async Task Draft_sync_is_readable_back_from_detail()
{
var a = await Create();
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 });
[Fact]
public async Task Draft_sync_is_readable_back_from_detail()
{
var a = await Create();
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 });
var detail = await _client.GetFromJsonAsync<ApplicationDetailDto>($"/api/v1/applications/{a.Id}");
Assert.NotNull(detail!.Draft);
Assert.Equal("verpleegkundige", detail.Draft!.Value.GetProperty("beroep").GetString());
}
var detail = await _client.GetFromJsonAsync<ApplicationDetailDto>($"/api/v1/applications/{a.Id}");
Assert.NotNull(detail!.Draft);
Assert.Equal("verpleegkundige", detail.Draft!.Value.GetProperty("beroep").GetString());
}
[Fact]
public async Task Submit_duo_registratie_is_in_behandeling_and_auto()
{
var a = await Create("registratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.StartsWith("BIG-2026-", body.Referentie);
Assert.Equal("InBehandeling", body.Status.Tag);
Assert.False(body.Status.Manual); // auto-approvable → not a manual case
}
[Fact]
public async Task Submit_duo_registratie_is_in_behandeling_and_auto()
{
var a = await Create("registratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.StartsWith("BIG-2026-", body.Referentie);
Assert.Equal("InBehandeling", body.Status.Tag);
Assert.False(body.Status.Manual); // auto-approvable → not a manual case
}
[Fact]
public async Task Submit_handmatig_registratie_succeeds_as_manual_case()
{
var a = await Create("registratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" });
res.EnsureSuccessStatusCode(); // no longer a 422
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.Equal("InBehandeling", body.Status.Tag);
Assert.True(body.Status.Manual);
}
[Fact]
public async Task Submit_handmatig_registratie_succeeds_as_manual_case()
{
var a = await Create("registratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" });
res.EnsureSuccessStatusCode(); // no longer a 422
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.Equal("InBehandeling", body.Status.Tag);
Assert.True(body.Status.Manual);
}
[Fact]
public async Task Submit_herregistratie_with_zero_uren_is_afgewezen()
{
var a = await Create("herregistratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 0 });
res.EnsureSuccessStatusCode(); // the submission is accepted...
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected
Assert.NotNull(body.Status.Reden);
}
[Fact]
public async Task Submit_herregistratie_with_zero_uren_is_afgewezen()
{
var a = await Create("herregistratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 0 });
res.EnsureSuccessStatusCode(); // the submission is accepted...
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected
Assert.NotNull(body.Status.Reden);
}
[Fact]
public async Task Submitting_twice_conflicts()
{
var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
var again = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
}
[Fact]
public async Task Submitting_twice_conflicts()
{
var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
var again = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
}
[Fact]
public async Task Cancel_concept_removes_it()
{
var a = await Create();
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode);
}
[Fact]
public async Task Cancel_concept_removes_it()
{
var a = await Create();
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode);
}
[Fact]
public async Task Cancel_submitted_aanvraag_conflicts()
{
var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
}
[Fact]
public async Task Cancel_submitted_aanvraag_conflicts()
{
var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
}
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
private static Aanvraag Accepted(bool autoApprovable) => new()
{
Id = "x", Type = "registratie", Owner = "test",
Submitted = true, AutoApprovable = autoApprovable, Referentie = "BIG-2026-1",
SubmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
};
private static Aanvraag Accepted(bool autoApprovable) => new()
{
Id = "x",
Type = "registratie",
Owner = "test",
Submitted = true,
AutoApprovable = autoApprovable,
Referentie = "BIG-2026-1",
SubmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
};
[Fact]
public void AutoApprovable_flips_to_goedgekeurd_after_the_window()
{
var a = Accepted(autoApprovable: true);
var t0 = a.SubmittedAt!.Value;
Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag);
Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag);
}
[Fact]
public void AutoApprovable_flips_to_goedgekeurd_after_the_window()
{
var a = Accepted(autoApprovable: true);
var t0 = a.SubmittedAt!.Value;
Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag);
Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag);
}
[Fact]
public void Manual_case_never_auto_advances()
{
var a = Accepted(autoApprovable: false);
var far = a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
var status = a.ToStatusDto(far);
Assert.Equal("InBehandeling", status.Tag);
Assert.True(status.Manual);
}
[Fact]
public void Manual_case_never_auto_advances()
{
var a = Accepted(autoApprovable: false);
var far = a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
var status = a.ToStatusDto(far);
Assert.Equal("InBehandeling", status.Tag);
Assert.True(status.Manual);
}
}

View File

@@ -0,0 +1,68 @@
using BigRegister.Domain.Authorization;
using Xunit;
namespace BigRegister.Tests;
/// <summary>
/// Unit tests for the single authorization helper (PRD-0002 phase P1) that both
/// computes the brief screen's decision flags and gates the mutation endpoints.
/// </summary>
public class AuthzTests
{
private static readonly Principal Drafter = new(PrincipalRole.Drafter);
private static readonly Principal Approver = new(PrincipalRole.Approver);
private const string DrafterId = "demo-drafter";
[Fact]
public void Drafter_may_not_approve_or_reject_even_when_submitted()
{
Assert.False(Authz.CanActOn(BriefAction.Approve, Drafter, DrafterId));
Assert.False(Authz.CanActOn(BriefAction.Reject, Drafter, DrafterId));
}
[Fact]
public void Approver_may_approve_and_reject_a_different_drafters_letter()
{
Assert.True(Authz.CanActOn(BriefAction.Approve, Approver, DrafterId));
Assert.True(Authz.CanActOn(BriefAction.Reject, Approver, DrafterId));
}
[Fact]
public void Send_is_not_role_gated()
{
Assert.True(Authz.CanActOn(BriefAction.Send, Drafter, DrafterId));
Assert.True(Authz.CanActOn(BriefAction.Send, Approver, DrafterId));
}
[Theory]
[InlineData("draft")]
[InlineData("rejected")]
public void Decisions_CanEdit_true_for_drafter_in_editable_statuses(string status)
{
Assert.True(Authz.Decisions(Drafter, status, DrafterId).CanEdit);
Assert.False(Authz.Decisions(Approver, status, DrafterId).CanEdit);
}
[Fact]
public void Decisions_CanApprove_requires_submitted_status_and_approver_role()
{
Assert.True(Authz.Decisions(Approver, "submitted", DrafterId).CanApprove);
Assert.False(Authz.Decisions(Approver, "draft", DrafterId).CanApprove);
Assert.False(Authz.Decisions(Drafter, "submitted", DrafterId).CanApprove);
}
[Fact]
public void Decisions_CanSend_requires_approved_status_only()
{
Assert.True(Authz.Decisions(Drafter, "approved", DrafterId).CanSend);
Assert.True(Authz.Decisions(Approver, "approved", DrafterId).CanSend);
Assert.False(Authz.Decisions(Approver, "submitted", DrafterId).CanSend);
}
[Fact]
public void RoleCapabilities_are_empty_for_drafter_and_the_three_brief_capabilities_for_approver()
{
Assert.Empty(Authz.RoleCapabilities(Drafter));
Assert.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, Authz.RoleCapabilities(Approver));
}
}

View File

@@ -23,4 +23,10 @@
<ProjectReference Include="..\..\src\BigRegister.Api\BigRegister.Api.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="LetterHtml.golden.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -11,152 +11,181 @@ namespace BigRegister.Tests;
/// (tests within a class run sequentially in xUnit). Role is the dev-only X-Role
/// header: absent = drafter, "approver" = a different identity.
/// </summary>
public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private readonly HttpClient _client = factory.CreateClient();
private static LetterBlockDto FreeText(string id) =>
new("freeText", id, new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) }));
private static LetterBlockDto FreeText(string id) =>
new("freeText", id, new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) }));
// Fill every REQUIRED section with a block so submit is allowed.
private static SaveBriefRequest FilledFrom(BriefDto brief)
{
var i = 0;
var sections = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required ? new[] { FreeText($"local-{++i}") } : s.Blocks))
.ToList();
return new SaveBriefRequest(sections);
}
// Fill every REQUIRED section with a block so submit is allowed.
private static SaveBriefRequest FilledFrom(BriefDto brief)
{
var i = 0;
var sections = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required ? new[] { FreeText($"local-{++i}") } : s.Blocks))
.ToList();
return new SaveBriefRequest(sections);
}
private async Task<BriefDto> Get()
{
BriefStore.Reset();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
return view!.Brief;
}
private async Task<BriefDto> Get()
{
BriefStore.Reset();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
return view!.Brief;
}
private HttpRequestMessage Post(string path, string? role = null, object? body = null)
{
var req = new HttpRequestMessage(HttpMethod.Post, path);
if (role is not null) req.Headers.Add("X-Role", role);
if (body is not null) req.Content = JsonContent.Create(body);
return req;
}
private HttpRequestMessage Post(string path, string? role = null, object? body = null)
{
var req = new HttpRequestMessage(HttpMethod.Post, path);
if (role is not null) req.Headers.Add("X-Role", role);
if (body is not null) req.Content = JsonContent.Create(body);
return req;
}
[Fact]
public async Task Get_creates_a_draft_from_the_template_with_scoped_passages()
{
var brief = await Get();
Assert.Equal("draft", brief.Status.Tag);
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
var aanhef = brief.Sections.Single(s => s.SectionKey == "aanhef");
Assert.True(aanhef.Locked);
Assert.NotEmpty(aanhef.Blocks);
Assert.True(brief.Sections.Single(s => s.SectionKey == "slot").Locked);
var kern = brief.Sections.Single(s => s.SectionKey == "kern");
Assert.False(kern.Locked);
Assert.Empty(kern.Blocks);
[Fact]
public async Task Get_creates_a_draft_from_the_template_with_scoped_passages()
{
var brief = await Get();
Assert.Equal("draft", brief.Status.Tag);
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
var aanhef = brief.Sections.Single(s => s.SectionKey == "aanhef");
Assert.True(aanhef.Locked);
Assert.NotEmpty(aanhef.Blocks);
Assert.True(brief.Sections.Single(s => s.SectionKey == "slot").Locked);
var kern = brief.Sections.Single(s => s.SectionKey == "kern");
Assert.False(kern.Locked);
Assert.Empty(kern.Blocks);
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
// global passages + the arts-scoped one; no other-beroep passages leak in.
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
}
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
// global passages + the arts-scoped one; no other-beroep passages leak in.
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
}
[Fact]
public async Task Save_is_drafter_only()
{
var brief = await Get();
var save = FilledFrom(brief);
[Fact]
public async Task Save_is_drafter_only()
{
var brief = await Get();
var save = FilledFrom(brief);
var approver = Post("/api/v1/brief", role: "approver");
approver.Method = HttpMethod.Put;
approver.Content = JsonContent.Create(save);
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(approver)).StatusCode);
var approver = Post("/api/v1/brief", role: "approver");
approver.Method = HttpMethod.Put;
approver.Content = JsonContent.Create(save);
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(approver)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await _client.PutAsJsonAsync("/api/v1/brief", save)).StatusCode);
}
Assert.Equal(HttpStatusCode.OK, (await _client.PutAsJsonAsync("/api/v1/brief", save)).StatusCode);
}
[Fact]
public async Task Submit_blocks_on_empty_required_section_then_succeeds_when_filled()
{
await Get();
// Nothing filled yet → required sections empty → 409.
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
[Fact]
public async Task Submit_blocks_on_empty_required_section_then_succeeds_when_filled()
{
await Get();
// Nothing filled yet → required sections empty → 409.
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
res.EnsureSuccessStatusCode();
var submitted = await res.Content.ReadFromJsonAsync<BriefDto>();
Assert.Equal("submitted", submitted!.Status.Tag);
}
var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
res.EnsureSuccessStatusCode();
var submitted = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("submitted", submitted!.Brief.Status.Tag);
}
[Fact]
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
{
var brief = await Get();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
[Fact]
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
{
var brief = await Get();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
// drafter role approving own letter → 403
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(Post("/api/v1/brief/approve"))).StatusCode);
// drafter role approving own letter → 403
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(Post("/api/v1/brief/approve"))).StatusCode);
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
res.EnsureSuccessStatusCode();
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
}
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
res.EnsureSuccessStatusCode();
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief.Status.Tag);
}
[Fact]
public async Task Reject_returns_comments_and_editing_reopens_to_draft()
{
var brief = await Get();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
[Fact]
public async Task Reject_returns_comments_and_editing_reopens_to_draft()
{
var brief = await Get();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
var rejected = await (await _client.SendAsync(
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefDto>();
Assert.Equal("rejected", rejected!.Status.Tag);
Assert.Equal("Graag aanvullen.", rejected.Status.Comments);
var rejected = await (await _client.SendAsync(
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("rejected", rejected!.Brief.Status.Tag);
Assert.Equal("Graag aanvullen.", rejected.Brief.Status.Comments);
// A drafter save on a rejected letter reopens it to draft.
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefDto>();
Assert.Equal("draft", reopened!.Status.Tag);
}
// A drafter save on a rejected letter reopens it to draft.
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("draft", reopened!.Brief.Status.Tag);
}
[Fact]
public async Task Send_only_from_approved()
{
var brief = await Get();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
[Fact]
public async Task Send_only_from_approved()
{
var brief = await Get();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
// submitted (not approved) → send 409
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/send"))).StatusCode);
// submitted (not approved) → send 409
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/send"))).StatusCode);
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
var res = await _client.SendAsync(Post("/api/v1/brief/send"));
res.EnsureSuccessStatusCode();
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
}
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
var res = await _client.SendAsync(Post("/api/v1/brief/send"));
res.EnsureSuccessStatusCode();
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief.Status.Tag);
}
[Fact]
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections()
{
var brief = await Get();
// Advance out of draft so the reset back to draft is observable.
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
[Fact]
public async Task Decisions_on_the_view_mirror_the_acting_principal_and_live_status()
{
var brief = await Get();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.True(view!.Decisions.CanEdit); // default (no X-Role) = drafter, draft status
Assert.False(view.Decisions.CanApprove);
var res = await _client.SendAsync(Post("/api/v1/brief/reset"));
res.EnsureSuccessStatusCode();
var view = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("draft", view!.Brief.Status.Tag);
var aanhef = view.Brief.Sections.Single(s => s.SectionKey == "aanhef");
Assert.True(aanhef.Locked);
Assert.NotEmpty(aanhef.Blocks);
Assert.Empty(view.Brief.Sections.Single(s => s.SectionKey == "kern").Blocks);
}
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
var asApprover = await _client.SendAsync(
new HttpRequestMessage(HttpMethod.Get, "/api/v1/brief") { Headers = { { "X-Role", "approver" } } });
var approverView = await asApprover.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.True(approverView!.Decisions.CanApprove);
Assert.False(approverView.Decisions.CanEdit); // approver never edits
}
[Fact]
public async Task Me_returns_no_capabilities_for_drafter_and_the_brief_set_for_approver()
{
var asDrafter = await _client.GetFromJsonAsync<MeDto>("/api/v1/me");
Assert.Empty(asDrafter!.Capabilities);
var res = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/v1/me") { Headers = { { "X-Role", "approver" } } });
var asApprover = await res.Content.ReadFromJsonAsync<MeDto>();
Assert.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, asApprover!.Capabilities);
}
[Fact]
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections()
{
var brief = await Get();
// Advance out of draft so the reset back to draft is observable.
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
var res = await _client.SendAsync(Post("/api/v1/brief/reset"));
res.EnsureSuccessStatusCode();
var view = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("draft", view!.Brief.Status.Tag);
var aanhef = view.Brief.Sections.Single(s => s.SectionKey == "aanhef");
Assert.True(aanhef.Locked);
Assert.NotEmpty(aanhef.Blocks);
Assert.Empty(view.Brief.Sections.Single(s => s.SectionKey == "kern").Blocks);
}
}

View File

@@ -7,225 +7,241 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task DashboardView_computes_eligibility_decision()
{
var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/v1/dashboard-view");
Assert.NotNull(dto);
Assert.Equal("19012345601", dto.Registration.BigNummer);
Assert.Equal("Geregistreerd", dto.Registration.Status.Tag);
// seed deadline 2027-03-01 is within 12 months of "today" (2026) → eligible
Assert.True(dto.Decisions.EligibleForHerregistratie);
Assert.NotNull(dto.Decisions.HerregistratieReason);
}
[Fact]
public async Task DashboardView_computes_eligibility_decision()
{
var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/v1/dashboard-view");
Assert.NotNull(dto);
Assert.Equal("19012345601", dto.Registration.BigNummer);
Assert.Equal("Geregistreerd", dto.Registration.Status.Tag);
// seed deadline 2027-03-01 is within 12 months of "today" (2026) → eligible
Assert.True(dto.Decisions.EligibleForHerregistratie);
Assert.NotNull(dto.Decisions.HerregistratieReason);
}
[Fact]
public async Task Notes_returns_seeded_aantekeningen()
{
var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/v1/notes");
Assert.Equal(3, notes!.Count);
}
[Fact]
public async Task Notes_returns_seeded_aantekeningen()
{
var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/v1/notes");
Assert.Equal(3, notes!.Count);
}
[Fact]
public async Task Brp_returns_address()
{
var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/v1/brp/address");
Assert.True(dto!.Gevonden);
Assert.Equal("2514 EA", dto.Adres!.Postcode);
}
[Fact]
public async Task Brp_returns_address()
{
var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/v1/brp/address");
Assert.True(dto!.Gevonden);
Assert.Equal("2514 EA", dto.Adres!.Postcode);
}
[Fact]
public async Task Duo_lookup_carries_server_decided_questions_and_professions()
{
var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/v1/duo/diplomas");
Assert.NotNull(dto);
[Fact]
public async Task Duo_lookup_carries_server_decided_questions_and_professions()
{
var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/v1/duo/diplomas");
Assert.NotNull(dto);
var english = dto.Diplomas.Single(d => d.Id == "d2");
Assert.Equal("Arts", english.Beroep);
Assert.Contains(english.PolicyQuestions, q => q.Id == "nl-taalvaardigheid");
var english = dto.Diplomas.Single(d => d.Id == "d2");
Assert.Equal("Arts", english.Beroep);
Assert.Contains(english.PolicyQuestions, q => q.Id == "nl-taalvaardigheid");
var dutch = dto.Diplomas.Single(d => d.Id == "d1");
Assert.Empty(dutch.PolicyQuestions);
var dutch = dto.Diplomas.Single(d => d.Id == "d1");
Assert.Empty(dutch.PolicyQuestions);
// DUO "not found" fallback: an unlisted diploma → user uses the manual path,
// which the same lookup provides (maximal question set + declarable professions).
Assert.DoesNotContain(dto.Diplomas, d => d.Id == "unknown-id");
Assert.Equal(3, dto.Handmatig.PolicyQuestions.Count);
Assert.Equal(5, dto.Handmatig.Beroepen.Count);
}
// DUO "not found" fallback: an unlisted diploma → user uses the manual path,
// which the same lookup provides (maximal question set + declarable professions).
Assert.DoesNotContain(dto.Diplomas, d => d.Id == "unknown-id");
Assert.Equal(3, dto.Handmatig.PolicyQuestions.Count);
Assert.Equal(5, dto.Handmatig.Beroepen.Count);
}
[Fact]
public async Task IntakePolicy_returns_scholing_threshold()
{
var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/v1/intake/policy");
Assert.Equal(1000, dto!.ScholingThreshold);
}
[Fact]
public async Task IntakePolicy_returns_scholing_threshold()
{
var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/v1/intake/policy");
Assert.Equal(1000, dto!.ScholingThreshold);
}
[Fact]
public async Task Registration_with_duo_diploma_succeeds()
{
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo"));
res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.StartsWith("BIG-2026-", body!.Referentie);
}
[Fact]
public async Task Registration_with_duo_diploma_succeeds()
{
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo"));
res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.StartsWith("BIG-2026-", body!.Referentie);
}
[Fact]
public async Task Registration_with_manual_diploma_is_rejected_with_problem_details()
{
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString());
}
[Fact]
public async Task Registration_with_manual_diploma_is_rejected_with_problem_details()
{
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString());
}
[Theory]
[InlineData("/api/v1/intakes")]
[InlineData("/api/v1/herregistraties")]
public async Task Zero_hours_submission_is_rejected(string route)
{
var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
}
[Theory]
[InlineData("/api/v1/intakes")]
[InlineData("/api/v1/herregistraties")]
public async Task Zero_hours_submission_is_rejected(string route)
{
var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
}
[Theory]
[InlineData("/api/v1/intakes")]
[InlineData("/api/v1/herregistraties")]
public async Task Worked_hours_submission_succeeds(string route)
{
var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
res.EnsureSuccessStatusCode();
}
[Theory]
[InlineData("/api/v1/intakes")]
[InlineData("/api/v1/herregistraties")]
public async Task Worked_hours_submission_succeeds(string route)
{
var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
res.EnsureSuccessStatusCode();
}
[Fact]
public async Task Change_request_with_valid_address_succeeds()
{
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" });
res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.StartsWith("BIG-2026-", body!.Referentie);
}
[Fact]
public async Task Change_request_with_valid_address_succeeds()
{
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" });
res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.StartsWith("BIG-2026-", body!.Referentie);
}
[Fact]
public async Task Change_request_with_bad_postcode_is_rejected()
{
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
}
[Fact]
public async Task Change_request_with_bad_postcode_is_rejected()
{
var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
}
[Fact]
public async Task Health_endpoint_is_ok()
{
var res = await _client.GetAsync("/health");
res.EnsureSuccessStatusCode();
}
[Fact]
public async Task Health_endpoint_is_ok()
{
var res = await _client.GetAsync("/health");
res.EnsureSuccessStatusCode();
}
// --- Document upload ---
[Fact]
public async Task Correlation_id_supplied_by_the_caller_is_echoed_back()
{
var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/notes");
req.Headers.Add("X-Correlation-Id", "test-cid-123");
var res = await _client.SendAsync(req);
Assert.Equal("test-cid-123", res.Headers.GetValues("X-Correlation-Id").Single());
}
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)
{
var content = new MultipartFormDataContent();
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
file.Headers.ContentType = new MediaTypeHeaderValue(contentType);
content.Add(file, "file", fileName);
content.Add(new StringContent(categoryId), "categoryId");
content.Add(new StringContent(localId), "localId");
content.Add(new StringContent(wizardId), "wizardId");
return content;
}
[Fact]
public async Task Correlation_id_is_generated_when_the_caller_omits_it()
{
var res = await _client.GetAsync("/api/v1/notes");
Assert.NotEmpty(res.Headers.GetValues("X-Correlation-Id").Single());
}
private async Task<UploadResponse> Upload(string localId, string categoryId = "diploma", string type = "application/pdf", string file = "d.pdf")
{
var res = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, categoryId, "registratie", file, type));
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
}
// --- Document upload ---
[Fact]
public async Task Categories_are_server_owned_config()
{
// A manual diploma requires a diploma upload; identiteit is always required.
var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/api/v1/uploads/categories?wizardId=registratie&diplomaHerkomst=handmatig");
Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery);
Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery);
}
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)
{
var content = new MultipartFormDataContent();
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
file.Headers.ContentType = new MediaTypeHeaderValue(contentType);
content.Add(file, "file", fileName);
content.Add(new StringContent(categoryId), "categoryId");
content.Add(new StringContent(localId), "localId");
content.Add(new StringContent(wizardId), "wizardId");
return content;
}
[Fact]
public async Task Upload_then_status_reports_complete_for_known_localId()
{
var localId = Guid.NewGuid().ToString();
var doc = await Upload(localId);
var status = await _client.GetFromJsonAsync<UploadStatusDto>($"/api/v1/uploads/status?localIds={localId},onbekend");
Assert.Contains(status!.Results, r => r.LocalId == localId && r.Status == "complete" && r.DocumentId == doc.DocumentId);
Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown");
}
private async Task<UploadResponse> Upload(string localId, string categoryId = "diploma", string type = "application/pdf", string file = "d.pdf")
{
var res = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, categoryId, "registratie", file, type));
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
}
[Fact]
public async Task Upload_content_is_served_back_with_its_type_inline_for_pdf()
{
var doc = await Upload(Guid.NewGuid().ToString());
var res = await _client.GetAsync($"/api/v1/uploads/{doc.DocumentId}/content");
res.EnsureSuccessStatusCode();
Assert.Equal("application/pdf", res.Content.Headers.ContentType!.MediaType);
Assert.Equal(new byte[] { 1, 2, 3 }, await res.Content.ReadAsByteArrayAsync());
// pdf/image → inline (no attachment disposition) so the browser previews it
Assert.NotEqual("attachment", res.Content.Headers.ContentDisposition?.DispositionType);
}
[Fact]
public async Task Categories_are_server_owned_config()
{
// A manual diploma requires a diploma upload; identiteit is always required.
var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/api/v1/uploads/categories?wizardId=registratie&diplomaHerkomst=handmatig");
Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery);
Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery);
}
[Fact]
public async Task Upload_content_404_for_unknown_document()
{
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync("/api/v1/uploads/demo-nope/content")).StatusCode);
}
[Fact]
public async Task Upload_then_status_reports_complete_for_known_localId()
{
var localId = Guid.NewGuid().ToString();
var doc = await Upload(localId);
var status = await _client.GetFromJsonAsync<UploadStatusDto>($"/api/v1/uploads/status?localIds={localId},onbekend");
Assert.Contains(status!.Results, r => r.LocalId == localId && r.Status == "complete" && r.DocumentId == doc.DocumentId);
Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown");
}
[Fact]
public async Task Upload_rejects_wrong_type()
{
var res = await _client.PostAsync("/api/v1/uploads",
UploadForm(Guid.NewGuid().ToString(), "diploma", "registratie", "d.txt", "text/plain"));
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
}
[Fact]
public async Task Upload_content_is_served_back_with_its_type_inline_for_pdf()
{
var doc = await Upload(Guid.NewGuid().ToString());
var res = await _client.GetAsync($"/api/v1/uploads/{doc.DocumentId}/content");
res.EnsureSuccessStatusCode();
Assert.Equal("application/pdf", res.Content.Headers.ContentType!.MediaType);
Assert.Equal(new byte[] { 1, 2, 3 }, await res.Content.ReadAsByteArrayAsync());
// pdf/image → inline (no attachment disposition) so the browser previews it
Assert.NotEqual("attachment", res.Content.Headers.ContentDisposition?.DispositionType);
}
[Fact]
public async Task User_delete_succeeds_then_404()
{
var doc = await Upload(Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
}
[Fact]
public async Task Upload_content_404_for_unknown_document()
{
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync("/api/v1/uploads/demo-nope/content")).StatusCode);
}
[Fact]
public async Task User_delete_blocked_with_409_once_linked_to_submission()
{
var doc = await Upload(Guid.NewGuid().ToString());
var submit = await _client.PostAsJsonAsync("/api/v1/registrations",
new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) }));
submit.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
}
[Fact]
public async Task Upload_rejects_wrong_type()
{
var res = await _client.PostAsync("/api/v1/uploads",
UploadForm(Guid.NewGuid().ToString(), "diploma", "registratie", "d.txt", "text/plain"));
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
}
[Fact]
public async Task Admin_delete_requires_admin_role()
{
var doc = await Upload(Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode);
[Fact]
public async Task User_delete_succeeds_then_404()
{
var doc = await Upload(Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
}
var req = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/uploads/{doc.DocumentId}");
req.Headers.Add("X-Admin", "true");
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode);
}
[Fact]
public async Task User_delete_blocked_with_409_once_linked_to_submission()
{
var doc = await Upload(Guid.NewGuid().ToString());
var submit = await _client.PostAsJsonAsync("/api/v1/registrations",
new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) }));
submit.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
}
[Fact]
public async Task Audit_log_records_upload_and_delete_metadata_only()
{
var doc = await Upload(Guid.NewGuid().ToString());
await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}");
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "upload");
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "delete-user");
}
[Fact]
public async Task Admin_delete_requires_admin_role()
{
var doc = await Upload(Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode);
var req = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/uploads/{doc.DocumentId}");
req.Headers.Add("X-Admin", "true");
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode);
}
[Fact]
public async Task Audit_log_records_upload_and_delete_metadata_only()
{
var doc = await Upload(Guid.NewGuid().ToString());
await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}");
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "upload");
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "delete-user");
}
}

View File

@@ -0,0 +1,69 @@
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
public class IdempotencyTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private static HttpRequestMessage ChangeRequestWithKey(string key)
{
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
{
Content = JsonContent.Create(new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" }),
};
req.Headers.Add("Idempotency-Key", key);
return req;
}
[Fact]
public async Task Replaying_the_same_idempotency_key_returns_the_same_reference_not_a_new_one()
{
var key = Guid.NewGuid().ToString();
var first = await _client.SendAsync(ChangeRequestWithKey(key));
first.EnsureSuccessStatusCode();
var firstBody = await first.Content.ReadFromJsonAsync<ReferentieResponse>();
var replay = await _client.SendAsync(ChangeRequestWithKey(key));
replay.EnsureSuccessStatusCode();
var replayBody = await replay.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.Equal(firstBody!.Referentie, replayBody!.Referentie);
}
[Fact]
public async Task Different_idempotency_keys_are_independent_submissions()
{
var first = await _client.SendAsync(ChangeRequestWithKey(Guid.NewGuid().ToString()));
var second = await _client.SendAsync(ChangeRequestWithKey(Guid.NewGuid().ToString()));
var firstBody = await first.Content.ReadFromJsonAsync<ReferentieResponse>();
var secondBody = await second.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.NotEqual(firstBody!.Referentie, secondBody!.Referentie);
}
[Fact]
public async Task A_rejected_submission_replays_the_same_rejection_not_a_retry()
{
var key = Guid.NewGuid().ToString();
var badRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
{
Content = JsonContent.Create(new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" }),
};
badRequest.Headers.Add("Idempotency-Key", key);
var first = await _client.SendAsync(badRequest);
Assert.Equal(System.Net.HttpStatusCode.UnprocessableEntity, first.StatusCode);
var replayRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
{
Content = JsonContent.Create(new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" }),
};
replayRequest.Headers.Add("Idempotency-Key", key);
var replay = await _client.SendAsync(replayRequest);
Assert.Equal(System.Net.HttpStatusCode.UnprocessableEntity, replay.StatusCode);
}
}

View File

@@ -0,0 +1,215 @@
<!doctype html><html lang="nl"><head><meta charset="utf-8"><title>golden-brief-1</title><style>/* letter.css — the FE⇄BE letter-rendering CONTRACT (WP-24/WP-25).
*
* One stylesheet, two consumers: the FE letter canvas loads it via <link>
* (index.html + Storybook preview-head), the backend HTML renderer (WP-25)
* inlines this same file. Its class-parity test is the fence against drift.
*
* Class vocabulary: .letter, .letter__letterhead, .letter__body,
* .letter__signature, .letter__footer, .letter__page-break.
* Margins arrive as --letter-margin-* custom props (mm, from the OrgTemplate);
* the defaults below match the seed template.
*
* Deliberately self-contained: no --rhc- or --bs- tokens — the backend renderer
* has no token bridge. Geometry follows the sample voorbeeldbrief-inschrijving
* (A4, return address above the envelope window, reference block, footer rule).
*/
.letter {
--letter-margin-top: 25mm;
--letter-margin-right: 25mm;
--letter-margin-bottom: 25mm;
--letter-margin-left: 25mm;
position: relative;
box-sizing: border-box;
width: 210mm; /* A4 */
max-width: 100%;
min-height: 297mm;
margin-inline: auto;
padding: var(--letter-margin-top) var(--letter-margin-right) var(--letter-margin-bottom)
var(--letter-margin-left);
background: #fff;
color: #1a1a1a;
/* Letter typography is the letter's, not the portal UI's. Licensed RO/Rijks
fonts are not shipped; system stack mirrors the app-wide decision (ADR-0003). */
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
font-size: 10.5pt;
line-height: 1.5;
display: flex;
flex-direction: column;
}
.letter p {
margin: 0 0 0.75em;
}
.letter ul,
.letter ol {
margin: 0 0 0.75em;
padding-inline-start: 1.4em;
}
/* --- Letterhead: wordmark, return address above the envelope window, reference block --- */
.letter__letterhead {
margin-block-end: 12mm;
}
.letter__letterhead .org-wordmark {
font-size: 13pt;
font-weight: 700;
margin: 0 0 10mm;
}
.letter__letterhead .return-address {
font-style: normal;
font-size: 7.5pt;
white-space: pre-line;
color: #555;
margin-block-end: 2mm;
}
/* Envelope-window position (~C5 venstercouvert): recipient block. */
.letter__letterhead .address-window {
min-height: 22mm;
font-style: normal;
white-space: pre-line;
margin-block-end: 8mm;
}
.letter__letterhead .reference {
display: flex;
gap: 10mm;
font-size: 8.5pt;
color: #333;
}
.letter__letterhead .reference dt {
font-weight: 700;
margin: 0;
}
.letter__letterhead .reference dd {
margin: 0;
}
/* --- Body: the case-type template's sections --- */
.letter__body {
flex: 1;
/* Above the absolutely-positioned page-break marks: the dashed line stays visible
in the gaps but never draws THROUGH opaque content (editors, pickers). */
position: relative;
z-index: 1;
}
.letter__body h3 {
font-size: inherit;
font-weight: 700;
margin: 0 0 0.25em;
}
.letter__body section {
margin-block-end: 1.5em;
}
/* --- Signature --- */
.letter__signature {
margin-block-start: 10mm;
}
.letter__signature p {
margin: 0;
}
.letter__signature .signature-name {
margin-block-start: 3em; /* room for the (not-shipped) handwritten signature */
font-weight: 700;
}
/* --- Footer: contact + legal, above a rule --- */
.letter__footer {
margin-block-start: 10mm;
padding-block-start: 3mm;
border-block-start: 0.5pt solid #999;
font-size: 7.5pt;
color: #555;
display: flex;
justify-content: space-between;
gap: 10mm;
}
.letter__footer .footer-contact {
white-space: pre-line;
}
.letter__footer .footer-legal {
text-align: end;
}
/* --- Approximate page-break indicator (screen only; PRD §2b honesty rule) ---
Positioned per A4-interval by the canvas; the print preview is authoritative. */
.letter__page-break {
position: absolute;
inset-inline: 0;
border-block-start: 1px dashed #b36200;
pointer-events: none;
}
.letter__page-break > span {
position: absolute;
inset-inline-end: 2mm;
inset-block-start: 0;
transform: translateY(-50%);
/* Above .letter__body: the honesty caption stays legible even over content. */
z-index: 2;
font-size: 7pt;
color: #b36200;
background: #fff;
padding-inline: 1mm;
white-space: nowrap;
}
/* --- Print: real pages, no indicator chrome --- */
@page {
size: A4;
margin: 0;
}
@media print {
.letter {
width: auto;
min-height: 0;
}
.letter__page-break {
display: none;
}
}
.preview-watermark {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 72pt;
font-weight: 700;
color: rgb(200 30 30 / 0.18);
transform: rotate(-30deg);
pointer-events: none;
z-index: 3;
}
.org-logo {
display: block;
max-height: 18mm;
margin-block-end: 4mm;
}</style></head><body><div class="letter" style="--letter-margin-top:25mm;--letter-margin-right:20mm;--letter-margin-bottom:20mm;--letter-margin-left:25mm;"><div class="letter__letterhead"><p class="org-wordmark">BIG-register</p><address class="return-address">Retouradres: Postbus 00000, 2500 AA Den Haag</address><address class="address-window">Adres van de geadresseerde<br>(wordt ingevuld bij verzending)</address><dl class="reference"><div><dt>Ons kenmerk</dt><dd>golden-brief-1</dd></div><div><dt>Datum</dt><dd>5 juli 2026</dd></div></dl></div><div class="letter__body"><section><h3>Aanhef</h3><p>Geachte heer/mevrouw Dr. A. (Anna) de Vries,</p></section><section><h3>Kern van het besluit</h3><p>Op </p><ul><li>Eerste punt: [NOG IN TE VULLEN: Reden besluit]</li><li>Tweede punt</li></ul></section><section><h3>Slot</h3><p>Met vriendelijke groet,</p></section></div><div class="letter__signature"><p>Met vriendelijke groet,</p><p class="signature-name">A. de Vries</p><p>Hoofd Registratie, BIG-register</p></div><div class="letter__footer"><div class="footer-contact">BIG-register &#183; Postbus 00000, 2500 AA Den Haag &#183; 070 000 00 00 &#183; info@voorbeeld.example</div><div class="footer-legal">Dit is een gegenereerd voorbeelddocument uit de register-reference PoC. Alle gegevens zijn fictief.</div></div><div class="preview-watermark" aria-hidden="true">VOORBEELD</div></div></body></html>

View File

@@ -0,0 +1,102 @@
using System.Text.RegularExpressions;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Letters;
namespace BigRegister.Tests;
/// <summary>
/// WP-25's fence against drift between the backend renderer and the FE letter
/// canvas: a golden-file snapshot of a fixed brief + template, and a class-parity
/// check that every `letter`-prefixed class the renderer emits exists in the
/// shared `public/letter.css` contract. Neither test launches a browser.
/// </summary>
public class LetterHtmlTests
{
private static BriefEntity FixtureBrief() => new()
{
BriefId = "golden-brief-1",
Owner = "golden",
Beroep = "arts",
TemplateId = "besluit-arts",
DrafterId = BriefStore.DrafterId,
Placeholders = new[]
{
new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true),
new PlaceholderDefDto("datum", "Datum", true),
new PlaceholderDefDto("reden_besluit", "Reden besluit", false),
},
Sections = new()
{
new("aanhef", "Aanhef", true, new List<LetterBlockDto>
{
new("freeText", "aanhef-1", new RichTextBlockDto(new[]
{
new ParagraphDto(new[]
{
new RichTextNodeDto("text", Text: "Geachte heer/mevrouw "),
new RichTextNodeDto("placeholder", Key: "naam_zorgverlener"),
new RichTextNodeDto("text", Text: ","),
}),
})),
}, Locked: true),
new("kern", "Kern van het besluit", true, new List<LetterBlockDto>
{
new("freeText", "kern-1", new RichTextBlockDto(new[]
{
new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "Op ") }),
new ParagraphDto(new RichTextNodeDto[]
{
new("text", Text: "Eerste punt: "),
new("placeholder", Key: "reden_besluit"),
}, List: "bullet"),
new ParagraphDto(new RichTextNodeDto[]
{
new("text", Text: "Tweede punt"),
}, List: "bullet"),
})),
}),
new("slot", "Slot", false, new List<LetterBlockDto>
{
new("freeText", "slot-1", new RichTextBlockDto(new[]
{
new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "Met vriendelijke groet,") }),
})),
}, Locked: true),
},
Status = new BriefStatusDto("draft"),
};
private static readonly OrgTemplateDto Template = new(
"cibg-registers", "BIG-register",
"Retouradres: Postbus 00000, 2500 AA Den Haag",
LogoDocumentId: null,
"BIG-register · Postbus 00000, 2500 AA Den Haag · 070 000 00 00 · info@voorbeeld.example",
"Dit is een gegenereerd voorbeelddocument uit de register-reference PoC. Alle gegevens zijn fictief.",
"A. de Vries", "Hoofd Registratie, BIG-register", "Met vriendelijke groet,",
new MarginsDto(25, 20, 20, 25), Version: 1);
private const string At = "2026-07-05T12:00:00.0000000+00:00";
private static readonly string GoldenPath = Path.Combine(AppContext.BaseDirectory, "LetterHtml.golden.html");
[Fact]
public void Render_matches_the_golden_file()
{
var html = LetterHtml.Render(FixtureBrief(), Template, At, watermark: true);
var golden = File.ReadAllText(GoldenPath);
Assert.Equal(golden, html);
}
[Fact]
public void Every_letter_prefixed_class_exists_in_letter_css()
{
var html = LetterHtml.Render(FixtureBrief(), Template, At, watermark: true);
var classes = Regex.Matches(html, "class=\"([^\"]+)\"")
.SelectMany(m => m.Groups[1].Value.Split(' '))
.Where(c => c.StartsWith("letter"))
.Distinct();
Assert.NotEmpty(classes);
Assert.All(classes, c => Assert.Contains($".{c}", LetterHtml.StyleSheet));
}
}

View File

@@ -0,0 +1,180 @@
using System.Net;
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
/// <summary>
/// Org templates (WP-23): admin-only endpoints, draft→publish versioning, and the
/// sent-brief immutability invariant (pin at send, republish touches unsent only).
/// Same reset discipline as BriefEndpointTests — the stores are process-global.
/// </summary>
public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private const string Registers = OrgTemplateSeed.Registers;
private readonly HttpClient _client = factory.CreateClient();
private HttpRequestMessage Req(HttpMethod method, string path, string? role = null, object? body = null)
{
var req = new HttpRequestMessage(method, path);
if (role is not null) req.Headers.Add("X-Role", role);
if (body is not null) req.Content = JsonContent.Create(body);
return req;
}
private async Task<OrgTemplateAdminViewDto> AdminView(string subOrgId = Registers)
{
var res = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{subOrgId}", role: "admin"));
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>())!;
}
private static void ResetStores()
{
OrgTemplateStore.Reset();
BriefStore.Reset();
}
[Fact]
public async Task Admin_endpoints_are_admin_only()
{
ResetStores();
// drafter (no header) and approver both bounce off every admin endpoint.
Assert.Equal(HttpStatusCode.Forbidden,
(await _client.GetAsync("/api/v1/admin/org-templates")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden,
(await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "approver"))).StatusCode);
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/admin/org-templates", role: "admin"));
res.EnsureSuccessStatusCode();
var list = await res.Content.ReadFromJsonAsync<List<SubOrgSummaryDto>>();
Assert.Equal(new[] { Registers, OrgTemplateSeed.Vakbekwaamheid }, list!.Select(s => s.SubOrgId));
Assert.All(list!, s => Assert.Equal(1, s.PublishedVersion));
}
[Fact]
public async Task Publish_increments_version_appends_history_and_counts_unsent_briefs()
{
ResetStores();
// One unsent brief for this sub-org (GetOrCreate on first read).
await _client.GetAsync("/api/v1/brief");
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
res.EnsureSuccessStatusCode();
var published = await res.Content.ReadFromJsonAsync<PublishOrgTemplateResponse>();
Assert.Equal(2, published!.Version);
Assert.Equal(1, published.AffectedUnsentBriefs);
var view = await AdminView();
Assert.Equal(2, view.PublishedVersion);
Assert.Equal(new[] { 1, 2 }, view.History.Select(h => h.Version));
Assert.Equal(1, view.UnsentBriefs);
}
[Fact]
public async Task Save_draft_validates_margins_and_round_trips()
{
ResetStores();
var draft = (await AdminView()).Draft;
var invalid = draft with { Margins = new MarginsDto(5, 20, 20, 25) };
Assert.Equal(HttpStatusCode.BadRequest, (await _client.SendAsync(
Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
body: new SaveOrgTemplateRequest(invalid)))).StatusCode);
var valid = draft with { OrgName = "BIG-register (nieuw)", Margins = new MarginsDto(30, 20, 20, 25) };
var res = await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
body: new SaveOrgTemplateRequest(valid)));
res.EnsureSuccessStatusCode();
var view = await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>();
Assert.Equal("BIG-register (nieuw)", view!.Draft.OrgName);
Assert.Equal(30, view.Draft.Margins.TopMm);
// Saving a draft publishes nothing.
Assert.Equal(1, view.PublishedVersion);
}
[Fact]
public async Task Rollback_copies_an_old_version_into_the_draft_without_rewriting_history()
{
ResetStores();
var draft = (await AdminView()).Draft;
await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
body: new SaveOrgTemplateRequest(draft with { OrgName = "Versie twee" })));
await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/rollback/1", role: "admin"));
res.EnsureSuccessStatusCode();
var view = await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>();
Assert.Equal("BIG-register", view!.Draft.OrgName); // v1 content back in the draft
Assert.Equal(2, view.PublishedVersion); // still live: v2 (rollback ≠ publish)
Assert.Equal(new[] { 1, 2 }, view.History.Select(h => h.Version)); // append-only, untouched
Assert.Equal(HttpStatusCode.NotFound, (await _client.SendAsync(
Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/rollback/99", role: "admin"))).StatusCode);
}
[Fact]
public async Task Sent_brief_keeps_its_pinned_template_while_an_unsent_brief_follows_a_republish()
{
ResetStores();
// Walk one brief to sent under template v1.
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
var filled = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
s.Required && s.Blocks.Count == 0
? new[] { new LetterBlockDto("freeText", "b1", new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) })) }
: s.Blocks))
.ToList();
await _client.PutAsJsonAsync("/api/v1/brief", new SaveBriefRequest(filled));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver"));
var sent = await (await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/send"))).Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal(1, sent!.OrgTemplate.Version);
// Republish with a new org name.
var draft = (await AdminView()).Draft;
await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
body: new SaveOrgTemplateRequest(draft with { OrgName = "Hertitelde organisatie" })));
await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
// The sent brief still renders v1 with the old name (immutable)...
var sentView = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.Equal(1, sentView!.OrgTemplate.Version);
Assert.Equal("BIG-register", sentView.OrgTemplate.OrgName);
// ...while a fresh (unsent) brief follows the new published version.
var freshView = await (await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/reset"))).Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal(2, freshView!.OrgTemplate.Version);
Assert.Equal("Hertitelde organisatie", freshView.OrgTemplate.OrgName);
}
[Fact]
public async Task Me_returns_the_orgtemplate_capability_for_admin()
{
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/me", role: "admin"));
var me = await res.Content.ReadFromJsonAsync<MeDto>();
Assert.Equal(new[] { "orgtemplate:edit" }, me!.Capabilities);
}
[Fact]
public async Task Admin_cannot_slip_into_the_brief_review_flow()
{
ResetStores();
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
var filled = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
s.Required && s.Blocks.Count == 0
? new[] { new LetterBlockDto("freeText", "b1", new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) })) }
: s.Blocks))
.ToList();
await _client.PutAsJsonAsync("/api/v1/brief", new SaveBriefRequest(filled));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
// Approve/reject require the Approver role explicitly — admin passes SoD
// (different identity) but must still be Forbidden.
Assert.Equal(HttpStatusCode.Forbidden,
(await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "admin"))).StatusCode);
}
}

View File

@@ -0,0 +1,96 @@
using System.Net;
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
/// <summary>
/// WP-25: the two HTML preview endpoints. Both are excluded from the OpenAPI doc
/// (see the drift check in the API-client generation step) — these tests hit them
/// as plain HTTP, the same way the hand-written FE fetch does.
/// </summary>
public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private const string Registers = OrgTemplateSeed.Registers;
private readonly HttpClient _client = factory.CreateClient();
private static LetterBlockDto FreeText(string id) =>
new("freeText", id, new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) }));
private static SaveBriefRequest FilledFrom(BriefDto brief)
{
var i = 0;
var sections = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required ? new[] { FreeText($"local-{++i}") } : s.Blocks))
.ToList();
return new SaveBriefRequest(sections);
}
private static void ResetStores()
{
BriefStore.Reset();
OrgTemplateStore.Reset();
}
private HttpRequestMessage Req(HttpMethod method, string path, string? role = null) =>
role is null ? new HttpRequestMessage(method, path) : new HttpRequestMessage(method, path) { Headers = { { "X-Role", role } } };
[Fact]
public async Task Preview_of_an_unsent_brief_renders_live_with_a_watermark()
{
ResetStores();
await _client.GetAsync("/api/v1/brief"); // GetOrCreate the demo draft
var res = await _client.GetAsync("/api/v1/brief/preview");
res.EnsureSuccessStatusCode();
Assert.Equal("text/html", res.Content.Headers.ContentType?.MediaType);
var html = await res.Content.ReadAsStringAsync();
Assert.Contains("<div class=\"preview-watermark\"", html);
}
[Fact]
public async Task Preview_of_a_sent_brief_serves_the_archive_unchanged_after_a_republish()
{
ResetStores();
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver"));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/send"));
var sentHtml = await (await _client.GetAsync("/api/v1/brief/preview")).Content.ReadAsStringAsync();
Assert.DoesNotContain("<div class=\"preview-watermark\"", sentHtml);
Assert.Contains("BIG-register", sentHtml);
// Republish the org template under a new name.
var adminView = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{Registers}", role: "admin"));
var draft = (await adminView.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>())!.Draft;
await _client.SendAsync(new HttpRequestMessage(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}")
{
Headers = { { "X-Role", "admin" } },
Content = JsonContent.Create(new SaveOrgTemplateRequest(draft with { OrgName = "Hertitelde organisatie" })),
});
await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
// The sent brief's preview is unchanged — still the archived rendering.
var afterRepublish = await (await _client.GetAsync("/api/v1/brief/preview")).Content.ReadAsStringAsync();
Assert.Equal(sentHtml, afterRepublish);
Assert.DoesNotContain("Hertitelde organisatie", afterRepublish);
}
[Fact]
public async Task Proefbrief_is_admin_only_and_renders_the_draft_template()
{
ResetStores();
Assert.Equal(HttpStatusCode.Forbidden,
(await _client.GetAsync($"/api/v1/admin/org-template/{Registers}/preview")).StatusCode);
var res = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{Registers}/preview", role: "admin"));
res.EnsureSuccessStatusCode();
var html = await res.Content.ReadAsStringAsync();
Assert.Contains("BIG-register", html);
Assert.Contains("<div class=\"preview-watermark\"", html);
}
}

View File

@@ -7,174 +7,174 @@ namespace BigRegister.Tests;
public class DocumentRuleTests
{
[Fact]
public void Rejects_unknown_category() =>
Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1));
[Fact]
public void Rejects_unknown_category() =>
Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1));
[Fact]
public void Rejects_disallowed_type()
{
var c = DocumentRules.Find("registratie", "diploma");
Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1));
}
[Fact]
public void Rejects_disallowed_type()
{
var c = DocumentRules.Find("registratie", "diploma");
Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1));
}
[Fact]
public void Rejects_oversized_file()
{
var c = DocumentRules.Find("registratie", "diploma");
Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024));
}
[Fact]
public void Rejects_oversized_file()
{
var c = DocumentRules.Find("registratie", "diploma");
Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024));
}
[Fact]
public void Accepts_valid_file()
{
var c = DocumentRules.Find("registratie", "diploma");
Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024));
}
[Fact]
public void Accepts_valid_file()
{
var c = DocumentRules.Find("registratie", "diploma");
Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024));
}
private static IReadOnlyList<string> Ids(string? herkomst, string? taalvaardigheid) =>
DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList();
private static IReadOnlyList<string> Ids(string? herkomst, string? taalvaardigheid) =>
DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList();
[Fact]
public void First_load_has_no_diploma_upload() => // no diploma chosen yet
Assert.Equal(new[] { "identiteit" }, Ids(null, null));
[Fact]
public void First_load_has_no_diploma_upload() => // no diploma chosen yet
Assert.Equal(new[] { "identiteit" }, Ids(null, null));
[Fact]
public void Manual_diploma_needs_a_diploma_upload() =>
Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null));
[Fact]
public void Manual_diploma_needs_a_diploma_upload() =>
Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null));
[Fact]
public void Duo_diploma_skips_diploma_upload() =>
Assert.DoesNotContain("diploma", Ids("duo", null));
[Fact]
public void Duo_diploma_skips_diploma_upload() =>
Assert.DoesNotContain("diploma", Ids("duo", null));
[Fact]
public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() =>
Assert.Contains("taalvaardigheid", Ids("handmatig", "ja"));
[Fact]
public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() =>
Assert.Contains("taalvaardigheid", Ids("handmatig", "ja"));
[Fact]
public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() =>
Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee"));
[Fact]
public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() =>
Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee"));
[Fact]
public void Find_resolves_taalvaardigheid_for_upload_validation() =>
Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid"));
[Fact]
public void Find_resolves_taalvaardigheid_for_upload_validation() =>
Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid"));
}
public class HerregistratieRuleTests
{
private static Registration Active(DateOnly deadline) => new(
"19012345601", "Test", "Arts",
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
private static Registration Active(DateOnly deadline) => new(
"19012345601", "Test", "Arts",
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
[Fact]
public void Eligible_within_window()
{
var (eligible, reason) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26));
Assert.True(eligible);
Assert.Contains("12 maanden", reason);
}
[Fact]
public void Eligible_within_window()
{
var (eligible, reason) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26));
Assert.True(eligible);
Assert.Contains("12 maanden", reason);
}
[Fact]
public void Not_eligible_before_window()
{
var (eligible, _) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1));
Assert.False(eligible);
}
[Fact]
public void Not_eligible_before_window()
{
var (eligible, _) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1));
Assert.False(eligible);
}
[Fact]
public void Eligible_on_window_boundary()
{
// window opens exactly 12 months before the deadline
var (eligible, _) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1));
Assert.True(eligible);
}
[Fact]
public void Eligible_on_window_boundary()
{
// window opens exactly 12 months before the deadline
var (eligible, _) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1));
Assert.True(eligible);
}
[Fact]
public void Suspended_is_not_eligible()
[Fact]
public void Suspended_is_not_eligible()
{
var reg = Active(new DateOnly(2027, 3, 1)) with
{
var reg = Active(new DateOnly(2027, 3, 1)) with
{
Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
};
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26));
Assert.False(eligible);
}
Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
};
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26));
Assert.False(eligible);
}
[Fact]
public void Status_consistency_invariant()
{
Assert.True(HerregistratieRule.IsStatusConsistent(
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
Assert.False(HerregistratieRule.IsStatusConsistent(
new RegistrationStatus(StatusTag.Geregistreerd)));
}
[Fact]
public void Status_consistency_invariant()
{
Assert.True(HerregistratieRule.IsStatusConsistent(
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
Assert.False(HerregistratieRule.IsStatusConsistent(
new RegistrationStatus(StatusTag.Geregistreerd)));
}
}
public class DiplomaRuleTests
{
private static Diploma Diploma(string opleiding, bool engelstalig) =>
new("x", "naam", "instelling", 2011, opleiding, engelstalig);
private static Diploma Diploma(string opleiding, bool engelstalig) =>
new("x", "naam", "instelling", 2011, opleiding, engelstalig);
[Theory]
[InlineData("geneeskunde", "Arts")]
[InlineData("verpleegkunde", "Verpleegkundige")]
[InlineData("onbekend-programma", "Onbekend")]
public void Profession_is_derived_from_program(string opleiding, string expected) =>
Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false)));
[Theory]
[InlineData("geneeskunde", "Arts")]
[InlineData("verpleegkunde", "Verpleegkundige")]
[InlineData("onbekend-programma", "Onbekend")]
public void Profession_is_derived_from_program(string opleiding, string expected) =>
Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false)));
[Fact]
public void English_diploma_requires_dutch_proficiency()
{
var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true));
Assert.Single(questions);
Assert.Equal("nl-taalvaardigheid", questions[0].Id);
}
[Fact]
public void English_diploma_requires_dutch_proficiency()
{
var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true));
Assert.Single(questions);
Assert.Equal("nl-taalvaardigheid", questions[0].Id);
}
[Fact]
public void Dutch_diploma_has_no_policy_questions() =>
Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false)));
[Fact]
public void Dutch_diploma_has_no_policy_questions() =>
Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false)));
[Fact]
public void Manual_diploma_gets_maximal_set()
{
var questions = DiplomaRules.ManualQuestions();
Assert.Equal(3, questions.Count);
Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" },
questions.Select(q => q.Id));
}
[Fact]
public void Manual_diploma_gets_maximal_set()
{
var questions = DiplomaRules.ManualQuestions();
Assert.Equal(3, questions.Count);
Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" },
questions.Select(q => q.Id));
}
[Fact]
public void Manual_professions_match_known_programs() =>
Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" },
DiplomaRules.ManualProfessions());
[Fact]
public void Manual_professions_match_known_programs() =>
Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" },
DiplomaRules.ManualProfessions());
}
public class SubmissionRuleTests
{
[Fact]
public void Manual_diploma_is_rejected() =>
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
[Fact]
public void Manual_diploma_is_rejected() =>
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
[Fact]
public void Duo_diploma_is_accepted() =>
Assert.Null(SubmissionRules.RejectRegistratie("duo"));
[Fact]
public void Duo_diploma_is_accepted() =>
Assert.Null(SubmissionRules.RejectRegistratie("duo"));
[Fact]
public void Zero_hours_is_rejected() =>
Assert.NotNull(SubmissionRules.RejectZeroUren(0));
[Fact]
public void Zero_hours_is_rejected() =>
Assert.NotNull(SubmissionRules.RejectZeroUren(0));
[Fact]
public void Worked_hours_are_accepted() =>
Assert.Null(SubmissionRules.RejectZeroUren(40));
[Fact]
public void Worked_hours_are_accepted() =>
Assert.Null(SubmissionRules.RejectZeroUren(40));
[Theory]
[InlineData("Lange Voorhout 9", "2514 EA", null)] // valid
[InlineData("", "2514 EA", "Vul straat en huisnummer in.")]
[InlineData("Straat 1", "nope", "Voer een geldige postcode in, bijv. 1234 AB.")]
public void Change_request_is_validated(string straat, string postcode, string? expected) =>
Assert.Equal(expected, SubmissionRules.RejectChangeRequest(straat, postcode));
[Theory]
[InlineData("Lange Voorhout 9", "2514 EA", null)] // valid
[InlineData("", "2514 EA", "Vul straat en huisnummer in.")]
[InlineData("Straat 1", "nope", "Voer een geldige postcode in, bijv. 1234 AB.")]
public void Change_request_is_validated(string straat, string postcode, string? expected) =>
Assert.Equal(expected, SubmissionRules.RejectChangeRequest(straat, postcode));
}

View File

@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
// WP-22's stores read a single static Db.ConnectionString (there's no DI, matching
// their pre-WP-22 static-Dictionary shape — see Data/Db.cs). That's correct for a
// real single-instance process, but xUnit's default parallel-across-classes
// execution would run multiple WebApplicationFactory hosts concurrently in this
// ONE test process, each overwriting that same static field with its own temp-file
// path — a real race (caught as "table already exists" from two Migrate() calls
// interleaving on whichever file won the race), not a hypothetical one. Serializing
// test classes is the fix, not a redesign of the stores for a test-only concern.
[assembly: CollectionBehavior(DisableTestParallelization = true)]
namespace BigRegister.Tests;
/// <summary>
/// WP-22 moved Applications/Documents/Briefs off in-memory dictionaries onto a real
/// SQLite file (see Data/Db.cs). Unlike static dictionaries, a shared file path
/// would let concurrent test classes' WebApplicationFactory instances hit the same
/// file at once — xUnit runs different test classes in parallel by default, and
/// SQLite tolerates only one writer at a time, so that's a real "database is
/// locked" flake risk, not a hypothetical one. Every test class below points at its
/// own throwaway file instead, deleted when the factory (and its class's tests) are
/// done — the same one-store-per-class isolation the old dictionaries gave for free.
/// </summary>
public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-test-{Guid.NewGuid():N}.db");
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.UseSetting("ConnectionStrings:AppDb", $"Data Source={_dbPath}");
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing) return;
File.Delete(_dbPath);
File.Delete(_dbPath + "-shm"); // ponytail: best-effort — WAL sidecar files if SQLite created any.
File.Delete(_dbPath + "-wal");
}
}

View File

@@ -9,11 +9,20 @@ services:
- ASPNETCORE_ENVIRONMENT=Development
volumes:
# ':z' relabels for SELinux (Fedora/RHEL); harmless on other hosts.
# WP-22: no separate volume needed for the SQLite file — `dotnet run` sets
# its cwd to the project dir (src/BigRegister.Api), which this bind mount
# already covers, so bigregister.db lands on the host and survives
# `docker compose restart api` / `down` + `up` for free (gitignored).
- ./backend:/src:z
- api-bin:/src/src/BigRegister.Api/bin
- api-obj:/src/src/BigRegister.Api/obj
# WP-25: LetterHtml.Render inlines public/letter.css (the FE⇄BE letter
# contract, repo-root sibling of backend/) — mounted here so the same
# walk-up-from-the-assembly lookup that works for `dotnet run`/tests also
# resolves inside this container, whose bind mount otherwise only sees backend/.
- ./public:/src/public:z
ports:
- "5000:5000"
- '5000:5000'
web:
image: node:24
@@ -24,7 +33,7 @@ services:
- ./:/app:z
- web-modules:/app/node_modules
ports:
- "4200:4200"
- '4200:4200'
depends_on:
- api

View File

@@ -16,7 +16,7 @@ re-registration — "herregistratie").
---
## 1. The big picture: three "contexts", four "layers"
## 1. The big picture: six "contexts", five "layers"
The code is split first by **business area** (a "bounded context" in DDD terms),
then inside each area by **layer**.
@@ -27,9 +27,14 @@ src/app/
auth/ logging in / the current session
registratie/ the user's BIG registration + personal data
herregistratie/ the re-registration application flow
showcase/ a teaching page; not a real feature
brief/ letter-composition teaching slice
showcase/ a teaching page; not a real feature (may read every context)
```
`showcase/` is a **sanctioned exception** to the direction rules: its whole point is
showing multiple contexts side by side, so it may import any context. Nothing imports
`showcase`. (Enforced in `eslint.config.mjs`; same precedent as the `debug-state` panel.)
### The atomic-design hierarchy, visualised
The UI is built bottom-up: tiny **atoms** combine into **molecules**, which combine
@@ -53,7 +58,7 @@ organism** (`intake-wizard`) — everything else (`form-field`, `text-input`, `b
`alert`, `spinner`, the page shell) was reused unchanged. That is the payoff of the
hierarchy.
Inside a context you'll see the same four folders. They answer four different
Inside a context you'll see the same five folders. They answer five different
questions:
| Layer | Answers… | May import Angular? | Example here |
@@ -61,14 +66,18 @@ questions:
| `domain/` | What are the business rules and data? | **No** (pure TS) | `registration.ts`, `registration.policy.ts` |
| `application/` | How do we coordinate a task / state? | Yes (signals) | `big-profile.store.ts` |
| `infrastructure/` | Where does data come from? | Yes (HTTP) | `big-register.adapter.ts`, `brp.adapter.ts` |
| `contracts/` | What's the FE⇄BE wire shape? | **No** (pure DTOs) | `dashboard-view.dto.ts` |
| `ui/` | How does it look? | Yes (components) | `dashboard.page.ts` |
**The one rule that keeps it sane: dependencies only point _inward_.** UI may use
application, application may use domain, everyone may use `shared`. Never the
other way around. The `domain/` layer imports nothing from Angular, so the
business rules are plain functions you can read and test in isolation.
other way around. In particular **`ui/` and `layout/` never import `infrastructure/`
directly** — they reach data through an application store or command (lint-enforced).
The `domain/` layer imports nothing from Angular, so the business rules are plain
functions you can read and test in isolation.
Allowed direction: `herregistratie → registratie → shared`, `auth → shared`.
Allowed direction: `herregistratie → registratie → shared`, `auth → shared`,
`brief → shared` (`showcase` may read every context; see above).
### Why the `shared/` kernel is split too
@@ -79,7 +88,7 @@ Allowed direction: `herregistratie → registratie → shared`, `auth → shared
- `shared/infrastructure/` — the demo HTTP interceptor.
Imports use path aliases so they read as direction statements:
`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`.
`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`, `@brief/*`.
---
@@ -205,7 +214,7 @@ profile = computed(() =>
The rule baked into `map2`: the combined result is a **Failure if either
failed**, **Loading if either is still loading**, and only **Success when both
succeeded**. So the page renders one state and the combiner callback only runs
when it's safe. (`map`, `map3`, `andThen` are variations on the same idea.)
when it's safe. (`map`, `andThen` are variations on the same idea.)
### 2c. The store — "all state changes go through one pure function"

View File

@@ -20,13 +20,13 @@ backend team.
## Options considered
| Option | Fewer calls? | Unifies policy? | Cost |
|---|---|---|---|
| 1. Status quo (client calls upstreams, aggregates, owns rules) | No | No | — |
| 2. Unified client API layer (one facade in the FE) | No — still N round-trips | No — rules still on client | Low, but misses the goal |
| **3. Screen-shaped endpoints on our own backend ("BFF-lite")** | **Yes** — 1 call/screen | **Yes** — server computes decisions | Lowmedium |
| 4. Separately-deployed BFF service | Yes | Yes | Medium — another deployable |
| 5. GraphQL gateway | Yes (client picks fields) | **No, not by itself** — still need resolvers to own rules | Mediumhigh; new infra |
| Option | Fewer calls? | Unifies policy? | Cost |
| -------------------------------------------------------------- | ------------------------- | --------------------------------------------------------- | --------------------------- |
| 1. Status quo (client calls upstreams, aggregates, owns rules) | No | No | — |
| 2. Unified client API layer (one facade in the FE) | No — still N round-trips | No — rules still on client | Low, but misses the goal |
| **3. Screen-shaped endpoints on our own backend ("BFF-lite")** | **Yes** — 1 call/screen | **Yes** — server computes decisions | Lowmedium |
| 4. Separately-deployed BFF service | Yes | Yes | Medium — another deployable |
| 5. GraphQL gateway | Yes (client picks fields) | **No, not by itself** — still need resolvers to own rules | Mediumhigh; new infra |
GraphQL solves over/under-fetching but does not, on its own, move rules
server-side — and our problem is policy unification + drift, not field-selection
@@ -40,7 +40,7 @@ them.** Keep it minimal: implement BFF-shaped endpoints on the backend we alread
own. Promote to a separately-deployed BFF service only when a second consumer
(mobile/partner) or a team boundary demands it — not before.
### Why DTOs *decouple* rather than couple
### Why DTOs _decouple_ rather than couple
The coupling people fear comes from **not** having DTOs — i.e. serializing internal
DB/domain entities straight onto the wire, so every schema change ripples to the
@@ -53,7 +53,7 @@ DB entity / domain model → DTO (the wire contract) → FE view model
Each side keeps its own internal model and refactors freely; only the DTO is a
deliberate, versioned change. The one coupling that remains — both sides agreeing
on the contract — is the *wanted*, reviewable seam. Manage it with **one source of
on the contract — is the _wanted_, reviewable seam. Manage it with **one source of
truth** (OpenAPI or TypeSpec) that **generates types for both sides**. That spec is
the governance/transparency artifact.
@@ -76,6 +76,7 @@ This POC has no real backend (static mock JSON + fake submit timers), so the
would compute. Two slices were implemented to demonstrate **both** policy shapes:
**A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).**
- Contract: `src/app/registratie/contracts/dashboard-view.dto.ts`
(`DashboardViewDto` = registration + person + `decisions`).
- Endpoint: `public/mock/dashboard-view.json` (one call replaces three).
@@ -92,6 +93,7 @@ would compute. Two slices were implemented to demonstrate **both** policy shapes
`brp.json`) were deleted — those calls live behind the BFF now.
**B. Intake scholing threshold → config value.**
- Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`.
- Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`).
- `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone;

View File

@@ -7,7 +7,7 @@ Status: Proposed · Date: 2026-07-01
Today the app knows exactly one actor. `auth/domain/session.ts` is a flat
`Session { bsn, naam }`, authentication is a faked DigiD flow, and the backend has no
role model at all (only an `X-Admin: true` header seam in `Program.cs` and a stringly-typed
`Actor` on audit entries). This whole repo *is* the **Zorgverlener** self-service portal (SSP).
`Actor` on audit entries). This whole repo _is_ the **Zorgverlener** self-service portal (SSP).
We now need a second user group — **Behandelaar** (backoffice: assessing and deciding on
applications) — and want room for others later (admin, auditor, institution rep). The question
@@ -27,11 +27,11 @@ Confirmed constraints (with the product owner):
## Options considered
| Option | Ubiquitous language respected? | Coupling | Verdict |
|---|---|---|---|
| 1. Split contexts **by role** (`zorgverlener/`, `behandelaar/` folders) | No — role ≠ capability; features smear across personas | High | Reject |
| 2. One catch-all **`users`/`identity`** context owning everything about people | No — becomes a god-context; mixes identity, authz, and features | High | Reject |
| 3. **Actors are personas; contexts are capabilities; identity is typed** | Yes | Low | **Adopt** |
| Option | Ubiquitous language respected? | Coupling | Verdict |
| ------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------- | --------- |
| 1. Split contexts **by role** (`zorgverlener/`, `behandelaar/` folders) | No — role ≠ capability; features smear across personas | High | Reject |
| 2. One catch-all **`users`/`identity`** context owning everything about people | No — becomes a god-context; mixes identity, authz, and features | High | Reject |
| 3. **Actors are personas; contexts are capabilities; identity is typed** | Yes | Low | **Adopt** |
## Decision
@@ -42,9 +42,9 @@ Confirmed constraints (with the product owner):
The same real-world thing is described in two different languages:
- **Zelfbediening (SSP)** — the Zorgverlener: *"ik vraag herregistratie aan"* — eligibility, fill in
- **Zelfbediening (SSP)** — the Zorgverlener: _"ik vraag herregistratie aan"_ — eligibility, fill in
my data, upload documents, submit. **This repo.**
- **Behandeling (backoffice)** — the Behandelaar: *"ik beoordeel de aanvraag"* — werkvoorraad,
- **Behandeling (backoffice)** — the Behandelaar: _"ik beoordeel de aanvraag"_ — werkvoorraad,
beoordeling, besluit, meer-info-opvragen, SLA, audit. **A sibling application**, not a folder here.
Diverging verbs over the same noun is the textbook signal for **two bounded contexts**.
@@ -52,9 +52,9 @@ Diverging verbs over the same noun is the textbook signal for **two bounded cont
### 2. The aggregate is owned by the backend; the contexts integrate through it
The aanvraag/registration is the **system of record in the backend domain**. Neither frontend owns
it. They integrate *through the backend* using the **BFF-lite decision DTOs of ADR-0001** — the same
aggregate projected into two screen-shaped views. The **aanvraag status lifecycle** is the *published
contract* between the two contexts:
it. They integrate _through the backend_ using the **BFF-lite decision DTOs of ADR-0001** — the same
aggregate projected into two screen-shaped views. The **aanvraag status lifecycle** is the _published
contract_ between the two contexts:
```
Ingediend → In behandeling → (Meer info gevraagd ⇄) → Goedgekeurd / Afgewezen
@@ -93,7 +93,7 @@ These are two concerns people habitually conflate; keeping them apart is the cru
```ts
type Principal =
| { kind: 'zorgverlener'; bsn: string; naam: string } // DigiD/BSN
| { kind: 'zorgverlener'; bsn: string; naam: string } // DigiD/BSN
| { kind: 'medewerker'; medewerkerId: string; naam: string; rollen: Rol[] }; // employee SSO
```
@@ -102,14 +102,14 @@ These are two concerns people habitually conflate; keeping them apart is the cru
second actor arrives.
- **Authorization — "what may you do"** → enforced at the **backend / context boundary**, where the
backend is the authority (per ADR-0001). It is *not* a permission matrix living in `auth`. The
backend is the authority (per ADR-0001). It is _not_ a permission matrix living in `auth`. The
frontend receives only the decisions it needs to render (e.g. a `canBeoordelen` flag), exactly like
every other server-owned rule.
### 4. "Other users" slot in without inventing contexts
Admin, auditor, institution-rep are additional **`Principal` variants** or additional **`rollen` on
`medewerker`** — never a new folder-per-role. A genuinely new *bounded context* is warranted only when
`medewerker`** — never a new folder-per-role. A genuinely new _bounded context_ is warranted only when
an actor brings a new **language and capability** (e.g. an "Toezicht/Handhaving" enforcement context),
not merely a new login.
@@ -121,7 +121,7 @@ not merely a new login.
`authGuard`/`SessionStore` seams already localise that (`auth.guard.ts`, `session.store.ts`).
- The backend becomes the authority for the **aanvraag status lifecycle** and for **authorization**,
publishing both as decision DTOs — a natural extension of ADR-0001, not a new pattern.
- `pendingHerregistratie` is understood as a *temporary stand-in* for a real, backend-owned status.
- `pendingHerregistratie` is understood as a _temporary stand-in_ for a real, backend-owned status.
## Out of scope here (next steps, not built)

View File

@@ -23,7 +23,7 @@ layer — not a palette swap.
references resolve at runtime. Storybook serves the same via `staticDirs`.
2. **Token bridge over token rewrite.** `src/styles.scss` redefines the app's ~54 `--rhc-*` tokens
onto CIBG values (`--bs-*` where one exists, CIBG palette hex otherwise). The `--rhc-*` names are
now an internal alias set; the *values* are CIBG. This avoided rewriting 300+ token references and
now an internal alias set; the _values_ are CIBG. This avoided rewriting 300+ token references and
keeps the "components reference tokens" convention intact. (`styles.scss` is exempt from
`check:tokens`, so palette hex lives in that one file only.)
3. **Re-skin atoms, keep their `input()` APIs.** Each `shared/ui` atom now emits Bootstrap/CIBG classes
@@ -42,9 +42,13 @@ layer — not a palette swap.
(`public/` already copied), and `.storybook/` — plus the class strings in ~40 `shared/ui` +
`shared/layout` + a few domain components. The `@rijkshuisstijl-community/*` deps are dropped.
- `check:tokens` still guards raw hex in components; the token bridge + hand-rolled surfaces comply.
- Known benign build warning: *"Unable to locate stylesheet: /cibg-huisstijl/css/huisstijl.min.css"*
- Known benign build warning: _"Unable to locate stylesheet: /cibg-huisstijl/css/huisstijl.min.css"_
Angular's index optimizer doesn't process a `public/` stylesheet at build time. The asset is copied
and the link is preserved (verified: served 200, `.btn-primary` present); the build exits green. The
alternative (adding the CSS to `angular.json` `styles`) would force-bundle the licensed fonts we
intentionally dropped, so we accept the warning.
- Renaming the internal token names from `--rhc-*` to `--app-*` is possible later but out of scope.
- Hand-rolled components (point 4) are tracked in the **CIBG gap register**
(`src/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation from the
design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than silently
drifting.

View File

@@ -30,38 +30,61 @@ From WP-01 onward, additionally:
npm run test-storybook:ci
```
Backend stays untouched throughout (frontend-only backlog); `cd backend && dotnet test`
only needs re-running if a WP unexpectedly touches `backend/`.
Phases 05 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 | todo |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo |
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | todo |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | todo |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | todo |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | todo |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | todo |
| 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 1015 (axe covers story churn);
03/04 before 0509 (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.
— if 11/12 run first, they define it and 13 adopts it. 1822 (phase 5, "productie-volwassenheid")
are independent of each other and of phases 14 — 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
@@ -72,12 +95,20 @@ 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
```

View File

@@ -6,7 +6,7 @@ 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*.
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.

View File

@@ -1,6 +1,6 @@
# WP-04 — Boundaries II: `ui ↛ infrastructure` + showcase sanction
Status: todo
Status: done (035e785)
Phase: 0 — enforcement & gates
## Why
@@ -54,11 +54,13 @@ Phase 0 — a pure move of wiring, no behavior change.
## Acceptance criteria
- [ ] Rule active; lint green; no disables beyond the documented showcase + debug-state
exemptions.
- [ ] No `**/ui/**` file imports from `**/infrastructure/**`.
- [ ] Both wizards behave unchanged (specs pass; manual smoke).
- [ ] CLAUDE.md and ARCHITECTURE.md list 6 contexts / 5 layers.
- [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

View File

@@ -1,6 +1,6 @@
# WP-05 — Parse-don't-validate closure + MDX
Status: todo
Status: done
Phase: 1 — FP/DDD core
## Why
@@ -30,7 +30,7 @@ principle (every response through a hand-written `parse*` returning `Result`).
- `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)
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`
@@ -45,10 +45,10 @@ principle (every response through a hand-written `parse*` returning `Result`).
## Acceptance criteria
- [ ] No unvalidated `as <DomainType>` casts in `**/infrastructure/**` (the sanctioned
- [x] No unvalidated `as <DomainType>` casts in `**/infrastructure/**` (the sanctioned
"narrow unknown to `Partial<Dto>` then parse" entry-cast is fine).
- [ ] Each new parser has a spec including a rejection case.
- [ ] MDX renders under Foundations in Storybook.
- [x] Each new parser has a spec including a rejection case.
- [x] MDX renders under Foundations in Storybook.
## Verification
@@ -57,7 +57,7 @@ GREEN + `npm run test-storybook:ci`. Smoke: intake wizard still loads its policy
## Out of scope
Runtime validation on *every* endpoint (explicitly out of scope for the POC per
Runtime validation on _every_ endpoint (explicitly out of scope for the POC per
CLAUDE.md); `digid.adapter.ts` (faked auth, sanctioned).
## Risks

View File

@@ -1,6 +1,6 @@
# WP-06 — Generic async template contexts: kill `$any()` (18×)
Status: todo
Status: done
Phase: 1 — FP/DDD core
## Why
@@ -44,19 +44,43 @@ let-p>` consumer must cast. The rest are template union-narrowing workarounds.
## Acceptance criteria
- [ ] `grep -rn '\$any(' src/app` → zero hits.
- [ ] No `as` casts added to compensate in component classes (typed getters are fine).
- [ ] Build green with strict template checking.
- [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`. Smoke: dashboard + registration detail render.
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.
## Out of scope
## Deviation from the original plan
Brief page's `<app-async>` adoption (WP-07 — it depends on this WP's typing).
`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).
## Risks
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.
Angular generic-component inference edge cases — the documented fallback keeps the WP
bounded.
`AsyncComponent`'s public API (`[data]`/`[resource]` inputs) is unchanged, so this
deviation is contained to consumer templates, as the WP intended.

View File

@@ -1,6 +1,6 @@
# WP-07 — Brief on the shared idioms + RemoteData MDX
Status: todo
Status: done
Phase: 1 — FP/DDD core
Depends on: WP-06 (typed `<app-async>`)
@@ -26,7 +26,7 @@ bypassing the shared molecule.
(`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
_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.
@@ -50,15 +50,53 @@ bypassing the shared molecule.
## Acceptance criteria
- [ ] No boolean-plus-error signal pairs in `brief/`.
- [ ] `/brief` renders all four async states (check with `?scenario=slow|empty|error`).
- [ ] Specs cover the transition union (Busy→Failed, Busy→Idle).
- [ ] MDX renders under Foundations.
- [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`. Manual: `npm start``/brief` with
`?scenario=slow`, `?scenario=error`; exercise autosave + submit + rejection flow.
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
@@ -67,4 +105,11 @@ 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; the machine spec pins it.
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.

View File

@@ -1,6 +1,6 @@
# WP-08 — One store idiom + machine naming + TEA MDX
Status: todo
Status: done
Phase: 1 — FP/DDD core
## Why
@@ -49,16 +49,34 @@ two idioms and copy the wrong one. Machine naming also drifts:
## Acceptance criteria
- [ ] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty.
- [ ] Every machine consumer wires via `createStore`; no local `signal(model)` +
- [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.
- [ ] Convention documented in CLAUDE.md; MDX renders.
- [ ] All machine specs pass unchanged (reducers untouched).
- [x] Convention documented in CLAUDE.md; MDX renders.
- [x] All machine specs pass unchanged (reducers untouched).
## Verification
GREEN + `npm run test-storybook:ci`. Smoke: all three wizards step forward/back and
submit.
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 34) had real work left.
## Out of scope

View File

@@ -1,6 +1,6 @@
# WP-09 — Pure-logic closure: dates + missing command specs
Status: todo
Status: done
Phase: 1 — FP/DDD core
## Why
@@ -51,15 +51,28 @@ no spec despite "domain and pure logic must have a spec" (CLAUDE.md §5).
## Acceptance criteria
- [ ] Exactly one hand-written date formatter in the repo; `grep -rn "toLocaleDateString" src/app`
hits only `datum.ts`.
- [ ] Both command specs exist; debounce coalescing + error path covered.
- [ ] `map3` / unused `variant` input removed (or a note here why kept).
- [ ] CLAUDE.md rule added.
- [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`.
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

View File

@@ -1,8 +1,17 @@
# WP-10 — CIBG button fidelity
Status: todo
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 /
@@ -46,9 +55,9 @@ and render as unstyled Bootstrap defaults instead of CIBG buttons.
## Acceptance criteria
- [ ] `grep -rn "btn-outline\|btn-sm" src/app` → empty.
- [ ] Button story shows all CIBG variants incl. ghost; visuals match the design system.
- [ ] Axe still green (contrast can change with real button styles).
- [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

View File

@@ -1,8 +1,15 @@
# WP-11 — CIBG markup fidelity: application-link + absent-class triage
Status: todo
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

View File

@@ -1,8 +1,14 @@
# WP-12 — CIBG Datablock for application data
Status: todo
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

View File

@@ -1,8 +1,31 @@
# WP-13 — CIBG-gap register + hygiene + MDX
Status: todo
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
@@ -32,6 +55,7 @@ the list-family rationale to document.
## Files
Components to mark (closest CIBG concept in parens):
- `skeleton`, `spinner` (Laadindicatie — no vendored class, verified)
- `upload/` suite (Bestand-upload)
- `rich-text-editor` (Tekstgebied)
@@ -41,7 +65,7 @@ Components to mark (closest CIBG concept in parens):
- `debug-state` (devtool, no CIBG concept)
- `status-badge` (deliberate custom, documented in code), `card` (`.app-card`),
`placeholder-chip`
Plus:
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`
@@ -60,11 +84,11 @@ Plus:
## Acceptance criteria
- [ ] Every component with hand-rolled surface CSS either wraps vendored classes or
- [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).
- [ ] Register MDX complete, linked from ADR-0003.
- [ ] `upload-status-banner` gone; consumer green; no story coverage lost.
- [ ] List trio documented.
- [x] Register MDX complete, linked from ADR-0003.
- [x] `upload-status-banner` gone; consumer green; no story coverage lost.
- [x] List trio documented.
## Verification

View File

@@ -1,8 +1,17 @@
# WP-14 — Storybook taxonomy reorg + Layers MDX
Status: todo
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
@@ -58,11 +67,11 @@ Domein/
## Acceptance criteria
- [ ] Sidebar shows exactly Foundations → Design System → Domein with the sub-order
- [x] Sidebar shows exactly Foundations → Design System → Domein with the sub-order
pinned.
- [ ] Zero story titles outside the scheme (grep `title:` and eyeball).
- [ ] `layers.mdx` renders; existing MDX pages unbroken.
- [ ] Convention in CLAUDE.md.
- [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

View File

@@ -1,9 +1,18 @@
# WP-15 — Missing stories: shell + brief components
Status: todo
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:
@@ -44,11 +53,12 @@ invisible to the axe gate.
## Acceptance criteria
- [ ] Every component in `src/app` has ≥1 story (verify: list components without a
- [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).
- [ ] All new stories pass the axe gate (or carry a justified skip).
- [ ] Titles follow WP-14.
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

View File

@@ -1,11 +1,12 @@
# WP-16 — Component a11y: description wiring + alert role
Status: todo
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
@@ -56,12 +57,25 @@ Audit findings axe can't (fully) catch:
## Acceptance criteria
- [ ] Description text is programmatically associated in the canonical composition;
- [x] Description text is programmatically associated in the canonical composition;
login-form BSN hint announced.
- [ ] `-error` id appended exactly when invalid; order stable.
- [ ] Error alerts are `role="alert"`; play tests assert both behaviors and run in the
- [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).

View File

@@ -1,14 +1,15 @@
# WP-17 — App-level a11y: route focus/scroll, template lint, WCAG checklist + MDX
Status: todo
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
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.
@@ -58,11 +59,30 @@ Three app-level gaps close the WCAG story:
## Acceptance criteria
- [ ] Navigating between routes moves focus to the new page's heading; scroll resets;
- [x] Navigating between routes moves focus to the new page's heading; scroll resets;
view transitions still play.
- [ ] Template a11y rules active and *proven* to fire; lint green.
- [ ] Checklist checked in with an initial pass filled in for the dashboard at minimum.
- [ ] `a11y.mdx` renders; links to checklist and WP-01 skip rules.
- [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

View File

@@ -0,0 +1,178 @@
# WP-18 — ABAC capability spine (Principal + capabilities, phase P1)
Status: done (7ec13d8)
Phase: 5 — productie-volwassenheid
## 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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, 1050) — 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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`).

View File

@@ -0,0 +1,93 @@
# WP-27 — Brief UX layer (undo/redo, standaardbrief, search, diff badges)
Status: todo
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 23 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
- [ ] 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.
- [ ] Empty kern + "Standaardbrief invoegen" → default passages inserted as one undo
step; button gone once kern is non-empty.
- [ ] Search filters passages by label and content.
- [ ] Reject → edit → resubmit: approver toggles "Toon wijzigingen", changed/added/
removed blocks are badged (block granularity).
- [ ] Autosave failure shows "Niet opgeslagen — opnieuw proberen"; retry works;
content never lost locally.
- [ ] 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.

View File

@@ -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.

View File

@@ -1,10 +1,10 @@
# PRD 0001 — "Mijn aanvragen": running wizards, application status & document preview
Status: Proposed · Date: 2026-07-01 · Context: SSP / Zorgverlener (see ADR-0002)
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
> _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.
@@ -24,7 +24,7 @@ uploaded. Concretely, today:
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.
submission to _succeed_ and sit in a pending (manual-review) state instead.
## 2. Goals
@@ -54,25 +54,25 @@ uploaded. Concretely, today:
## 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
"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 | |
| 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
@@ -94,7 +94,7 @@ FE-mirrored discriminated union (illegal states unrepresentable, same reflex as
```ts
type AanvraagStatus =
| { tag: 'Concept'; stepIndex: number; stepCount: number }
| { tag: 'InBehandeling'; referentie: string; manual: boolean } // manual=true → "wordt beoordeeld"
| { tag: 'InBehandeling'; referentie: string; manual: boolean } // manual=true → "wordt beoordeeld"
| { tag: 'Goedgekeurd'; referentie: string }
| { tag: 'Afgewezen'; referentie: string; reden: string };
```
@@ -117,12 +117,12 @@ 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 | — |
| 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.

View File

@@ -4,7 +4,7 @@ Status: Proposed · Date: 2026-07-02 · Context: SSP / backoffice actors (see AD
> 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
> 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.
@@ -19,8 +19,8 @@ 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-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.
@@ -35,13 +35,13 @@ Today the codebase has none of this, and what stands in for a "role" is not a se
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*
- 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`:
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`.
@@ -49,13 +49,13 @@ The building block we need already exists in one place: the **decision-flag seam
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.
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
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.
@@ -69,7 +69,7 @@ PRD extends that same seam from *business* decisions to *authorization* decision
## 3. Non-goals / Out of scope (POC)
- **Real AD / OIDC / SAML integration.** The AD roles remain *simulated*; how claims actually arrive
- **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
@@ -85,17 +85,17 @@ PRD extends that same seam from *business* decisions to *authorization* decision
## 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 —
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 |
| 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.
@@ -124,7 +124,7 @@ 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
_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.
@@ -181,7 +181,7 @@ The FE's job is to **mirror** server decisions cleanly and deny-by-default. It r
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
> browser changes only what they _see_; every gated route, action, and field is independently enforced
> by the backend (§7).
## 7. Backend design
@@ -193,7 +193,7 @@ Extends ADR-0001's decision-DTO pattern; closes the "fully open" gap.
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
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.

View File

@@ -24,82 +24,82 @@ onto: spacing `--rhc-space-max-{sm..5xl}`, type `--rhc-text-font-size-*` /
`--rhc-color-{lintblauw,donkerblauw,cool-grey}-*` / `--rhc-color-wit`, radius
`--rhc-border-radius-*`.
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 1.1 | **H** | `var(--rhc-color-grijs-700)` is referenced in 3 wizards but **`--rhc-color-grijs-*` does not exist** in the package → the "Stap X van Y" text falls back to the default color, not grey. | `--rhc-color-foreground-subtle` |
| 1.2 | **H** | `site-header`/`site-footer` set bg to `var(--rhc-color-lintblauw-700,#154273)` / `…-900,#01689b``lintblauw-900` doesn't exist (only 50700), so the **hex fallback renders**. | header `--rhc-color-lintblauw-700`; footer `--rhc-color-donkerblauw-700`; text `--rhc-color-wit` |
| 1.3 | M | Raw spacing everywhere: `0.25/0.5/0.75/1/1.5/2/3rem` as `gap`/`margin`/`padding` (≈30 occurrences). | `--rhc-space-max-{sm,md,lg,xl,2xl,3xl,5xl}` |
| 1.4 | M | Inconsistent form/content widths: `28rem`, `30rem`, `32rem`, `64rem` as raw `max-width`. | app width tokens in `styles.scss` (`--app-form-max`, `--app-content-max`) mapped once |
| 1.5 | M | `spinner` & `skeleton` hardcode greys/accent (`#cad0d6`, `#e8ebee`, `#f3f5f6`) and the spinner accent via bogus `--rhc-color-lintblauw-700,#154273` fallback. | `--rhc-color-cool-grey-{200,300}`, `--rhc-color-lintblauw-700` |
| 1.6 | L | `site-header` hardcodes `font-weight:700/400`, `font-size:0.9rem`, `opacity:0.85`. | `--rhc-text-font-weight-{bold,regular}`, `--rhc-text-font-size-sm` |
| # | Pri | Finding | Resolves to |
| --- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| 1.1 | **H** | `var(--rhc-color-grijs-700)` is referenced in 3 wizards but **`--rhc-color-grijs-*` does not exist** in the package → the "Stap X van Y" text falls back to the default color, not grey. | `--rhc-color-foreground-subtle` |
| 1.2 | **H** | `site-header`/`site-footer` set bg to `var(--rhc-color-lintblauw-700,#154273)` / `…-900,#01689b``lintblauw-900` doesn't exist (only 50700), so the **hex fallback renders**. | header `--rhc-color-lintblauw-700`; footer `--rhc-color-donkerblauw-700`; text `--rhc-color-wit` |
| 1.3 | M | Raw spacing everywhere: `0.25/0.5/0.75/1/1.5/2/3rem` as `gap`/`margin`/`padding` (≈30 occurrences). | `--rhc-space-max-{sm,md,lg,xl,2xl,3xl,5xl}` |
| 1.4 | M | Inconsistent form/content widths: `28rem`, `30rem`, `32rem`, `64rem` as raw `max-width`. | app width tokens in `styles.scss` (`--app-form-max`, `--app-content-max`) mapped once |
| 1.5 | M | `spinner` & `skeleton` hardcode greys/accent (`#cad0d6`, `#e8ebee`, `#f3f5f6`) and the spinner accent via bogus `--rhc-color-lintblauw-700,#154273` fallback. | `--rhc-color-cool-grey-{200,300}`, `--rhc-color-lintblauw-700` |
| 1.6 | L | `site-header` hardcodes `font-weight:700/400`, `font-size:0.9rem`, `opacity:0.85`. | `--rhc-text-font-weight-{bold,regular}`, `--rhc-text-font-size-sm` |
## 2. Typography & heading hierarchy
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| # | Pri | Finding | Resolves to |
| --- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| 2.1 | **H** | Each page has one `<h1>` (page-shell) but the **wizard steps have no `<h2>`** — step title is a plain `<p>`. Screen-reader users get no step heading to navigate to. | per-step `<h2>` via `app-heading [level]="2"` |
| 2.2 | M | Type scale not applied to bespoke text (header wordmark uses raw sizes). | `--rhc-text-font-size-*`, `app-heading` |
| 2.2 | M | Type scale not applied to bespoke text (header wordmark uses raw sizes). | `--rhc-text-font-size-*`, `app-heading` |
## 3. Color & contrast
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 3.1 | **H** | Because of 1.1/1.2 the actual rendered colors are partly accidental (default text color, hex fallbacks) — contrast is unverified. | map to real tokens, then verify AA |
| 3.2 | M | Palette discipline: confirm a single primary blue (lintblauw) + neutrals + status-only accents (`alert` types ok/info/warning/error already map to Utrecht alert variants). | keep alert variants; route blues to lintblauw/donkerblauw tokens |
| # | Pri | Finding | Resolves to |
| --- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| 3.1 | **H** | Because of 1.1/1.2 the actual rendered colors are partly accidental (default text color, hex fallbacks) — contrast is unverified. | map to real tokens, then verify AA |
| 3.2 | M | Palette discipline: confirm a single primary blue (lintblauw) + neutrals + status-only accents (`alert` types ok/info/warning/error already map to Utrecht alert variants). | keep alert variants; route blues to lintblauw/donkerblauw tokens |
## 4. Spacing & layout
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 4.1 | M | Repeated inline idioms: `display:flex;gap:0.5rem;margin-top:1rem` (button rows, 6×), `max-width:30rem` (forms, 3×), `margin:1rem 0` (summaries). | utility classes `.app-button-row`, `.app-form`, `.app-stack` in `styles.scss` |
| 4.2 | L | `shell` uses a custom `--app-content-max:64rem` defined inline. | promote to the `styles.scss` token layer |
| # | Pri | Finding | Resolves to |
| --- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| 4.1 | M | Repeated inline idioms: `display:flex;gap:0.5rem;margin-top:1rem` (button rows, 6×), `max-width:30rem` (forms, 3×), `margin:1rem 0` (summaries). | utility classes `.app-button-row`, `.app-form`, `.app-stack` in `styles.scss` |
| 4.2 | L | `shell` uses a custom `--app-content-max:64rem` defined inline. | promote to the `styles.scss` token layer |
## 5. Component reuse
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 5.1 | L | `intake-wizard` review step uses bespoke `<div><dt><dd>` instead of `app-data-row` (out of scope here; note for later). | `app-data-row` |
| 5.2 | M | Button rows / form containers are ad-hoc inline layout rather than a shared idiom. | utility classes (4.1) |
| # | Pri | Finding | Resolves to |
| --- | --- | ----------------------------------------------------------------------------------------------------------------------- | --------------------- |
| 5.1 | L | `intake-wizard` review step uses bespoke `<div><dt><dd>` instead of `app-data-row` (out of scope here; note for later). | `app-data-row` |
| 5.2 | M | Button rows / form containers are ad-hoc inline layout rather than a shared idiom. | utility classes (4.1) |
## 6. Form, validation & status patterns
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 6.1 | **H** | `form-field` renders the error with `role="alert"` but **no `id`**, and the projected input has **no `aria-describedby`** → error not programmatically linked. | error `id="${fieldId}-error"`; input `aria-describedby` |
| 6.2 | **H** | `text-input` never sets `aria-invalid` even when `invalid()` is true. | `[attr.aria-invalid]` |
| 6.3 | **H** | `radio-group` has `role="radiogroup"` but **no accessible name** and no invalid state. | `aria-labelledby="${name}-label"`, add `invalid` input → `aria-invalid`/`aria-describedby` |
| 6.4 | M | Async states (`app-async`) render but aren't announced (no live region) — SR users miss loading→loaded/empty/error. | wrap in `aria-live="polite"` + `aria-busy` |
| 6.5 | L | No error-summary pattern; per-field inline errors only. Acceptable for short steps; revisit if steps grow. | (defer) |
| # | Pri | Finding | Resolves to |
| --- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| 6.1 | **H** | `form-field` renders the error with `role="alert"` but **no `id`**, and the projected input has **no `aria-describedby`** → error not programmatically linked. | error `id="${fieldId}-error"`; input `aria-describedby` |
| 6.2 | **H** | `text-input` never sets `aria-invalid` even when `invalid()` is true. | `[attr.aria-invalid]` |
| 6.3 | **H** | `radio-group` has `role="radiogroup"` but **no accessible name** and no invalid state. | `aria-labelledby="${name}-label"`, add `invalid` input → `aria-invalid`/`aria-describedby` |
| 6.4 | M | Async states (`app-async`) render but aren't announced (no live region) — SR users miss loading→loaded/empty/error. | wrap in `aria-live="polite"` + `aria-busy` |
| 6.5 | L | No error-summary pattern; per-field inline errors only. Acceptable for short steps; revisit if steps grow. | (defer) |
## 7. Page chrome
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 7.1 | M | No breadcrumb / location context in the portal chrome. | new `app-breadcrumb` (`.rhc-breadcrumb-nav`), wired via `page-shell` |
| 7.2 | M | Step progress is non-semantic text. | new `app-stepper` (`<ol>`, `aria-current="step"`) |
| 7.3 | L | Skip-link uses `left:-999px` (works) and landmarks (`header/main/footer`) are correct. | keep; tokenize offset |
| # | Pri | Finding | Resolves to |
| --- | --- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| 7.1 | M | No breadcrumb / location context in the portal chrome. | new `app-breadcrumb` (`.rhc-breadcrumb-nav`), wired via `page-shell` |
| 7.2 | M | Step progress is non-semantic text. | new `app-stepper` (`<ol>`, `aria-current="step"`) |
| 7.3 | L | Skip-link uses `left:-999px` (works) and landmarks (`header/main/footer`) are correct. | keep; tokenize offset |
## 8. Copy & tone
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 8.1 | M | Microcopy is functional but informal/uneven (labels, helper text, button text, the manual-diploma warning, the controle summary). | formal, plain official Dutch; consistent with domain terms (registratie wizard + chrome only) |
| # | Pri | Finding | Resolves to |
| --- | --- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| 8.1 | M | Microcopy is functional but informal/uneven (labels, helper text, button text, the manual-diploma warning, the controle summary). | formal, plain official Dutch; consistent with domain terms (registratie wizard + chrome only) |
## 9. Accessibility (WCAG 2.1 AA) — consolidated
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 9.1 | **H** | No focus management: after Next/Back focus stays on the button; SR/keyboard users aren't moved to the new step. | move focus to the step `<h2>` (`tabindex="-1"`) on `cursor` change |
| 9.2 | **H** | Error/label/invalid association missing (6.16.3). | as above |
| 9.3 | M | Step changes & async states not announced (6.4). | `aria-live` |
| 9.4 | M | `skeleton` placeholders are announced as content. | `aria-hidden="true"` |
| 9.5 | L | No automated a11y in CI; only Storybook `addon-a11y` (axe) exists. | add a11y `parameters` in `preview.ts`; keep manual keyboard/SR pass; no new deps |
| # | Pri | Finding | Resolves to |
| --- | ----- | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| 9.1 | **H** | No focus management: after Next/Back focus stays on the button; SR/keyboard users aren't moved to the new step. | move focus to the step `<h2>` (`tabindex="-1"`) on `cursor` change |
| 9.2 | **H** | Error/label/invalid association missing (6.16.3). | as above |
| 9.3 | M | Step changes & async states not announced (6.4). | `aria-live` |
| 9.4 | M | `skeleton` placeholders are announced as content. | `aria-hidden="true"` |
| 9.5 | L | No automated a11y in CI; only Storybook `addon-a11y` (axe) exists. | add a11y `parameters` in `preview.ts`; keep manual keyboard/SR pass; no new deps |
## 10. Responsive
| # | Pri | Finding | Resolves to |
|---|-----|---------|-------------|
| 10.1 | M | Fixed `rem` widths and inline flex rows; verify reflow + ≥24px targets at narrow widths. | token widths + `flex-wrap` on button rows; manual check |
| # | Pri | Finding | Resolves to |
| ---- | --- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| 10.1 | M | Fixed `rem` widths and inline flex rows; verify reflow + ≥24px targets at narrow widths. | token widths + `flex-wrap` on button rows; manual check |
---

59
docs/wcag-checklist.md Normal file
View File

@@ -0,0 +1,59 @@
# WCAG manual checklist
Automation (axe on every story — WP-01; template a11y lint — WP-17; the form-field/alert
play tests — WP-16) catches structural and component-level issues. It cannot catch
cross-page flows: tab order across a whole page, focus traps, zoom/reflow, or how a
screen reader actually narrates a journey. This checklist is the manual complement —
a living doc, filled in per page as it's walked, not a one-time sign-off.
See `src/docs/a11y.mdx` (Storybook → Foundations → Accessibility) for how this fits
with the automated layers.
## How to run a page through this checklist
1. **Keyboard walk**: `Tab`/`Shift+Tab` through the whole page. Every interactive
element reachable, in a sensible order, with a visible focus ring; no trap (you can
always tab back out).
2. **No traps**: a modal/dropdown/menu, if present, returns focus on close/`Escape`.
3. **200% zoom / reflow**: browser zoom to 200% (or a 320px-wide viewport). Content
reflows to one column; nothing is clipped or requires horizontal scroll.
4. **Screen reader pass**: NVDA (Windows) or VoiceOver (macOS) — navigate by heading
and by tab; confirm labels, descriptions, and error announcements are heard, not
just visible.
5. **Visible focus**: every focused element has a visible indicator (no
`outline: none` without a replacement).
6. **Error announcement**: submitting an invalid form announces the error (this is
what WP-16's `role="alert"` + `aria-describedby` wiring is for) — confirm it's
actually heard, not just present in the DOM.
## Status
Legend: ✅ pass · ⚠️ pass with notes · ❌ fails · — not yet walked
| Page | Keyboard walk | No traps | 200% zoom/reflow | Screen reader | Visible focus | Error announcement |
| -------------------------- | :-----------: | :------: | :--------------: | :-----------: | :-----------: | :----------------: |
| Login (`/login`) | ✅ | ✅ | ✅ | — | ✅ | n/a¹ |
| Dashboard (`/dashboard`) | — | — | ❌² | — | — | — |
| Registratie wizard | — | — | — | — | ✅³ | ✅³ |
| Herregistratie wizard | — | — | — | — | — | — |
| Brief (letter composition) | — | — | — | — | — | — |
¹ Login's demo form has no client-side validation/error state to exercise.
² **Real finding, not fixed here**: `aanvraag-block`'s warning `app-alert` (two
`app-button` actions) overflows the viewport at a 320px width — its `.feedback` flex
row doesn't wrap, pushing the second button past the edge. Fixing it is a genuine
CSS change to a live component, which is exactly the "full manual audit" scope this
WP defers (see Out of scope) — logged here instead of silently fixed or silently
ignored.
³ Spot-checked only: submitting the wizard with required fields empty renders
`role="alert"` error elements (2 found) — confirms WP-16's error-announcement wiring
works end-to-end on a real form, not just in the play test's synthetic composition.
Full keyboard walk / zoom / screen-reader pass on this page not yet done.
"Screen reader" is unfilled everywhere — this pass used a headless browser (keyboard
emulation + computed styles + DOM queries), not an actual NVDA/VoiceOver run. Don't
read the automation above as a substitute for that row; it isn't one.
Filling in the remaining rows (and fixing finding ²) is ongoing work (WP-17's
out-of-scope note) — this table makes what's been checked, and what hasn't, visible
rather than assumed.

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More