Compare commits

...

32 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
324 changed files with 32466 additions and 8596 deletions

View File

@@ -34,6 +34,7 @@ server re-validates as authority).
The mapping may read as an identity copy today — the point is the **types** differ, 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. so the compiler forces a real mapping the moment the wire diverges.
5. **Application store** exposes the resource as `RemoteData` (`fromResource`, 5. **Application store** exposes the resource as `RemoteData` (`fromResource`,
combine sources with `map`/`map2`/`andThen` from `@shared/application/remote-data`). combine sources with `map`/`map2`/`andThen` from `@shared/application/remote-data`).
UI never imports infrastructure (lint-enforced). UI never imports infrastructure (lint-enforced).

View File

@@ -16,9 +16,15 @@ unrepresentable.
```ts ```ts
import { Result, assertNever } from '@shared/kernel/fp'; import { Result, assertNever } from '@shared/kernel/fp';
export interface Draft { postcode: string; uren: string } // raw strings as typed export interface Draft {
postcode: string;
uren: string;
} // raw strings as typed
export type StepErrors = Partial<Record<keyof Draft, string>>; export type StepErrors = Partial<Record<keyof Draft, string>>;
export interface Valid { postcode: Postcode; uren: Uren } // branded, proven valid export interface Valid {
postcode: Postcode;
uren: Uren;
} // branded, proven valid
export type State = export type State =
| { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors } | { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors }
@@ -28,23 +34,31 @@ export type State =
export type Msg = export type Msg =
| { tag: 'SetField'; key: keyof Draft; value: string } | { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Next' } | { tag: 'Back' } | { tag: 'Submit' } | { tag: 'Retry' } | { tag: 'Next' }
| { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string } | { tag: 'Back' }
| { tag: 'Seed'; state: State }; // mount any state (stories, resume) | { 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 const initial: State = { tag: 'Editing', step: 1, draft: emptyDraft, errors: {} };
export function reduce(s: State, m: Msg): State { export function reduce(s: State, m: Msg): State {
switch (m.tag) { switch (m.tag) {
/* … pure transitions only … */ /* … pure transitions only … */
default: return assertNever(m); // exhaustiveness enforced default:
return assertNever(m); // exhaustiveness enforced
} }
} }
export function validate(draft: Draft): Result<StepErrors, Valid> { /* calls value-object parsers */ } export function validate(draft: Draft): Result<StepErrors, Valid> {
/* calls value-object parsers */
}
``` ```
Rules: Rules:
- **Reducer stays pure.** HTTP lives in a command that dispatches `SubmitConfirmed` / `SubmitFailed` (see **mutation-command** skill). - **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. - **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). - Server-owned thresholds arrive as config values; keep only an offline fallback constant (see `SCHOLING_THRESHOLD_DEFAULT` in the intake machine).

View File

