Compare commits

..

25 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
224 changed files with 27509 additions and 7941 deletions

View File

@@ -30,7 +30,10 @@ jobs:
- run: npm run format:check - 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. # The shipped bundle must stay clean; dev-only advisories are excluded.
- run: npm audit --omit=dev - run: npm audit --omit=dev
@@ -60,6 +63,29 @@ jobs:
- run: dotnet format backend/BigRegister.slnx --verify-no-changes - run: dotnet format backend/BigRegister.slnx --verify-no-changes
- run: dotnet test backend/BigRegister.slnx - 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: codeql:
# Static analysis (SAST) for both sides; results appear under the Security tab. # Static analysis (SAST) for both sides; results appear under the Security tab.
runs-on: ubuntu-latest runs-on: ubuntu-latest

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

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

@@ -25,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

View File

@@ -31,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**.
@@ -166,12 +167,21 @@ degrade to an instant navigation.
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in - Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the `public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency. `--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
- Data: ASP.NET Core backend (`backend/`, in-memory seeded) exposed via an OpenAPI - Data: ASP.NET Core backend (`backend/`, EF Core/SQLite-persisted; BRP/DUO
contract; the FE consumes an **NSwag-generated** typed client (`npm run gen:api`). 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
@@ -185,6 +195,11 @@ We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 2
### Deliberately out of scope (POC) ### Deliberately out of scope (POC)
Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, i18n, Real auth/DigiD, real BRP/DUO upstreams, a production-grade database (Postgres/SQL
NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark). (The backend Server — SQLite persists applications/documents/the brief + a real audit table,
itself _is_ implemented.) 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

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

@@ -29,34 +29,48 @@ public sealed class Aanvraag
} }
/// <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 now = DateTimeOffset.UtcNow;
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now }; var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
lock (_gate) _apps[a.Id] = a; lock (_gate)
{
using var db = Db.Create();
db.Applications.Add(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) return _apps.TryGetValue(id, out var a) && a.Owner == owner ? a : null; lock (_gate)
{
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) return _apps.Values.Where(a => a.Owner == owner).ToList(); lock (_gate)
{
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.
@@ -64,12 +78,15 @@ public static class ApplicationStore
{ {
lock (_gate) lock (_gate)
{ {
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false; using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null || a.Owner != owner || a.Submitted) return false;
a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call) a.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;
db.SaveChanges();
return true; return true;
} }
} }
@@ -81,9 +98,12 @@ public static class ApplicationStore
List<string> docs; List<string> docs;
lock (_gate) lock (_gate)
{ {
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner) return false; using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null || a.Owner != owner) return false;
docs = a.DocumentIds.ToList(); docs = a.DocumentIds.ToList();
_apps.Remove(id); db.Applications.Remove(a);
db.SaveChanges();
} }
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner); foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
return true; return true;
@@ -96,7 +116,9 @@ public static class ApplicationStore
{ {
lock (_gate) lock (_gate)
{ {
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return null; using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null || a.Owner != owner || a.Submitted) return null;
a.Submitted = true; a.Submitted = true;
a.SubmittedAt = DateTimeOffset.UtcNow; a.SubmittedAt = DateTimeOffset.UtcNow;
a.UpdatedAt = a.SubmittedAt.Value; a.UpdatedAt = a.SubmittedAt.Value;
@@ -104,6 +126,7 @@ public static class ApplicationStore
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();
db.SaveChanges();
return a; return a;
} }
} }

View File

@@ -1,13 +1,17 @@
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
{ {
@@ -19,28 +23,40 @@ public sealed class BriefEntity
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)
{ {
if (_byOwner.TryGetValue(owner, out var existing)) return existing; using var db = Db.Create();
var existing = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (existing is not null) return existing;
var created = BriefSeed.NewBrief(owner); var created = BriefSeed.NewBrief(owner);
_byOwner[owner] = created; db.Briefs.Add(created);
db.SaveChanges();
return created; return created;
} }
} }
@@ -51,11 +67,14 @@ public static class BriefStore
{ {
lock (_gate) lock (_gate)
{ {
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null); if (!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");
db.SaveChanges();
return (Outcome.Ok, e); return (Outcome.Ok, e);
} }
} }
@@ -64,27 +83,42 @@ public static class BriefStore
{ {
lock (_gate) lock (_gate)
{ {
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null); if (!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);
db.SaveChanges();
return (Outcome.Ok, e); 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)
{ {
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (e.Status.Tag != "approved") return (Outcome.Conflict, null); if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
e.Status = new BriefStatusDto("sent", SentAt: at); e.Status = new BriefStatusDto("sent", SentAt: at);
// Pin the org-template version the letter was sent with (WP-23): from here on
// its appearance is frozen — republishing the template touches unsent briefs only.
e.SentOrgTemplateVersion = OrgTemplateStore.PublishedVersionOf(e.SubOrgId);
// Archive the composed HTML at this exact instant (WP-25): the preview endpoint
// serves this verbatim once sent, so a later republish never re-renders it.
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.SentOrgTemplateVersion);
e.ArchivedHtml = LetterHtml.Render(e, template, at, watermark: false);
db.SaveChanges();
return (Outcome.Ok, e); return (Outcome.Ok, e);
} }
} }
@@ -92,7 +126,11 @@ public static class BriefStore
/// 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) _byOwner.Clear(); lock (_gate)
{
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
@@ -101,23 +139,31 @@ public static class BriefStore
{ {
lock (_gate) lock (_gate)
{ {
_byOwner.Remove(owner); using var db = Db.Create();
db.Briefs.Where(e => e.Owner == owner).ExecuteDelete();
var created = BriefSeed.NewBrief(owner); var created = BriefSeed.NewBrief(owner);
_byOwner[owner] = created; db.Briefs.Add(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)
{ {
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); using var db = Db.Create();
if (actingId == e.DrafterId) return (Outcome.Forbidden, null); var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null);
if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null); if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
e.Status = next(e); e.Status = next();
db.SaveChanges();
return (Outcome.Ok, e); return (Outcome.Ok, e);
} }
} }

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>
@@ -13,33 +13,48 @@ public sealed record StoredDocument(
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 List<AuditEntry> _audit = new();
private static readonly object _gate = new(); private static readonly object _gate = new();
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner) public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner)
{ {
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow); var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow);
lock (_gate) _docs[doc.DocumentId] = doc; lock (_gate)
{
using var db = Db.Create();
db.Documents.Add(doc);
db.SaveChanges();
}
Audit("upload", doc.DocumentId, categoryId, owner); Audit("upload", doc.DocumentId, categoryId, owner);
return doc; return doc;
} }
public static StoredDocument? Get(string documentId) public static StoredDocument? Get(string documentId)
{ {
lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null; lock (_gate)
{
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
@@ -47,15 +62,26 @@ public static class DocumentStore
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds) public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
{ {
var set = localIds.ToHashSet(); var set = localIds.ToHashSet();
lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList(); lock (_gate)
{
using var db = Db.Create();
return db.Documents.Where(d => set.Contains(d.LocalId)).ToList();
}
} }
/// Mark digital documents as linked to a finalised submission (blocks user delete). /// 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 }
@@ -66,10 +92,13 @@ public static class DocumentStore
string categoryId; string categoryId;
lock (_gate) lock (_gate)
{ {
if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound; using var db = Db.Create();
var d = db.Documents.Find(documentId);
if (d is null || d.Owner != owner) return DeleteResult.NotFound;
if (d.Linked) return DeleteResult.Linked; if (d.Linked) return DeleteResult.Linked;
categoryId = d.CategoryId; categoryId = d.CategoryId;
_docs.Remove(documentId); db.Documents.Remove(d);
db.SaveChanges();
} }
Audit("delete-user", documentId, categoryId, owner); Audit("delete-user", documentId, categoryId, owner);
return DeleteResult.Ok; return DeleteResult.Ok;
@@ -82,9 +111,12 @@ public static class DocumentStore
string categoryId; string categoryId;
lock (_gate) lock (_gate)
{ {
if (!_docs.TryGetValue(documentId, out var d)) return false; using var db = Db.Create();
var d = db.Documents.Find(documentId);
if (d is null) return false;
categoryId = d.CategoryId; categoryId = d.CategoryId;
_docs.Remove(documentId); db.Documents.Remove(d);
db.SaveChanges();
} }
Audit("delete-admin", documentId, categoryId, actor); Audit("delete-admin", documentId, categoryId, actor);
return true; return true;
@@ -92,11 +124,23 @@ public static class DocumentStore
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) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor)); lock (_gate)
{
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 { lock (_gate) return _audit.ToList(); } get
{
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

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

@@ -19,6 +19,7 @@ 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);
@@ -42,6 +43,13 @@ public static class DocumentRules
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),
}, },
// 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[]
{
new DocumentCategory("org-logo", "Organisatielogo",
"Upload het logo van de organisatie (PNG of JPG).", false, Image, 1, false, false),
},
_ => Array.Empty<DocumentCategory>(), _ => Array.Empty<DocumentCategory>(),
}; };

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

@@ -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);
@@ -19,13 +23,46 @@ 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);
@@ -239,97 +276,187 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
.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(r, "De beoordelaar mag niet de opsteller zijn."); return BriefResult(ctx, 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(r, "De beoordelaar mag niet de opsteller zijn."); return BriefResult(ctx, 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
// today — see Authz.CanActOn(Send, …), a mechanical dispatch step).
var r = BriefStore.Send(DocumentStore.DemoOwner, Now()); var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
LogBrief("send", r); LogBrief("send", r);
return BriefResult(r, "Versturen kan niet in deze status."); 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) =>
{
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
return Results.Content(archived, "text/html");
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html");
})
.ExcludeFromDescription();
// Proefbrief: the admin's unpublished draft template rendered over a fixture
// brief, so the appearance can be checked before publishing touches real letters.
api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
{
var view = OrgTemplateStore.AdminView(subOrgId);
if (view is null) return Results.NotFound();
var fixture = BriefSeed.NewBrief("proefbrief");
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
}))
.ExcludeFromDescription();
api.MapPost("/brief/reset", (HttpContext ctx) =>
{ {
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only. // Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner); var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep)); 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),
}; };
@@ -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 (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached))
{
app.Logger.LogInformation("submit kind={Kind} outcome=replayed correlationId={Cid}", kind, cid);
return cached!;
}
IResult result;
if (reject is not null) if (reject is not null)
{ {
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid); app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity); result = Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
} }
else
if (documents is not null)
{ {
// Link digital documents (blocks later user delete) and record post-delivery if (documents is not null)
// 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!)); // Link digital documents (blocks later user delete) and record post-delivery
foreach (var d in documents.Where(d => d.Channel == "post")) // intent so a caseworker knows to expect the physical document.
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid); DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
foreach (var d in documents.Where(d => d.Channel == "post"))
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
}
var reference = SubmissionRules.NewReference();
app.Logger.LogInformation(
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
kind, reference, cid, DateTimeOffset.UtcNow);
result = Results.Ok(new ReferentieResponse(reference));
} }
var reference = SubmissionRules.NewReference(); if (idemKey is not null) IdempotencyStore.Set(idemKey, result);
app.Logger.LogInformation( return result;
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
kind, reference, cid, DateTimeOffset.UtcNow);
return Results.Ok(new ReferentieResponse(reference));
} }
// 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,7 +6,7 @@ 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();

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,7 +11,7 @@ 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();
@@ -90,8 +90,8 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
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]
@@ -106,7 +106,7 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
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]
@@ -117,13 +117,13 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
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]
@@ -139,7 +139,36 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
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]
public async Task Decisions_on_the_view_mirror_the_acting_principal_and_live_status()
{
var brief = await Get();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.True(view!.Decisions.CanEdit); // default (no X-Role) = drafter, draft status
Assert.False(view.Decisions.CanApprove);
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
var asApprover = await _client.SendAsync(
new HttpRequestMessage(HttpMethod.Get, "/api/v1/brief") { Headers = { { "X-Role", "approver" } } });
var approverView = await asApprover.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.True(approverView!.Decisions.CanApprove);
Assert.False(approverView.Decisions.CanEdit); // approver never edits
}
[Fact]
public async Task Me_returns_no_capabilities_for_drafter_and_the_brief_set_for_approver()
{
var asDrafter = await _client.GetFromJsonAsync<MeDto>("/api/v1/me");
Assert.Empty(asDrafter!.Capabilities);
var res = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/v1/me") { Headers = { { "X-Role", "approver" } } });
var asApprover = await res.Content.ReadFromJsonAsync<MeDto>();
Assert.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, asApprover!.Capabilities);
} }
[Fact] [Fact]

View File

@@ -7,7 +7,7 @@ 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();
@@ -125,6 +125,22 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
} }
[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());
}
[Fact]
public async Task Correlation_id_is_generated_when_the_caller_omits_it()
{
var res = await _client.GetAsync("/api/v1/notes");
Assert.NotEmpty(res.Headers.GetValues("X-Correlation-Id").Single());
}
// --- Document upload --- // --- Document upload ---
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType) private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)

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

@@ -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,9 +9,18 @@ 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'

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

@@ -48,3 +48,7 @@ layer — not a palette swap.
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,8 +30,13 @@ 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
@@ -44,24 +49,30 @@ for its existing violations, so every WP ends green.
| [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 | todo | | [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 | todo | | [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 | todo | | [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 | todo | | [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 | todo | | [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
@@ -71,6 +82,9 @@ are independent of each other and of phases 14 — pick any order; **18 is th
first pick** (it's the headline gap: no authorization spine exists yet, and it closes the 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 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. 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

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
@@ -45,10 +45,10 @@ PassageScope` → validated parse (the file is otherwise parse-heavy; this one f
## 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

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>`)
@@ -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`,
@@ -72,11 +84,11 @@ Components to mark (closest CIBG concept in parens):
## 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,6 +1,6 @@
# 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
@@ -57,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,6 +1,6 @@
# 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
@@ -59,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

@@ -1,6 +1,6 @@
# WP-18 — ABAC capability spine (Principal + capabilities, phase P1) # WP-18 — ABAC capability spine (Principal + capabilities, phase P1)
Status: todo Status: done (7ec13d8)
Phase: 5 — productie-volwassenheid Phase: 5 — productie-volwassenheid
## Why ## Why
@@ -8,146 +8,171 @@ Phase: 5 — productie-volwassenheid
The single biggest gap between this POC and a production SSP: identity carries no 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 roles/capabilities (`Session { bsn, naam }` only), the only "role" is an unverified
`?role=` query param stamped as an `X-Role` header, and `BriefStore.editable` `?role=` query param stamped as an `X-Role` header, and `BriefStore.editable`
computes its authorization gate **in the frontend** from that header — the exact 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). anti-pattern ADR-0001 exists to prevent (FE renders decisions, never computes them).
The backend is fully open: no `[Authorize]`, no principal, ownership is a constant The backend was fully open: no `[Authorize]`, no principal, ownership is a constant
`DemoOwner`. ADR-0002 and PRD-0002 already designed the fix; nothing is built. This `DemoOwner`. ADR-0002 and PRD-0002 already designed the fix; this WP implements
WP implements PRD-0002's **P1 — Capability spine** only (§9), the smallest slice PRD-0002's **P1 — Capability spine** only (§9), the smallest slice that closes the
that closes the anti-pattern and gives every later phase (data-scoping, PII anti-pattern and gives every later phase (data-scoping, PII redaction, step-up/audit)
redaction, step-up/audit) a real foundation to extend. a real foundation to extend.
## Read first ## Read first
- `docs/architecture/0002-user-groups-and-bounded-contexts.md` (the `Principal` - `docs/architecture/0002-user-groups-and-bounded-contexts.md` (the `Principal`
union, identity-vs-authorization split) union, identity-vs-authorization split — see the deviation noted below)
- `docs/prd/0002-attribute-based-access-control.md` §5a, §6, §7, §9-P1 (this WP - `docs/prd/0002-attribute-based-access-control.md` §5a, §6, §7, §9-P1
implements exactly P1 — don't reach into P2/P3) - `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new — the single
- `src/app/auth/domain/session.ts` (flat `Session` to replace) authorization helper)
- `src/app/auth/application/session.store.ts`, `src/app/auth/auth.guard.ts` (seams - `backend/src/BigRegister.Api/Data/BriefStore.cs` (`Review` — now delegates its
that already localise the `Session → Principal` swap, per ADR-0002) SoD guard to `Authz.CanActOn`)
- `src/app/shared/domain/role.ts`, `src/app/shared/infrastructure/role.ts` + - `src/app/brief/application/brief.store.ts` (the FE-computed gate that was removed)
`role.interceptor.ts` (the dev stub being retired as an authority, kept as a dev
toggle)
- `src/app/brief/application/brief.store.ts:28,41-46` (`readonly role = currentRole()`
and the `editable` computed — the FE-computed gate to remove)
- `backend/src/BigRegister.Api/Contracts/Dtos.cs:25-27` (`HerregistratieDecisionsDto`
— the decision-flag pattern this WP extends to a `BriefDecisionsDto`)
- `backend/src/BigRegister.Api/Program.cs:318-335` (`IsAdmin`, the `X-Role` reader
around brief endpoints — becomes `Authz.Can`)
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`Review`, the
`actingId == e.DrafterId → Forbidden` SoD check — keep this check, move it behind
the verified principal)
## Decisions (pre-made, don't relitigate) ## Decisions (pre-made, don't relitigate)
- **P1 scope only.** No data-scoping, no PII redaction/BSN reveal, no step-up or - **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. This WP: `Principal`, audit log — those are PRD-0002 §9 P2/P3, separate future WPs.
`AccessStore`/`can()`, `capabilityGuard`, `GET /me`, capability flags on the brief
screen DTO, and server-side enforcement via one shared `Authz.Can` helper.
- **The AD/OIDC identity provider stays simulated** (PRD-0002 §3 non-goal). The - **The AD/OIDC identity provider stays simulated** (PRD-0002 §3 non-goal). The
`Principal` is still built server-side from the existing dev stand-ins `Principal` is built server-side from the existing dev stand-in (`X-Role` header),
(`X-Role`/`X-Admin` headers), but it becomes the backend's own construct — the FE but it becomes the backend's own construct — the FE never re-derives capabilities
never re-derives capabilities from the header, it only reads what the backend sends. from the header, it only reads what the backend sends.
- **Capability naming**: stable, namespaced strings per PRD-0002 §5a — start with - **Capability naming**: stable, namespaced strings per PRD-0002 §5a — exactly
exactly `brief:approve`, `brief:reject`, `brief:send` (the brief flow is the only `brief:approve`, `brief:reject`, `brief:send` (the only role-gated flow that
role-gated flow that exists today). Do not invent capabilities for flows that don't exists today). The brief screen's fourth flag, `canEdit`, is a **screen decision**
exist yet (e.g. `aanvraag:beoordelen` — that's the backoffice, ADR-0002, out of scope). on `BriefDecisionsDto`, not a named capability string — it's resource/state-scoped
- **Emit and enforce are the same code path.** `Authz.Can(principal, action, resource)` (draft/rejected + drafter role) the same way `HerregistratieDecisionsDto` blends
is called both to compute the DTO flag and to gate the endpoint — never two separate business state into a decision flag, and `GET /me`'s coarse `RoleCapabilities` set
checks that can drift (PRD-0002 §7, the classic BOLA bug it calls out). stays exactly the three above.
- **Dev role toggle survives**, but moves behind the `Principal` seam: `?role=` still - **Emit and enforce are the same code path for approve/reject.**
picks an identity for demo purposes, but it flows into building the `Principal` `Authz.CanActOn(action, principal, drafterId)` is the SAME check
server-side (still asserted by the client — this is _not_ real auth, just moving `BriefStore.Review` uses to gate the mutation and `Authz.Decisions` uses to compute
the authority from FE-computed to BE-computed within the POC's honesty envelope). the DTO flag — never two separate checks that can drift (PRD-0002 §7, the classic
Keep it explicitly commented `// dev stub — NOT a security boundary` per PRD-0002 §3. 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 ## Files (as built)
- `src/app/auth/domain/session.ts` — replace `Session` with the `Principal` - `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new) — `Principal`,
discriminated union from ADR-0002 (`{ kind: 'zorgverlener'; bsn; naam }` — no `PrincipalRole`, `BriefAction`, `Authz.ResolvePrincipal/ActingId/RoleCapabilities/
`medewerker` variant yet, that's ADR-0002/backoffice scope; keep the union shape so CanActOn/Decisions`.
it's additive later). Update `isAuthenticated`. - `backend/src/BigRegister.Api/Contracts/Dtos.cs` — added `BriefDecisionsDto(CanEdit,
- `src/app/auth/application/session.store.ts` — carries the `Principal`; unchanged CanApprove, CanReject, CanSend)` on `BriefViewDto`; added `MeDto(Capabilities)`.
persistence rules (never persist the BSN, per existing comment). - `backend/src/BigRegister.Api/Data/BriefStore.cs` — `Approve`/`Reject`/`Review` take
- New `src/app/shared/domain/capability.ts` — branded/union `Capability` type a `Principal` + `BriefAction` and delegate the SoD check to `Authz.CanActOn`
(`'brief:approve' | 'brief:reject' | 'brief:send'`), framework-free. (same Forbidden-before-Conflict ordering as before).
- New `src/app/shared/application/access.store.ts``providedIn: 'root'`, holds - `backend/src/BigRegister.Api/Program.cs` — `GET /api/v1/me`; every brief endpoint
resolved capabilities as `RemoteData` from `GET /me`; `can(capability): boolean`, (including `send`, which had no `HttpContext` before) now returns a fresh
deny-by-default on absence. `BriefViewDto` (via a shared `ToView`/`BriefResult` helper) so decisions are never
- New `src/app/shared/infrastructure/me.adapter.ts` (+ spec) — calls `GET /me`, stale after a mutation.
`parseMe(): Result` boundary. - `backend/tests/BigRegister.Tests/AuthzTests.cs` (new) — unit tests for `Authz`.
- New `capabilityGuard` in `src/app/auth/auth.guard.ts` (or co-located - `backend/tests/BigRegister.Tests/BriefEndpointTests.cs` — updated to deserialize
`capability.guard.ts`) — factory `CanActivateFn` extending `authGuard`'s shape. `BriefViewDto` (not bare `BriefDto`) from submit/approve/reject/send; two new
- `src/app/brief/application/brief.store.ts` — delete `readonly role = currentRole()` tests for live decisions and `/me`.
and the FE-computed `editable`; read `canApprove`/`canReject`/`canSend` off the - `src/app/shared/domain/capability.ts` (new) — the `Capability` union type.
loaded `BriefViewDto`'s new `BriefDecisionsDto` instead. - `src/app/shared/infrastructure/me.adapter.ts` (+ spec, new) — `GET /me` adapter +
- `src/app/shared/infrastructure/role.interceptor.ts` — keep (still asserts the dev `parseMe` boundary (unknown capability strings are dropped, not rejected).
identity), retitle its comment to "feeds Principal construction, not an authority - `src/app/shared/application/access.store.ts` (new) — `AccessStore.can()`,
the FE reads back." deny-by-default.
- Backend: `backend/src/BigRegister.Api/Contracts/Dtos.cs` — add - `src/app/auth/auth.guard.ts` — added `capabilityGuard(capability)` factory.
`BriefDecisionsDto(bool CanApprove, bool CanReject, bool CanSend)`; add it to **Built but deliberately unwired**: no route in this app needs a capability gate
`BriefViewDto`. today (both drafter and approver land on the same `/brief` page; the gating is
- New `backend/src/BigRegister.Api/Domain/Authorization/Principal.cs` + per-action, not per-page). It's the available building block for a future
`Authz.cs``Principal` (mirrors the FE union), `Authz.Can(principal, action)` approver-only page.
covering the three brief capabilities + the existing SoD rule. - `src/app/brief/domain/brief.ts` — added the `BriefDecisions` domain type.
- `backend/src/BigRegister.Api/Program.cs` — add `GET /api/v1/me` returning the - `src/app/brief/domain/brief.machine.ts` (+ spec) — `BriefState.loaded` and the
resolved `Principal`'s capabilities; replace the ad-hoc `X-Role` reads around `BriefLoaded`/`Submitted`/`Approved`/`Rejected`/`Sent` messages now carry
brief endpoints with `Authz.Can`; compute `BriefDecisionsDto` via the same helper. `decisions`; the pure `transition()` helper replaces them with each fresh
- New backend test `backend/tests/BigRegister.Tests/AuthzTests.cs``Authz.Can` server value.
unit tests (approve/reject/send × drafter/approver × SoD). - `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 ## Steps (as executed)
1. Backend: `Principal`, `Authz.Can`, `GET /me`, `BriefDecisionsDto` wired into the 1. Backend: `Authz.cs`, DTOs, `BriefStore` delegation, `Program.cs` wiring
existing brief endpoints (replace `IsDrafter`/`X-Role` reads one at a time, (`GET /me` + `BriefResult`/`ToView`) — kept `dotnet test` green throughout
keeping `BriefEndpointTests.cs` green after each). (79/79 including 10 new tests).
2. FE: `Capability` type, `access.store.ts`, `me.adapter.ts`, `capabilityGuard`. 2. `npm run gen:api` to pick up the new endpoint/DTOs before touching the FE.
3. FE: `Session → Principal` in `auth/domain`; thread through `SessionStore`, 3. FE domain: `BriefDecisions`, machine state/messages, machine spec fixtures.
`auth.guard.ts` (both keep working — `isAuthenticated` still means "has a 4. FE infrastructure: `parseDecisions`/`parseBriefView` in `brief.adapter.ts` (+spec).
Principal"). 5. FE application: `brief.store.ts`'s computed flags; `access.store.ts` +
4. FE: `brief.store.ts` reads `canApprove`/`canReject`/`canSend` from the DTO; `me.adapter.ts` (+spec) as the general capability-spine infrastructure.
delete `currentRole()` import and the FE-computed `editable`. Update the brief 6. FE UI: `letter-composer` inputs/template, `brief.page.ts` bindings, stories.
UI components consuming `.editable`/`.role` to consume the new flags. 7. Full GREEN gate + a live curl smoke test against the running backend (submit as
5. Update PRD-0002's own status: this WP completes phase P1 — note it in the PRD or drafter → 403 on approve as drafter → 200 on approve as approver, with decisions
leave for a follow-up doc pass (don't rewrite the PRD's phasing table mid-WP). flipping correctly at each step).
## Acceptance criteria ## Acceptance criteria
- [ ] `brief.store.ts` contains no `currentRole()` call and no FE-computed - [x] `brief.store.ts` contains no `currentRole()` call and no FE-computed
permission boolean; `canApprove`/`canReject`/`canSend` come from the DTO. permission boolean; `canApprove`/`canReject`/`canSend` come from the DTO.
- [ ] Forging `?role=approver` in the browser with a stale/absent server capability - [x] The SoD rule is enforced server-side regardless of FE state — verified by
still gets a 403 from the backend (verified by a test hitting the endpoint curl directly against the backend (drafter calling `/brief/approve` → 403)
directly, bypassing the FE). and by `AuthzTests`/`BriefEndpointTests`, bypassing the FE entirely.
- [ ] `Authz.Can` is the only place brief authorization logic lives; the emit path - [x] `Authz.CanActOn`/`Authz.Decisions` is the only place brief authorization logic
(DTO flags) and the enforce path (endpoint gating) both call it. lives; the emit path (DTO flags) and the enforce path (`BriefStore.Review`)
- [ ] `GET /me` returns capabilities; `AccessStore.can()` defaults to `false` for an both call it.
unknown capability. - [x] `GET /me` returns capabilities; `AccessStore.can()` defaults to `false` for an
- [ ] The existing SoD rule (approver ≠ drafter) still holds, now expressed as an unknown capability (deny-by-default, verified in `me.adapter.spec.ts`).
`Authz.Can` precondition rather than inline in `BriefStore.Review`. - [x] The existing SoD rule (approver ≠ drafter) still holds, expressed as
- [ ] `capabilityGuard` compiles and is demonstrated on at least one route (or `Authz.CanActOn` instead of the old inline check in `BriefStore.Review`.
documented as available-but-unwired if no route needs it yet — brief has no - [x] `capabilityGuard` compiles; documented as available-but-unwired (no route
route today, it's a page section). needs it yet — see Files).
## Verification ## Verification
GREEN (`docs/backlog/README.md`) + `cd backend && dotnet test`. Manual smoke: GREEN gate, all green: `npm run lint && npm run check:tokens && npm test && npm run
`npm start` with `?role=drafter` — draft-only actions enabled; `?role=approver` build && npm run build-storybook && npm run test-storybook:ci` (189 unit tests, 137
approve/reject enabled, editing disabled. Confirm via browser devtools that removing Storybook/a11y tests) + `cd backend && dotnet test` (79/79) +
the `X-Role` header (or backend patched to ignore it) makes every capability `false` `dotnet format --verify-no-changes`. Manual smoke via curl against a running
— i.e. deny-by-default actually denies. 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 ## Out of scope
PRD-0002 P2 (data-scoping, PII/BSN redaction) and P3 (step-up, break-glass, audit PRD-0002 P2 (data-scoping, PII/BSN redaction) and P3 (step-up, break-glass, audit
log) — separate future WPs. The Behandeling/backoffice app and `medewerker` log) — separate future WPs. The Behandeling/backoffice app and a `medewerker`
`Principal` variant (ADR-0002 — no second actor exists yet, still YAGNI). Real `Principal` variant (ADR-0002 — no second actor exists yet, still YAGNI). Real
AD/OIDC integration (identity provider stays simulated). 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 ## Risks
Threading `Principal` through `SessionStore`/`auth.guard.ts` touches the one `Send` was already unauthenticated/unauthorized before this WP (no role check on
authenticated-session seam every route depends on — keep the observable shape `POST /brief/send`) — `Authz.CanActOn(Send, …)` preserves that exactly
(`isAuthenticated(): boolean`) identical so no route wiring needs to change, only (`=> true`, a mechanical dispatch step) rather than silently introducing a new gate
what's inside `Session`/`Principal`. Backend `Authz.Can` replacing inline `X-Role` that would have broken the existing `Send_only_from_approved` test (which calls
reads must preserve the existing 403 `Outcome.Forbidden` mapping `send` as the default drafter identity and expects success). If a future WP decides
(`Program.cs:330-335`) exactly, or `BriefEndpointTests.cs` breaks. `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

@@ -1,6 +1,6 @@
# WP-19 — Playwright e2e smoke # WP-19 — Playwright e2e smoke
Status: todo Status: done (pending commit)
Phase: 5 — productie-volwassenheid Phase: 5 — productie-volwassenheid
## Why ## Why
@@ -70,14 +70,46 @@ serve` backgrounded), wait-on both ports, `npm run e2e`. `timeout-minutes: 15`
## Acceptance criteria ## Acceptance criteria
- [ ] `npm run e2e` passes locally against `docker compose up` or `npm start` + - [x] `npm run e2e` passes locally against `docker compose up` or `npm start` +
`dotnet run` run manually. `dotnet run` run manually.
- [ ] CI job `e2e` is green and runs on every PR alongside the existing jobs. - [x] CI job `e2e` is green and runs on every PR alongside the existing jobs.
- [ ] The happy-path spec exercises a real wizard submit against the real backend - [x] The happy-path spec exercises a real wizard submit against the real backend
(not mocked) and asserts on the resulting UI state. (not mocked) and asserts on the resulting UI state.
- [ ] The error-path spec exercises `<app-async>`'s error slot + retry via the real - [x] The error-path spec exercises `<app-async>`'s error slot + retry via the real
`?scenario=error` toggle, not a mocked HTTP response. `?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 ## Verification
`npm run e2e` locally; then push a branch and confirm the new `e2e` CI job appears `npm run e2e` locally; then push a branch and confirm the new `e2e` CI job appears

View File

@@ -1,6 +1,6 @@
# WP-20 — Second locale proof # WP-20 — Second locale proof
Status: todo Status: done (e276629)
Phase: 5 — productie-volwassenheid Phase: 5 — productie-volwassenheid
## Why ## Why
@@ -73,12 +73,14 @@ seam is built into every component but never proven to actually work end to end.
## Acceptance criteria ## Acceptance criteria
- [ ] `ng extract-i18n` runs clean (no missing/duplicate `@@id`s). - [x] `ng extract-i18n` runs clean (no missing/duplicate `@@id`s).
- [ ] `messages.en.xlf` exists with a translation for every extracted unit. - [x] `messages.en.xlf` exists with a translation for every extracted unit.
- [ ] `ng build --localize` (or equivalent) produces both an `nl` and an `en` output - [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. bundle in CI, and CI fails if the `en` file is missing a unit the source gains.
- [ ] Manually verified: the `en` build actually shows English strings in a browser, - [x] Manually verified: the `en` build actually shows English strings in a browser,
not just "the build succeeded." 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 ## Verification

View File

@@ -1,6 +1,6 @@
# WP-21 — Resilience seams (correlation-id, idempotency, retry) # WP-21 — Resilience seams (correlation-id, idempotency, retry)
Status: todo Status: done (40dbcb2)
Phase: 5 — productie-volwassenheid Phase: 5 — productie-volwassenheid
## Why ## Why
@@ -98,22 +98,41 @@ delay })` in `httpClientFetch`'s pipe, per the existing header comment's own
## Acceptance criteria ## Acceptance criteria
- [ ] Every backend log line for a given request shares one correlation id (not - [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. just lines inside `Submit`); the id is echoed in the response headers.
- [ ] Replaying a submit with the same `Idempotency-Key` returns the same result Verified manually: `LogBrief`'s log line — which never interpolates a `Cid`
without minting a second reference (backend test proves this). itself — now prints `=> CorrelationId:scope-check-5` from the middleware's
- [ ] A logical wizard submit generates exactly one `Idempotency-Key`, reused across `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). any FE-side retry of that submit (not regenerated per HTTP attempt).
- [ ] GET requests retry on transient failure (e.g. simulated via `?scenario=slow` `runSubmit` mints it once and threads it via `withIdempotencyKey`; covered by
or a forced 5xx); POST/PUT/DELETE never auto-retry. `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 ## Verification
GREEN + `cd backend && dotnet test`. Manual: `?scenario=error` on a GET-backed page, GREEN + `cd backend && dotnet test` (84 passing) — done. Manual: confirmed via curl
confirm a retry attempt happens (network tab shows 2 requests) before the error against a locally running backend that `X-Correlation-Id` is echoed (client-supplied
state renders; submit a wizard twice with a manually replayed idempotency key (e.g. and server-generated) and that replaying `/api/v1/change-requests` with the same
via curl against the backend directly) and confirm the second call doesn't create a `Idempotency-Key` returns the identical `referentie` while a different key mints a
duplicate application. 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 ## Out of scope

View File

@@ -1,6 +1,6 @@
# WP-22 — Durable persistence (optional tier) # WP-22 — Durable persistence (optional tier)
Status: todo Status: done (556f2f4)
Phase: 5 — productie-volwassenheid Phase: 5 — productie-volwassenheid
## Why ## Why
@@ -98,22 +98,30 @@ speculatively.
## Acceptance criteria ## Acceptance criteria
- [ ] All three stores are EF Core/SQLite-backed; no `static Dictionary` remains in - [x] All three stores are EF Core/SQLite-backed; no `static Dictionary` remains in
`Data/*.cs` for application/document/brief state. `Data/*.cs` for application/document/brief state.
- [ ] Every existing backend test passes unchanged (signatures didn't change). - [x] Every existing backend test passes unchanged (signatures didn't change).
- [ ] Restarting the backend process preserves previously created applications, 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). documents, and brief drafts (manually verified).
- [ ] The audit log survives a restart and is queryable (even if no new endpoint - [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). exposes it yet — persistence is the bar, not a new audit UI). `AuditEntries`
- [ ] `docker compose up` with the new volume also survives a container restart. 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 ## Verification
`cd backend && dotnet test`. Manual: `dotnet run --project src/BigRegister.Api`, `cd backend && dotnet test` — 84/84 green. Manual: `dotnet run --project
create an application via the FE or a curl request, kill and restart the process, src/BigRegister.Api`, created an application via curl, killed and restarted the
confirm `GET /api/v1/applications` still returns it. Repeat with `docker compose up` process, confirmed `GET /api/v1/applications` still returned it (repeated for the
brief). Repeated the same check against the **real** `docker compose up` stack
- `docker compose restart api`. (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 ## Out of scope
@@ -131,3 +139,47 @@ synchronously; converting to `async`/`await` may ripple further than "just the
Data/ layer" if minimal-API handlers aren't already `async`. Check this before 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, 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). 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.

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'],

4372
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,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",
@@ -39,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,
},
});

197
public/letter.css Normal file
View File

@@ -0,0 +1,197 @@
/* 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;
}
}

View File

@@ -4,7 +4,7 @@ import {
isDevMode, isDevMode,
provideBrowserGlobalErrorListeners, provideBrowserGlobalErrorListeners,
} from '@angular/core'; } from '@angular/core';
import { provideRouter, withViewTransitions } from '@angular/router'; import { provideRouter, withInMemoryScrolling, withViewTransitions } from '@angular/router';
import type { ActivatedRouteSnapshot } from '@angular/router'; import type { ActivatedRouteSnapshot } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { registerLocaleData } from '@angular/common'; import { registerLocaleData } from '@angular/common';
@@ -16,6 +16,7 @@ import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
import { provideApiClient } from '@shared/infrastructure/api-client.provider'; import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { SESSION_PORT } from '@shared/application/session.port'; import { SESSION_PORT } from '@shared/application/session.port';
import { SessionStore } from '@auth/application/session.store'; import { SessionStore } from '@auth/application/session.store';
import { provideRouteFocus } from '@shared/layout/route-focus';
registerLocaleData(localeNl); registerLocaleData(localeNl);
@@ -24,6 +25,7 @@ export const appConfig: ApplicationConfig = {
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter( provideRouter(
routes, routes,
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
// Cross-fade page-to-page navigations only. A silent same-route nav — e.g. // Cross-fade page-to-page navigations only. A silent same-route nav — e.g.
// draft-sync stamping `?aanvraag=<id>` into the URL mid-wizard — must NOT // draft-sync stamping `?aanvraag=<id>` into the URL mid-wizard — must NOT
// animate: for the transition's duration Firefox's `::view-transition` // animate: for the transition's duration Firefox's `::view-transition`
@@ -48,5 +50,6 @@ export const appConfig: ApplicationConfig = {
provideApiClient(), provideApiClient(),
{ provide: SESSION_PORT, useExisting: SessionStore }, { provide: SESSION_PORT, useExisting: SessionStore },
{ provide: LOCALE_ID, useValue: 'nl' }, { provide: LOCALE_ID, useValue: 'nl' },
provideRouteFocus(),
], ],
}; };

View File

@@ -51,6 +51,12 @@ export const routes: Routes = [
canActivate: [authGuard], canActivate: [authGuard],
loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage), loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage),
}, },
{
path: 'brief/huisstijl',
canActivate: [authGuard],
loadComponent: () =>
import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage),
},
{ {
path: 'concepts', path: 'concepts',
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage), loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),

View File

@@ -1,5 +1,7 @@
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router'; import { CanActivateFn, Router } from '@angular/router';
import { AccessStore } from '@shared/application/access.store';
import { Capability } from '@shared/domain/capability';
import { SessionStore } from './application/session.store'; import { SessionStore } from './application/session.store';
/** Route guard: only let authenticated users in; otherwise redirect to /login. */ /** Route guard: only let authenticated users in; otherwise redirect to /login. */
@@ -8,3 +10,22 @@ export const authGuard: CanActivateFn = () => {
const router = inject(Router); const router = inject(Router);
return store.isAuthenticated() ? true : router.createUrlTree(['/login']); return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
}; };
/**
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
* redirect. No route in this app currently needs a capability gate — brief's
* canApprove/canReject/canSend are per-action, not per-page (both actors land on
* the same `/brief` page and see different actions) — so this exists as the
* available building block for the day a route-level gate is needed, e.g. a future
* approver-only page.
*/
export function capabilityGuard(capability: Capability): CanActivateFn {
return () => {
const session = inject(SessionStore);
const access = inject(AccessStore);
const router = inject(Router);
return session.isAuthenticated() && access.can(capability)
? true
: router.createUrlTree(['/login']);
};
}

View File

@@ -24,7 +24,13 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
i18n-description="@@login.bsnDescription" i18n-description="@@login.bsnDescription"
description="9 cijfers (demo: vul iets in)" description="9 cijfers (demo: vul iets in)"
> >
<app-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" /> <app-text-input
inputId="bsn"
hasDescription
[(ngModel)]="bsn"
name="bsn"
placeholder="123456789"
/>
</app-form-field> </app-form-field>
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required> <app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { LoginFormComponent } from './login-form.component'; import { LoginFormComponent } from './login-form.component';
const meta: Meta<LoginFormComponent> = { const meta: Meta<LoginFormComponent> = {
title: 'Organisms/Login Form', title: 'Domein/Auth/Login Form',
component: LoginFormComponent, component: LoginFormComponent,
}; };
export default meta; export default meta;

View File

@@ -0,0 +1,146 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { Result } from '@shared/kernel/fp';
import { Brief, BriefDecisions } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { BriefStore } from './brief.store';
const decisions: BriefDecisions = {
canEdit: true,
canApprove: true,
canReject: true,
canSend: true,
};
const brief: Brief = {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
placeholders: [],
sections: [],
status: { tag: 'draft' },
drafterId: 'u1',
};
const orgTemplate: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'Postbus 00000\n2500 AA Den Haag',
footerContact: 'info@voorbeeld.example',
footerLegal: 'KvK 00000000',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1,
};
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate };
function setup(adapter: Partial<BriefAdapter>): BriefStore {
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
return TestBed.inject(BriefStore);
}
describe('BriefStore action state (Idle | Busy | Failed)', () => {
it('is Busy synchronously once a transition starts, then Idle on success', async () => {
const approved: BriefView = {
...view,
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
};
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: approved }),
});
await store.load();
const pending = store.approve();
expect(store.busy()).toBe(true); // set synchronously, before any await resolves
await pending;
expect(store.busy()).toBe(false);
expect(store.lastError()).toBeNull();
});
it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: false, error: 'niet toegestaan' }),
});
await store.load();
await store.approve();
expect(store.busy()).toBe(false);
expect(store.lastError()).toBe('niet toegestaan');
});
it('a subsequent successful transition clears a prior Failed state', async () => {
let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> => Promise.resolve(approveResult),
});
await store.load();
await store.approve();
expect(store.lastError()).toBe('eerste poging mislukt');
approveResult = {
ok: true,
value: {
...view,
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
},
};
await store.approve();
expect(store.busy()).toBe(false);
expect(store.lastError()).toBeNull();
});
});
describe('BriefStore.previewLetter', () => {
// vi.spyOn reuses an existing spy (and its call history) if one is already on
// the property — window.open/URL.createObjectURL must be restored between tests.
afterEach(() => vi.restoreAllMocks());
it('opens the composed letter in a new tab on success', async () => {
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
});
await store.load();
const blob = new Blob(['<html></html>'], { type: 'text/html' });
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
ok: true,
value: blob,
});
await store.previewLetter();
expect(open).toHaveBeenCalledWith('blob:mock', '_blank');
expect(store.lastError()).toBeNull();
});
it('surfaces the error without opening a tab on failure', async () => {
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
});
await store.load();
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
ok: false,
error: 'De voorvertoning kon niet worden geopend.',
});
await store.previewLetter();
expect(open).not.toHaveBeenCalled();
expect(store.lastError()).toBe('De voorvertoning kon niet worden geopend.');
});
});