@@ -17,7 +17,9 @@ machine's `Valid` type, returns the server's answer, no parse (server is authori
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ChangeRequestAdapter { export class ChangeRequestAdapter {
private client = inject(ApiClient); private client = inject(ApiClient);
async changeRequest(data: Valid): Promise<string> { /* → server reference */ } async changeRequest(data: Valid): Promise<string> {
/* → server reference */
}
} }
``` ```

View File

@@ -33,6 +33,7 @@ Every feature is built inward-out. Never start with the component.
## Worked example ## Worked example
The intake wizard slice, end to end: The intake wizard slice, end to end:
- `src/app/herregistratie/domain/intake.machine.ts` (+ spec) - `src/app/herregistratie/domain/intake.machine.ts` (+ spec)
- `src/app/herregistratie/infrastructure/` - `src/app/herregistratie/infrastructure/`
- `src/app/herregistratie/ui/` (wizard component + page) - `src/app/herregistratie/ui/` (wizard component + page)

View File

@@ -28,6 +28,7 @@ export function parsePostcode(raw: string): Result<string, Postcode> {
``` ```
Rules: Rules:
- The `as Brand` cast appears **only** inside the parser — the type is mintable nowhere else. - 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. - Error message is user-facing → `$localize` with a stable `@@validation.<name>` id.
- Trim/normalise before testing; return the cleaned value. - Trim/normalise before testing; return the cleaned value.

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: on:
push: push:
branches: [main]
tags: ['v*']
pull_request: 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: jobs:
frontend: frontend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@@ -15,13 +27,20 @@ jobs:
cache: npm cache: npm
- run: npm ci - run: npm ci
- run: npm run lint - run: npm run lint
- run: npm run format:check
- run: npm run check:tokens - run: npm run check:tokens
- run: npm test - 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: storybook-a11y:
# Axe runs against every story in the static build; a violation fails the build. # Axe runs against every story in the static build; a violation fails the build.
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@@ -35,16 +54,66 @@ jobs:
backend: backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 10.0.x 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: api-client-drift:
# The committed typed client must match the backend OpenAPI doc. # The committed typed client must match the backend OpenAPI doc.
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4

6
.gitignore vendored
View File

@@ -45,3 +45,9 @@ Thumbs.db
*storybook.log *storybook.log
storybook-static 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'; import type { StorybookConfig } from '@storybook/angular';
const config: StorybookConfig = { const config: StorybookConfig = {
"stories": [ stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
"../src/**/*.mdx", addons: ['@storybook/addon-a11y', '@storybook/addon-docs', '@storybook/addon-onboarding'],
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)" framework: '@storybook/angular',
],
"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 // 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. // 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. <!-- 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. --> Mirrors src/index.html so stories match the app. System font per styles.scss. -->
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" /> <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 = { const preview: Preview = {
// CIBG/Bootstrap styles bare elements — no theme wrapper class needed; just pad. // 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). // The token bridge lives in styles.scss (loaded via the app build target).
decorators: [ decorators: [componentWrapperDecorator((story) => `<div style="padding:1.5rem">${story}</div>`)],
componentWrapperDecorator((story) => `<div style="padding:1.5rem">${story}</div>`),
],
parameters: { parameters: {
layout: 'padded', layout: 'padded',
// Accessibility (axe) via @storybook/addon-a11y. Runs WCAG 2.0/2.1 A+AA rule // 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, 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, POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
view their registration, apply for re-registration). Angular 22, standalone, 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 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 Core backend** (`backend/`, see its README) and consumed through an NSwag-generated
through an NSwag-generated typed client. The FE renders the backend's decisions. 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 ## Commands
@@ -68,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`: model a discriminated union instead.** Three tools, all in `shared/application`:
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`. - **`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 Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four
templates, mutually exclusive by construction. Default loading spinner/skeleton templates, mutually exclusive by construction. Default loading spinner/skeleton
is delay-gated (~250ms) so fast connections don't flash. is delay-gated (~250ms) so fast connections don't flash.
- **Elm-style store** (`store.ts``createStore(initial, reduce)`) — all state in - **Elm-style store** (`store.ts``createStore(initial, reduce)`) — all state in
one Model; change only by `dispatch(msg)`**pure** `reduce(model, msg)`. Models one Model; change only by `dispatch(msg)`**pure** `reduce(model, msg)`. Models
are tagged unions (see `herregistratie.machine.ts`, `intake.machine.ts`). Templates 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 - **`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/`: branded type only via a parser returning `Result` (`registratie/domain/value-objects/`:
`Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it. `Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it.
@@ -113,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 Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters). spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
Test the pure function directly — no Angular TestBed for domain. UI is exercised via 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), Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests.
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 ## Conventions
@@ -138,6 +151,11 @@ not heavy component tests.
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the (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 same shape as the wizards, whether it's one step or many. Don't hand-roll mutable
fields + ad-hoc error signals. 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: - Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
[authGuard]` on protected routes (`app.routes.ts`). [authGuard]` on protected routes (`app.routes.ts`).
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under - Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under

113
README.md
View File

@@ -2,14 +2,15 @@
A small Angular app that shows how **atomic design** makes a frontend cheap to build, 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 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 register of healthcare professionals, run by CIBG). It is styled with the **CIBG
app, branded **Rijkshuisstijl**, and demonstrates a robust **async-state pattern** where Huisstijl** design system (a customized Bootstrap 5.2 build, vendored — see ADR-0003),
the UI can never reach an inconsistent state. 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 > 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 > (`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. > 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 storybook # component library, organized by atomic layer
npm run gen:api # regenerate the typed API client from the backend OpenAPI doc 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**. 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: Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state:
| URL | What you see | | URL | What you see |
|-----|--------------| | ----------------------------- | -------------------------------------------- |
| `/dashboard` | real data (fast) | | `/dashboard` | real data (fast) |
| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data | | `/dashboard?scenario=slow` | skeletons for ~2.5s, then data |
| `/dashboard?scenario=loading` | the loading state, held open | | `/dashboard?scenario=loading` | the loading state, held open |
| `/dashboard?scenario=empty` | "geen gegevens" empty state | | `/dashboard?scenario=empty` | "geen gegevens" empty state |
| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) | | `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) |
--- ---
## How atomic design works here (folder = layer) ## How atomic design works here (folder = layer)
Atomic design organizes UI into five layers, each built from the one below. In this repo 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 | | 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` | | **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) | | **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` | | **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) | | **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` | | **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 Each atom is a thin Angular standalone component that applies CIBG Huisstijl
CSS classes — so the design system does the visual work and we only own a small, typed (Bootstrap 5.2) CSS classes (`btn`, `form-control`, `card`, …) — so the design system
component API. 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.** **1. Reuse — the same blocks appear everywhere.**
| Component | Appears in | | Component | Appears in |
|-----------|-----------| | ----------------------------- | ------------------------------------------------------------- |
| `button` | login, change-request, herregistratie, async retry, Storybook | | `button` | login, change-request, herregistratie, async retry, Storybook |
| `form-field` + `text-input` | login form *and* change-request *and* herregistratie | | `form-field` + `text-input` | login form _and_ change-request _and_ herregistratie |
| `status-badge` | dashboard summary, detail summary | | `status-badge` | dashboard summary, detail summary |
| `page-shell` / `page-layout` | all four pages | | `page-shell` / `page-layout` | all four pages |
| `site-header` / `site-footer` | every page | | `site-header` / `site-footer` | every page |
| `async` + `skeleton` | dashboard, detail | | `async` + `skeleton` | dashboard, detail |
Change a component once and every screen that uses it updates. 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` 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="…">…`. captures that once; pages now read like `<app-page-shell heading="…" backLink="…">…`.
**4. Theming is one import.** **4. Theming is one stylesheet + a token bridge.**
The look comes from `@rijkshuisstijl-community/design-tokens`. `src/styles.scss` imports The look comes from **CIBG Huisstijl**, vendored under `public/cibg-huisstijl/` and
the `lintblauw` palette and applies `rhc-theme lintblauw` on `<body>`. Swap the palette loaded via a `<link>` in `index.html`; `body.brand--cibg` activates CIBG's
import to re-theme the whole app — no component changes. 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 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 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. time, or show stale content during a hard failure: those states are unrepresentable.
```html ```html
<app-async [resource]="reg" [isEmpty]="regEmpty"> <app-async [resource]="reg" [isEmpty]="regEmpty">
<ng-template appAsyncLoaded let-r> <app-registration-summary [reg]="r" /> </ng-template> <ng-template appAsyncLoaded let-r> <app-registration-summary [reg]="r" /> </ng-template>
<ng-template appAsyncLoading> <app-skeleton [count]="6" /> </ng-template> <ng-template appAsyncLoading> <app-skeleton [count]="6" /> </ng-template>
<!-- appAsyncEmpty / appAsyncError are optional → sensible defaults --> <!-- appAsyncEmpty / appAsyncError are optional → sensible defaults -->
</app-async> </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. - **Loaded** — your content, with the value.
- **Loading** — your skeleton, or a default **delayed spinner** (only appears after - **Loading** — your skeleton, or a default **delayed spinner** (only appears after
~250ms, so fast connections never flash a spinner; slow ones get feedback). Skeletons ~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 - **Empty** — your message, or a default "Geen gegevens gevonden" (driven by an
`isEmpty` predicate). `isEmpty` predicate).
- **Error** — your template, or a default alert + a **retry** button that calls - **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, - Angular 22 (standalone components, signals, `httpResource`, view transitions,
control flow `@if/@for`). control flow `@if/@for`).
- Styling: `@rijkshuisstijl-community/{design-tokens,components-css}` (Utrecht + RHC CSS, - Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
pre-themed Rijkshuisstijl) — imported in `src/styles.scss`, no hand-written theme. `public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
- Data: ASP.NET Core backend (`backend/`, in-memory seeded) exposed via an OpenAPI `--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
contract; the FE consumes an **NSwag-generated** typed client (`npm run gen:api`). - 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 The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
**dev-only** — it is not wired into production builds. **dev-only** — it is not wired into production builds.
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags - `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
Angular 22; the builder runs fine (build verified). 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 ### 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. We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21.
### Deliberately out of scope (POC) ### 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": "", "root": "",
"sourceRoot": "src", "sourceRoot": "src",
"prefix": "app", "prefix": "app",
"i18n": {
"sourceLocale": "nl",
"locales": {
"en": {
"translation": "src/locale/messages.en.xlf"
}
}
},
"architect": { "architect": {
"build": { "build": {
"builder": "@angular/build:application", "builder": "@angular/build:application",
@@ -31,7 +39,8 @@
} }
], ],
"styles": ["src/styles.scss"], "styles": ["src/styles.scss"],
"polyfills": ["@angular/localize/init"] "polyfills": ["@angular/localize/init"],
"i18nMissingTranslation": "error"
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -59,6 +68,9 @@
"optimization": false, "optimization": false,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true "sourceMap": true
},
"en": {
"localize": ["en"]
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production"
@@ -71,6 +83,9 @@
}, },
"development": { "development": {
"buildTarget": "atomic-design-poc:build:development" "buildTarget": "atomic-design-poc:build:development"
},
"en": {
"buildTarget": "atomic-design-poc:build:development,en"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"

5
backend/.gitignore vendored
View File

@@ -1,2 +1,7 @@
bin/ bin/
obj/ 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 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`). (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`), No real BRP/DUO: the reference data they'd return (registration, person, diplomas,
but the endpoints, DTOs, status codes and error envelope are production-shaped. 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 ## Run

View File

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

View File

@@ -7,6 +7,15 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <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" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup> </ItemGroup>

View File

@@ -147,7 +147,47 @@ public sealed record BriefDto(
IReadOnlyList<LetterSectionDto> Sections, IReadOnlyList<LetterSectionDto> Sections,
BriefStatusDto Status, string DrafterId); 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 SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
public sealed record RejectBriefRequest(string Comments); 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> /// <summary>Domain → wire DTO mapping (the anti-corruption boundary, server side).</summary>
public static class Mappers 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( public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new(
Tag: s.Tag.ToString(), Tag: s.Tag.ToString(),
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null, HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null, GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
Reden: s.Reden, Reden: s.Reden,
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null); DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
public static RegistrationDto ToDto(this Registration r) => new( public static RegistrationDto ToDto(this Registration r) => new(
r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto()); 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( public static PolicyQuestionDto ToDto(this PolicyQuestion q) => new(
q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst"); q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst");
public static DuoDiplomaDto ToDto(this Diploma d) => new( public static DuoDiplomaDto ToDto(this Diploma d) => new(
d.Id, d.Naam, d.Instelling, d.Jaar, d.Id, d.Naam, d.Instelling, d.Jaar,
DiplomaRules.ProfessionFor(d), DiplomaRules.ProfessionFor(d),
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList()); DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList());
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new( public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery); 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 // Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
// Goedgekeurd once past the processing window, else In behandeling; a manual case // Goedgekeurd once past the processing window, else In behandeling; a manual case
// stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable // stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable
// by passing different `now` values without waiting for the wall clock. // by passing different `now` values without waiting for the wall clock.
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
{ {
if (!a.Submitted) if (!a.Submitted)
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount); return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
if (a.Reden is not null) if (a.Reden is not null)
return new("Afgewezen", Referentie: a.Referentie, Reden: a.Reden); return new("Afgewezen", Referentie: a.Referentie, Reden: a.Reden);
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow) if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
return new("Goedgekeurd", Referentie: a.Referentie); return new("Goedgekeurd", Referentie: a.Referentie);
return new("InBehandeling", Referentie: a.Referentie, Manual: !a.AutoApprovable); return new("InBehandeling", Referentie: a.Referentie, Manual: !a.AutoApprovable);
} }
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new( public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds, a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o")); a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new( public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds, a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o")); 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> /// </summary>
public sealed class Aanvraag public sealed class Aanvraag
{ {
public required string Id { get; init; } public required string Id { get; init; }
public required string Type { get; init; } // registratie | herregistratie | intake public required string Type { get; init; } // registratie | herregistratie | intake
public required string Owner { get; init; } public required string Owner { get; init; }
public JsonElement? Draft { get; set; } // opaque wizard machine snapshot (Concept only) public JsonElement? Draft { get; set; } // opaque wizard machine snapshot (Concept only)
public int StepIndex { get; set; } public int StepIndex { get; set; }
public int StepCount { get; set; } public int StepCount { get; set; }
public List<string> DocumentIds { get; set; } = new(); public List<string> DocumentIds { get; set; } = new();
public string? Referentie { get; set; } // set on submit public string? Referentie { get; set; } // set on submit
public bool AutoApprovable { get; set; } // set on submit: duo (registratie) / other types public bool AutoApprovable { get; set; } // set on submit: duo (registratie) / other types
public string? Reden { get; set; } // set on submit when rejected → Afgewezen public string? Reden { get; set; } // set on submit when rejected → Afgewezen
public bool Submitted { get; set; } public bool Submitted { get; set; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? SubmittedAt { get; set; } public DateTimeOffset? SubmittedAt { get; set; }
} }
/// <summary> /// <summary>
/// In-memory application store (no DB), mirrors <see cref="DocumentStore"/>. /// EF Core/SQLite-backed application store (WP-22 — was a static Dictionary),
/// ponytail: one global lock — fine for a single-process demo; swap for per-key /// mirrors <see cref="DocumentStore"/>. ponytail: one global lock — SQLite
/// locks if it ever serves load. /// tolerates only one writer at a time anyway, and this was already a single
/// coarse gate before the DB existed.
/// </summary> /// </summary>
public static class ApplicationStore public static class ApplicationStore
{ {
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read). /// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8); 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; using var db = Db.Create();
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now }; db.Applications.Add(a);
lock (_gate) _apps[a.Id] = a; db.SaveChanges();
return a;
} }
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. /// 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) public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
{
lock (_gate)
{ {
lock (_gate) using var db = Db.Create();
{ var a = db.Applications.Find(id);
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false; 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.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
a.StepIndex = stepIndex; a.StepIndex = stepIndex;
a.StepCount = stepCount; a.StepCount = stepCount;
if (documentIds is not null) a.DocumentIds = documentIds.ToList(); if (documentIds is not null) a.DocumentIds = documentIds.ToList();
a.UpdatedAt = DateTimeOffset.UtcNow; a.UpdatedAt = DateTimeOffset.UtcNow;
return true; db.SaveChanges();
} return true;
} }
}
/// Cancel a Concept: remove it and delete its (unlinked) documents. Linked docs /// Cancel a Concept: remove it and delete its (unlinked) documents. Linked docs
/// (belonging to a submitted aanvraag) are left untouched by DocumentStore. /// (belonging to a submitted aanvraag) are left untouched by DocumentStore.
public static bool Delete(string id, string owner) public static bool Delete(string id, string owner)
{
List<string> docs;
lock (_gate)
{ {
List<string> docs; using var db = Db.Create();
lock (_gate) var a = db.Applications.Find(id);
{ if (a is null || a.Owner != owner) return false;
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner) return false; docs = a.DocumentIds.ToList();
docs = a.DocumentIds.ToList(); db.Applications.Remove(a);
_apps.Remove(id); db.SaveChanges();
}
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
return true;
} }
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
return true;
}
/// Submit transition. reject != null → Afgewezen; else accepted (In behandeling, /// Submit transition. reject != null → Afgewezen; else accepted (In behandeling,
/// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null /// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null
/// if the aanvraag is gone or already submitted (idempotency guard). /// 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) public static Aanvraag? Submit(string id, string owner, string? reject, bool autoApprovable, IReadOnlyList<string>? documentIds)
{
lock (_gate)
{ {
lock (_gate) using var db = Db.Create();
{ var a = db.Applications.Find(id);
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return null; if (a is null || a.Owner != owner || a.Submitted) return null;
a.Submitted = true; a.Submitted = true;
a.SubmittedAt = DateTimeOffset.UtcNow; a.SubmittedAt = DateTimeOffset.UtcNow;
a.UpdatedAt = a.SubmittedAt.Value; a.UpdatedAt = a.SubmittedAt.Value;
a.Referentie = SubmissionRules.NewReference(); a.Referentie = SubmissionRules.NewReference();
a.AutoApprovable = autoApprovable; a.AutoApprovable = autoApprovable;
a.Reden = reject; a.Reden = reject;
if (documentIds is not null) a.DocumentIds = documentIds.ToList(); if (documentIds is not null) a.DocumentIds = documentIds.ToList();
return a; db.SaveChanges();
} return a;
} }
}
} }

View File

@@ -1,144 +1,190 @@
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Domain.Authorization;
using BigRegister.Domain.Letters;
using Microsoft.EntityFrameworkCore;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// The letter (brief) — one demo brief per owner, created from a template on first /// 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 /// read. EF Core/SQLite-backed (WP-22 — was a static Dictionary), mirrors
/// its guards live here (the server is authoritative for transitions); the FE mirrors /// <see cref="ApplicationStore"/>. The status machine and its guards live here (the
/// them in its pure reducer for UX. Rich-text content is stored opaquely as DTOs — the /// server is authoritative for transitions); the FE mirrors them in its pure reducer
/// stub does not interpret it (no server-side placeholder linting in this slice). /// 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> /// </summary>
public sealed class BriefEntity public sealed class BriefEntity
{ {
public required string BriefId { get; init; } public required string BriefId { get; init; }
public required string Owner { get; init; } public required string Owner { get; init; }
public required string Beroep { get; init; } public required string Beroep { get; init; }
public required string TemplateId { get; init; } public required string TemplateId { get; init; }
public required string DrafterId { get; init; } public required string DrafterId { get; init; }
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; } public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
public List<LetterSectionDto> Sections { get; set; } = new(); public List<LetterSectionDto> Sections { get; set; } = new();
public BriefStatusDto Status { get; set; } = new("draft"); 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 public static class BriefStore
{ {
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle). // Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
public const string DrafterId = "demo-drafter"; public const string DrafterId = "demo-drafter";
public const string ApproverId = "demo-approver"; 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) using var db = Db.Create();
{ var existing = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (_byOwner.TryGetValue(owner, out var existing)) return existing; if (existing is not null) return existing;
var created = BriefSeed.NewBrief(owner); var created = BriefSeed.NewBrief(owner);
_byOwner[owner] = created; db.Briefs.Add(created);
return created; db.SaveChanges();
} return created;
} }
}
/// Save draft content. Drafter-only; only editable in draft/rejected; a rejected /// Save draft content. Drafter-only; only editable in draft/rejected; a rejected
/// letter reopens to draft on edit (mirrors the FE reducer). /// letter reopens to draft on edit (mirrors the FE reducer).
public static (Outcome, BriefEntity?) Save(string owner, IReadOnlyList<LetterSectionDto> sections, bool isDrafter) public static (Outcome, BriefEntity?) Save(string owner, IReadOnlyList<LetterSectionDto> sections, bool isDrafter)
{
lock (_gate)
{ {
lock (_gate) using var db = Db.Create();
{ var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); if (e is null) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null); if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null); if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
e.Sections = sections.ToList(); e.Sections = sections.ToList();
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft"); if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
return (Outcome.Ok, e); 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) using var db = Db.Create();
{ var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); if (e is null) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null); if (!isDrafter) return (Outcome.Forbidden, null);
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null); if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at); e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
return (Outcome.Ok, e); db.SaveChanges();
} return (Outcome.Ok, e);
} }
}
public static (Outcome, BriefEntity?) Approve(string owner, string actingId, string at) => public static (Outcome, BriefEntity?) Approve(string owner, Principal principal, string at) =>
Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, ApprovedAt: 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) => public static (Outcome, BriefEntity?) Reject(string owner, Principal principal, string comments, string at) =>
Review(owner, actingId, e => new BriefStatusDto("rejected", RejectedBy: actingId, RejectedAt: at, Comments: comments)); 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) using var db = Db.Create();
{ var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); if (e is null) return (Outcome.Conflict, null);
if (e.Status.Tag != "approved") return (Outcome.Conflict, null); if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("sent", SentAt: at); e.Status = new BriefStatusDto("sent", SentAt: at);
return (Outcome.Ok, e); // 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. /// Test seam: clear the demo brief between tests.
public static void Reset() 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 /// 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. /// "start over" for the showcase, not a real workflow transition.
public static BriefEntity ResetAndCreate(string owner) public static BriefEntity ResetAndCreate(string owner)
{
lock (_gate)
{ {
lock (_gate) using var db = Db.Create();
{ db.Briefs.Where(e => e.Owner == owner).ExecuteDelete();
_byOwner.Remove(owner); var created = BriefSeed.NewBrief(owner);
var created = BriefSeed.NewBrief(owner); db.Briefs.Add(created);
_byOwner[owner] = created; db.SaveChanges();
return created; return created;
}
} }
}
// Approve/reject share the guard: must be submitted, and the approver must differ // Approve/reject share the guard: must be submitted, and the approver must differ
// from the drafter (a drafter cannot approve their own letter). // from the drafter (a drafter cannot approve their own letter). The SoD check is
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next) // 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) using var db = Db.Create();
{ var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); if (e is null) return (Outcome.Conflict, null);
if (actingId == e.DrafterId) return (Outcome.Forbidden, null); if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null); if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
e.Status = next(e); e.Status = next();
return (Outcome.Ok, e); 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> /// <summary>Seeded template (sections + placeholder fields) and passage library.</summary>
public static class BriefSeed public static class BriefSeed
{ {
public const string TemplateId = "besluit-arts"; public const string TemplateId = "besluit-arts";
public const string Beroep = "arts"; public const string Beroep = "arts";
private static RichTextNodeDto T(string t) => new("text", Text: t); private static RichTextNodeDto T(string t) => new("text", Text: t);
private static RichTextNodeDto P(string key) => new("placeholder", Key: key); private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
private static RichTextBlockDto Block(params RichTextNodeDto[] nodes) => new(new[] { new ParagraphDto(nodes) }); 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: // The template's valid placeholder fields. Two are flagged to exercise the linter:
// a deprecated field and one not fillable for this beroep. // a deprecated field and one not fillable for this beroep.
private static readonly IReadOnlyList<PlaceholderDefDto> Placeholders = new[] private static readonly IReadOnlyList<PlaceholderDefDto> Placeholders = new[]
{ {
new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true), new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true),
new PlaceholderDefDto("datum", "Datum", true), new PlaceholderDefDto("datum", "Datum", true),
new PlaceholderDefDto("big_nummer", "BIG-nummer", true), new PlaceholderDefDto("big_nummer", "BIG-nummer", true),
@@ -147,18 +193,18 @@ public static class BriefSeed
new PlaceholderDefDto("specialisme_code", "Specialismecode", true, Fillable: false), new PlaceholderDefDto("specialisme_code", "Specialismecode", true, Fillable: false),
}; };
public static BriefEntity NewBrief(string owner) => new() public static BriefEntity NewBrief(string owner) => new()
{ {
BriefId = Guid.NewGuid().ToString(), BriefId = Guid.NewGuid().ToString(),
Owner = owner, Owner = owner,
Beroep = Beroep, Beroep = Beroep,
TemplateId = TemplateId, TemplateId = TemplateId,
DrafterId = BriefStore.DrafterId, DrafterId = BriefStore.DrafterId,
Placeholders = Placeholders, Placeholders = Placeholders,
// aanhef + slot are predefined, locked template text (prefilled); the drafter // aanhef + slot are predefined, locked template text (prefilled); the drafter
// composes only the unlocked `kern`. Save trusts the FE not to mutate locked // 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 (FE-authoritative for this POC — the FE reducer also refuses it).
Sections = new() Sections = new()
{ {
new("aanhef", "Aanhef", true, new List<LetterBlockDto> new("aanhef", "Aanhef", true, new List<LetterBlockDto>
{ {
@@ -170,13 +216,13 @@ public static class BriefSeed
new("freeText", "slot-1", Block(T("Met vriendelijke groet,"))), new("freeText", "slot-1", Block(T("Met vriendelijke groet,"))),
}, Locked: true), }, Locked: true),
}, },
Status = new BriefStatusDto("draft"), Status = new BriefStatusDto("draft"),
}; };
/// Global passages plus those scoped to the given beroep. /// Global passages plus those scoped to the given beroep.
public static IReadOnlyList<LibraryPassageDto> PassagesFor(string beroep) public static IReadOnlyList<LibraryPassageDto> PassagesFor(string beroep)
{ {
var all = new List<LibraryPassageDto> var all = new List<LibraryPassageDto>
{ {
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef", new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1), 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)", new("p-slot-code", "global", "slot", "Specialismecode (niet invulbaar)",
Block(T("Specialisme: "), P("specialisme_code"), T(".")), 1), 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; namespace BigRegister.Api.Data;
/// <summary> /// <summary>
/// Stored document: metadata + bytes. The demo holds bytes IN-MEMORY (reset on /// Stored document: metadata + bytes. The demo persists bytes in the SQLite file
/// restart) purely so re-opened wizards can preview/download what was uploaded — a /// (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 /// 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. /// serialized into a JSON response; only the dedicated content endpoint streams them.
/// </summary> /// </summary>
@@ -10,93 +10,137 @@ public sealed record StoredDocument(
string DocumentId, string LocalId, string CategoryId, string WizardId, string DocumentId, string LocalId, string CategoryId, string WizardId,
string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt) 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> /// <summary>
/// In-memory document store + audit log (no DB). ponytail: one global lock — fine /// EF Core/SQLite-backed document store + audit log (WP-22 — was a static
/// for a single-process demo store; swap for per-key locks if it ever serves load. /// Dictionary). ponytail: one global lock, same as before — SQLite tolerates only
/// The audit log holds metadata only (never file content or other PII). /// 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> /// </summary>
public static class DocumentStore public static class DocumentStore
{ {
/// The single seeded user (the demo has no real auth; ownership = this id). /// The single seeded user (the demo has no real auth; ownership = this id).
public const string DemoOwner = "19012345601"; public const string DemoOwner = "19012345601";
private static readonly Dictionary<string, StoredDocument> _docs = new(); private static readonly object _gate = new();
private static readonly List<AuditEntry> _audit = 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); using var db = Db.Create();
lock (_gate) _docs[doc.DocumentId] = doc; db.Documents.Add(doc);
Audit("upload", doc.DocumentId, categoryId, owner); db.SaveChanges();
return doc;
} }
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 /// Status for the poll-on-return pattern: a known localId is "complete" (it
/// arrived), an unknown one is still in flight / never started. /// arrived), an unknown one is still in flight / never started.
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds) public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
{
var set = localIds.ToHashSet();
lock (_gate)
{ {
var set = localIds.ToHashSet(); using var db = Db.Create();
lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList(); return db.Documents.Where(d => set.Contains(d.LocalId)).ToList();
} }
}
/// Mark digital documents as linked to a finalised submission (blocks user delete). /// Mark digital documents as linked to a finalised submission (blocks user delete).
public static void Link(IEnumerable<string> documentIds) public static void Link(IEnumerable<string> documentIds)
{
lock (_gate)
{ {
lock (_gate) using var db = Db.Create();
foreach (var id in documentIds) foreach (var id in documentIds)
if (_docs.TryGetValue(id, out var d)) d.Linked = true; {
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. /// User delete: owner-scoped; blocked once linked to a finalised submission.
public static DeleteResult DeleteOwned(string documentId, string owner) public static DeleteResult DeleteOwned(string documentId, string owner)
{
string categoryId;
lock (_gate)
{ {
string categoryId; using var db = Db.Create();
lock (_gate) var d = db.Documents.Find(documentId);
{ if (d is null || d.Owner != owner) return DeleteResult.NotFound;
if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound; if (d.Linked) return DeleteResult.Linked;
if (d.Linked) return DeleteResult.Linked; categoryId = d.CategoryId;
categoryId = d.CategoryId; db.Documents.Remove(d);
_docs.Remove(documentId); db.SaveChanges();
}
Audit("delete-user", documentId, categoryId, owner);
return DeleteResult.Ok;
} }
Audit("delete-user", documentId, categoryId, owner);
return DeleteResult.Ok;
}
/// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for /// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for
/// review so a caseworker is notified. Returns false if the document is unknown. /// review so a caseworker is notified. Returns false if the document is unknown.
public static bool AdminDelete(string documentId, string actor) public static bool AdminDelete(string documentId, string actor)
{
string categoryId;
lock (_gate)
{ {
string categoryId; using var db = Db.Create();
lock (_gate) var d = db.Documents.Find(documentId);
{ if (d is null) return false;
if (!_docs.TryGetValue(documentId, out var d)) return false; categoryId = d.CategoryId;
categoryId = d.CategoryId; db.Documents.Remove(d);
_docs.Remove(documentId); db.SaveChanges();
}
Audit("delete-admin", documentId, categoryId, actor);
return true;
} }
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> /// </summary>
public static class SeedData public static class SeedData
{ {
public static readonly Registration Registration = new( public static readonly Registration Registration = new(
BigNummer: "19012345601", BigNummer: "19012345601",
Naam: "Dr. A. (Anna) de Vries", Naam: "Dr. A. (Anna) de Vries",
Beroep: "Arts", Beroep: "Arts",
Registratiedatum: new DateOnly(2012, 9, 1), Registratiedatum: new DateOnly(2012, 9, 1),
Geboortedatum: new DateOnly(1985, 3, 14), Geboortedatum: new DateOnly(1985, 3, 14),
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))); Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
public static readonly Person Person = new( public static readonly Person Person = new(
Naam: "Dr. A. (Anna) de Vries", Naam: "Dr. A. (Anna) de Vries",
Geboortedatum: new DateOnly(1985, 3, 14), Geboortedatum: new DateOnly(1985, 3, 14),
Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag")); Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag"));
/// <summary>The address BRP returns for the seeded citizen.</summary> /// <summary>The address BRP returns for the seeded citizen.</summary>
public static readonly Adres BrpAddress = Person.Adres; 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("d1", "Geneeskunde", "Universiteit Leiden", 2011, "geneeskunde", Engelstalig: false),
new Diploma("d2", "Medicine (MBChB)", "University of Edinburgh", 2013, "geneeskunde", Engelstalig: true), new Diploma("d2", "Medicine (MBChB)", "University of Edinburgh", 2013, "geneeskunde", Engelstalig: true),
new Diploma("d3", "HBO-Verpleegkunde", "Hogeschool Utrecht", 2016, "verpleegkunde", Engelstalig: false), 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"), ("Specialisme", "Huisartsgeneeskunde", "2016-04-12"),
("Aantekening", "Erkend opleider huisartsgeneeskunde", "2019-01-08"), ("Aantekening", "Erkend opleider huisartsgeneeskunde", "2019-01-08"),
("Specialisme", "Spoedeisende hulp (kaderopleiding)", "2021-06-30"), ("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 public enum QuestionType
{ {
JaNee, JaNee,
Tekst, Tekst,
} }
public sealed record PolicyQuestion(string Id, string Vraag, QuestionType Type); public sealed record PolicyQuestion(string Id, string Vraag, QuestionType Type);

View File

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

View File

@@ -17,67 +17,75 @@ public sealed record DocumentCategory(
public static class DocumentRules public static class DocumentRules
{ {
private static readonly string[] Pdf = { "application/pdf" }; private static readonly string[] Pdf = { "application/pdf" };
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" }; 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", private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false); "Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
private static readonly DocumentCategory Identiteit = new("identiteit", "Identiteitsbewijs", private static readonly DocumentCategory Identiteit = new("identiteit", "Identiteitsbewijs",
"Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true); "Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true);
private static readonly DocumentCategory Taalvaardigheid = new("taalvaardigheid", "Bewijs Nederlandse taalvaardigheid", 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); "Upload een bewijs van uw Nederlandse taalvaardigheid op het vereiste niveau (B2).", true, PdfImage, 10, false, true);
/// <summary> /// <summary>
/// The MAXIMAL set of categories a wizard can ever ask for. Used to validate an /// 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"/> /// upload POST (any real category must resolve) — <see cref="CategoriesFor"/>
/// decides which subset is actually presented for a given set of answers. /// decides which subset is actually presented for a given set of answers.
/// </summary> /// </summary>
public static IReadOnlyList<DocumentCategory> AllCategoriesFor(string wizardId) => wizardId switch 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", new DocumentCategory("werkervaring", "Bewijs van werkervaring",
"Upload bewijs van uw gewerkte uren (bijv. een werkgeversverklaring).", true, Pdf, 10, true, true), "Upload bewijs van uw gewerkte uren (bijv. een werkgeversverklaring).", true, Pdf, 10, true, true),
new DocumentCategory("nascholing", "Nascholingscertificaten", new DocumentCategory("nascholing", "Nascholingscertificaten",
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true), "Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
}, },
_ => Array.Empty<DocumentCategory>(), // 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[]
/// <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); new DocumentCategory("org-logo", "Organisatielogo",
var result = new List<DocumentCategory>(); "Upload het logo van de organisatie (PNG of JPG).", false, Image, 1, false, false),
if (diplomaHerkomst == "handmatig") result.Add(Diploma); },
result.Add(Identiteit); _ => Array.Empty<DocumentCategory>(),
if (taalvaardigheid == "ja") result.Add(Taalvaardigheid); };
return result;
}
public static DocumentCategory? Find(string wizardId, string categoryId) => /// <summary>
AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId); /// 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> public static DocumentCategory? Find(string wizardId, string categoryId) =>
/// Authoritative upload validation (the client check is UX-only). Returns a AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
/// rejection reason, or null when the file is acceptable.
/// </summary> /// <summary>
public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes) /// Authoritative upload validation (the client check is UX-only). Returns a
{ /// rejection reason, or null when the file is acceptable.
if (category is null) return "Onbekende documentcategorie."; /// </summary>
if (!category.AcceptedTypes.Contains(contentType)) public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes)
return $"Bestandstype niet toegestaan voor {category.Label}."; {
if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024) if (category is null) return "Onbekende documentcategorie.";
return $"Bestand is groter dan {category.MaxSizeMb} MB."; if (!category.AcceptedTypes.Contains(contentType))
return null; 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> /// </summary>
public static class IntakePolicy 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> /// </summary>
public static class HerregistratieRule public static class HerregistratieRule
{ {
public const int WindowMonths = 12; public const int WindowMonths = 12;
public static DateOnly? Deadline(Registration reg) => public static DateOnly? Deadline(Registration reg) =>
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null; reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
public static (bool Eligible, string? Reason) Evaluate( public static (bool Eligible, string? Reason) Evaluate(
Registration reg, DateOnly today, int windowMonths = WindowMonths) Registration reg, DateOnly today, int windowMonths = WindowMonths)
{ {
var deadline = Deadline(reg); var deadline = Deadline(reg);
if (deadline is null) if (deadline is null)
return (false, "Geen actieve registratie."); return (false, "Geen actieve registratie.");
var windowStart = deadline.Value.AddMonths(-windowMonths); var windowStart = deadline.Value.AddMonths(-windowMonths);
return today >= windowStart return today >= windowStart
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).") ? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}."); : (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
} }
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary> /// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
public static bool IsStatusConsistent(RegistrationStatus s) => public static bool IsStatusConsistent(RegistrationStatus s) =>
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null; 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> /// <summary>The three states a BIG registration can be in.</summary>
public enum StatusTag public enum StatusTag
{ {
Geregistreerd, Geregistreerd,
Geschorst, Geschorst,
Doorgehaald, Doorgehaald,
} }
/// <summary> /// <summary>

View File

@@ -9,29 +9,29 @@ namespace BigRegister.Domain.Submissions;
/// </summary> /// </summary>
public static class SubmissionRules public static class SubmissionRules
{ {
// RULE: a manually entered diploma cannot be auto-verified. // RULE: a manually entered diploma cannot be auto-verified.
public static string? RejectRegistratie(string diplomaHerkomst) => public static string? RejectRegistratie(string diplomaHerkomst) =>
diplomaHerkomst == "handmatig" diplomaHerkomst == "handmatig"
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling." ? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
: null; : null;
// RULE: an application reporting zero worked hours is rejected. // RULE: an application reporting zero worked hours is rejected.
public static string? RejectZeroUren(int uren) => public static string? RejectZeroUren(int uren) =>
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null; uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
private static readonly Regex PostcodePattern = private static readonly Regex PostcodePattern =
new(@"^[1-9]\d{3}\s?[A-Z]{2}$", RegexOptions.IgnoreCase | RegexOptions.Compiled); 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 // 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). // server re-validates format authoritatively (the FE check is UX-only).
public static string? RejectChangeRequest(string straat, string postcode) public static string? RejectChangeRequest(string straat, string postcode)
{ {
if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in."; if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in.";
if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB."; if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB.";
return null; return null;
} }
public static string NewReference() => public static string NewReference() =>
// ponytail: random reference is fine for a demo; a real system reserves it transactionally. // ponytail: random reference is fine for a demo; a real system reserves it transactionally.
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000); "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 System.Text.Json.Serialization;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
using BigRegister.Domain.Diplomas; using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents; using BigRegister.Domain.Documents;
using BigRegister.Domain.Intake; using BigRegister.Domain.Intake;
using BigRegister.Domain.Letters;
using BigRegister.Domain.Registrations; using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions; using BigRegister.Domain.Submissions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Console;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -16,16 +20,49 @@ builder.Services.AddSwaggerGen(c =>
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.ConfigureHttpJsonOptions(o => builder.Services.ConfigureHttpJsonOptions(o =>
{ {
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; 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"; const string SpaCors = "spa";
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p => builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod())); 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(); 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.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
app.UseCors(SpaCors); app.UseCors(SpaCors);
@@ -42,10 +79,10 @@ var api = app.MapGroup("/api/v1");
api.MapGet("/dashboard-view", () => api.MapGet("/dashboard-view", () =>
{ {
var reg = SeedData.Registration; var reg = SeedData.Registration;
var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today)); var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(), return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
new HerregistratieDecisionsDto(eligible, reason)); new HerregistratieDecisionsDto(eligible, reason));
}); });
api.MapGet("/notes", () => 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). // and size authoritatively; stores metadata only (no file bytes / PII held).
api.MapPost("/uploads", async (HttpRequest request) => api.MapPost("/uploads", async (HttpRequest request) =>
{ {
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400); if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
var form = await request.ReadFormAsync(); var form = await request.ReadFormAsync();
var file = form.Files.GetFile("file"); var file = form.Files.GetFile("file");
string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString(); string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
if (file is null || categoryId == "" || localId == "" || wizardId == "") if (file is null || categoryId == "" || localId == "" || wizardId == "")
return Results.Problem(detail: "Onvolledige upload.", statusCode: 400); return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
var category = DocumentRules.Find(wizardId, categoryId); var category = DocumentRules.Find(wizardId, categoryId);
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length); var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400); if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
using var ms = new MemoryStream(); using var ms = new MemoryStream();
await file.CopyToAsync(ms); await file.CopyToAsync(ms);
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner); 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)); return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId));
}) })
.ExcludeFromDescription(); .ExcludeFromDescription();
@@ -118,10 +155,10 @@ api.MapPost("/uploads", async (HttpRequest request) =>
// for pdf/image (browser renders it), attachment otherwise (download). // for pdf/image (browser renders it), attachment otherwise (download).
api.MapGet("/uploads/{documentId}/content", (string documentId) => api.MapGet("/uploads/{documentId}/content", (string documentId) =>
{ {
var doc = DocumentStore.Get(documentId); var doc = DocumentStore.Get(documentId);
if (doc is null) return Results.NotFound(); if (doc is null) return Results.NotFound();
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/"); var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName); return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
}) })
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound); .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. // Poll-on-return: which of these client localIds have arrived at the BFF.
api.MapGet("/uploads/status", (string? localIds) => api.MapGet("/uploads/status", (string? localIds) =>
{ {
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId); var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
var results = ids.Select(id => found.TryGetValue(id, out var d) var results = ids.Select(id => found.TryGetValue(id, out var d)
? new UploadStatusItemDto(id, "complete", d.DocumentId) ? new UploadStatusItemDto(id, "complete", d.DocumentId)
: new UploadStatusItemDto(id, "unknown", null)).ToList(); : new UploadStatusItemDto(id, "unknown", null)).ToList();
return new UploadStatusDto(results); return new UploadStatusDto(results);
}); });
// User delete: owner-scoped; 409 once linked to a finalised submission. // User delete: owner-scoped; 409 once linked to a finalised submission.
api.MapDelete("/uploads/{documentId}", (string documentId) => api.MapDelete("/uploads/{documentId}", (string documentId) =>
DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch
{ {
DocumentStore.DeleteResult.Ok => Results.NoContent(), DocumentStore.DeleteResult.Ok => Results.NoContent(),
DocumentStore.DeleteResult.Linked => Results.Problem( DocumentStore.DeleteResult.Linked => Results.Problem(
detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.", detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.",
statusCode: StatusCodes.Status409Conflict), statusCode: StatusCodes.Status409Conflict),
_ => Results.NotFound(), _ => Results.NotFound(),
}) })
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status409Conflict)
@@ -164,10 +201,10 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx
api.MapGet("/applications", () => api.MapGet("/applications", () =>
{ {
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
return ApplicationStore.List(DocumentStore.DemoOwner) return ApplicationStore.List(DocumentStore.DemoOwner)
.OrderByDescending(a => a.UpdatedAt) .OrderByDescending(a => a.UpdatedAt)
.Select(a => a.ToSummaryDto(now)).ToList(); .Select(a => a.ToSummaryDto(now)).ToList();
}); });
api.MapGet("/applications/{id}", (string id) => api.MapGet("/applications/{id}", (string id) =>
@@ -179,8 +216,8 @@ api.MapGet("/applications/{id}", (string id) =>
api.MapPost("/applications", (CreateApplicationRequest req) => api.MapPost("/applications", (CreateApplicationRequest req) =>
{ {
var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner); var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner);
return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow)); return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow));
}) })
.Produces<ApplicationDetailDto>(StatusCodes.Status201Created); .Produces<ApplicationDetailDto>(StatusCodes.Status201Created);
@@ -195,12 +232,12 @@ api.MapPut("/applications/{id}", (string id, DraftSyncRequest req) =>
// be withdrawn (out of scope — no "intrekken"). // be withdrawn (out of scope — no "intrekken").
api.MapDelete("/applications/{id}", (string id) => api.MapDelete("/applications/{id}", (string id) =>
{ {
var a = ApplicationStore.Get(id, DocumentStore.DemoOwner); var a = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (a is null) return Results.NotFound(); if (a is null) return Results.NotFound();
if (a.Submitted) if (a.Submitted)
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict); return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
ApplicationStore.Delete(id, DocumentStore.DemoOwner); ApplicationStore.Delete(id, DocumentStore.DemoOwner);
return Results.NoContent(); return Results.NoContent();
}) })
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status409Conflict) .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. // aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case.
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) => api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) =>
{ {
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner); var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (existing is null) return Results.NotFound(); if (existing is null) return Results.NotFound();
if (existing.Submitted) if (existing.Submitted)
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict); return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);
// Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves. // Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves.
(string? reject, bool autoApprovable) = existing.Type switch (string? reject, bool autoApprovable) = existing.Type switch
{ {
"registratie" => (null, req.DiplomaHerkomst == "duo"), "registratie" => (null, req.DiplomaHerkomst == "duo"),
_ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true), _ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
}; };
var docs = req.Documents; var docs = req.Documents;
if (docs is not null) if (docs is not null)
DocumentStore.Link(docs.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!)); 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 documentIds = docs?.Where(d => d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds); var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds);
if (submitted is null) return Results.Conflict(); if (submitted is null) return Results.Conflict();
app.Logger.LogInformation( app.Logger.LogInformation(
"aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}", "aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie); id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow))); return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow)));
}) })
.Produces<SubmitApplicationResponse>() .Produces<SubmitApplicationResponse>()
.ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// --- Brief (letter composition). One demo brief per owner; the server owns the // PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
// status machine + role rules. Role is a dev-only stand-in via X-Role (mirrors the // tied to a specific brief's live status — see BriefDecisionsDto for that).
// X-Admin seam and the FE ?role= toggle) — no real identities in this POC. --- 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); var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep)); return ToView(ctx, e);
}) })
.Produces<BriefViewDto>(); .Produces<BriefViewDto>();
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) => api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{ {
var (_, isDrafter) = BriefRole(ctx); var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken."); 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.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict); .ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/submit", (HttpContext ctx) => api.MapPost("/brief/submit", (HttpContext ctx) =>
{ {
var (_, isDrafter) = BriefRole(ctx); var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now()); var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
LogBrief("submit", r); LogBrief("submit", r);
return BriefResult(r, "Alleen de opsteller mag indienen."); return BriefResult(ctx, r, "Alleen de opsteller mag indienen.");
}) })
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2` .WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
.Produces<BriefDto>() .Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict); .ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/approve", (HttpContext ctx) => api.MapPost("/brief/approve", (HttpContext ctx) =>
{ {
var (acting, _) = BriefRole(ctx); var r = BriefStore.Approve(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), Now());
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now()); LogBrief("approve", r);
LogBrief("approve", r); return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
}) })
.Produces<BriefDto>() .Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict); .ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) => api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
{ {
var (acting, _) = BriefRole(ctx); var r = BriefStore.Reject(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), req.Comments, Now());
var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now()); LogBrief("reject", r);
LogBrief("reject", r); return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
}) })
.Produces<BriefDto>() .Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict); .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 // Send-time placeholder linting is FE-authoritative in this slice (no C# parity
// port); the backend only guards the approved→sent transition. // port); the backend only guards the approved→sent transition (not role-gated
var r = BriefStore.Send(DocumentStore.DemoOwner, Now()); // today — see Authz.CanActOn(Send, …), a mechanical dispatch step).
LogBrief("send", r); var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
return BriefResult(r, "Versturen kan niet in deze status."); LogBrief("send", r);
return BriefResult(ctx, r, "Versturen kan niet in deze status.");
}) })
.Produces<BriefDto>() .Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status409Conflict); .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.GetOrCreate(DocumentStore.DemoOwner);
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner); if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep)); 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") .WithName("briefReset")
.Produces<BriefViewDto>(); .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(); app.Run();
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true"; 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"); static string Now() => DateTimeOffset.UtcNow.ToString("o");
// Dev-only role stand-in: X-Role: approver acts as the approver identity, anything BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
// else (default) acts as the drafter. e.ToDto(),
static (string acting, bool isDrafter) BriefRole(HttpContext ctx) BriefSeed.PassagesFor(e.Beroep),
{ Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId),
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver"; // Sent letters render with the version pinned at send; everything else follows
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter); // 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.Ok => Results.Ok(ToView(ctx, r.entity!)),
BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden), 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), _ => 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) => 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, // Audit + outcome for a submit, with NO personal data: only kind, outcome,
// generated reference and the caller's correlation id (the observability seam — a // 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) 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) var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
? v.ToString() var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
: "none"; ? k.ToString()
: null;
if (reject is not null) if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached))
{ {
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid); app.Logger.LogInformation("submit kind={Kind} outcome=replayed correlationId={Cid}", kind, cid);
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity); 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) if (documents is not null)
{ {
// Link digital documents (blocks later user delete) and record post-delivery // Link digital documents (blocks later user delete) and record post-delivery
// intent so a caseworker knows to expect the physical document. // 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!)); 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")) foreach (var d in documents.Where(d => d.Channel == "post"))
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid); DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
} }
var reference = SubmissionRules.NewReference(); var reference = SubmissionRules.NewReference();
app.Logger.LogInformation( app.Logger.LogInformation(
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}", "submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
kind, reference, cid, DateTimeOffset.UtcNow); 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. // 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": { "/api/v1/brief": {
"get": { "get": {
"tags": [ "tags": [
@@ -679,7 +698,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/BriefDto" "$ref": "#/components/schemas/BriefViewDto"
} }
} }
} }
@@ -719,7 +738,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/BriefDto" "$ref": "#/components/schemas/BriefViewDto"
} }
} }
} }
@@ -758,7 +777,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/BriefDto" "$ref": "#/components/schemas/BriefViewDto"
} }
} }
} }
@@ -807,7 +826,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/BriefDto" "$ref": "#/components/schemas/BriefViewDto"
} }
} }
} }
@@ -846,7 +865,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "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": { "components": {
@@ -1030,6 +1281,24 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"BriefDecisionsDto": {
"type": "object",
"properties": {
"canEdit": {
"type": "boolean"
},
"canApprove": {
"type": "boolean"
},
"canReject": {
"type": "boolean"
},
"canSend": {
"type": "boolean"
}
},
"additionalProperties": false
},
"BriefDto": { "BriefDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1123,6 +1392,12 @@
"$ref": "#/components/schemas/LibraryPassageDto" "$ref": "#/components/schemas/LibraryPassageDto"
}, },
"nullable": true "nullable": true
},
"decisions": {
"$ref": "#/components/schemas/BriefDecisionsDto"
},
"orgTemplate": {
"$ref": "#/components/schemas/OrgTemplateDto"
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -1469,6 +1744,131 @@
}, },
"additionalProperties": false "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": { "ParagraphDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1573,6 +1973,20 @@
}, },
"additionalProperties": { } "additionalProperties": { }
}, },
"PublishOrgTemplateResponse": {
"type": "object",
"properties": {
"version": {
"type": "integer",
"format": "int32"
},
"affectedUnsentBriefs": {
"type": "integer",
"format": "int32"
}
},
"additionalProperties": false
},
"ReferentieResponse": { "ReferentieResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1716,6 +2130,33 @@
}, },
"additionalProperties": false "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": { "SubmitApplicationRequest": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -6,131 +6,137 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; 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") private async Task<ApplicationDetailDto> Create(string type = "registratie")
{ {
var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type }); var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type });
Assert.Equal(HttpStatusCode.Created, res.StatusCode); Assert.Equal(HttpStatusCode.Created, res.StatusCode);
return (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!; return (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
} }
private Task<List<ApplicationSummaryDto>?> List() => private Task<List<ApplicationSummaryDto>?> List() =>
_client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/applications"); _client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/applications");
// --- Lifecycle over HTTP --- // --- Lifecycle over HTTP ---
[Fact] [Fact]
public async Task Create_then_list_shows_a_concept_with_step_progress() public async Task Create_then_list_shows_a_concept_with_step_progress()
{ {
var a = await Create(); var a = await Create();
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}", await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 }); new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 });
var mine = (await List())!.Single(x => x.Id == a.Id); var mine = (await List())!.Single(x => x.Id == a.Id);
Assert.Equal("Concept", mine.Status.Tag); Assert.Equal("Concept", mine.Status.Tag);
Assert.Equal(1, mine.Status.StepIndex); Assert.Equal(1, mine.Status.StepIndex);
Assert.Equal(4, mine.Status.StepCount); Assert.Equal(4, mine.Status.StepCount);
} }
[Fact] [Fact]
public async Task Draft_sync_is_readable_back_from_detail() public async Task Draft_sync_is_readable_back_from_detail()
{ {
var a = await Create(); var a = await Create();
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}", await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 }); new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 });
var detail = await _client.GetFromJsonAsync<ApplicationDetailDto>($"/api/v1/applications/{a.Id}"); var detail = await _client.GetFromJsonAsync<ApplicationDetailDto>($"/api/v1/applications/{a.Id}");
Assert.NotNull(detail!.Draft); Assert.NotNull(detail!.Draft);
Assert.Equal("verpleegkundige", detail.Draft!.Value.GetProperty("beroep").GetString()); Assert.Equal("verpleegkundige", detail.Draft!.Value.GetProperty("beroep").GetString());
} }
[Fact] [Fact]
public async Task Submit_duo_registratie_is_in_behandeling_and_auto() public async Task Submit_duo_registratie_is_in_behandeling_and_auto()
{ {
var a = await Create("registratie"); var a = await Create("registratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" }); var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!; var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.StartsWith("BIG-2026-", body.Referentie); Assert.StartsWith("BIG-2026-", body.Referentie);
Assert.Equal("InBehandeling", body.Status.Tag); Assert.Equal("InBehandeling", body.Status.Tag);
Assert.False(body.Status.Manual); // auto-approvable → not a manual case Assert.False(body.Status.Manual); // auto-approvable → not a manual case
} }
[Fact] [Fact]
public async Task Submit_handmatig_registratie_succeeds_as_manual_case() public async Task Submit_handmatig_registratie_succeeds_as_manual_case()
{ {
var a = await Create("registratie"); var a = await Create("registratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" }); var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" });
res.EnsureSuccessStatusCode(); // no longer a 422 res.EnsureSuccessStatusCode(); // no longer a 422
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!; var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.Equal("InBehandeling", body.Status.Tag); Assert.Equal("InBehandeling", body.Status.Tag);
Assert.True(body.Status.Manual); Assert.True(body.Status.Manual);
} }
[Fact] [Fact]
public async Task Submit_herregistratie_with_zero_uren_is_afgewezen() public async Task Submit_herregistratie_with_zero_uren_is_afgewezen()
{ {
var a = await Create("herregistratie"); var a = await Create("herregistratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 0 }); var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 0 });
res.EnsureSuccessStatusCode(); // the submission is accepted... res.EnsureSuccessStatusCode(); // the submission is accepted...
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!; var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected
Assert.NotNull(body.Status.Reden); Assert.NotNull(body.Status.Reden);
} }
[Fact] [Fact]
public async Task Submitting_twice_conflicts() public async Task Submitting_twice_conflicts()
{ {
var a = await Create("registratie"); var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); (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" }); var again = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
} }
[Fact] [Fact]
public async Task Cancel_concept_removes_it() public async Task Cancel_concept_removes_it()
{ {
var a = await Create(); var a = await Create();
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode); 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); Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode);
} }
[Fact] [Fact]
public async Task Cancel_submitted_aanvraag_conflicts() public async Task Cancel_submitted_aanvraag_conflicts()
{ {
var a = await Create("registratie"); var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); (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); 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() private static Aanvraag Accepted(bool autoApprovable) => new()
{ {
Id = "x", Type = "registratie", Owner = "test", Id = "x",
Submitted = true, AutoApprovable = autoApprovable, Referentie = "BIG-2026-1", Type = "registratie",
SubmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, Owner = "test",
}; Submitted = true,
AutoApprovable = autoApprovable,
Referentie = "BIG-2026-1",
SubmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
};
[Fact] [Fact]
public void AutoApprovable_flips_to_goedgekeurd_after_the_window() public void AutoApprovable_flips_to_goedgekeurd_after_the_window()
{ {
var a = Accepted(autoApprovable: true); var a = Accepted(autoApprovable: true);
var t0 = a.SubmittedAt!.Value; var t0 = a.SubmittedAt!.Value;
Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag); Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag);
Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag); Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag);
} }
[Fact] [Fact]
public void Manual_case_never_auto_advances() public void Manual_case_never_auto_advances()
{ {
var a = Accepted(autoApprovable: false); var a = Accepted(autoApprovable: false);
var far = a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1); var far = a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
var status = a.ToStatusDto(far); var status = a.ToStatusDto(far);
Assert.Equal("InBehandeling", status.Tag); Assert.Equal("InBehandeling", status.Tag);
Assert.True(status.Manual); 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" /> <ProjectReference Include="..\..\src\BigRegister.Api\BigRegister.Api.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Content Include="LetterHtml.golden.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project> </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 /// (tests within a class run sequentially in xUnit). Role is the dev-only X-Role
/// header: absent = drafter, "approver" = a different identity. /// header: absent = drafter, "approver" = a different identity.
/// </summary> /// </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) => private static LetterBlockDto FreeText(string id) =>
new("freeText", id, new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) })); 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. // Fill every REQUIRED section with a block so submit is allowed.
private static SaveBriefRequest FilledFrom(BriefDto brief) private static SaveBriefRequest FilledFrom(BriefDto brief)
{ {
var i = 0; var i = 0;
var sections = brief.Sections var sections = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required ? new[] { FreeText($"local-{++i}") } : s.Blocks)) .Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required ? new[] { FreeText($"local-{++i}") } : s.Blocks))
.ToList(); .ToList();
return new SaveBriefRequest(sections); return new SaveBriefRequest(sections);
} }
private async Task<BriefDto> Get() private async Task<BriefDto> Get()
{ {
BriefStore.Reset(); BriefStore.Reset();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"); var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
return view!.Brief; return view!.Brief;
} }
private HttpRequestMessage Post(string path, string? role = null, object? body = null) private HttpRequestMessage Post(string path, string? role = null, object? body = null)
{ {
var req = new HttpRequestMessage(HttpMethod.Post, path); var req = new HttpRequestMessage(HttpMethod.Post, path);
if (role is not null) req.Headers.Add("X-Role", role); if (role is not null) req.Headers.Add("X-Role", role);
if (body is not null) req.Content = JsonContent.Create(body); if (body is not null) req.Content = JsonContent.Create(body);
return req; return req;
} }
[Fact] [Fact]
public async Task Get_creates_a_draft_from_the_template_with_scoped_passages() public async Task Get_creates_a_draft_from_the_template_with_scoped_passages()
{ {
var brief = await Get(); var brief = await Get();
Assert.Equal("draft", brief.Status.Tag); Assert.Equal("draft", brief.Status.Tag);
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey)); Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty. // aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
var aanhef = brief.Sections.Single(s => s.SectionKey == "aanhef"); var aanhef = brief.Sections.Single(s => s.SectionKey == "aanhef");
Assert.True(aanhef.Locked); Assert.True(aanhef.Locked);
Assert.NotEmpty(aanhef.Blocks); Assert.NotEmpty(aanhef.Blocks);
Assert.True(brief.Sections.Single(s => s.SectionKey == "slot").Locked); Assert.True(brief.Sections.Single(s => s.SectionKey == "slot").Locked);
var kern = brief.Sections.Single(s => s.SectionKey == "kern"); var kern = brief.Sections.Single(s => s.SectionKey == "kern");
Assert.False(kern.Locked); Assert.False(kern.Locked);
Assert.Empty(kern.Blocks); Assert.Empty(kern.Blocks);
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"); var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
// global passages + the arts-scoped one; no other-beroep passages leak in. // global passages + the arts-scoped one; no other-beroep passages leak in.
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts"); Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts")); Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
} }
[Fact] [Fact]
public async Task Save_is_drafter_only() public async Task Save_is_drafter_only()
{ {
var brief = await Get(); var brief = await Get();
var save = FilledFrom(brief); var save = FilledFrom(brief);
var approver = Post("/api/v1/brief", role: "approver"); var approver = Post("/api/v1/brief", role: "approver");
approver.Method = HttpMethod.Put; approver.Method = HttpMethod.Put;
approver.Content = JsonContent.Create(save); approver.Content = JsonContent.Create(save);
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(approver)).StatusCode); 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] [Fact]
public async Task Submit_blocks_on_empty_required_section_then_succeeds_when_filled() public async Task Submit_blocks_on_empty_required_section_then_succeeds_when_filled()
{ {
await Get(); await Get();
// Nothing filled yet → required sections empty → 409. // Nothing filled yet → required sections empty → 409.
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief; var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
var res = await _client.SendAsync(Post("/api/v1/brief/submit")); var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var submitted = await res.Content.ReadFromJsonAsync<BriefDto>(); var submitted = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("submitted", submitted!.Status.Tag); Assert.Equal("submitted", submitted!.Brief.Status.Tag);
} }
[Fact] [Fact]
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can() public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
{ {
var brief = await Get(); var brief = await Get();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync(Post("/api/v1/brief/submit"));
// drafter role approving own letter → 403 // drafter role approving own letter → 403
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(Post("/api/v1/brief/approve"))).StatusCode); 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")); var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag); Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief.Status.Tag);
} }
[Fact] [Fact]
public async Task Reject_returns_comments_and_editing_reopens_to_draft() public async Task Reject_returns_comments_and_editing_reopens_to_draft()
{ {
var brief = await Get(); var brief = await Get();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync(Post("/api/v1/brief/submit"));
var rejected = await (await _client.SendAsync( var rejected = await (await _client.SendAsync(
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefDto>(); Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("rejected", rejected!.Status.Tag); Assert.Equal("rejected", rejected!.Brief.Status.Tag);
Assert.Equal("Graag aanvullen.", rejected.Status.Comments); Assert.Equal("Graag aanvullen.", rejected.Brief.Status.Comments);
// A drafter save on a rejected letter reopens it to draft. // 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>(); var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("draft", reopened!.Status.Tag); Assert.Equal("draft", reopened!.Brief.Status.Tag);
} }
[Fact] [Fact]
public async Task Send_only_from_approved() public async Task Send_only_from_approved()
{ {
var brief = await Get(); var brief = await Get();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync(Post("/api/v1/brief/submit"));
// submitted (not approved) → send 409 // submitted (not approved) → send 409
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/send"))).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/send"))).StatusCode);
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver")); await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
var res = await _client.SendAsync(Post("/api/v1/brief/send")); var res = await _client.SendAsync(Post("/api/v1/brief/send"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag); Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief.Status.Tag);
} }
[Fact] [Fact]
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections() public async Task Decisions_on_the_view_mirror_the_acting_principal_and_live_status()
{ {
var brief = await Get(); var brief = await Get();
// Advance out of draft so the reset back to draft is observable. var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); Assert.True(view!.Decisions.CanEdit); // default (no X-Role) = drafter, draft status
await _client.SendAsync(Post("/api/v1/brief/submit")); Assert.False(view.Decisions.CanApprove);
var res = await _client.SendAsync(Post("/api/v1/brief/reset")); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
res.EnsureSuccessStatusCode(); await _client.SendAsync(Post("/api/v1/brief/submit"));
var view = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("draft", view!.Brief.Status.Tag); var asApprover = await _client.SendAsync(
var aanhef = view.Brief.Sections.Single(s => s.SectionKey == "aanhef"); new HttpRequestMessage(HttpMethod.Get, "/api/v1/brief") { Headers = { { "X-Role", "approver" } } });
Assert.True(aanhef.Locked); var approverView = await asApprover.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.NotEmpty(aanhef.Blocks); Assert.True(approverView!.Decisions.CanApprove);
Assert.Empty(view.Brief.Sections.Single(s => s.SectionKey == "kern").Blocks); 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; 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] [Fact]
public async Task DashboardView_computes_eligibility_decision() public async Task DashboardView_computes_eligibility_decision()
{ {
var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/v1/dashboard-view"); var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/v1/dashboard-view");
Assert.NotNull(dto); Assert.NotNull(dto);
Assert.Equal("19012345601", dto.Registration.BigNummer); Assert.Equal("19012345601", dto.Registration.BigNummer);
Assert.Equal("Geregistreerd", dto.Registration.Status.Tag); Assert.Equal("Geregistreerd", dto.Registration.Status.Tag);
// seed deadline 2027-03-01 is within 12 months of "today" (2026) → eligible // seed deadline 2027-03-01 is within 12 months of "today" (2026) → eligible
Assert.True(dto.Decisions.EligibleForHerregistratie); Assert.True(dto.Decisions.EligibleForHerregistratie);
Assert.NotNull(dto.Decisions.HerregistratieReason); Assert.NotNull(dto.Decisions.HerregistratieReason);
} }
[Fact] [Fact]
public async Task Notes_returns_seeded_aantekeningen() public async Task Notes_returns_seeded_aantekeningen()
{ {
var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/v1/notes"); var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/v1/notes");
Assert.Equal(3, notes!.Count); Assert.Equal(3, notes!.Count);
} }
[Fact] [Fact]
public async Task Brp_returns_address() public async Task Brp_returns_address()
{ {
var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/v1/brp/address"); var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/v1/brp/address");
Assert.True(dto!.Gevonden); Assert.True(dto!.Gevonden);
Assert.Equal("2514 EA", dto.Adres!.Postcode); Assert.Equal("2514 EA", dto.Adres!.Postcode);
} }
[Fact] [Fact]
public async Task Duo_lookup_carries_server_decided_questions_and_professions() public async Task Duo_lookup_carries_server_decided_questions_and_professions()
{ {
var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/v1/duo/diplomas"); var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/v1/duo/diplomas");
Assert.NotNull(dto); Assert.NotNull(dto);
var english = dto.Diplomas.Single(d => d.Id == "d2"); var english = dto.Diplomas.Single(d => d.Id == "d2");
Assert.Equal("Arts", english.Beroep); Assert.Equal("Arts", english.Beroep);
Assert.Contains(english.PolicyQuestions, q => q.Id == "nl-taalvaardigheid"); Assert.Contains(english.PolicyQuestions, q => q.Id == "nl-taalvaardigheid");
var dutch = dto.Diplomas.Single(d => d.Id == "d1"); var dutch = dto.Diplomas.Single(d => d.Id == "d1");
Assert.Empty(dutch.PolicyQuestions); Assert.Empty(dutch.PolicyQuestions);
// DUO "not found" fallback: an unlisted diploma → user uses the manual path, // DUO "not found" fallback: an unlisted diploma → user uses the manual path,
// which the same lookup provides (maximal question set + declarable professions). // which the same lookup provides (maximal question set + declarable professions).
Assert.DoesNotContain(dto.Diplomas, d => d.Id == "unknown-id"); Assert.DoesNotContain(dto.Diplomas, d => d.Id == "unknown-id");
Assert.Equal(3, dto.Handmatig.PolicyQuestions.Count); Assert.Equal(3, dto.Handmatig.PolicyQuestions.Count);
Assert.Equal(5, dto.Handmatig.Beroepen.Count); Assert.Equal(5, dto.Handmatig.Beroepen.Count);
} }
[Fact] [Fact]
public async Task IntakePolicy_returns_scholing_threshold() public async Task IntakePolicy_returns_scholing_threshold()
{ {
var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/v1/intake/policy"); var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/v1/intake/policy");
Assert.Equal(1000, dto!.ScholingThreshold); Assert.Equal(1000, dto!.ScholingThreshold);
} }
[Fact] [Fact]
public async Task Registration_with_duo_diploma_succeeds() public async Task Registration_with_duo_diploma_succeeds()
{ {
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo")); var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>(); var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.StartsWith("BIG-2026-", body!.Referentie); Assert.StartsWith("BIG-2026-", body!.Referentie);
} }
[Fact] [Fact]
public async Task Registration_with_manual_diploma_is_rejected_with_problem_details() public async Task Registration_with_manual_diploma_is_rejected_with_problem_details()
{ {
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig")); var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString()); Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString());
} }
[Theory] [Theory]
[InlineData("/api/v1/intakes")] [InlineData("/api/v1/intakes")]
[InlineData("/api/v1/herregistraties")] [InlineData("/api/v1/herregistraties")]
public async Task Zero_hours_submission_is_rejected(string route) public async Task Zero_hours_submission_is_rejected(string route)
{ {
var res = await _client.PostAsJsonAsync(route, new { uren = 0 }); var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
} }
[Theory] [Theory]
[InlineData("/api/v1/intakes")] [InlineData("/api/v1/intakes")]
[InlineData("/api/v1/herregistraties")] [InlineData("/api/v1/herregistraties")]
public async Task Worked_hours_submission_succeeds(string route) public async Task Worked_hours_submission_succeeds(string route)
{ {
var res = await _client.PostAsJsonAsync(route, new { uren = 40 }); var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
} }
[Fact] [Fact]
public async Task Change_request_with_valid_address_succeeds() public async Task Change_request_with_valid_address_succeeds()
{ {
var res = await _client.PostAsJsonAsync("/api/v1/change-requests", var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" }); new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" });
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>(); var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.StartsWith("BIG-2026-", body!.Referentie); Assert.StartsWith("BIG-2026-", body!.Referentie);
} }
[Fact] [Fact]
public async Task Change_request_with_bad_postcode_is_rejected() public async Task Change_request_with_bad_postcode_is_rejected()
{ {
var res = await _client.PostAsJsonAsync("/api/v1/change-requests", var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" }); new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
} }
[Fact] [Fact]
public async Task Health_endpoint_is_ok() public async Task Health_endpoint_is_ok()
{ {
var res = await _client.GetAsync("/health"); var res = await _client.GetAsync("/health");
res.EnsureSuccessStatusCode(); 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) [Fact]
{ public async Task Correlation_id_is_generated_when_the_caller_omits_it()
var content = new MultipartFormDataContent(); {
var file = new ByteArrayContent(new byte[] { 1, 2, 3 }); var res = await _client.GetAsync("/api/v1/notes");
file.Headers.ContentType = new MediaTypeHeaderValue(contentType); Assert.NotEmpty(res.Headers.GetValues("X-Correlation-Id").Single());
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;
}
private async Task<UploadResponse> Upload(string localId, string categoryId = "diploma", string type = "application/pdf", string file = "d.pdf") // --- Document upload ---
{
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] private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)
public async Task Categories_are_server_owned_config() {
{ var content = new MultipartFormDataContent();
// A manual diploma requires a diploma upload; identiteit is always required. var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/api/v1/uploads/categories?wizardId=registratie&diplomaHerkomst=handmatig"); file.Headers.ContentType = new MediaTypeHeaderValue(contentType);
Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery); content.Add(file, "file", fileName);
Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery); content.Add(new StringContent(categoryId), "categoryId");
} content.Add(new StringContent(localId), "localId");
content.Add(new StringContent(wizardId), "wizardId");
return content;
}
[Fact] private async Task<UploadResponse> Upload(string localId, string categoryId = "diploma", string type = "application/pdf", string file = "d.pdf")
public async Task Upload_then_status_reports_complete_for_known_localId() {
{ var res = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, categoryId, "registratie", file, type));
var localId = Guid.NewGuid().ToString(); Assert.Equal(HttpStatusCode.Created, res.StatusCode);
var doc = await Upload(localId); return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
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] [Fact]
public async Task Upload_content_is_served_back_with_its_type_inline_for_pdf() public async Task Categories_are_server_owned_config()
{ {
var doc = await Upload(Guid.NewGuid().ToString()); // A manual diploma requires a diploma upload; identiteit is always required.
var res = await _client.GetAsync($"/api/v1/uploads/{doc.DocumentId}/content"); var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/api/v1/uploads/categories?wizardId=registratie&diplomaHerkomst=handmatig");
res.EnsureSuccessStatusCode(); Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery);
Assert.Equal("application/pdf", res.Content.Headers.ContentType!.MediaType); Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery);
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] [Fact]
public async Task Upload_content_404_for_unknown_document() public async Task Upload_then_status_reports_complete_for_known_localId()
{ {
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync("/api/v1/uploads/demo-nope/content")).StatusCode); 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] [Fact]
public async Task Upload_rejects_wrong_type() public async Task Upload_content_is_served_back_with_its_type_inline_for_pdf()
{ {
var res = await _client.PostAsync("/api/v1/uploads", var doc = await Upload(Guid.NewGuid().ToString());
UploadForm(Guid.NewGuid().ToString(), "diploma", "registratie", "d.txt", "text/plain")); var res = await _client.GetAsync($"/api/v1/uploads/{doc.DocumentId}/content");
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); 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] [Fact]
public async Task User_delete_succeeds_then_404() public async Task Upload_content_404_for_unknown_document()
{ {
var doc = await Upload(Guid.NewGuid().ToString()); Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync("/api/v1/uploads/demo-nope/content")).StatusCode);
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] [Fact]
public async Task User_delete_blocked_with_409_once_linked_to_submission() public async Task Upload_rejects_wrong_type()
{ {
var doc = await Upload(Guid.NewGuid().ToString()); var res = await _client.PostAsync("/api/v1/uploads",
var submit = await _client.PostAsJsonAsync("/api/v1/registrations", UploadForm(Guid.NewGuid().ToString(), "diploma", "registratie", "d.txt", "text/plain"));
new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) })); Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
submit.EnsureSuccessStatusCode(); }
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
}
[Fact] [Fact]
public async Task Admin_delete_requires_admin_role() public async Task User_delete_succeeds_then_404()
{ {
var doc = await Upload(Guid.NewGuid().ToString()); var doc = await Upload(Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode); 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}"); [Fact]
req.Headers.Add("X-Admin", "true"); public async Task User_delete_blocked_with_409_once_linked_to_submission()
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode); {
} 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] [Fact]
public async Task Audit_log_records_upload_and_delete_metadata_only() public async Task Admin_delete_requires_admin_role()
{ {
var doc = await Upload(Guid.NewGuid().ToString()); var doc = await Upload(Guid.NewGuid().ToString());
await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}"); Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode);
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"); 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 public class DocumentRuleTests
{ {
[Fact] [Fact]
public void Rejects_unknown_category() => public void Rejects_unknown_category() =>
Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1)); Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1));
[Fact] [Fact]
public void Rejects_disallowed_type() public void Rejects_disallowed_type()
{ {
var c = DocumentRules.Find("registratie", "diploma"); var c = DocumentRules.Find("registratie", "diploma");
Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1)); Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1));
} }
[Fact] [Fact]
public void Rejects_oversized_file() public void Rejects_oversized_file()
{ {
var c = DocumentRules.Find("registratie", "diploma"); var c = DocumentRules.Find("registratie", "diploma");
Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024)); Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024));
} }
[Fact] [Fact]
public void Accepts_valid_file() public void Accepts_valid_file()
{ {
var c = DocumentRules.Find("registratie", "diploma"); var c = DocumentRules.Find("registratie", "diploma");
Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024)); Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024));
} }
private static IReadOnlyList<string> Ids(string? herkomst, string? taalvaardigheid) => private static IReadOnlyList<string> Ids(string? herkomst, string? taalvaardigheid) =>
DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList(); DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList();
[Fact] [Fact]
public void First_load_has_no_diploma_upload() => // no diploma chosen yet public void First_load_has_no_diploma_upload() => // no diploma chosen yet
Assert.Equal(new[] { "identiteit" }, Ids(null, null)); Assert.Equal(new[] { "identiteit" }, Ids(null, null));
[Fact] [Fact]
public void Manual_diploma_needs_a_diploma_upload() => public void Manual_diploma_needs_a_diploma_upload() =>
Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null)); Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null));
[Fact] [Fact]
public void Duo_diploma_skips_diploma_upload() => public void Duo_diploma_skips_diploma_upload() =>
Assert.DoesNotContain("diploma", Ids("duo", null)); Assert.DoesNotContain("diploma", Ids("duo", null));
[Fact] [Fact]
public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() => public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() =>
Assert.Contains("taalvaardigheid", Ids("handmatig", "ja")); Assert.Contains("taalvaardigheid", Ids("handmatig", "ja"));
[Fact] [Fact]
public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() => public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() =>
Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee")); Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee"));
[Fact] [Fact]
public void Find_resolves_taalvaardigheid_for_upload_validation() => public void Find_resolves_taalvaardigheid_for_upload_validation() =>
Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid")); Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid"));
} }
public class HerregistratieRuleTests public class HerregistratieRuleTests
{ {
private static Registration Active(DateOnly deadline) => new( private static Registration Active(DateOnly deadline) => new(
"19012345601", "Test", "Arts", "19012345601", "Test", "Arts",
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14), new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline)); new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
[Fact] [Fact]
public void Eligible_within_window() public void Eligible_within_window()
{ {
var (eligible, reason) = HerregistratieRule.Evaluate( var (eligible, reason) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26)); Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26));
Assert.True(eligible); Assert.True(eligible);
Assert.Contains("12 maanden", reason); Assert.Contains("12 maanden", reason);
} }
[Fact] [Fact]
public void Not_eligible_before_window() public void Not_eligible_before_window()
{ {
var (eligible, _) = HerregistratieRule.Evaluate( var (eligible, _) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1)); Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1));
Assert.False(eligible); Assert.False(eligible);
} }
[Fact] [Fact]
public void Eligible_on_window_boundary() public void Eligible_on_window_boundary()
{ {
// window opens exactly 12 months before the deadline // window opens exactly 12 months before the deadline
var (eligible, _) = HerregistratieRule.Evaluate( var (eligible, _) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1)); Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1));
Assert.True(eligible); Assert.True(eligible);
} }
[Fact] [Fact]
public void Suspended_is_not_eligible() 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"),
{ };
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);
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26)); }
Assert.False(eligible);
}
[Fact] [Fact]
public void Status_consistency_invariant() public void Status_consistency_invariant()
{ {
Assert.True(HerregistratieRule.IsStatusConsistent( Assert.True(HerregistratieRule.IsStatusConsistent(
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)))); new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
Assert.False(HerregistratieRule.IsStatusConsistent( Assert.False(HerregistratieRule.IsStatusConsistent(
new RegistrationStatus(StatusTag.Geregistreerd))); new RegistrationStatus(StatusTag.Geregistreerd)));
} }
} }
public class DiplomaRuleTests public class DiplomaRuleTests
{ {
private static Diploma Diploma(string opleiding, bool engelstalig) => private static Diploma Diploma(string opleiding, bool engelstalig) =>
new("x", "naam", "instelling", 2011, opleiding, engelstalig); new("x", "naam", "instelling", 2011, opleiding, engelstalig);
[Theory] [Theory]
[InlineData("geneeskunde", "Arts")] [InlineData("geneeskunde", "Arts")]
[InlineData("verpleegkunde", "Verpleegkundige")] [InlineData("verpleegkunde", "Verpleegkundige")]
[InlineData("onbekend-programma", "Onbekend")] [InlineData("onbekend-programma", "Onbekend")]
public void Profession_is_derived_from_program(string opleiding, string expected) => public void Profession_is_derived_from_program(string opleiding, string expected) =>
Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false))); Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false)));
[Fact] [Fact]
public void English_diploma_requires_dutch_proficiency() public void English_diploma_requires_dutch_proficiency()
{ {
var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true)); var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true));
Assert.Single(questions); Assert.Single(questions);
Assert.Equal("nl-taalvaardigheid", questions[0].Id); Assert.Equal("nl-taalvaardigheid", questions[0].Id);
} }
[Fact] [Fact]
public void Dutch_diploma_has_no_policy_questions() => public void Dutch_diploma_has_no_policy_questions() =>
Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false))); Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false)));
[Fact] [Fact]
public void Manual_diploma_gets_maximal_set() public void Manual_diploma_gets_maximal_set()
{ {
var questions = DiplomaRules.ManualQuestions(); var questions = DiplomaRules.ManualQuestions();
Assert.Equal(3, questions.Count); Assert.Equal(3, questions.Count);
Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" }, Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" },
questions.Select(q => q.Id)); questions.Select(q => q.Id));
} }
[Fact] [Fact]
public void Manual_professions_match_known_programs() => public void Manual_professions_match_known_programs() =>
Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" }, Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" },
DiplomaRules.ManualProfessions()); DiplomaRules.ManualProfessions());
} }
public class SubmissionRuleTests public class SubmissionRuleTests
{ {
[Fact] [Fact]
public void Manual_diploma_is_rejected() => public void Manual_diploma_is_rejected() =>
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig")); Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
[Fact] [Fact]
public void Duo_diploma_is_accepted() => public void Duo_diploma_is_accepted() =>
Assert.Null(SubmissionRules.RejectRegistratie("duo")); Assert.Null(SubmissionRules.RejectRegistratie("duo"));
[Fact] [Fact]
public void Zero_hours_is_rejected() => public void Zero_hours_is_rejected() =>
Assert.NotNull(SubmissionRules.RejectZeroUren(0)); Assert.NotNull(SubmissionRules.RejectZeroUren(0));
[Fact] [Fact]
public void Worked_hours_are_accepted() => public void Worked_hours_are_accepted() =>
Assert.Null(SubmissionRules.RejectZeroUren(40)); Assert.Null(SubmissionRules.RejectZeroUren(40));
[Theory] [Theory]
[InlineData("Lange Voorhout 9", "2514 EA", null)] // valid [InlineData("Lange Voorhout 9", "2514 EA", null)] // valid
[InlineData("", "2514 EA", "Vul straat en huisnummer in.")] [InlineData("", "2514 EA", "Vul straat en huisnummer in.")]
[InlineData("Straat 1", "nope", "Voer een geldige postcode in, bijv. 1234 AB.")] [InlineData("Straat 1", "nope", "Voer een geldige postcode in, bijv. 1234 AB.")]
public void Change_request_is_validated(string straat, string postcode, string? expected) => public void Change_request_is_validated(string straat, string postcode, string? expected) =>
Assert.Equal(expected, SubmissionRules.RejectChangeRequest(straat, postcode)); 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 - ASPNETCORE_ENVIRONMENT=Development
volumes: volumes:
# ':z' relabels for SELinux (Fedora/RHEL); harmless on other hosts. # ':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 - ./backend:/src:z
- api-bin:/src/src/BigRegister.Api/bin - api-bin:/src/src/BigRegister.Api/bin
- api-obj:/src/src/BigRegister.Api/obj - 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: ports:
- "5000:5000" - '5000:5000'
web: web:
image: node:24 image: node:24
@@ -24,7 +33,7 @@ services:
- ./:/app:z - ./:/app:z
- web-modules:/app/node_modules - web-modules:/app/node_modules
ports: ports:
- "4200:4200" - '4200:4200'
depends_on: depends_on:
- api - api

View File

@@ -214,7 +214,7 @@ profile = computed(() =>
The rule baked into `map2`: the combined result is a **Failure if either 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 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 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" ### 2c. The store — "all state changes go through one pure function"

View File

@@ -20,13 +20,13 @@ backend team.
## Options considered ## Options considered
| Option | Fewer calls? | Unifies policy? | Cost | | Option | Fewer calls? | Unifies policy? | Cost |
|---|---|---|---| | -------------------------------------------------------------- | ------------------------- | --------------------------------------------------------- | --------------------------- |
| 1. Status quo (client calls upstreams, aggregates, owns rules) | No | No | — | | 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 | | 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 | | **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 | | 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 | | 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 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 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 own. Promote to a separately-deployed BFF service only when a second consumer
(mobile/partner) or a team boundary demands it — not before. (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 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 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 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 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 truth** (OpenAPI or TypeSpec) that **generates types for both sides**. That spec is
the governance/transparency artifact. 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: would compute. Two slices were implemented to demonstrate **both** policy shapes:
**A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).** **A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).**
- Contract: `src/app/registratie/contracts/dashboard-view.dto.ts` - Contract: `src/app/registratie/contracts/dashboard-view.dto.ts`
(`DashboardViewDto` = registration + person + `decisions`). (`DashboardViewDto` = registration + person + `decisions`).
- Endpoint: `public/mock/dashboard-view.json` (one call replaces three). - 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. `brp.json`) were deleted — those calls live behind the BFF now.
**B. Intake scholing threshold → config value.** **B. Intake scholing threshold → config value.**
- Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`. - Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`.
- Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`). - Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`).
- `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone; - `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 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 `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 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 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 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 ## Options considered
| Option | Ubiquitous language respected? | Coupling | Verdict | | Option | Ubiquitous language respected? | Coupling | Verdict |
|---|---|---|---| | ------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------- | --------- |
| 1. Split contexts **by role** (`zorgverlener/`, `behandelaar/` folders) | No — role ≠ capability; features smear across personas | High | Reject | | 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 | | 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** | | 3. **Actors are personas; contexts are capabilities; identity is typed** | Yes | Low | **Adopt** |
## Decision ## Decision
@@ -42,9 +42,9 @@ Confirmed constraints (with the product owner):
The same real-world thing is described in two different languages: 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.** 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. 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**. 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 ### 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 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 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 aggregate projected into two screen-shaped views. The **aanvraag status lifecycle** is the _published
contract* between the two contexts: contract_ between the two contexts:
``` ```
Ingediend → In behandeling → (Meer info gevraagd ⇄) → Goedgekeurd / Afgewezen 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 ```ts
type Principal = 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 | { 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. second actor arrives.
- **Authorization — "what may you do"** → enforced at the **backend / context boundary**, where the - **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 frontend receives only the decisions it needs to render (e.g. a `canBeoordelen` flag), exactly like
every other server-owned rule. every other server-owned rule.
### 4. "Other users" slot in without inventing contexts ### 4. "Other users" slot in without inventing contexts
Admin, auditor, institution-rep are additional **`Principal` variants** or additional **`rollen` on 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), an actor brings a new **language and capability** (e.g. an "Toezicht/Handhaving" enforcement context),
not merely a new login. 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`). `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**, - 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. 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) ## 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`. 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 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 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 keeps the "components reference tokens" convention intact. (`styles.scss` is exempt from
`check:tokens`, so palette hex lives in that one file only.) `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 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` + (`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. `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. - `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 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 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 alternative (adding the CSS to `angular.json` `styles`) would force-bundle the licensed fonts we
intentionally dropped, so we accept the warning. intentionally dropped, so we accept the warning.
- Renaming the internal token names from `--rhc-*` to `--app-*` is possible later but out of scope. - 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 npm run test-storybook:ci
``` ```
Backend stays untouched throughout (frontend-only backlog); `cd backend && dotnet test` Phases 05 were frontend-only; **phase 6 (Brief v2) touches `backend/`** — for those
only needs re-running if a WP unexpectedly touches `backend/`. 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 ## Order
Gates land before the work they cover; each lint rule lands in the same WP as the fixes 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. for its existing violations, so every WP ends green.
| WP | Title | Phase | Status | | WP | Title | Phase | Status |
|----|-------|-------|--------| | --------------------------------------- | ------------------------------------------------------------ | --------------------------- | ------ |
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done | | [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-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-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-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 | todo | | [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 | todo | | [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 | todo | | [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 | todo | | [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 | todo | | [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 | todo | | [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-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-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 | todo | | [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 | todo | | [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 | todo | | [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 | todo | | [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 | todo | | [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); 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 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 `<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 ## WP template
@@ -72,12 +95,20 @@ Status: todo | in-progress | done (<commit>)
Phase: N — name Phase: N — name
## Why ## Why
## Read first ## Read first
## Decisions (pre-made, don't relitigate) ## Decisions (pre-made, don't relitigate)
## Files ## Files
## Steps ## Steps
## Acceptance criteria ## Acceptance criteria
## Verification ## Verification
## Out of scope ## Out of scope
## Risks ## Risks
``` ```

View File

@@ -6,7 +6,7 @@ Phase: 0 — enforcement & gates
## Why ## Why
The Storybook a11y addon (`@storybook/addon-a11y`, configured in `.storybook/preview.ts` 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 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. build", so every story added or changed by later WPs is automatically covered.

View File

@@ -1,6 +1,6 @@
# WP-05 — Parse-don't-validate closure + MDX # WP-05 — Parse-don't-validate closure + MDX
Status: todo Status: done
Phase: 1 — FP/DDD core Phase: 1 — FP/DDD core
## Why ## 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) — - `src/app/registratie/infrastructure/big-register.adapter.ts` (~line 25) —
`n.type as AantekeningType` → validated parse `n.type as AantekeningType` → validated parse
- `src/app/brief/infrastructure/brief.adapter.ts` (~line 189) — `dto.scope as - `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 - New co-located specs: `intake-policy.adapter.spec.ts`, extend
`big-register.adapter.spec.ts` / `brief.adapter.spec.ts` (create if missing) `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` - 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 ## 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). "narrow unknown to `Partial<Dto>` then parse" entry-cast is fine).
- [ ] Each new parser has a spec including a rejection case. - [x] Each new parser has a spec including a rejection case.
- [ ] MDX renders under Foundations in Storybook. - [x] MDX renders under Foundations in Storybook.
## Verification ## Verification
@@ -57,7 +57,7 @@ GREEN + `npm run test-storybook:ci`. Smoke: intake wizard still loads its policy
## Out of scope ## 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). CLAUDE.md); `digid.adapter.ts` (faked auth, sanctioned).
## Risks ## Risks

View File

@@ -1,6 +1,6 @@
# WP-06 — Generic async template contexts: kill `$any()` (18×) # WP-06 — Generic async template contexts: kill `$any()` (18×)
Status: todo Status: done
Phase: 1 — FP/DDD core Phase: 1 — FP/DDD core
## Why ## Why
@@ -44,19 +44,43 @@ let-p>` consumer must cast. The rest are template union-narrowing workarounds.
## Acceptance criteria ## Acceptance criteria
- [ ] `grep -rn '\$any(' src/app` → zero hits. - [x] `grep -rn '\$any(' src/app` → zero hits.
- [ ] No `as` casts added to compensate in component classes (typed getters are fine). - [x] No `as` casts added to compensate in component classes (typed getters are fine).
- [ ] Build green with strict template checking. - [x] Build green with strict template checking.
## Verification ## 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 `AsyncComponent`'s public API (`[data]`/`[resource]` inputs) is unchanged, so this
bounded. 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 # WP-07 — Brief on the shared idioms + RemoteData MDX
Status: todo Status: done
Phase: 1 — FP/DDD core Phase: 1 — FP/DDD core
Depends on: WP-06 (typed `<app-async>`) 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 (`Idle | Busy | Failed{error}`), replacing `busy`+`lastError`. `saveState` keeps its
union shape (align tag style). union shape (align tag style).
- Load lifecycle → `RemoteData` + `<app-async>`; the machine keeps owning the letter's - 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). the fetch — keep the seam: RemoteData = fetch, machine = letter).
- Keep the debounced-save sequencing identical; only re-type the state. - Keep the debounced-save sequencing identical; only re-type the state.
@@ -50,15 +50,53 @@ bypassing the shared molecule.
## Acceptance criteria ## Acceptance criteria
- [ ] No boolean-plus-error signal pairs in `brief/`. - [x] No boolean-plus-error signal pairs in `brief/`.
- [ ] `/brief` renders all four async states (check with `?scenario=slow|empty|error`). - [x] `/brief` renders all four async states (checked with `?scenario=slow|error`; see
- [ ] Specs cover the transition union (Busy→Failed, Busy→Idle). Deviation for why `empty` isn't meaningful here).
- [ ] MDX renders under Foundations. - [x] Specs cover the transition union (Busy→Failed, Busy→Idle) — `brief.store.spec.ts`
(new).
- [x] MDX renders under Foundations.
## Verification ## Verification
GREEN + `npm run test-storybook:ci`. Manual: `npm start``/brief` with GREEN + `npm run test-storybook:ci` (197 unit tests, 137 Storybook/a11y — both up from
`?scenario=slow`, `?scenario=error`; exercise autosave + submit + rejection flow. 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 ## Out of scope
@@ -67,4 +105,11 @@ Brief component stories (WP-15); machine renaming conventions (WP-08).
## Risks ## Risks
Autosave (debounced) interplay with the new transition union — flush ordering must stay 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 # WP-08 — One store idiom + machine naming + TEA MDX
Status: todo Status: done
Phase: 1 — FP/DDD core Phase: 1 — FP/DDD core
## Why ## Why
@@ -49,16 +49,34 @@ two idioms and copy the wrong one. Machine naming also drifts:
## Acceptance criteria ## Acceptance criteria
- [ ] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty. - [x] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty.
- [ ] Every machine consumer wires via `createStore`; no local `signal(model)` + - [x] Every machine consumer wires via `createStore`; no local `signal(model)` +
hand-rolled dispatch remains. hand-rolled dispatch remains.
- [ ] Convention documented in CLAUDE.md; MDX renders. - [x] Convention documented in CLAUDE.md; MDX renders.
- [ ] All machine specs pass unchanged (reducers untouched). - [x] All machine specs pass unchanged (reducers untouched).
## Verification ## Verification
GREEN + `npm run test-storybook:ci`. Smoke: all three wizards step forward/back and GREEN + `npm run test-storybook:ci` (197 unit / 137 Storybook, unchanged from WP-07 —
submit. 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 ## Out of scope

View File

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

View File

@@ -1,8 +1,17 @@
# WP-10 — CIBG button fidelity # WP-10 — CIBG button fidelity
Status: todo Status: done (69880ef)
Phase: 2 — CIBG fidelity 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 ## Why
The vendored CIBG build ships `.btn-primary / .btn-secondary / .btn-danger / .btn-ghost / 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 ## Acceptance criteria
- [ ] `grep -rn "btn-outline\|btn-sm" src/app` → empty. - [x] `grep -rn "btn-outline\|btn-sm" src/app` → empty.
- [ ] Button story shows all CIBG variants incl. ghost; visuals match the design system. - [x] Button story shows all CIBG variants incl. ghost; visuals match the design system.
- [ ] Axe still green (contrast can change with real button styles). - [x] Axe still green (contrast can change with real button styles).
## Verification ## Verification

View File

@@ -1,8 +1,20 @@
# WP-13 — CIBG-gap register + hygiene + MDX # WP-13 — CIBG-gap register + hygiene + MDX
Status: todo Status: done (9d58f59)
Phase: 2 — CIBG fidelity 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 > **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"). > 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`, > The vendored build actually ships a full upload vocabulary (`.file-picker-drop-area`,
@@ -43,6 +55,7 @@ the list-family rationale to document.
## Files ## Files
Components to mark (closest CIBG concept in parens): Components to mark (closest CIBG concept in parens):
- `skeleton`, `spinner` (Laadindicatie — no vendored class, verified) - `skeleton`, `spinner` (Laadindicatie — no vendored class, verified)
- `upload/` suite (Bestand-upload) - `upload/` suite (Bestand-upload)
- `rich-text-editor` (Tekstgebied) - `rich-text-editor` (Tekstgebied)
@@ -52,7 +65,7 @@ Components to mark (closest CIBG concept in parens):
- `debug-state` (devtool, no CIBG concept) - `debug-state` (devtool, no CIBG concept)
- `status-badge` (deliberate custom, documented in code), `card` (`.app-card`), - `status-badge` (deliberate custom, documented in code), `card` (`.app-card`),
`placeholder-chip` `placeholder-chip`
Plus: Plus:
- Delete `upload-status-banner` + its story; inline alert at its consumer - Delete `upload-status-banner` + its story; inline alert at its consumer
- Header comments on `task-list`/`application-list`/`choice-list` - Header comments on `task-list`/`application-list`/`choice-list`
- New `src/docs/cibg-gaps.mdx` — title `Foundations/CIBG Gap Register` - New `src/docs/cibg-gaps.mdx` — title `Foundations/CIBG Gap Register`
@@ -71,11 +84,11 @@ Plus:
## Acceptance criteria ## 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). carries the marker (spot-check with a grep for `styles: [` vs markers).
- [ ] Register MDX complete, linked from ADR-0003. - [x] Register MDX complete, linked from ADR-0003.
- [ ] `upload-status-banner` gone; consumer green; no story coverage lost. - [x] `upload-status-banner` gone; consumer green; no story coverage lost.
- [ ] List trio documented. - [x] List trio documented.
## Verification ## Verification

View File

@@ -1,8 +1,17 @@
# WP-14 — Storybook taxonomy reorg + Layers MDX # WP-14 — Storybook taxonomy reorg + Layers MDX
Status: todo Status: done (8b19fad)
Phase: 3 — Storybook as curriculum 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 ## Why
49 stories sit in a flat `Atoms/Molecules/Organisms/Templates/Layout` scheme with domain 49 stories sit in a flat `Atoms/Molecules/Organisms/Templates/Layout` scheme with domain
@@ -58,11 +67,11 @@ Domein/
## Acceptance criteria ## 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. pinned.
- [ ] Zero story titles outside the scheme (grep `title:` and eyeball). - [x] Zero story titles outside the scheme (grep `title:` and eyeball).
- [ ] `layers.mdx` renders; existing MDX pages unbroken. - [x] `layers.mdx` renders; existing MDX pages unbroken.
- [ ] Convention in CLAUDE.md. - [x] Convention in CLAUDE.md.
## Verification ## Verification

View File

@@ -1,9 +1,18 @@
# WP-15 — Missing stories: shell + brief components # WP-15 — Missing stories: shell + brief components
Status: todo Status: done (0cfb01f)
Phase: 3 — Storybook as curriculum Phase: 3 — Storybook as curriculum
Depends on: WP-14 (titles), WP-01 (axe gate covers the new stories automatically) 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 ## Why
"UI is exercised via Storybook stories" (CLAUDE.md §5) — but 7 components have none: "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 ## 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 co-located `*.stories.ts`; expect zero, pages excepted if that's the existing
norm — note the norm in this file when checked). norm — note the norm in this file when checked). **Confirmed norm:** `*.page.ts`
- [ ] All new stories pass the axe gate (or carry a justified skip). files (9 of them) have never had stories; every `*.component.ts` now does.
- [ ] Titles follow WP-14. - [x] All new stories pass the axe gate (or carry a justified skip).
- [x] Titles follow WP-14.
## Verification ## Verification