View File

@@ -1,8 +1,7 @@
import { Injectable, computed, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp'; import { Result } from '@shared/kernel/fp';
import { RemoteData } from '@shared/application/remote-data';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { Role } from '@shared/domain/role';
import { currentRole } from '@shared/infrastructure/role';
import { import {
Brief, Brief,
allDiagnostics, allDiagnostics,
@@ -11,38 +10,87 @@ import {
unresolvedPlaceholders, unresolvedPlaceholders,
} from '@brief/domain/brief'; } from '@brief/domain/brief';
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine'; import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BriefAdapter } from '@brief/infrastructure/brief.adapter'; import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { uploadContentUrl } from '@shared/upload/upload.adapter';
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
instead of a busy boolean + a nullable error sitting side by side. */
type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
/** Debounced-autosave indicator, shown in a small status line near the toolbar —
a separate concern from ActionState (a stale autosave error doesn't block
submit/approve/reject), but tag-aligned with it for one consistent idiom. */
type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
type LoadedBriefState = Extract<BriefState, { tag: 'loaded' }>;
/** /**
* Root singleton for the letter: the Elm store (Model + dispatch), the derived * Root singleton for the letter: the Elm store (Model + dispatch), the derived
* read-model, and the commands (effects) that call the adapter and dispatch the * read-model, and the commands (effects) that call the adapter and dispatch the
* outcome. Mirrors `BigProfileStore`. All of `editable`, `diagnostics`, * outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
* `unresolved`, `canSubmit` are DERIVED here — never stored. * `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class BriefStore { export class BriefStore {
private adapter = inject(BriefAdapter); private adapter = inject(BriefAdapter);
private previewAdapter = inject(LetterPreviewAdapter);
private store = createStore<BriefState, BriefMsg>(initial, reduce); private store = createStore<BriefState, BriefMsg>(initial, reduce);
readonly model = this.store.model; readonly model = this.store.model;
readonly role: Role = currentRole();
readonly busy = signal(false); private actionState = signal<ActionState>({ tag: 'Idle' });
readonly lastError = signal<string | null>(null); readonly busy = computed(() => this.actionState().tag === 'Busy');
readonly lastError = computed(() => {
const s = this.actionState();
return s.tag === 'Failed' ? s.error : null;
});
/** Surfaced autosave state for the indicator + aria-live region. */ /** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<'idle' | 'saving' | 'saved' | 'error'>('idle'); readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** The org template the letter renders with (WP-24). Server-owned appearance data,
not letter state — held beside the machine, never inside it (`brief.machine.ts`
stays untouched by design). Set from every server view that carries it. */
readonly orgTemplate = signal<OrgTemplate | null>(null);
/** The org logo's content URL for the letterhead, or null when the template has none. */
readonly logoUrl = computed<string | null>(() => {
const id = this.orgTemplate()?.logoDocumentId;
return id ? uploadContentUrl(id) : null;
});
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
purely a projection of its loading/failed tags onto the shared async seam. */
readonly remoteData = computed<RemoteData<Error | undefined, LoadedBriefState>>(() => {
const s = this.model();
switch (s.tag) {
case 'loading':
return { tag: 'Loading' };
case 'failed':
return { tag: 'Failure', error: new Error(s.reason) };
case 'loaded':
return { tag: 'Success', value: s };
}
});
private brief = computed<Brief | null>(() => { private brief = computed<Brief | null>(() => {
const s = this.model(); const s = this.model();
return s.tag === 'loaded' ? s.brief : null; return s.tag === 'loaded' ? s.brief : null;
}); });
/** The single source of truth for edit permission: drafter, and a status the reducer readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
treats as editable (draft, or rejected — which reopens to draft on the first edit). */ readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);
readonly editable = computed(() => { readonly canReject = computed(() => this.decisions()?.canReject ?? false);
const b = this.brief(); readonly canSend = computed(() => this.decisions()?.canSend ?? false);
return (
!!b && (b.status.tag === 'draft' || b.status.tag === 'rejected') && this.role === 'drafter' private decisions = computed(() => {
); const s = this.model();
return s.tag === 'loaded' ? s.decisions : null;
}); });
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : [])); readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : [])); readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
@@ -54,13 +102,12 @@ export class BriefStore {
async load() { async load() {
const r = await this.adapter.load(); const r = await this.adapter.load();
if (r.ok) if (r.ok) {
this.store.dispatch({ this.orgTemplate.set(r.value.orgTemplate);
tag: 'BriefLoaded', this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
brief: r.value.brief, } else {
availablePassages: r.value.availablePassages, this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
}); }
else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
} }
/** An edit: apply it optimistically in the pure reducer, then debounce-save. */ /** An edit: apply it optimistically in the pure reducer, then debounce-save. */
@@ -71,7 +118,7 @@ export class BriefStore {
private saveTimer?: ReturnType<typeof setTimeout>; private saveTimer?: ReturnType<typeof setTimeout>;
private scheduleSave() { private scheduleSave() {
if (!this.editable()) return; if (!this.canEdit()) return;
clearTimeout(this.saveTimer); clearTimeout(this.saveTimer);
// ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record. // ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.
this.saveTimer = setTimeout(() => void this.flushSave(), 600); this.saveTimer = setTimeout(() => void this.flushSave(), 600);
@@ -79,31 +126,29 @@ export class BriefStore {
private async flushSave() { private async flushSave() {
const b = this.brief(); const b = this.brief();
if (!b) return; if (!b) return;
this.saveState.set('saving'); this.saveState.set({ tag: 'Saving' });
const r = await this.adapter.save(b.sections); const r = await this.adapter.save(b.sections);
if (r.ok) { if (r.ok) {
this.saveState.set('saved'); this.saveState.set({ tag: 'Saved' });
} else { } else {
this.lastError.set(r.error); this.actionState.set({ tag: 'Failed', error: r.error });
this.saveState.set('error'); this.saveState.set({ tag: 'Error' });
} }
} }
/** Demo "start over": recreate the brief server-side and load the fresh view. */ /** Demo "start over": recreate the brief server-side and load the fresh view. */
async resetDemo() { async resetDemo() {
this.busy.set(true); this.actionState.set({ tag: 'Busy' });
this.lastError.set(null);
clearTimeout(this.saveTimer); clearTimeout(this.saveTimer);
const r = await this.adapter.reset(); const r = await this.adapter.reset();
this.busy.set(false); this.saveState.set({ tag: 'Idle' });
this.saveState.set('idle'); if (r.ok) {
if (r.ok) this.actionState.set({ tag: 'Idle' });
this.store.dispatch({ this.orgTemplate.set(r.value.orgTemplate);
tag: 'BriefLoaded', this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
brief: r.value.brief, } else {
availablePassages: r.value.availablePassages, this.actionState.set({ tag: 'Failed', error: r.error });
}); }
else this.lastError.set(r.error);
} }
submit = () => this.transition(() => this.adapter.submit()); submit = () => this.transition(() => this.adapter.submit());
@@ -111,30 +156,46 @@ export class BriefStore {
reject = (comments: string) => this.transition(() => this.adapter.reject(comments)); reject = (comments: string) => this.transition(() => this.adapter.reject(comments));
send = () => this.transition(() => this.adapter.send()); send = () => this.transition(() => this.adapter.send());
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and
the tab outlives this call; not worth a teardown hook for a POC. */
async previewLetter() {
this.actionState.set({ tag: 'Busy' });
const r = await this.previewAdapter.preview();
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
window.open(URL.createObjectURL(r.value), '_blank');
}
// A transition: flush any pending save, call the server (authoritative), then mirror // A transition: flush any pending save, call the server (authoritative), then mirror
// the returned status through the pure reducer's guarded transition. // the returned status through the pure reducer's guarded transition.
private async transition(action: () => Promise<Result<string, Brief>>) { private async transition(action: () => Promise<Result<string, BriefView>>) {
this.busy.set(true); this.actionState.set({ tag: 'Busy' });
this.lastError.set(null);
clearTimeout(this.saveTimer); clearTimeout(this.saveTimer);
await this.flushSave(); await this.flushSave();
const r = await action(); const r = await action();
this.busy.set(false);
if (!r.ok) { if (!r.ok) {
this.lastError.set(r.error); this.actionState.set({ tag: 'Failed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' });
this.applyServerStatus(r.value); this.applyServerStatus(r.value);
} }
private applyServerStatus(brief: Brief) { private applyServerStatus(view: BriefView) {
// `send` pins the org-template version server-side — mirror whatever came back.
this.orgTemplate.set(view.orgTemplate);
const { brief, decisions } = view;
const s = brief.status; const s = brief.status;
switch (s.tag) { switch (s.tag) {
case 'submitted': case 'submitted':
this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt }); this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt, decisions });
break; break;
case 'approved': case 'approved':
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt }); this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
break; break;
case 'rejected': case 'rejected':
this.store.dispatch({ this.store.dispatch({
@@ -142,10 +203,11 @@ export class BriefStore {
by: s.rejectedBy, by: s.rejectedBy,
at: s.rejectedAt, at: s.rejectedAt,
comments: s.comments, comments: s.comments,
decisions,
}); });
break; break;
case 'sent': case 'sent':
this.store.dispatch({ tag: 'Sent', at: s.sentAt }); this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });
break; break;
case 'draft': case 'draft':
// reopened by a save on a rejected letter — reducer already handled it locally. // reopened by a save on a rejected letter — reducer already handled it locally.

View File

@@ -0,0 +1,271 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { createStore } from '@shared/application/store';
import { UploadAdapter } from '@shared/upload/upload.adapter';
import { UploadShellService } from '@shared/upload/upload-shell.service';
import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';
import {
MARGIN_MAX_MM,
MARGIN_MIN_MM,
OrgTemplate,
SubOrgSummary,
} from '@brief/domain/org-template';
import {
OrgTemplateMsg,
OrgTemplateState,
initial,
reduce,
} from '@brief/domain/org-template.machine';
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
/** Transient action state for publish/rollback/proefbrief — the BriefStore idiom. */
type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
const LOGO_CATEGORY = 'org-logo';
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
/**
* Root singleton for the admin org-template editor (WP-26). The Elm machine owns the
* editable draft; commands here do the debounced save, publish (impact-confirm),
* rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The
* logo upload reuses the shared upload transport; its completion mutates the draft
* (in the reducer) and triggers a save (here). Mirrors `BriefStore`.
*/
@Injectable({ providedIn: 'root' })
export class OrgTemplateStore {
private adapter = inject(OrgTemplateAdapter);
private uploadAdapter = inject(UploadAdapter);
private shell = inject(UploadShellService);
private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
readonly model = this.store.model;
readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
readonly selectedSubOrgId = signal<string | null>(null);
private actionState = signal<ActionState>({ tag: 'Idle' });
readonly busy = computed(() => this.actionState().tag === 'Busy');
readonly lastError = computed(() => {
const s = this.actionState();
return s.tag === 'Failed' ? s.error : null;
});
readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */
readonly pendingPublish = signal(false);
readonly remoteData = computed<RemoteData<Error | undefined, LoadedState>>(() => {
const s = this.model();
switch (s.tag) {
case 'loading':
return { tag: 'Loading' };
case 'failed':
return { tag: 'Failure', error: new Error(s.reason) };
case 'loaded':
return { tag: 'Success', value: s };
}
});
private loaded = computed<LoadedState | null>(() => {
const s = this.model();
return s.tag === 'loaded' ? s : null;
});
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
readonly history = computed(() => this.loaded()?.history ?? []);
readonly publishedVersion = computed(() => this.loaded()?.publishedVersion ?? 0);
readonly unsentBriefs = computed(() => this.loaded()?.unsentBriefs ?? 0);
readonly logoUrl = computed<string | null>(() => {
const id = this.draft()?.logoDocumentId;
return id ? this.uploadAdapter.contentUrl(id) : null;
});
/** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;
the server re-validates and stays the authority — publish is gated on this. */
readonly draftValid = computed(() => {
const d = this.draft();
if (!d) return false;
const marginsOk = [d.margins.topMm, d.margins.rightMm, d.margins.bottomMm, d.margins.leftMm].every(
(v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM,
);
return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk;
});
// Live File blobs keyed by localId — needed to retry a failed upload (a reducer can't hold these).
private files = new Map<string, File>();
private categoriesRes = this.uploadAdapter.categoriesResource('org-template');
constructor() {
// Feed the logo category into the machine's upload sub-state once loaded. Tracks
// `model()` so it re-fires after a sub-org switch reseeds an empty upload state;
// the length guard makes it idempotent (no dispatch loop).
effect(() => {
const s = this.model();
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;
const status = this.categoriesRes.status();
if (status === 'resolved' || status === 'local')
this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] });
});
}
async load() {
this.store.dispatch({ tag: 'Loading' });
const list = await this.adapter.list();
if (!list.ok) {
this.store.dispatch({ tag: 'LoadFailed', reason: list.error });
return;
}
this.subOrgs.set(list.value);
const first = list.value[0];
if (!first) {
this.store.dispatch({ tag: 'LoadFailed', reason: NO_SUBORGS });
return;
}
await this.selectSubOrg(first.subOrgId);
}
async selectSubOrg(subOrgId: string) {
this.selectedSubOrgId.set(subOrgId);
this.saveState.set({ tag: 'Idle' });
clearTimeout(this.saveTimer);
this.store.dispatch({ tag: 'Loading' });
const r = await this.adapter.load(subOrgId);
if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });
else this.store.dispatch({ tag: 'LoadFailed', reason: r.error });
}
/** An in-place canvas or margin edit: apply optimistically, then debounce-save. */
edit(msg: OrgTemplateMsg) {
this.store.dispatch(msg);
this.scheduleSave();
}
private saveTimer?: ReturnType<typeof setTimeout>;
private scheduleSave() {
if (this.loaded() === null) return;
clearTimeout(this.saveTimer);
// ponytail: 600ms debounce, same as BriefStore; the server is the store of record.
this.saveTimer = setTimeout(() => void this.flushSave(), 600);
}
private async flushSave() {
const s = this.loaded();
if (!s || !s.dirty) return;
const { subOrgId, draft } = s;
this.saveState.set({ tag: 'Saving' });
const r = await this.adapter.save(subOrgId, draft);
if (r.ok) {
this.saveState.set({ tag: 'Saved' });
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
} else {
this.saveState.set({ tag: 'Error' });
this.actionState.set({ tag: 'Failed', error: r.error });
}
}
// --- publish (impact-confirm) / rollback / proefbrief ---
requestPublish() {
this.pendingPublish.set(true);
}
cancelPublish() {
this.pendingPublish.set(false);
}
async confirmPublish() {
const s = this.loaded();
if (!s) return;
this.pendingPublish.set(false);
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
await this.flushSave(); // publish the saved draft — flush any pending edit first
const r = await this.adapter.publish(s.subOrgId);
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0
}
async rollback(version: number) {
const s = this.loaded();
if (!s) return;
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
const r = await this.adapter.rollback(s.subOrgId, version);
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft
}
async proefbrief() {
const s = this.loaded();
if (!s) return;
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
await this.flushSave(); // the proefbrief renders the server's draft
const r = await this.adapter.proefbrief(s.subOrgId);
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
window.open(URL.createObjectURL(r.value), '_blank');
}
// --- logo upload (reuses the shared upload transport; single `org-logo` file) ---
onLogoSelected(files: File[]) {
const s = this.loaded();
const cat = s?.upload.categories.find((c) => c.categoryId === LOGO_CATEGORY);
const file = files[0];
if (!s || !cat || !file) return;
const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 });
if (reason) {
this.dispatchUpload({ type: 'FileRejected', categoryId: cat.categoryId, reason });
return;
}
const localId = crypto.randomUUID();
this.files.set(localId, file);
this.dispatchUpload({
type: 'FileSelected',
categoryId: cat.categoryId,
localId,
fileName: file.name,
fileSizeMb: file.size / 1e6,
});
this.shell.upload({ localId, categoryId: cat.categoryId, wizardId: 'org-template', file }, (m) =>
this.onUploadMsg(m),
);
}
onLogoRemoved(localId: string) {
this.shell.cancel([localId]);
this.files.delete(localId);
this.onUploadMsg({ type: 'UploadRemoved', localId });
}
onLogoRetry(localId: string) {
const file = this.files.get(localId);
const up = this.loaded()?.upload.uploads.find((u) => u.localId === localId);
if (!file || !up) return;
this.dispatchUpload({ type: 'UploadRetried', localId });
this.shell.upload({ localId, categoryId: up.categoryId, wizardId: 'org-template', file }, (m) =>
this.onUploadMsg(m),
);
}
private dispatchUpload(msg: UploadMsg) {
this.store.dispatch({ tag: 'Upload', msg });
}
/** Upload effects arriving from the transport: a finished/removed logo edits the
draft (in the reducer) and needs persisting. */
private onUploadMsg(msg: UploadMsg) {
this.dispatchUpload(msg);
if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved') this.scheduleSave();
}
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { Brief, BriefStatus, LibraryPassage } from './brief'; import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from './brief';
import { RichTextBlock } from '@shared/kernel/rich-text'; import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderDef } from './placeholders'; import { PlaceholderDef } from './placeholders';
import { BriefState, BriefMsg, reduce } from './brief.machine'; import { BriefState, BriefMsg, reduce } from './brief.machine';
@@ -37,6 +37,15 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
}; };
} }
// A machine test cares about status transitions, not who may act — a fixed,
// unrestrictive fixture keeps every existing assertion focused on that.
const decisions: BriefDecisions = {
canEdit: true,
canApprove: true,
canReject: true,
canSend: true,
};
const loaded = ( const loaded = (
status: BriefStatus = { tag: 'draft' }, status: BriefStatus = { tag: 'draft' },
sections?: Brief['sections'], sections?: Brief['sections'],
@@ -44,6 +53,7 @@ const loaded = (
tag: 'loaded', tag: 'loaded',
brief: briefWith(status, sections), brief: briefWith(status, sections),
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')], availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
decisions,
}); });
const sectionBlocks = (s: BriefState, key: string) => const sectionBlocks = (s: BriefState, key: string) =>
@@ -56,6 +66,7 @@ describe('brief.machine reduce', () => {
tag: 'BriefLoaded', tag: 'BriefLoaded',
brief: briefWith({ tag: 'draft' }), brief: briefWith({ tag: 'draft' }),
availablePassages: [], availablePassages: [],
decisions,
}).tag, }).tag,
).toBe('loaded'); ).toBe('loaded');
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({ expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
@@ -175,10 +186,10 @@ describe('brief.machine reduce', () => {
it('Submitted fires only from draft and only when required sections are filled', () => { it('Submitted fires only from draft and only when required sections are filled', () => {
// required 'aanhef' empty → no-op // required 'aanhef' empty → no-op
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't' })).toEqual(loaded()); expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't', decisions })).toEqual(loaded());
// fill the required section, then submit // fill the required section, then submit
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' }); const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' });
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't' }); const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({ expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
tag: 'submitted', tag: 'submitted',
submittedBy: 'u1', submittedBy: 'u1',
@@ -189,15 +200,21 @@ describe('brief.machine reduce', () => {
it('approve/reject fire only from submitted; send only from approved', () => { it('approve/reject fire only from submitted; send only from approved', () => {
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' }); const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
// approve from draft is a no-op // approve from draft is a no-op
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't' })).toEqual(loaded()); expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2' }); const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({ expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
tag: 'approved', tag: 'approved',
approvedBy: 'u2', approvedBy: 'u2',
approvedAt: 't2', approvedAt: 't2',
}); });
// reject carries comments // reject carries comments
const rejected = reduce(submitted, { tag: 'Rejected', by: 'u2', at: 't2', comments: 'nee' }); const rejected = reduce(submitted, {
tag: 'Rejected',
by: 'u2',
at: 't2',
comments: 'nee',
decisions,
});
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({ expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
tag: 'rejected', tag: 'rejected',
rejectedBy: 'u2', rejectedBy: 'u2',
@@ -205,10 +222,27 @@ describe('brief.machine reduce', () => {
comments: 'nee', comments: 'nee',
}); });
// send only from approved // send only from approved
expect(reduce(submitted, { tag: 'Sent', at: 't' })).toBe(submitted); expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
const sent = reduce(approved, { tag: 'Sent', at: 't3' }); const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' }); expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
}); });
it('a status transition replaces decisions with the fresh server value', () => {
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
const staleApprover: BriefDecisions = {
canEdit: false,
canApprove: false,
canReject: false,
canSend: false,
};
const approved = reduce(submitted, {
tag: 'Approved',
by: 'u2',
at: 't2',
decisions: staleApprover,
});
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover);
});
}); });
function initialLoading(): BriefState { function initialLoading(): BriefState {

View File

@@ -1,6 +1,7 @@
import { assertNever } from '@shared/kernel/fp'; import { assertNever } from '@shared/kernel/fp';
import { import {
Brief, Brief,
BriefDecisions,
BriefStatus, BriefStatus,
LetterBlock, LetterBlock,
LetterSection, LetterSection,
@@ -21,9 +22,11 @@ import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-te
* it back to `draft`. Sections can never be added, removed, or reordered — there * it back to `draft`. Sections can never be added, removed, or reordered — there
* is no Msg for it, so it is unrepresentable. * is no Msg for it, so it is unrepresentable.
* *
* Role (drafter vs approver) is NOT a reducer concern: the UI derives `editable` from * Authorization is NOT a reducer concern: `decisions` (canEdit/canApprove/canReject/
* role+status and simply doesn't dispatch edits when the actor may not edit. The * canSend) arrives from the server on every load and every status transition (PRD-0002
* reducer guards the status invariant; the UI guards the role invariant. * phase P1) and is carried through unchanged by the reducer — never recomputed here.
* The reducer guards the status invariant; the server is the sole authority on who may
* act on it.
* *
* Note: there is no `PlaceholderInserted` Msg. The editor inserts a placeholder NODE * Note: there is no `PlaceholderInserted` Msg. The editor inserts a placeholder NODE
* at the caret and emits the whole new block via `BlockContentEdited`; its insert menu * at the caret and emits the whole new block via `BlockContentEdited`; its insert menu
@@ -33,23 +36,33 @@ import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-te
export type BriefState = export type BriefState =
| { tag: 'loading' } | { tag: 'loading' }
| { tag: 'loaded'; brief: Brief; availablePassages: readonly LibraryPassage[] } | {
tag: 'loaded';
brief: Brief;
availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions;
}
| { tag: 'failed'; reason: string }; | { tag: 'failed'; reason: string };
export const initial: BriefState = { tag: 'loading' }; export const initial: BriefState = { tag: 'loading' };
export type BriefMsg = export type BriefMsg =
| { tag: 'BriefLoaded'; brief: Brief; availablePassages: readonly LibraryPassage[] } | {
tag: 'BriefLoaded';
brief: Brief;
availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions;
}
| { tag: 'BriefLoadFailed'; reason: string } | { tag: 'BriefLoadFailed'; reason: string }
| { tag: 'PassagesInserted'; sectionKey: string; passages: readonly LibraryPassage[] } // multi-select | { tag: 'PassagesInserted'; sectionKey: string; passages: readonly LibraryPassage[] } // multi-select
| { tag: 'FreeTextBlockAdded'; sectionKey: string } | { tag: 'FreeTextBlockAdded'; sectionKey: string }
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock } | { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
| { tag: 'BlockRemoved'; blockId: string } | { tag: 'BlockRemoved'; blockId: string }
| { tag: 'BlockMovedWithinSection'; blockId: string; toIndex: number } | { tag: 'BlockMovedWithinSection'; blockId: string; toIndex: number }
| { tag: 'Submitted'; by: string; at: string } // draft → submitted | { tag: 'Submitted'; by: string; at: string; decisions: BriefDecisions } // draft → submitted
| { tag: 'Approved'; by: string; at: string } // submitted → approved | { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
| { tag: 'Rejected'; by: string; at: string; comments: string } // submitted → rejected | { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
| { tag: 'Sent'; at: string } // approved → sent | { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
| { tag: 'Seed'; state: BriefState }; | { tag: 'Seed'; state: BriefState };
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */ /** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
@@ -158,7 +171,12 @@ function moveWithinSection(
export function reduce(s: BriefState, m: BriefMsg): BriefState { export function reduce(s: BriefState, m: BriefMsg): BriefState {
switch (m.tag) { switch (m.tag) {
case 'BriefLoaded': case 'BriefLoaded':
return { tag: 'loaded', brief: m.brief, availablePassages: m.availablePassages }; return {
tag: 'loaded',
brief: m.brief,
availablePassages: m.availablePassages,
decisions: m.decisions,
};
case 'BriefLoadFailed': case 'BriefLoadFailed':
return { tag: 'failed', reason: m.reason }; return { tag: 'failed', reason: m.reason };
case 'Seed': case 'Seed':
@@ -203,36 +221,41 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
s, s,
'draft', 'draft',
() => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }), () => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }),
m.decisions,
canSubmit, canSubmit,
); );
case 'Approved': case 'Approved':
return transition(s, 'submitted', () => ({ return transition(
tag: 'approved', s,
approvedBy: m.by, 'submitted',
approvedAt: m.at, () => ({ tag: 'approved', approvedBy: m.by, approvedAt: m.at }),
})); m.decisions,
);
case 'Rejected': case 'Rejected':
return transition(s, 'submitted', () => ({ return transition(
tag: 'rejected', s,
rejectedBy: m.by, 'submitted',
rejectedAt: m.at, () => ({ tag: 'rejected', rejectedBy: m.by, rejectedAt: m.at, comments: m.comments }),
comments: m.comments, m.decisions,
})); );
case 'Sent': case 'Sent':
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at })); return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
default: default:
return assertNever(m); return assertNever(m);
} }
} }
/** A guarded status transition: only fires from `from`, and only if `guard` passes. */ /** A guarded status transition: only fires from `from`, and only if `guard` passes.
`decisions` replaces the prior server-computed flags — always fresh from the
same response that carried the new status. */
function transition( function transition(
s: BriefState, s: BriefState,
from: BriefStatus['tag'], from: BriefStatus['tag'],
next: () => BriefStatus, next: () => BriefStatus,
decisions: BriefDecisions,
guard: (b: Brief) => boolean = () => true, guard: (b: Brief) => boolean = () => true,
): BriefState { ): BriefState {
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s; if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
return { ...s, brief: { ...s.brief, status: next() } }; return { ...s, brief: { ...s.brief, status: next() }, decisions };
} }

View File

@@ -107,3 +107,12 @@ export function unresolvedPlaceholders(brief: Brief): string[] {
export function canSubmit(brief: Brief): boolean { export function canSubmit(brief: Brief): boolean {
return brief.sections.every((s) => !s.required || s.blocks.length > 0); return brief.sections.every((s) => !s.required || s.blocks.length > 0);
} }
/** Server-computed decision flags for the acting principal + this brief's live
status (PRD-0002 phase P1) — rendered as-is, never recomputed here. */
export interface BriefDecisions {
readonly canEdit: boolean;
readonly canApprove: boolean;
readonly canReject: boolean;
readonly canSend: boolean;
}

View File

@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
import { OrgTemplateState, reduce } from './org-template.machine';
import { DocumentCategory } from '@shared/upload/upload.machine';
const template: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG',
returnAddress: 'Postbus 1\n2500 AA Den Haag',
footerContact: 'info@cibg.nl',
footerLegal: 'CIBG is onderdeel van VWS',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 },
version: 3,
};
const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView => ({
draft: template,
publishedVersion: 3,
history: [],
unsentBriefs: 2,
...over,
});
const loaded = (): OrgTemplateState => reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
const logoCategory: DocumentCategory = {
categoryId: 'org-logo',
label: 'Logo',
description: '',
required: false,
acceptedTypes: ['image/png'],
maxSizeMb: 2,
multiple: false,
allowPostDelivery: false,
};
describe('org-template.machine', () => {
it('DraftLoaded moves to loaded with the draft, clean', () => {
const s = loaded();
expect(s.tag).toBe('loaded');
if (s.tag !== 'loaded') return;
expect(s.draft.orgName).toBe('CIBG');
expect(s.subOrgId).toBe('cibg-registers');
expect(s.unsentBriefs).toBe(2);
expect(s.dirty).toBe(false);
});
it('LoadFailed carries the reason', () => {
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' });
expect(s).toEqual({ tag: 'failed', reason: 'boom' });
});
it('FieldEdited edits the draft and marks dirty', () => {
const s = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' });
expect(s.tag === 'loaded' && s.draft.orgName).toBe('CIBG Nieuw');
expect(s.tag === 'loaded' && s.dirty).toBe(true);
});
it('MarginEdited edits one edge and marks dirty', () => {
const s = reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 });
expect(s.tag === 'loaded' && s.draft.margins.topMm).toBe(40);
expect(s.tag === 'loaded' && s.draft.margins.leftMm).toBe(20);
expect(s.tag === 'loaded' && s.dirty).toBe(true);
});
it('DraftSaved clears dirty when the saved draft is the current one', () => {
const edited = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
const savedDraft = edited.tag === 'loaded' ? edited.draft : template;
const s = reduce(edited, { tag: 'DraftSaved', savedDraft });
expect(s.tag === 'loaded' && s.dirty).toBe(false);
expect(s.tag === 'loaded' && s.draft.orgName).toBe('X');
});
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
const editing = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
const savedDraft = editing.tag === 'loaded' ? editing.draft : template;
// a further edit changes the draft reference before the save resolves
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
const s = reduce(raced, { tag: 'DraftSaved', savedDraft });
expect(s.tag === 'loaded' && s.dirty).toBe(true);
});
it('edits are no-ops in non-loaded states', () => {
expect(reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' })).toEqual({
tag: 'loading',
});
});
it('a completed logo upload sets logoDocumentId + dirty', () => {
const withCat = reduce(loaded(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
});
const selected = reduce(withCat, {
tag: 'Upload',
msg: { type: 'FileSelected', categoryId: 'org-logo', localId: 'a', fileName: 'l.png', fileSizeMb: 0.1 },
});
const done = reduce(selected, {
tag: 'Upload',
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
});
expect(done.tag === 'loaded' && done.draft.logoDocumentId).toBe('doc-1');
expect(done.tag === 'loaded' && done.dirty).toBe(true);
});
it('removing the logo clears logoDocumentId + dirty', () => {
const withLogo = reduce(loaded(), {
tag: 'Upload',
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
});
const removed = reduce(withLogo, {
tag: 'Upload',
msg: { type: 'UploadRemoved', localId: 'a' },
});
expect(removed.tag === 'loaded' && removed.draft.logoDocumentId).toBeUndefined();
expect(removed.tag === 'loaded' && removed.dirty).toBe(true);
});
it('DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads', () => {
const withCat = reduce(loaded(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
});
const switched = reduce(withCat, {
tag: 'DraftLoaded',
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
});
expect(switched.tag === 'loaded' && switched.upload.categories).toHaveLength(1);
expect(switched.tag === 'loaded' && switched.upload.uploads).toHaveLength(0);
expect(switched.tag === 'loaded' && switched.subOrgId).toBe('cibg-vakbekwaamheid');
});
});

View File

@@ -0,0 +1,100 @@
import { assertNever } from '@shared/kernel/fp';
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine';
/**
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —
* the same idiom as the wizards. The DRAFT org template is form state (edited in
* place on the canvas); publish/rollback are effects that come back as `DraftLoaded`.
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
* the composable upload sub-machine folded in, exactly like the wizards fold
* `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`.
*/
/** The org-identity text fields editable directly on the letter canvas. */
export type OrgTemplateTextField =
| 'orgName'
| 'returnAddress'
| 'footerContact'
| 'footerLegal'
| 'signatureName'
| 'signatureRole'
| 'signatureClosing';
export type OrgTemplateState =
| { tag: 'loading' }
| { tag: 'failed'; reason: string }
| {
tag: 'loaded';
subOrgId: string;
draft: OrgTemplate;
publishedVersion: number;
history: readonly OrgTemplateVersion[];
unsentBriefs: number;
dirty: boolean;
/** Logo upload sub-state (single file, `org-logo` category). */
upload: UploadState;
};
export const initial: OrgTemplateState = { tag: 'loading' };
export type OrgTemplateMsg =
| { tag: 'Loading' }
| { tag: 'DraftLoaded'; view: OrgTemplateAdminView }
| { tag: 'LoadFailed'; reason: string }
| { tag: 'FieldEdited'; field: OrgTemplateTextField; value: string }
| { tag: 'MarginEdited'; edge: keyof Margins; value: number }
/** Carries the draft that was saved: clears `dirty` only if no edit landed during
the round-trip (reference-equal), so a concurrent edit keeps its pending save. */
| { tag: 'DraftSaved'; savedDraft: OrgTemplate }
| { tag: 'Upload'; msg: UploadMsg };
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
function editDraft(
s: OrgTemplateState,
f: (draft: OrgTemplate) => OrgTemplate,
): OrgTemplateState {
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
}
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
switch (m.tag) {
case 'Loading':
return { tag: 'loading' };
case 'LoadFailed':
return { tag: 'failed', reason: m.reason };
case 'DraftLoaded':
return {
tag: 'loaded',
subOrgId: m.view.draft.subOrgId,
draft: m.view.draft,
publishedVersion: m.view.publishedVersion,
history: m.view.history,
unsentBriefs: m.view.unsentBriefs,
dirty: false,
// Keep the loaded logo category across sub-org switches (it's the same
// `org-logo` category, loaded once); drop only any in-flight/finished uploads.
upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
};
case 'FieldEdited':
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
case 'MarginEdited':
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
case 'DraftSaved':
return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
case 'Upload': {
if (s.tag !== 'loaded') return s;
const upload = reduceUpload(s.upload, m.msg);
// A completed/removed logo upload also updates the draft's logoDocumentId.
if (m.msg.type === 'UploadComplete')
return { ...s, upload, draft: { ...s.draft, logoDocumentId: m.msg.documentId }, dirty: true };
if (m.msg.type === 'UploadRemoved') {
const { logoDocumentId: _dropped, ...rest } = s.draft;
return { ...s, upload, draft: rest, dirty: true };
}
return { ...s, upload };
}
default:
return assertNever(m);
}
}