View File

@@ -1,11 +1,12 @@
# WP-16 — Component a11y: description wiring + alert role # WP-16 — Component a11y: description wiring + alert role
Status: todo Status: done (pending commit)
Phase: 4 — a11y Phase: 4 — a11y
## Why ## Why
Audit findings axe can't (fully) catch: Audit findings axe can't (fully) catch:
- `form-field` renders a description `<div [id]="fieldId()+'-desc'">` that **no control - `form-field` renders a description `<div [id]="fieldId()+'-desc'">` that **no control
ever references** — `text-input` sets `aria-describedby` only to `…-error` and only 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 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 ## 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. login-form BSN hint announced.
- [ ] `-error` id appended exactly when invalid; order stable. - [x] `-error` id appended exactly when invalid; order stable.
- [ ] Error alerts are `role="alert"`; play tests assert both behaviors and run in the - [x] Error alerts are `role="alert"`; play tests assert both behaviors and run in the
CI gate. 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 ## Verification
GREEN + `npm run test-storybook:ci` (includes the new play tests). 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 # WP-17 — App-level a11y: route focus/scroll, template lint, WCAG checklist + MDX
Status: todo Status: done (pending commit)
Phase: 4 — a11y Phase: 4 — a11y
## Why ## Why
Three app-level gaps close the WCAG story: Three app-level gaps close the WCAG story:
- **No route-change focus/scroll management** — `app.config.ts` has only - **No route-change focus/scroll management** — `app.config.ts` has only
`provideRouter(routes, withViewTransitions())`; after navigation, focus stays wherever `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.) skip link is the only cross-page mechanism.)
- **No template a11y linting** — `@angular-eslint` is entirely absent. - **No template a11y linting** — `@angular-eslint` is entirely absent.
- User decision: a **manual WCAG checklist** documents what automation can't test. - 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 ## 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. view transitions still play.
- [ ] Template a11y rules active and *proven* to fire; lint green. - [x] Template a11y rules active and _proven_ to fire; lint green.
- [ ] Checklist checked in with an initial pass filled in for the dashboard at minimum. - [x] 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] `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 ## 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 # 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 > 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 > 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, > self-service context; the Behandelaar/backoffice app that advances manual cases stays a separate,
> unbuilt context. > unbuilt context.
@@ -24,7 +24,7 @@ uploaded. Concretely, today:
and there is **no GET-content endpoint**. and there is **no GET-content endpoint**.
- A **manual diploma is hard-rejected**: `SubmissionRules.RejectRegistratie("handmatig")` returns a - 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 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 ## 2. Goals
@@ -54,25 +54,25 @@ uploaded. Concretely, today:
## 4. Personas ## 4. Personas
Single actor: the **Zorgverlener** (healthcare professional, DigiD/BSN, self-service). Per ADR-0002, 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. context that is not part of this app.
## 5. Domain model — the `Aanvraag` aggregate (backend-owned) ## 5. Domain model — the `Aanvraag` aggregate (backend-owned)
The backend gains an `Aanvraag` (application) aggregate — the system of record the dashboard reads. The backend gains an `Aanvraag` (application) aggregate — the system of record the dashboard reads.
| Field | Type | Notes | | Field | Type | Notes |
|---|---|---| | ------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------- |
| `id` | string (uuid) | client-visible handle; used in the resume deep link | | `id` | string (uuid) | client-visible handle; used in the resume deep link |
| `type` | `registratie` \| `herregistratie` \| `intake` | which wizard | | `type` | `registratie` \| `herregistratie` \| `intake` | which wizard |
| `status` | discriminated union (below) | computed on read for auto-approval | | `status` | discriminated union (below) | computed on read for auto-approval |
| `draft` | opaque JSON | the wizard's persisted machine snapshot (Concept only) | | `draft` | opaque JSON | the wizard's persisted machine snapshot (Concept only) |
| `stepIndex`, `stepCount` | int | for the "Stap X van Y" progress on the block | | `stepIndex`, `stepCount` | int | for the "Stap X van Y" progress on the block |
| `documentIds` | string[] | documents linked to this aanvraag | | `documentIds` | string[] | documents linked to this aanvraag |
| `referentie` | string? | set on submit (e.g. `BIG-2026-456789`) | | `referentie` | string? | set on submit (e.g. `BIG-2026-456789`) |
| `owner` | string | `DemoOwner` | | `owner` | string | `DemoOwner` |
| `autoApprovable` | bool | set at submit: `diplomaHerkomst === 'duo'` (registratie); other types auto | | `autoApprovable` | bool | set at submit: `diplomaHerkomst === 'duo'` (registratie); other types auto |
| `createdAt` / `updatedAt` / `submittedAt?` | timestamps | | | `createdAt` / `updatedAt` / `submittedAt?` | timestamps | |
### Status lifecycle ### Status lifecycle
@@ -94,7 +94,7 @@ FE-mirrored discriminated union (illegal states unrepresentable, same reflex as
```ts ```ts
type AanvraagStatus = type AanvraagStatus =
| { tag: 'Concept'; stepIndex: number; stepCount: number } | { 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: 'Goedgekeurd'; referentie: string }
| { tag: 'Afgewezen'; referentie: string; reden: 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 Per-status block (new `aanvraag-block` component; **which** actions/badge a block shows comes from a
pure `blockActions(status)`): pure `blockActions(status)`):
| Status | Badge | Body | Actions | | Status | Badge | Body | Actions |
|---|---|---|---| | ------------------ | ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------ |
| **Concept** | grey "Concept" | wizard type + "Stap X van Y" | **Verder gaan** (resume), **Annuleren** (cancel) | | **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 | | **In behandeling** | amber "In behandeling" | referentie + ingediend-datum; manual → "wordt handmatig beoordeeld in de backoffice" | view/preview documents |
| **Goedgekeurd** | green "Goedgekeurd" | referentie | — | | **Goedgekeurd** | green "Goedgekeurd" | referentie | — |
| **Afgewezen** | red "Afgewezen" | referentie + reden | — | | **Afgewezen** | red "Afgewezen" | referentie + reden | — |
- **Verder gaan** deep-links to the wizard with `?aanvraag=<id>`; the wizard loads the draft from the - **Verder gaan** deep-links to the wizard with `?aanvraag=<id>`; the wizard loads the draft from the
backend and seeds its machine. 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 > 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). > 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 > **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. > 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 - **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. BSN, or advance a manual application; others may not.
- **Data-scoping** — the same role sees *different rows*: only their own region / office / caseload. - **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 - **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. 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, - **Segregation-of-duty / step-up** — combinations and conditions: approver ≠ drafter, four-eyes,
recent MFA, time-boxed break-glass. 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`, 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 registered only under `isDevMode()` in `src/app/app.config.ts:22`). `X-Admin: true` is the parallel
admin stand-in. 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 check. There is **no** role/permission guard, and **no** `can` / `hasRole` / `isAuthorized` helper
anywhere. anywhere.
- The backend is **fully open**: `backend/src/BigRegister.Api/Program.cs` has no authentication or - 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 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` 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 `if (actingId == e.DrafterId) return Forbidden`) — but against the **unverified** `X-Role` header, so
any caller can assert `X-Role: approver`. 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 computes `(bool, reason)` and embeds it in a screen DTO — `HerregistratieDecisionsDto` inside
`DashboardViewDto` (`backend/src/BigRegister.Api/Contracts/Dtos.cs:25-27`), computed by `DashboardViewDto` (`backend/src/BigRegister.Api/Contracts/Dtos.cs:25-27`), computed by
`HerregistratieRule.Evaluate` (`backend/.../Domain/Registrations/HerregistratieRule.cs:16-27`). This `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 ## 2. Goals
1. Support all four control types above — **capability gating, data-scoping, field/PII-level, and 1. Support all four control types above — **capability gating, data-scoping, field/PII-level, and
step-up/SoD** — as one coherent model. 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. 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 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. 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) ## 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 (token, header, SSO) is a wiring concern for later, isolated to `infrastructure/` + the backend
authn middleware. authn middleware.
- **A general policy engine (OPA/Cedar/XACML).** We express access as named **capabilities** computed - **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 ## 4. Personas & attributes
Actors (per ADR-0002): the **Zorgverlener** (self-service, DigiD/BSN) and one or more **backoffice** 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. diverge without a folder-per-role explosion.
An access decision is a function of four attribute sets: An access decision is a function of four attribute sets:
| Attribute set | Source | Examples | | 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 | | **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 | | **Resource** | the domain entity | owner id, region, sensitivity class (contains BSN / art. 9 data), status |
| **Action** | the request | `view`, `edit`, `approve`, `reveal-bsn`, `beoordelen` | | **Action** | the request | `view`, `edit`, `approve`, `reveal-bsn`, `beoordelen` |
| **Environment** | the request context | MFA/assurance level, time-of-day, break-glass flag | | **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 > 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. > 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) ### 5b. Data-scoping (row-level)
The server **filters rows by the subject's scope attributes at the source** — a Beoordelaar for region 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 them (no client-side "fetch all, hide some"). Scope is a subject attribute (overlay/derived), applied
in the query, not a UI filter. 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. never read directly by feature code.
> **Non-negotiable:** none of the above is a security boundary. A user who forges `can()` in the > **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). > by the backend (§7).
## 7. Backend design ## 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 authn middleware later). Merge **AD roles + the app-owned overlay** into one `Principal` here — the
FE never sees the merge. FE never sees the merge.
- **Resolve + enforce capabilities** in a single shared authorization helper (`Authz.Can(principal, - **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, 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 `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. 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-color-{lintblauw,donkerblauw,cool-grey}-*` / `--rhc-color-wit`, radius
`--rhc-border-radius-*`. `--rhc-border-radius-*`.
| # | Pri | Finding | Resolves to | | # | 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.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.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.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.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.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` | | 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 ## 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.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 ## 3. Color & contrast
| # | Pri | Finding | Resolves to | | # | 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.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 | | 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 ## 4. Spacing & layout
| # | Pri | Finding | Resolves to | | # | 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.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 | | 4.2 | L | `shell` uses a custom `--app-content-max:64rem` defined inline. | promote to the `styles.scss` token layer |
## 5. Component reuse ## 5. Component reuse
| # | Pri | Finding | Resolves to | | # | 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.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) | | 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 ## 6. Form, validation & status patterns
| # | Pri | Finding | Resolves to | | # | 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.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.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.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.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) | | 6.5 | L | No error-summary pattern; per-field inline errors only. Acceptable for short steps; revisit if steps grow. | (defer) |
## 7. Page chrome ## 7. Page chrome
| # | Pri | Finding | Resolves to | | # | 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.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.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 | | 7.3 | L | Skip-link uses `left:-999px` (works) and landmarks (`header/main/footer`) are correct. | keep; tokenize offset |
## 8. Copy & tone ## 8. Copy & tone
| # | Pri | Finding | Resolves to | | # | 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) | | 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 ## 9. Accessibility (WCAG 2.1 AA) — consolidated
| # | Pri | Finding | Resolves to | | # | 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.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.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.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.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 | | 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 ## 10. Responsive
| # | Pri | Finding | Resolves to | | # | 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 | | 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

32
e2e/error-state.spec.ts Normal file
View File

@@ -0,0 +1,32 @@
import { expect, test } from '@playwright/test';
// The dev-only `?scenario=error` toggle forces the scenario.interceptor to fail
// the request WITHOUT ever reaching the real HTTP transport (it substitutes a
// `throwError` in the rxjs pipe before `next(req)` runs) — so this is not
// observable as a browser network request. It IS observable as a real resource
// reload: `retry()` calls `resource.reload()`, which flips <app-async> back to
// its Loading (`aria-busy="true"`) state before failing again 400ms later.
// `currentScenario()` re-reads `window.location.search` on every call, not a
// one-shot value, so as long as the query param is still there, the retry
// genuinely re-runs and genuinely fails the same way — that's what's asserted
// here: a real reload cycle, not a no-op button.
test('dashboard error state renders, retry re-fetches (and fails again)', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('BSN').fill('123456789');
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto('/dashboard?scenario=error');
// The dashboard has more than one independent <app-async> (profile,
// aantekeningen) — both fail under the blanket ?scenario=error, so this text
// legitimately appears more than once. Assert at least one, not exactly one.
const errorAlert = page.getByText('Er ging iets mis bij het laden van de gegevens.').first();
await expect(errorAlert).toBeVisible();
const retry = page.getByRole('button', { name: 'Opnieuw proberen' }).first();
await expect(retry).toBeVisible();
await retry.click();
// A real reload cycle: back to Loading (aria-busy) before failing again.
await expect(page.locator('[aria-busy="true"]').first()).toBeAttached({ timeout: 1_000 });
await expect(errorAlert).toBeVisible({ timeout: 5_000 });
});

59
e2e/smoke.spec.ts Normal file
View File

@@ -0,0 +1,59 @@
import { expect, test } from '@playwright/test';
// One happy-path flow through the real FE+backend: log in, land on the real
// dashboard, run the registratie wizard's minimum required path (a DUO diploma
// with zero policy questions, so the only required upload is identiteit), submit,
// and see the real confirmation. Not a full wizard-coverage suite — see WP-19.
//
// The backend is in-memory and shared across runs; this test mutates real state
// (creates a registratie application for the fixed demo identity). Restart the
// backend between CI runs — a second run would see a leftover Concept/submitted
// application on the dashboard, which this test doesn't assert against, but a
// stricter future test might.
test('login → dashboard → registratie wizard → submitted', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('BSN').fill('123456789');
await page.getByLabel('Wachtwoord').fill('demo');
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('heading', { level: 1, name: 'Mijn overzicht' })).toBeVisible();
// Real backend content, not a loading/error state.
await expect(page.getByText('Persoonsgegevens (BRP)')).toBeVisible();
await page.goto('/registreren');
await expect(
page.getByRole('heading', { level: 1, name: 'Inschrijven in het BIG-register' }),
).toBeVisible();
// Step 1 — adres: BRP prefill is real backend data; "Post" skips the email field.
// The CIBG-styled radio hides the native input behind its label, so click the
// label (real user interaction) rather than `.check()` the covered input.
await expect(page.getByText('Vooraf ingevuld op basis van de BRP')).toBeVisible();
await page.locator('label[for="correspondentie-post"]').click();
await page.locator('button[type="submit"]').click();
// Step 2 — beroep: the first DUO diploma (Geneeskunde, non-English) carries zero
// policy questions, so the only required document is identiteit.
await expect(page.locator('#diploma-d1')).toBeVisible({ timeout: 10_000 });
await page.locator('label[for="diploma-d1"]').click();
await expect(page.getByText('Beroep (afgeleid uit diploma)')).toBeVisible();
await page.locator('#identiteit-file').setInputFiles({
name: 'identiteit.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('%PDF-1.4 fake e2e fixture'),
});
// Real upload against the backend: wait for the row to leave "uploading".
await expect(page.locator('.upload-progress')).toHaveCount(0, { timeout: 10_000 });
await expect(page.locator('.icon-remove')).toBeVisible();
await page.locator('button[type="submit"]').click();
// Step 3 — controle: review + real submit.
await expect(page.getByText('Controleer uw gegevens en dien de registratie in.')).toBeVisible();
await page.locator('button[type="submit"]').click();
await expect(page.getByText('Uw registratie is ontvangen')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/Uw referentienummer is/)).toBeVisible();
});