View File

@@ -0,0 +1,67 @@
/**
* The organization template (Brief v2 PRD §3, WP-23/24): 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, on the letter canvas. Server-owned: the FE renders it verbatim,
* never edits it here (the admin editor is WP-26).
*/
export interface Margins {
readonly topMm: number;
readonly rightMm: number;
readonly bottomMm: number;
readonly leftMm: number;
}
export interface OrgTemplate {
readonly subOrgId: string;
readonly orgName: string;
/** Multiline; rendered above the envelope window. */
readonly returnAddress: string;
readonly logoDocumentId?: string;
/** Multiline contact block in the footer. */
readonly footerContact: string;
readonly footerLegal: string;
readonly signatureName: string;
readonly signatureRole: string;
readonly signatureClosing: string;
readonly margins: Margins;
/** 0 = draft; n>0 = the published snapshot this letter renders with. */
readonly version: number;
}
// --- admin editor (WP-26) ---
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */
export interface OrgTemplateVersion {
readonly version: number;
readonly publishedAt: string;
readonly template: OrgTemplate;
}
/** The admin editor's view of one sub-org: the editable draft plus publish metadata. */
export interface OrgTemplateAdminView {
readonly draft: OrgTemplate;
readonly publishedVersion: number;
readonly history: readonly OrgTemplateVersion[];
/** How many not-yet-sent letters a publish would re-render (the impact count). */
readonly unsentBriefs: number;
}
/** One row in the sub-org switcher. */
export interface SubOrgSummary {
readonly subOrgId: string;
readonly orgName: string;
readonly publishedVersion: number;
}
/** Publish outcome: the new version and how many unsent letters it touched. */
export interface PublishResult {
readonly version: number;
readonly affectedUnsentBriefs: number;
}
/** Margin bounds (server-owned, `OrgTemplateRules`): the FE mirrors them for instant
feedback via `<input min max>`; the server re-validates and stays the authority. */
export const MARGIN_MIN_MM = 10;
export const MARGIN_MAX_MM = 50;