View File

@@ -1,4 +1,5 @@
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
/** /**
* Enforces the architecture's working agreements that were previously only * Enforces the architecture's working agreements that were previously only
@@ -28,8 +29,18 @@ export default [
}, },
plugins: { '@typescript-eslint': tseslint.plugin }, plugins: { '@typescript-eslint': tseslint.plugin },
rules: { '@typescript-eslint/no-explicit-any': 'error' }, rules: { '@typescript-eslint/no-explicit-any': 'error' },
// This repo has no .html files — every template is inline. This processor
// extracts each component's template string into a virtual `*.html` file,
// which the template-a11y block below (`files: ['src/**/*.html']`) then lints.
processor: angular.processInlineTemplates,
}, },
// Template a11y (WP-17): the official angular-eslint accessibility bundle
// (alt-text, label-has-associated-control, click/mouse-events-have-key-events,
// interactive-supports-focus, valid-aria, no-autofocus, and more) linting the
// virtual `.html` files the processor above extracts from inline templates.
...angular.configs.templateAccessibility.map((c) => ({ ...c, files: ['src/**/*.html'] })),
// Tests legitimately use `any` to feed invalid messages/states into reducers. // Tests legitimately use `any` to feed invalid messages/states into reducers.
{ {
files: ['src/**/*.spec.ts'], files: ['src/**/*.spec.ts'],
@@ -42,7 +53,14 @@ export default [
rules: { rules: {
'no-restricted-imports': [ 'no-restricted-imports': [
'error', 'error',
{ patterns: [{ group: ['@angular/*', '@angular/**'], message: 'domain/ must stay framework-free (pure TS) — no Angular imports.' }] }, {
patterns: [
{
group: ['@angular/*', '@angular/**'],
message: 'domain/ must stay framework-free (pure TS) — no Angular imports.',
},
],
},
], ],
}, },
}, },
@@ -55,7 +73,14 @@ export default [
rules: { rules: {
'no-restricted-imports': [ 'no-restricted-imports': [
'error', 'error',
{ patterns: [{ group: ['@auth/*', '@registratie/*', '@herregistratie/*', '@brief/*'], message: 'shared/ must not depend on a feature context.' }] }, {
patterns: [
{
group: ['@auth/*', '@registratie/*', '@herregistratie/*', '@brief/*'],
message: 'shared/ must not depend on a feature context.',
},
],
},
], ],
}, },
}, },
@@ -66,7 +91,14 @@ export default [
rules: { rules: {
'no-restricted-imports': [ 'no-restricted-imports': [
'error', 'error',
{ patterns: [{ group: ['@registratie/*', '@herregistratie/*', '@brief/*'], message: 'auth/ may depend only on shared.' }] }, {
patterns: [
{
group: ['@registratie/*', '@herregistratie/*', '@brief/*'],
message: 'auth/ may depend only on shared.',
},
],
},
], ],
}, },
}, },
@@ -77,7 +109,14 @@ export default [
rules: { rules: {
'no-restricted-imports': [ 'no-restricted-imports': [
'error', 'error',
{ patterns: [{ group: ['@herregistratie/*', '@brief/*'], message: 'Dependencies point herregistratie → registratie → shared, never back.' }] }, {
patterns: [
{
group: ['@herregistratie/*', '@brief/*'],
message: 'Dependencies point herregistratie → registratie → shared, never back.',
},
],
},
], ],
}, },
}, },
@@ -88,7 +127,14 @@ export default [
rules: { rules: {
'no-restricted-imports': [ 'no-restricted-imports': [
'error', 'error',
{ patterns: [{ group: ['@auth/*', '@registratie/*', '@herregistratie/*'], message: 'brief/ may depend only on shared.' }] }, {
patterns: [
{
group: ['@auth/*', '@registratie/*', '@herregistratie/*'],
message: 'brief/ may depend only on shared.',
},
],
},
], ],
}, },
}, },
@@ -105,8 +151,20 @@ export default [
{ {
patterns: [ patterns: [
{ {
group: ['@angular/**', '@shared/**', '@auth/**', '@registratie/**', '@herregistratie/**', '@brief/**', './*', '../*', './**', '../**'], group: [
message: 'contracts/ is the wire seam — it must import NOTHING (pure DTO shapes). Map wire → domain in the infrastructure adapter, not here.', '@angular/**',
'@shared/**',
'@auth/**',
'@registratie/**',
'@herregistratie/**',
'@brief/**',
'./*',
'../*',
'./**',
'../**',
],
message:
'contracts/ is the wire seam — it must import NOTHING (pure DTO shapes). Map wire → domain in the infrastructure adapter, not here.',
}, },
], ],
}, },
@@ -129,7 +187,8 @@ export default [
{ {
group: ['@shared/infrastructure/api-client'], group: ['@shared/infrastructure/api-client'],
allowTypeImports: true, allowTypeImports: true,
message: 'The ApiClient lives only in infrastructure/ adapters (ADR-0001). UI/application call an adapter or a command, not the network client. (Type-only DTO imports are fine: use `import type`.)', message:
'The ApiClient lives only in infrastructure/ adapters (ADR-0001). UI/application call an adapter or a command, not the network client. (Type-only DTO imports are fine: use `import type`.)',
}, },
], ],
}, },
@@ -168,7 +227,8 @@ export default [
'@brief/infrastructure/*', '@brief/infrastructure/*',
], ],
allowTypeImports: true, allowTypeImports: true,
message: 'ui/ and layout/ must not import infrastructure/ directly (CLAUDE.md §1: ui → application → domain). Reach data through an application store or command. (Type-only DTO imports are fine: use `import type`.)', message:
'ui/ and layout/ must not import infrastructure/ directly (CLAUDE.md §1: ui → application → domain). Reach data through an application store or command. (Type-only DTO imports are fine: use `import type`.)',
}, },
], ],
}, },