View File

@@ -1,6 +1,12 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { BriefViewDto } from '@shared/infrastructure/api-client'; import { BriefViewDto } from '@shared/infrastructure/api-client';
import { parseBrief, parseBriefView, parseNode, parseStatus } from './brief.adapter'; import {
parseBrief,
parseBriefView,
parseNode,
parseOrgTemplate,
parseStatus,
} from './brief.adapter';
const view: BriefViewDto = { const view: BriefViewDto = {
brief: { brief: {
@@ -46,6 +52,19 @@ const view: BriefViewDto = {
version: 1, version: 1,
}, },
], ],
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false },
orgTemplate: {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
footerLegal: 'KvK 00000000',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1,
},
}; };
describe('brief.adapter parse boundary', () => { describe('brief.adapter parse boundary', () => {
@@ -67,6 +86,39 @@ describe('brief.adapter parse boundary', () => {
autoResolvable: true, autoResolvable: true,
fillable: false, fillable: false,
}); });
expect(r.value.decisions).toEqual({
canEdit: false,
canApprove: true,
canReject: true,
canSend: false,
});
});
it('parses the org template and drops a null logoDocumentId', () => {
const r = parseOrgTemplate(view.orgTemplate);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.orgName).toBe('CIBG — Registers');
expect(r.value.margins).toEqual({ topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 });
expect('logoDocumentId' in r.value).toBe(false);
});
it('rejects a view whose org template is missing or malformed', () => {
expect(parseBriefView({ ...view, orgTemplate: undefined }).ok).toBe(false);
expect(parseOrgTemplate({ ...view.orgTemplate, signatureName: undefined }).ok).toBe(false);
expect(
parseOrgTemplate({
...view.orgTemplate,
margins: { topMm: 25, rightMm: 25, bottomMm: 25 },
}).ok,
).toBe(false);
});
it('rejects a view whose decisions are missing or malformed', () => {
expect(parseBriefView({ ...view, decisions: undefined as never }).ok).toBe(false);
expect(
parseBriefView({ ...view, decisions: { ...view.decisions, canSend: 'yes' as never } }).ok,
).toBe(false);
}); });
it('narrows node variants and rejects unknown ones', () => { it('narrows node variants and rejects unknown ones', () => {
@@ -127,6 +179,14 @@ describe('brief.adapter parse boundary', () => {
]); ]);
}); });
it('rejects a library passage with an unknown scope', () => {
const r = parseBriefView({
...view,
availablePassages: [{ ...view.availablePassages![0], scope: 'bogus' as never }],
});
expect(r.ok).toBe(false);
});
it('rejects a passage block missing provenance', () => { it('rejects a passage block missing provenance', () => {
const r = parseBrief({ const r = parseBrief({
...view.brief, ...view.brief,

View File

@@ -3,24 +3,27 @@ import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit'; import { runSubmit } from '@shared/application/submit';
import { import {
ApiClient, ApiClient,
BriefDecisionsDto,
BriefDto, BriefDto,
BriefStatusDto, BriefStatusDto,
BriefViewDto, BriefViewDto,
LetterBlockDto, LetterBlockDto,
LetterSectionDto, LetterSectionDto,
LibraryPassageDto, LibraryPassageDto,
OrgTemplateDto,
PlaceholderDefDto, PlaceholderDefDto,
RichTextBlockDto, RichTextBlockDto,
RichTextNodeDto, RichTextNodeDto,
} from '@shared/infrastructure/api-client'; } from '@shared/infrastructure/api-client';
import { import {
Brief, Brief,
BriefDecisions,
BriefStatus, BriefStatus,
LetterBlock, LetterBlock,
LetterSection, LetterSection,
LibraryPassage, LibraryPassage,
PassageScope,
} from '@brief/domain/brief'; } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { PlaceholderDef } from '@brief/domain/placeholders'; import { PlaceholderDef } from '@brief/domain/placeholders';
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text'; import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
@@ -35,6 +38,8 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
export interface BriefView { export interface BriefView {
readonly brief: Brief; readonly brief: Brief;
readonly availablePassages: LibraryPassage[]; readonly availablePassages: LibraryPassage[];
readonly decisions: BriefDecisions;
readonly orgTemplate: OrgTemplate;
} }
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`; export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
@@ -49,32 +54,32 @@ export class BriefAdapter {
return r.ok ? parseBriefView(r.value) : r; return r.ok ? parseBriefView(r.value) : r;
} }
async save(sections: readonly LetterSection[]): Promise<Result<string, Brief>> { async save(sections: readonly LetterSection[]): Promise<Result<string, BriefView>> {
const r = await runSubmit( const r = await runSubmit(
() => this.client.briefPUT({ sections: sections.map(sectionToDto) }), () => this.client.briefPUT({ sections: sections.map(sectionToDto) }),
BRIEF_ACTION_FAILED, BRIEF_ACTION_FAILED,
); );
return r.ok ? parseBrief(r.value) : r; return r.ok ? parseBriefView(r.value) : r;
} }
async submit(): Promise<Result<string, Brief>> { async submit(): Promise<Result<string, BriefView>> {
const r = await runSubmit(() => this.client.briefSubmit(), BRIEF_ACTION_FAILED); const r = await runSubmit(() => this.client.briefSubmit(), BRIEF_ACTION_FAILED);
return r.ok ? parseBrief(r.value) : r; return r.ok ? parseBriefView(r.value) : r;
} }
async approve(): Promise<Result<string, Brief>> { async approve(): Promise<Result<string, BriefView>> {
const r = await runSubmit(() => this.client.approve(), BRIEF_ACTION_FAILED); const r = await runSubmit(() => this.client.approve(), BRIEF_ACTION_FAILED);
return r.ok ? parseBrief(r.value) : r; return r.ok ? parseBriefView(r.value) : r;
} }
async reject(comments: string): Promise<Result<string, Brief>> { async reject(comments: string): Promise<Result<string, BriefView>> {
const r = await runSubmit(() => this.client.reject({ comments }), BRIEF_ACTION_FAILED); const r = await runSubmit(() => this.client.reject({ comments }), BRIEF_ACTION_FAILED);
return r.ok ? parseBrief(r.value) : r; return r.ok ? parseBriefView(r.value) : r;
} }
async send(): Promise<Result<string, Brief>> { async send(): Promise<Result<string, BriefView>> {
const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED); const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED);
return r.ok ? parseBrief(r.value) : r; return r.ok ? parseBriefView(r.value) : r;
} }
/** Demo "start over" — recreate a fresh brief server-side and return the new view. */ /** Demo "start over" — recreate a fresh brief server-side and return the new view. */
@@ -225,8 +230,9 @@ export function parseStatus(dto: BriefStatusDto | undefined): Result<string, Bri
} }
function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> { function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep')) if (typeof dto.passageId !== 'string') return err('passage: bad shape');
return err('passage: bad shape'); if (dto.scope !== 'global' && dto.scope !== 'beroep')
return err(`passage: unknown scope ${dto.scope}`);
if ( if (
typeof dto.sectionKey !== 'string' || typeof dto.sectionKey !== 'string' ||
typeof dto.label !== 'string' || typeof dto.label !== 'string' ||
@@ -237,7 +243,7 @@ function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
if (!content.ok) return content; if (!content.ok) return content;
return ok({ return ok({
passageId: dto.passageId, passageId: dto.passageId,
scope: dto.scope as PassageScope, scope: dto.scope,
sectionKey: dto.sectionKey, sectionKey: dto.sectionKey,
label: dto.label, label: dto.label,
content: content.value, content: content.value,
@@ -281,17 +287,81 @@ export function parseBrief(dto: BriefDto): Result<string, Brief> {
}); });
} }
function parseDecisions(dto: BriefDecisionsDto | undefined): Result<string, BriefDecisions> {
if (
typeof dto?.canEdit !== 'boolean' ||
typeof dto.canApprove !== 'boolean' ||
typeof dto.canReject !== 'boolean' ||
typeof dto.canSend !== 'boolean'
) {
return err('brief-view: missing/invalid decisions');
}
return ok({
canEdit: dto.canEdit,
canApprove: dto.canApprove,
canReject: dto.canReject,
canSend: dto.canSend,
});
}
export function parseOrgTemplate(dto: OrgTemplateDto | undefined): Result<string, OrgTemplate> {
if (
typeof dto?.subOrgId !== 'string' ||
typeof dto.orgName !== 'string' ||
typeof dto.returnAddress !== 'string' ||
typeof dto.footerContact !== 'string' ||
typeof dto.footerLegal !== 'string' ||
typeof dto.signatureName !== 'string' ||
typeof dto.signatureRole !== 'string' ||
typeof dto.signatureClosing !== 'string' ||
typeof dto.version !== 'number'
) {
return err('org-template: bad shape');
}
const m = dto.margins;
if (
typeof m?.topMm !== 'number' ||
typeof m.rightMm !== 'number' ||
typeof m.bottomMm !== 'number' ||
typeof m.leftMm !== 'number'
) {
return err('org-template: bad margins');
}
return ok({
subOrgId: dto.subOrgId,
orgName: dto.orgName,
returnAddress: dto.returnAddress,
...(dto.logoDocumentId != null ? { logoDocumentId: dto.logoDocumentId } : {}),
footerContact: dto.footerContact,
footerLegal: dto.footerLegal,
signatureName: dto.signatureName,
signatureRole: dto.signatureRole,
signatureClosing: dto.signatureClosing,
margins: { topMm: m.topMm, rightMm: m.rightMm, bottomMm: m.bottomMm, leftMm: m.leftMm },
version: dto.version,
});
}
export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> { export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
if (!dto.brief) return err('brief-view: missing brief'); if (!dto.brief) return err('brief-view: missing brief');
const brief = parseBrief(dto.brief); const brief = parseBrief(dto.brief);
if (!brief.ok) return brief; if (!brief.ok) return brief;
const decisions = parseDecisions(dto.decisions);
if (!decisions.ok) return decisions;
const orgTemplate = parseOrgTemplate(dto.orgTemplate);
if (!orgTemplate.ok) return orgTemplate;
const availablePassages: LibraryPassage[] = []; const availablePassages: LibraryPassage[] = [];
for (const p of dto.availablePassages ?? []) { for (const p of dto.availablePassages ?? []) {
const parsed = parsePassage(p); const parsed = parsePassage(p);
if (!parsed.ok) return parsed; if (!parsed.ok) return parsed;
availablePassages.push(parsed.value); availablePassages.push(parsed.value);
} }
return ok({ brief: brief.value, availablePassages }); return ok({
brief: brief.value,
availablePassages,
decisions: decisions.value,
orgTemplate: orgTemplate.value,
});
} }
// --- toDto: domain → wire, for save (collapses the union to the flat shape) --- // --- toDto: domain → wire, for save (collapses the union to the flat shape) ---

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '../../../environments/environment';
const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`;
/**
* `/brief/preview` returns `text/html`, not JSON, and is `.ExcludeFromDescription()`'d
* to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
* `roleInterceptor`, so `X-Role` is set here explicitly.
*/
@Injectable({ providedIn: 'root' })
export class LetterPreviewAdapter {
async preview(): Promise<Result<string, Blob>> {
let res: Response;
try {
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
headers: { 'X-Role': currentRole() },
});
} catch {
return err(PREVIEW_FAILED);
}
if (!res.ok) return err(await errorMessage(res));
return ok(await res.blob());
}
}
async function errorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), PREVIEW_FAILED);
} catch {
return PREVIEW_FAILED;
}
}

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
import { parseOrgTemplateAdminView } from './org-template.adapter';
const draft: OrgTemplateDto = {
subOrgId: 'cibg-registers',
orgName: 'CIBG',
returnAddress: 'Postbus 1',
footerContact: 'info@cibg.nl',
footerLegal: 'onderdeel van VWS',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 },
version: 3,
};
const view: OrgTemplateAdminViewDto = {
draft,
publishedVersion: 3,
unsentBriefs: 2,
history: [{ version: 2, publishedAt: '2026-06-01', template: draft }],
};
describe('parseOrgTemplateAdminView', () => {
it('parses a well-formed admin view', () => {
const r = parseOrgTemplateAdminView(view);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.draft.orgName).toBe('CIBG');
expect(r.value.publishedVersion).toBe(3);
expect(r.value.unsentBriefs).toBe(2);
expect(r.value.history).toHaveLength(1);
expect(r.value.history[0].version).toBe(2);
});
it('rejects a missing draft', () => {
const r = parseOrgTemplateAdminView({ ...view, draft: undefined });
expect(r.ok).toBe(false);
});
it('rejects a missing count field', () => {
const r = parseOrgTemplateAdminView({ ...view, unsentBriefs: undefined });
expect(r.ok).toBe(false);
});
it('rejects a malformed history entry', () => {
const r = parseOrgTemplateAdminView({
...view,
history: [{ version: 2, publishedAt: '2026-06-01', template: { ...draft, orgName: undefined } }],
});
expect(r.ok).toBe(false);
});
});

View File

@@ -0,0 +1,163 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit';
import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '../../../environments/environment';
import {
ApiClient,
OrgTemplateAdminViewDto,
OrgTemplateDto,
OrgTemplateVersionDto,
PublishOrgTemplateResponse,
SubOrgSummaryDto,
} from '@shared/infrastructure/api-client';
import {
OrgTemplate,
OrgTemplateAdminView,
OrgTemplateVersion,
PublishResult,
SubOrgSummary,
} from '@brief/domain/org-template';
import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';
/**
* The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/
* rollback go through the generated client (X-Role added by `roleInterceptor`);
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.
*/
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
@Injectable({ providedIn: 'root' })
export class OrgTemplateAdapter {
private client = inject(ApiClient);
async list(): Promise<Result<string, SubOrgSummary[]>> {
const r = await runSubmit(() => this.client.orgTemplates(), FAILED);
if (!r.ok) return r;
const out: SubOrgSummary[] = [];
for (const s of r.value ?? []) {
const parsed = parseSubOrg(s);
if (!parsed.ok) return parsed;
out.push(parsed.value);
}
return ok(out);
}
async load(subOrgId: string): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED);
return r.ok ? parseAdminView(r.value) : r;
}
async save(subOrgId: string, draft: OrgTemplate): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(
() => this.client.orgTemplatePUT(subOrgId, { draft: toDto(draft) }),
FAILED,
);
return r.ok ? parseAdminView(r.value) : r;
}
async publish(subOrgId: string): Promise<Result<string, PublishResult>> {
const r = await runSubmit(() => this.client.orgTemplatePublish(subOrgId), FAILED);
return r.ok ? parsePublish(r.value) : r;
}
async rollback(
subOrgId: string,
version: number,
): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED);
return r.ok ? parseAdminView(r.value) : r;
}
/** Proefbrief: the unpublished draft rendered over a fixture letter, opened as a Blob. */
async proefbrief(subOrgId: string): Promise<Result<string, Blob>> {
let res: Response;
try {
res = await fetch(
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
{ headers: { 'X-Role': currentRole() } },
);
} catch {
return err(PROEFBRIEF_FAILED);
}
if (!res.ok) {
try {
return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));
} catch {
return err(PROEFBRIEF_FAILED);
}
}
return ok(await res.blob());
}
}
// --- parse: wire → domain, validating at the boundary ---
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
if (typeof dto.subOrgId !== 'string' || typeof dto.orgName !== 'string')
return err('sub-org: bad shape');
return ok({
subOrgId: dto.subOrgId,
orgName: dto.orgName,
publishedVersion: dto.publishedVersion ?? 0,
});
}
function parseVersion(dto: OrgTemplateVersionDto): Result<string, OrgTemplateVersion> {
if (typeof dto.version !== 'number' || typeof dto.publishedAt !== 'string')
return err('version: bad shape');
const template = parseOrgTemplate(dto.template);
if (!template.ok) return template;
return ok({ version: dto.version, publishedAt: dto.publishedAt, template: template.value });
}
export function parseOrgTemplateAdminView(
dto: OrgTemplateAdminViewDto,
): Result<string, OrgTemplateAdminView> {
const draft = parseOrgTemplate(dto.draft);
if (!draft.ok) return draft;
if (typeof dto.publishedVersion !== 'number' || typeof dto.unsentBriefs !== 'number')
return err('admin-view: bad shape');
const history: OrgTemplateVersion[] = [];
for (const v of dto.history ?? []) {
const parsed = parseVersion(v);
if (!parsed.ok) return parsed;
history.push(parsed.value);
}
return ok({
draft: draft.value,
publishedVersion: dto.publishedVersion,
history,
unsentBriefs: dto.unsentBriefs,
});
}
const parseAdminView = parseOrgTemplateAdminView;
function parsePublish(dto: PublishOrgTemplateResponse): Result<string, PublishResult> {
if (typeof dto.version !== 'number' || typeof dto.affectedUnsentBriefs !== 'number')
return err('publish: bad shape');
return ok({ version: dto.version, affectedUnsentBriefs: dto.affectedUnsentBriefs });
}
// --- toDto: domain → wire (for save) ---
function toDto(t: OrgTemplate): OrgTemplateDto {
return {
subOrgId: t.subOrgId,
orgName: t.orgName,
returnAddress: t.returnAddress,
...(t.logoDocumentId != null ? { logoDocumentId: t.logoDocumentId } : {}),
footerContact: t.footerContact,
footerLegal: t.footerLegal,
signatureName: t.signatureName,
signatureRole: t.signatureRole,
signatureClosing: t.signatureClosing,
margins: { ...t.margins },
version: t.version,
};
}