4372
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,8 @@
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"lint": "eslint .", "lint": "eslint .",
"format:check": "prettier --check .",
"format": "prettier --write .",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
"gen:api": "cd backend && dotnet tool restore && dotnet build src/BigRegister.Api -v q && dotnet swagger tofile --output swagger.json src/BigRegister.Api/bin/Debug/net10.0/BigRegister.Api.dll v1 && cd .. && nswag run nswag.json", "gen:api": "cd backend && dotnet tool restore && dotnet build src/BigRegister.Api -v q && dotnet swagger tofile --output swagger.json src/BigRegister.Api/bin/Debug/net10.0/BigRegister.Api.dll v1 && cd .. && nswag run nswag.json",
"build": "ng build", "build": "ng build",
@@ -13,7 +15,9 @@
"build-storybook": "ng run atomic-design-poc:build-storybook", "build-storybook": "ng run atomic-design-poc:build-storybook",
"test-storybook": "test-storybook", "test-storybook": "test-storybook",
"test-storybook:ci": "concurrently -k -s first -n sb,axe \"http-server storybook-static -p 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\"", "test-storybook:ci": "concurrently -k -s first -n sb,axe \"http-server storybook-static -p 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\"",
"check:tokens": "bash scripts/check-tokens.sh" "check:tokens": "bash scripts/check-tokens.sh",
"e2e": "playwright test",
"extract-i18n": "ng extract-i18n --output-path src/locale"
}, },
"private": true, "private": true,
"packageManager": "npm@11.12.1", "packageManager": "npm@11.12.1",
@@ -37,11 +41,13 @@
"@angular/localize": "^22.0.4", "@angular/localize": "^22.0.4",
"@angular/platform-browser-dynamic": "^22.0.0", "@angular/platform-browser-dynamic": "^22.0.0",
"@compodoc/compodoc": "^1.2.1", "@compodoc/compodoc": "^1.2.1",
"@playwright/test": "^1.61.1",
"@storybook/addon-a11y": "^10.4.6", "@storybook/addon-a11y": "^10.4.6",
"@storybook/addon-docs": "^10.4.6", "@storybook/addon-docs": "^10.4.6",
"@storybook/addon-onboarding": "^10.4.6", "@storybook/addon-onboarding": "^10.4.6",
"@storybook/angular": "^10.4.6", "@storybook/angular": "^10.4.6",
"@storybook/test-runner": "^0.24.4", "@storybook/test-runner": "^0.24.4",
"angular-eslint": "^22.0.0",
"axe-playwright": "^2.2.2", "axe-playwright": "^2.2.2",
"concurrently": "^10.0.3", "concurrently": "^10.0.3",
"eslint": "^10.6.0", "eslint": "^10.6.0",

29
playwright.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig } from '@playwright/test';
// Smoke-level e2e (WP-19): one happy path, one degraded path, against the REAL
// backend — proving the FE+BE seam, not replacing component/unit tests.
const baseURL = process.env['E2E_BASE_URL'] ?? 'http://localhost:4200';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
fullyParallel: true,
retries: process.env['CI'] ? 1 : 0,
reporter: process.env['CI'] ? 'github' : 'list',
use: {
baseURL,
trace: 'on-first-retry',
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
// CI starts `ng serve` + the backend as separate job steps (both need to be up
// before the suite runs); locally, boot `npm start` automatically so `npm run e2e`
// works standalone — the backend still needs `dotnet run` running separately.
webServer: process.env['CI']
? undefined
: {
command: 'npm start',
url: baseURL,
reuseExistingServer: true,
timeout: 120_000,
},
});

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