View File

@@ -1,8 +1,8 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { BriefStore } from '@brief/application/brief.store'; import { BriefStore } from '@brief/application/brief.store';
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component'; import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
@@ -11,13 +11,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
this just wires signals to the organism and events back to store commands. */ this just wires signals to the organism and events back to store commands. */
@Component({ @Component({
selector: 'app-brief-page', selector: 'app-brief-page',
imports: [ imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent],
PageShellComponent,
SpinnerComponent,
AlertComponent,
ButtonComponent,
LetterComposerComponent,
],
styles: [ styles: [
` `
.brief-toolbar { .brief-toolbar {
@@ -39,37 +33,43 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
<app-alert type="error">{{ err }}</app-alert> <app-alert type="error">{{ err }}</app-alert>
} }
@switch (model().tag) { <app-async [data]="store.remoteData()">
@case ('loading') { <ng-template appAsyncError>
<app-spinner />
}
@case ('failed') {
<app-alert type="error">{{ failedText }}</app-alert> <app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button> <app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
} </ng-template>
@case ('loaded') { <ng-template appAsyncLoaded>
<div class="brief-toolbar"> @if (loaded(); as s) {
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span> @if (store.orgTemplate(); as orgTemplate) {
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{ <div class="brief-toolbar">
resetLabel <span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
}}</app-button> <app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
</div> resetLabel
<app-letter-composer }}</app-button>
[brief]="brief()!" </div>
[availablePassages]="availablePassages()" <app-letter-composer
[diagnostics]="store.diagnostics()" [brief]="s.brief"
[editable]="store.editable()" [orgTemplate]="orgTemplate"
[role]="store.role" [logoUrl]="store.logoUrl()"
[canSubmit]="store.canSubmit()" [availablePassages]="s.availablePassages"
[busy]="store.busy()" [diagnostics]="store.diagnostics()"
(edit)="store.edit($event)" [canEdit]="store.canEdit()"
(submit)="store.submit()" [canApprove]="store.canApprove()"
(approve)="store.approve()" [canReject]="store.canReject()"
(reject)="store.reject($event)" [canSend]="store.canSend()"
(send)="store.send()" [canSubmit]="store.canSubmit()"
/> [busy]="store.busy()"
} (edit)="store.edit($event)"
} (submit)="store.submit()"
(approve)="store.approve()"
(reject)="store.reject($event)"
(send)="store.send()"
(preview)="store.previewLetter()"
/>
}
}
</ng-template>
</app-async>
</app-page-shell> </app-page-shell>
`, `,
}) })
@@ -90,12 +90,12 @@ export class BriefPage {
/** Debounced-save state, surfaced in a polite live region. */ /** Debounced-save state, surfaced in a polite live region. */
protected saveText = computed(() => { protected saveText = computed(() => {
switch (this.store.saveState()) { switch (this.store.saveState().tag) {
case 'saving': case 'Saving':
return this.savingText; return this.savingText;
case 'saved': case 'Saved':
return this.savedText; return this.savedText;
case 'error': case 'Error':
return this.saveErrorText; return this.saveErrorText;
default: default:
return ''; return '';
@@ -110,15 +110,13 @@ export class BriefPage {
void this.store.resetDemo(); void this.store.resetDemo();
} }
// Narrow the loaded state for the template. /** Typed narrowing for the `<app-async>` loaded slot — see WP-06: a structural
protected brief() { directive's context can't inherit a generic from a sibling host input, so the
Success value is unwrapped here instead of through `let-`. */
protected readonly loaded = computed(() => {
const s = this.model(); const s = this.model();
return s.tag === 'loaded' ? s.brief : null; return s.tag === 'loaded' ? s : undefined;
} });
protected availablePassages() {
const s = this.model();
return s.tag === 'loaded' ? s.availablePassages : [];
}
protected reload() { protected reload() {
void this.store.load(); void this.store.load();

View File

@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { Diagnostic } from '@brief/domain/placeholders';
import { DiagnosticsPanelComponent } from './diagnostics-panel.component';
const location = { blockId: 'local-2', paragraphIndex: 0, nodeIndex: 1 };
const errorAndWarning: Diagnostic[] = [
{
severity: 'error',
code: 'unknown-placeholder',
placeholderKey: 'onbekend_veld',
location,
message: 'Onbekend veld "onbekend_veld" — controleer de spelling.',
},
{
severity: 'warning',
code: 'unresolved-at-send',
placeholderKey: 'reden_besluit',
location,
message: '"Reden besluit" is nog niet ingevuld.',
},
];
const meta: Meta<DiagnosticsPanelComponent> = {
title: 'Domein/Brief/Diagnostics Panel',
component: DiagnosticsPanelComponent,
args: { locate: () => {} },
};
export default meta;
type Story = StoryObj<DiagnosticsPanelComponent>;
export const Findings: Story = { args: { diagnostics: errorAndWarning } };
export const Clean: Story = { args: { diagnostics: [] } };

View File

@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LetterBlock } from '@brief/domain/brief';
import { LetterBlockComponent } from './letter-block.component';
const passageBlock: LetterBlock = {
type: 'passage',
blockId: 'local-1',
sourcePassageId: 'p1',
sourceVersion: 1,
edited: false,
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
};
const freeTextBlock: LetterBlock = {
type: 'freeText',
blockId: 'local-2',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Wij hebben besloten om reden ' },
{ type: 'placeholder', key: 'reden_besluit' },
{ type: 'text', text: '.' },
],
},
],
},
};
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
const meta: Meta<LetterBlockComponent> = {
title: 'Domein/Brief/Letter Block',
component: LetterBlockComponent,
args: {
block: passageBlock,
placeholders,
contentChanged: () => {},
removed: () => {},
moved: () => {},
},
};
export default meta;
type Story = StoryObj<LetterBlockComponent>;
export const ReadOnlyPassage: Story = { args: { editable: false } };
export const EditableFreeText: Story = { args: { block: freeTextBlock, editable: true } };

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