Compare commits

...
Author SHA1 Message Date
ehoandClaude Opus 5 fc2a3c348b refactor: one member order for the 3 wizard containers (RD-38)
RD-22 and RD-23 brought the wizard containers under the 250-line budget, so
`max-lines` reports nothing. The files still read badly. Line count was never
the problem.

Fix three things in all three containers:

1. The member order was scrambled, and it differed per file. `registratie`
   declared `draftSync` in the middle of a run of `computed`s; `herregistratie`
   read `this.stepLabels.length` seven lines before `stepLabels` existed; the
   three files put the copy arrays in three different places. All three now use
   one nine-section order, so they compare side by side.
2. Pure logic sat in the container. Extract `digitalDocumentIds` into
   `upload.machine.ts` — the "digital and finished uploading" filter was
   written out four times, and it removes a `documentId!` assertion from both
   containers. Extract `diplomaMsg` into a sibling of the step files.
3. Comments carried archaeology. Drop the three RD-05 references and keep the
   rule. Drop "replaces sessionStorage" and the note about focus management that
   moved to the shell. Fix `intake`'s class comment, which claimed answers
   persist to sessionStorage and was contradicted 30 lines below.

`phase` deliberately stays in all three: it cannot live in `domain/`, and three
siblings plus three specs is a worse trade than 17 readable lines. The store ⇄
`draftSync` cycle also stays — both callbacks are deferred, so it is safe, and
one comment now names it.

No behaviour change. Member lists and every `private`/`protected`/`readonly`
modifier are unchanged, which the showcase depends on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:16:23 +02:00
ehoandClaude Opus 5 551cabce5e docs: add RD-37 — five a11y suppressions name a ticket that closed
Found while measuring RD-30, which archives the directory these references
point into.

Five stories disable the axe check with the reason "WP-11 reworks this
markup". WP-11 is done, and so is WP-13, the gap register it handed the
remainder to. No open ticket owns the defect, so the rule that a disabled
check must name the ticket that removes it holds only in letter.

The defect ships: `app-choice-link` and `app-aanvraag-block` put a component
host between the keuzelijst `<ul>` and its `<li>`, which breaks axe's
list/listitem rule. WP-11 solved the same problem for `application-link` by
making the host be the `<li>`, but `atomic-design.mdx` documents the current
split as deliberate — so whether that move fights the vendored CSS is the
question RD-37 must answer first.

Sequenced before RD-30.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:37:26 +02:00
ehoandClaude Sonnet 5 b6bd7eea5a refactor: enforce the atomic ladder in dependency-cruiser (RD-29)
Add three dependency-cruiser rules that forbid upward edges inside
libs/shared/src/ui: atoms-compose-nothing-above, molecules-below-organisms,
and design-system-not-layout. RD-27 made the atomic ladder expressible by
moving components into atoms/molecules/organisms folders; these rules make
it enforced, so an upward import now fails dep:check instead of shipping.

No pathNot exemption for specs and stories: measured, zero upward edges
exist anywhere in libs/shared/src/ui today, in production code, specs, and
stories alike, so the exemption PLAN proposed has nothing to protect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:32:52 +02:00
ehoandClaude Sonnet 5 d4c5a9450a fix: layer-tag mismatches + libs/beheer title rule (RD-28)
Two components disagreed with their own story title. async.component.ts
had no layer tag; add `Molecule:` to its class header, not to the
Convenience: comment on the ASYNC array (that comment describes an
export, not a layer, and stays as it is). breadcrumb.component.ts had
`Chrome:`; rename it to `Molecule:` to match its story title.

CLAUDE.md and layers.mdx both claimed `libs/beheer/ui` is Design System.
The code disagrees: its story title is `Domein/Beheer/...`, because
libs/beheer is a bounded context that lives under libs/ only because two
apps share it. Fix the two doc lines to match the code; the story title
does not change.

The ticket's own tag-count check asserted 68 after the edits, but adding
a new tag to async.component.ts (which had none) is a net +1 over the
Chrome-to-Molecule rename (a wash) — the true post-edit count is 69.
Corrected the number in the ticket text rather than deleting the
mandated tag to force the wrong count. Also fixed pre-existing invalid
nested-backtick markdown in the ticket's decision 4 bullets: prettier
mis-rewrapped it and dropped spaces, so the phrasing was rewritten in
valid CommonMark with no change in meaning.

npm run ci --full passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:26:47 +02:00
ehoandClaude Opus 5 57fc72f254 docs: correct RD-27's occurrence count, the fourth time in one pattern
Decision 3 said the `@shared/ui/` occurrence count must not change at 200,
while decision 4, nine lines below, mandated converting five relative imports
into `@shared/ui/` aliases — five new occurrences by construction. The answer
is 205, and the agent verified by hand that the 21 occurrences now inside
`libs/shared/src/ui/` are the 16 from before plus exactly those five.

This is the fourth instance of the pattern named in the previous docs commit,
and the first ticket written after naming it. Recorded as such: when two
decisions both touch the thing you are counting, add them up first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:15:20 +02:00
ehoandClaude Sonnet 5 43dc3210cd refactor: move libs/shared/src/ui/ into atoms/molecules/organisms (RD-27)
The folder now equals the layer, as CLAUDE.md decision 2 requires. 33
directories move by git mv (25 flat, plus upload/'s 8 subfolders split
across all three layers). 28 distinct @shared/ui/* specifiers rewrite
across 73 files, longest-first. Five relative imports inside upload/
become @shared/ui aliases because their sibling now lives in a
different layer; two stay relative because both ends stay in the same
layer. Four .mdx docs get their seven broken story imports fixed;
atomic-design.mdx's page-shell import is untouched, because layout/
does not move.

No component, template, story title, or layer-tag comment changes.
That is RD-28's job.

Verified against the ticket's acceptance commands: the 26 flat
directories become exactly 3 layer folders with the counts the ticket
names, only three @shared/ui/* prefixes remain (atoms, molecules,
organisms), the .mdx import count holds at 7, and the relative-import
count inside ui/ drops from 7 to 2 as decision 4 requires. The
@shared/ui/ occurrence count moves from 200 to 205: decision 4
mandates turning 5 of those 7 relative imports into @shared/ui/*
aliases, which decision 3's "200 before, 200 after" check does not
account for. The 5-occurrence gap is exactly the 5 conversions decision
4 names, not a lost or duplicated specifier.

npm run ci --full passes: lint, typecheck, dep:check, format, tokens,
seam, both apps' + both libraries' tests, both apps' localized build,
audit, backend tests, all three generated-artifact drift checks, and
both Storybook instances' build + axe-core a11y suite (67+45 suites,
198+112 tests, all green).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:14:10 +02:00
ehoandClaude Sonnet 5 7a8eab917b refactor: inline letter-canvas's 19 pure labels, extract letter-line (RD-26)
Twenty of letter-canvas's twenty-eight input()s are $localize labels that no
caller ever binds. Inline nineteen of them as template i18n, using the same
ids and source text so messages.en.xlf does not change. recipientText stays
an input() because its message embeds a literal \n, which as template text
becomes a different source string to Angular's extractor.

Extract letter-line.component.ts for the #line template plus the
label/auto/state/sample helpers it needs, replacing letter-canvas's three
ngTemplateOutlet incantations with one tag each. Its helpers are exported
pure functions with a spec, no TestBed.

The file stays over the 250-line budget (77 lines of CSS plus one letter's
markup), so the eslint-disable max-lines directive stays too, with its
reason rewritten to say so plainly — the only such disable left in the repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:25:35 +02:00
ehoandClaude Opus 5 bcac3789d3 docs: fix RD-25's decision 4, and name the pattern behind three misses
Decision 4 said the parent keeps seven outputs while decision 5, four lines
below, said all eleven remain and are re-emitted from children. Seven is how
many the parent raises; eleven is how many it declares. The acceptance line
copied the wrong one, and satisfying it would have broken
`org-template.page.ts`, which binds all eleven.

RD-08, RD-23 and RD-25 are the same mistake: a decision describes the design
in one vocabulary, and the acceptance line counts something else that shares a
word. Name what the command counts before writing the number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:03:30 +02:00
ehoandClaude Sonnet 5 cf1f641534 refactor: split org-template-editor by output cluster (RD-25)
org-template-editor.component.ts carried an eslint-disable for max-lines,
padded by a dead sample-letter constant, 13 label inputs that were never
bindable, and two self-contained mutation clusters. Split all three out:

- SAMPLE_LETTER_BRIEF moves to brief/domain/sample-letter.ts. It is
  production content (the letter the admin previews), not a test fixture,
  so it stays out of brief.testing.ts (no-testing-in-production forbids
  production code from reaching a *.testing.ts file).
- 11 of the 13 label inputs become inline i18n template text. The two
  that interpolate MARGIN_MIN_MM/MARGIN_MAX_MM (marginsLegend,
  invalidHint) stay in TS, because moving an interpolated $localize call
  into a template renames the xlf placeholder and breaks the translation
  merge. Every id is preserved; messages.en.xlf is unchanged.
- logo-upload.component.ts and version-history.component.ts each take
  one output cluster. The parent still declares and re-emits all 11
  outputs — org-template.page.ts binds them directly on
  <app-org-template-editor> and is out of this ticket's file scope, so
  the parent's public surface cannot shrink.

Correction to the ticket while executing it: its acceptance check for
"= output" on the parent read "MUST be 7", copying decision 4's cluster
count instead of decision 5's (and the ticket's own Risks section's)
explicit requirement that the parent keep all 11 declarations. Fixed the
ticket's acceptance section to the correct number.

npm run ci --full is green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:02:30 +02:00
ehoandClaude Opus 5 5aed15bb98 docs: record the supervisor-as-second-writer mistake from RD-24
A task notification fires every time an agent stops with no live children,
including a pause mid-task. RD-24's agent paused and notified; the supervisor
read that as abandonment, took over the tree, ran the gate and edited the
ticket Status while the agent was still working. The agent resumed, correctly
detected a second writer, and refused to commit.

This is the mirror image of the RD-17 race the section above records: there a
fresh supervisor met a running agent, here a running agent met its own
supervisor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:44:17 +02:00
ehoandClaude Sonnet 5 630d68045f refactor: split concepts.page into 6 sections, fix dead highlighting (RD-24)
The page held six teaching sections and a 142-line `styles:` block, at 471
effective lines against a limit of 250. It is now 36 lines of composition.

Angular scopes a component's CSS to markup that component rendered, so the
split had to move each rule to its owner. `concept-card` owns the card
vocabulary and renders it. `.app-code`, `.app-lead`, `.app-cols` and
`.app-note` become globals, because their targets are projected or arrive
through `[innerHTML]`.

That constraint exposed a live bug. The syntax-highlighting rules compiled to
`pre[_ngcontent-%COMP%] .k[_ngcontent-%COMP%]`, but `highlight-ts` injects the
`.k`/`.s`/`.c` spans through `[innerHTML]`, so they carry no scope attribute
and the rule never matched. Keywords, strings and comments have always
rendered in the plain foreground colour. The rules are global now, on five new
`--app-code-*` tokens.

Widen the colour guard while here: it scanned only `*.component.ts`, so every
`*.page.ts`, `*.section.ts` and `*.step.ts` was invisible to it. That is how
this page collected 21 hardcoded colours. One other file needed a fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:44:01 +02:00
ehoandClaude Opus 5 d5a7a25a78 docs: record RD-23's two acceptance misses as recurrences
Both repeat a habit this README already documents, so they belong beside it
rather than as new entries.

`git grep -c` counts lines: a symbol that a file imports and then calls is two
lines, never one. And a check must not grep for a word that the ticket's own
mandated snippet contains — RD-23 required the upload controller's
`dispatch:` property and then asserted zero occurrences of `dispatch`.

The agent hit both, refused to distort the code, and corrected the ticket text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:18:26 +02:00
ehoandClaude Sonnet 5 11e3191099 refactor: split registratie-wizard into three steps (RD-23)
Move the adres, beroep and controle cases out of registratie-wizard.component.ts
into adres.step.ts, beroep.step.ts and controle.step.ts, matching RD-22's
*.step.ts convention. The parent drops from ~568 to 274 lines and loses its
`eslint-disable max-lines`.

The upload controller moves into beroep.step.ts and emits `uploadMsg` instead of
dispatching directly; the parent maps that back onto the machine's `Upload`
message. `onDiplomaKeuze` stays in the parent (message construction from the DUO
payload belongs in the container) and now takes only the chosen id, reading its
own `duoData` computed instead of receiving the DUO payload as an argument. Each
step injects `RegistratieLookupStore` directly for its own async presentation
(adresStatus, the DUO lookup, samenvattingVragen) — the sanctioned exception,
since it is a root singleton. Markup moved verbatim; the `@@` id count across
the directory stays 43.

Two of the ticket's acceptance numbers do not hold against correct code and are
corrected in the ticket file: `createUploadController` is 2 lines (import +
call), not 1 — `git grep -c` counts lines, and the same shape gives 2 for
`createStore` and 3 for `createDraftSync` elsewhere in this codebase. `dispatch`
is 1, not 0 — decision 4's mandated `UploadControllerDeps.dispatch` property
name is that string even though it is not the machine's dispatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:17:25 +02:00
ehoandClaude Sonnet 5 8e1de38c68 refactor: split intake-wizard into three step components (RD-22)
The parent held one @switch with three @case blocks — three screens'
markup in one file. Each case is independent and needs only the
answers, the errors, and (for two of them) the scholing threshold.

Extract buitenland.step.ts, werk.step.ts, and review.step.ts as pure,
presentational steps: inputs down, one narrow output up, dispatch
never passed down. The parent keeps the store, the shell, and
draftSync, and maps each step's output back to a machine message.

This is the first *.step.ts in the repo, so it sets the naming
convention that RD-23 does the same job with.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:58:52 +02:00
ehoandClaude Sonnet 5 4b3e6a6cfd refactor: move selection surgery into rich-text-dom.ts (RD-21)
deleteAdjacentChip and insert did getSelection()/Range work inside the
component, which pushed it over the max-lines budget under a disable
comment. rich-text-dom.ts already owns the DOM boundary, so the surgery
moves there as two new exports, chipAtCaret and insertChipAtCaret, and
the component keeps only its event-handling and output concerns.

adjacentChip stays exported with its own spec case. The component
disable comment is gone, since the file is now under the line budget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:42:21 +02:00
ehoandClaude Opus 5 8a3e42015b docs: add RD-36 — rename the stale ui/dashboard/ folder
RD-03 moved the dashboard page into the `overzicht` context and RD-04 left the
folder name alone, recording it as stale. It stays stale: the folder is named
after a page that lives somewhere else, so a reader looking for the dashboard
finds four sections that are not it.

Measured cost: 8 `git mv`s and four import lines in one file. Fold in two doc
paths that still name the moved page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:31:55 +02:00
ehoandClaude Opus 5 dff5f96bb3 docs: correct RD-20's file count, and record the ninth miss
RD-20 asserted `git grep -l "toWizardErrors"` would find 5 files, but its own
Steps list regenerates `behaviour-spec.mdx`, and the generator publishes every
`describe` title. Naming a spec after the function it tests puts the name in
the generated document too, so the honest count is 6.

The agent refused to rename the describe block to satisfy the number, which is
the correct response and matches the precedent from RD-14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:01:01 +02:00
ehoandClaude Sonnet 5 831940f1b9 refactor: extract toWizardErrors, adopted by all 3 wizards (RD-20)
Each wizard flattened its per-field error record into the shell's
WizardError[] summary with its own copy of the same loop. Extract one
pure helper, wizard-errors.ts, next to naarStapLabel. Add a spec that
covers a flat record, an empty record, skipped undefined/empty-string
values, the idPrefix, and a skipped nested object.

registratie-wizard.machine.ts changes Errors from an interface to a
type alias, because only a type alias gets an implicit index
signature and is assignable to the helper's Record<string, unknown>
parameter. The other two machines already declare their error maps as
type aliases, so this also makes the three consistent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:00:04 +02:00
ehoandClaude Opus 5 a196a380ce docs: restore the two backlog paths RD-19's target of 0 removed
RD-18 established that a ticket reference inside a path to a document that
still exists is a pointer, not provenance, and exempted one. RD-19 did not
re-run that check against `backend/`, and asserted a target of 0. Two such
paths existed, so the agent described the two documents in prose instead. The
path no longer resolves and the reader must search.

Restore both paths, correct the ticket's decision 1 and acceptance target to
2, and record the miss as the eighth in the README's list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:50:06 +02:00
ehoandClaude Sonnet 5 8560746d15 refactor: strip WP-/RB- ticket refs from backend (RD-19)
The backend half of the sweep RD-18 did for the front end. git blame
holds the provenance and stays correct when the code moves; the
comment names a closed ticket and tells the reader nothing the
sentence around it does not.

public/letter.css and LetterHtml.golden.html change together, because
the renderer inlines the CSS and the golden file snapshots the
result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:48:08 +02:00
ehoandClaude Sonnet 5 dd11eafe50 refactor: strip WP-/RB- ticket refs from apps and libs (RD-18)
204 WP-NN/RB-NN comments named a closed ticket instead of the code they
sit next to. git blame already records history and stays correct when
code moves; the comment does not. This sweep removes the reference and
keeps the sentence, across 95 files in apps/ and libs/ plus the
behaviour-spec generator's header text.

Eleven references stay: five story files justify an a11y disable per
the README's rule, and one line in a11y.mdx documents that convention.
Two sentences needed a rewrite, not a deletion, so the reference's
meaning survives its removal. behaviour-spec.mdx is regenerated, not
hand-edited.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:23:07 +02:00
ehoandClaude Sonnet 5 3895588b9a refactor: story titles to Domein/<Context>/<Name>; add 2 missing stories (RD-04)
Three dashboard-section stories used a fourth title segment
(`Domein/Registratie/Dashboard/<Name>`) that the sidebar rule does not have.
Drop the `Dashboard/` segment so all story titles follow the one rule from
CLAUDE.md decision 5.

Add the two missing stories for sections that have more than one visual
state: `wat-moet-ik-regelen` (4 states) and `wat-wilt-u-doen` (2 states, the
first story in the new `Domein/Overzicht/` bucket). `beheer-links` gets no
story — it has one visual state and its other branch renders nothing.

The `MetTaken` story disables the a11y addon with the same reason and WP-11
reference already used on `task-list.stories.ts` and `choice-list.stories.ts`:
`app-choice-link`'s host sits between the keuzelijst `<ul>` and its `<li>`,
a pre-existing structural gap this ticket does not fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:56:17 +02:00
ehoandClaude Opus 5 fb2e58ab87 docs: record the concurrent-agent trap that RD-17 hit
`/clear` ends the supervisor's context. It does not stop the developer agent
that session spawned. A fresh supervisor read the half-finished tree as an
interrupted session and dispatched a second agent onto the same ticket. Both
agents then ran `dotnet test` in one checkout, which truncated the shared
SQLite file and produced the stale-database failure the Troubleshooting
section already records under a different cause.

Also record that a bare `dotnet test` runs the OpenZaak integration test that
both CI scripts filter out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:41:32 +02:00
ehoandClaude Sonnet 5 e221834f6e refactor: add successOr, sweep remaining inline unwraps (RD-17)
Eight sites hand-rolled `rd.tag === 'Success' ? rd.value : fallback`. Six
take the new `successOr(rd, fallback)`, one takes the existing `successOf`,
and one (`big-profile.store.ts`) uses the existing `map`, since it returns a
RemoteData rather than an unwrapped value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:33:56 +02:00
ehoandClaude Opus 5 c36d9e3ff0 docs: drop RD-16 — the instruction would have discarded server decisions
RD-16 was to make parseDashboardView return BigProfile directly, on the
plan's claim that DashboardViewDto, DashboardView and BigProfile were
three names for one payload. Reading the type disproves it.

DashboardView is a pair of BigProfile and HerregistratieDecisions.
BigProfile is { registration, person } and has nowhere to put decisions,
so returning it directly would silently drop the server-computed
herregistratie eligibility — the value ADR-0001 says the front end must
render rather than recompute.

The store's two map calls are not a redundant hop either. They project
one aggregate into two independently consumed signals, and six files read
them separately.

Also withdraw the earlier correction that "Step 2 did not fully land".
That claim came from reading the parse signature without reading the type
it returns. Commit 42e7a1e did the right parts, including moving
HerregistratieDecisions into domain, and correctly left alone the part
that would have been wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:50:38 +02:00
ehoandClaude Sonnet 5 fb7b531fdf chore: remove 22 abandoned agent worktrees (RD-15)
The repository carried 22 abandoned agent worktrees under
.claude/worktrees/, left behind by past agent runs. They are
gitignored, so they never reached a commit, but they stayed on disk
and every unqualified repository-wide grep or find walked all 22
copies of the source tree.

Measured before: 48,005 files under .claude/worktrees/, against 856
tracked in the repository. An unqualified search walked 56 times more
files than the repository contains.

The verification gate confirmed both safety conditions before removal:
all 22 worktree-agent-* branch tips were already ancestors of main, and
all 22 worktrees were clean (unmerged: 0, dirty: 0).

Removal steps:
- git worktree remove for each of the 22 worktrees (no rm -rf, so the
  registrations in .git/worktrees/ stay consistent)
- git branch -d for each worktree-agent-* branch (lowercase -d, so an
  unmerged branch would block deletion instead of being force-deleted)
- git worktree prune to clear administrative entries

Measured after: 0 files under .claude/worktrees/, .claude/ shrank from
4.7 GB to 72 KB. The 856 tracked files are unchanged. HEAD is unchanged
from before the removals. npm run ci exits 0.

This ticket changes no tracked source file. The diff is this ticket
file and the README row, because the work is entirely in gitignored
paths and local branch refs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:48:52 +02:00
ehoandClaude Opus 5 c45d1bc0ff docs: git grep -c counts lines, not occurrences (the seventh miss)
RD-14 asserted that a grep for the four SaveState tags would count at
least 4. All four live on one line of a single-line type declaration, so
the honest answer is 1. The executing agent refused to reformat the type
across four lines to satisfy the number, which would have fought prettier
and violated the ticket's own "move verbatim" decision, and flagged the
check instead.

Use `grep -o | wc -l` when you mean occurrences. Say "lines" when you
mean lines.

This is the seventh miscalibrated acceptance command in this backlog, and
the third an executing agent caught using these rules rather than the
supervisor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:46:00 +02:00
ehoandClaude Sonnet 5 a8c7a573fc refactor: move SaveState into debounced-save.ts, delete action-state.ts (RD-14)
RD-12 and RD-13 moved both ActionState consumers into their machines. This
left ActionState with zero users, so this ticket deletes it outright.

SaveState is different. It has two four-way consumers (brief.page.ts and
org-template.page.ts) that still switch on all four cases, so it survives.
It moves into debounced-save.ts, next to createDebouncedSave, the only
function that sets it. Both store imports merge into the existing
debounced-save import line. The doc comments that named ActionState are
reworded, since the type no longer exists. No store's public saveState
signature changes, so brief/ui/ needs no edit.

This completes the phase. Two encodings survive: RemoteData for fetched
data, and each machine's own state union. SaveState stays as an explicitly
justified third encoding, for a separate concern (debounced autosave status)
that the other two do not cover.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:45:04 +02:00
ehoandClaude Sonnet 5 c599fee8e2 refactor: fold org-template's action lifecycle + pendingPublish into one union (RD-13)
Before this change, org-template.store.ts held the action lifecycle in an
actionState signal and the publish impact-confirm gate in an independent
pendingPublish signal. The two were representable in combination, so
pendingPublish === true and busy === true could both hold at once. That
state was meaningless: the UI would show the publish-impact confirmation
while a publish was already in flight.

OrgTemplateState.Loaded now carries one action field, a four-variant union
(Idle | ConfirmingPublish | Busy | Failed). ActionStarted overwrites the
field straight to Busy from any prior tag, so ConfirmingPublish and Busy
can never coexist — not by convention, but because one field can only
hold one tag. requestPublish and cancelPublish become dispatches
(PublishRequested/PublishCancelled); as the reducer already no-ops
outside Loaded, this changes no behaviour. The other four commands
(confirmPublish, rollback, proefbrief, flushSave) keep their existing
loaded() guards. busy, lastError and pendingPublish stay on the store as
computed values reading the new union, with byte-identical public
signatures — no file under brief/ui/ changes.

Ran gen:behaviour-spec for the six new reducer cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:38:23 +02:00
ehoandClaude Opus 5 8e5f48c5d2 docs: scope acceptance checks to the ticket's files, not a directory
The sixth acceptance-check miss, and the first the executing agent
caught using these rules rather than the supervisor. RD-12 asserted
`git grep "ActionState" -- apps/ssp/src/app/brief` returns nothing, but
org-template.store.ts lives in that directory and is deliberately out of
scope until RD-13. The check could never pass.

Sharpen the existing habit into a mechanical step: name the files, never
a parent directory.

The agent also caught the RD-11 substring trap in its own work, where the
new type BriefActionState contains ActionState, and word-anchored the
check instead of renaming away from the natural name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:28:13 +02:00
ehoandClaude Sonnet 5 02d41536df refactor(brief): move the action lifecycle into the machine (RD-12)
The action lifecycle (Idle | Busy | Failed) lived in an imperative
store-level signal, set from ten call sites outside the reducer. The
reducer could not enforce which action transitions are legal.

Add `action` to `BriefState.Loaded`, driven by three new messages
(ActionStarted, ActionFinished, ActionFailed) and handled in `reduce`.
Replace every `actionState.set(...)` call in `brief.store.ts` with the
matching `dispatch`. `BriefLoaded` resets `action` to Idle, so a fresh
load clears a stale action error instead of letting it outlive the
reload.

`busy` and `lastError` stay as `computed`s on the store with a
byte-identical public signature — they are the render seam for four
components and two page templates, and the union belongs in the
machine, not the components. `revealBigNummer` still sets only
`Failed`, never `Busy` — an existing asymmetry, not changed here.
`SaveState`, `org-template.store.ts`, and `pendingPublish` are out of
scope (RD-13, RD-14).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:27:09 +02:00
ehoandClaude Opus 5 43f62ddfee docs: record the fifth acceptance-check miss, and the stale SQLite trap
RD-11 asserted that `git grep "machineRemoteData\|LoadLifecycle"` returns
nothing, but the replacement it mandates is named `fromLoadLifecycle`,
which contains the old name as a substring. The check could never pass.
When a new name contains the old one, anchor on word boundaries.

Add a fourth habit: prefer a number over a prohibition for anything that
must not change. "Do not rename BriefStatus" invites reasoning around it.
"This count must still be 54, and if it moves, revert rather than adjust
the number" does not. RD-11 renamed tags across 19 files with a wire
contract in the same file, and the count held.

Also record a real trap found during RD-11: stale gitignored
bigregister.db artifacts fail backend tests with "no such table" on an
otherwise clean tree. A 0-byte file months old failed 6 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:08:21 +02:00
ehoandClaude Sonnet 5 827c655c1b refactor: fold machine-remote-data into remote-data.ts, PascalCase load lifecycle (RD-11)
`machine-remote-data.ts` defined a third encoding of an in-flight fetch:
`LoadLifecycle`. It had three call sites, all one identical line, and the type
was never imported by name. Move the mapping into `remote-data.ts` as
`fromLoadLifecycle`, beside its neighbour `fromResource` — a `RemoteData`
constructor, not a sixth encoding.

The lowercase `loading`/`failed`/`loaded` tags on `BriefState`,
`OrgTemplateState` and `StamdataEditorState` existed only because
`LoadLifecycle` required them. Now that the constraint is inline and
PascalCase, the three machines' load-lifecycle tags become `Loading`,
`Failed` and `Loaded` — matching their own PascalCase message tags in the
same file. `stamdata-editor.machine.spec.ts` no longer asserts a PascalCase
message producing a lowercase state.

`BriefStatus` (the letter's draft/submitted/approved/rejected/sent status,
parsed off the wire from `BriefViewDto`) is a separate tag family and is
untouched — its tag count stays 54 before and after this change.

Delete `machine-remote-data.ts` and merge its spec into `remote-data.spec.ts`.
Regenerate `behaviour-spec.mdx` (the `machineRemoteData` section heading
becomes `fromLoadLifecycle`) and confirm `gen:snippets` reports no drift, since
`remote-data.ts` carries a showcase region.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:07:00 +02:00
ehoandClaude Sonnet 5 11664d2efa refactor: WizardStatus to a payload-carrying WizardPhase (RD-10)
The wizard shell took two inputs to say one thing: a flat WizardStatus
string and a separate errorMessage input. Each wizard needed three
computeds (failedError, errorMessage, shellStatus) to take the state
apart and put it back together for the shell.

WizardPhase replaces both inputs with one discriminated union. Its
Failed variant carries the message directly, so no data travels through
a second channel. Each wizard now maps its own tags onto WizardPhase in
one computed, composing the localized failure prefix at the same spot
errorMessage did before. The three machines and their own vocabulary
(Editing/Answering/Invullen, Indienen/Ingediend/Mislukt) are unchanged;
only the shell's input contract changes.

The shell reads the Failed message via the existing whenTag helper,
because @switch cannot narrow a union in an Angular template.

Both $localize ids (wizard.indienenMislukt, regWizard.indienenMislukt)
keep byte-identical source text, so no locale file changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 17:45:05 +02:00
ehoandClaude Opus 5 84cbf3f7d8 docs: use git grep for acceptance checks, and fix RD-09's ledger row
Four acceptance commands in this backlog could not pass as written. The
fourth, in RD-09, reached this backlog's own ticket files and 22
gitignored worktrees, so satisfying it literally would have rewritten the
history of completed tickets.

The general fix is `git grep` instead of `grep -r`: it searches tracked
files only, so untracked and gitignored paths cannot pollute the result.
Measured here, `grep -r` finds 132 hits under .claude/ where `git grep`
finds none. RD-17, RD-18 and RD-19 are repo-wide sweeps and depend on
this.

Also correct RD-09's Order row. It claimed the ticket covered a generator
and a skill file; neither teaches the deleted idiom, as recorded in
PLAN.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:28:37 +02:00
ehoandClaude Sonnet 5 d15943bb36 docs: teach the effect map, not the deleted submit method (RD-09)
RD-05 through RD-08 replaced the hand-called submit method with
createStore's effect map. Two teaching documents still showed the old
method in a code block, as the answer to "how does a submit happen?".
Both blocks also called a function that no longer exists.

Rewrite the code block in ARCHITECTURE.md section 2d and its
fp-tea-atomic-design.md counterpart. Both now show the effect map, keyed
on the Submitting tag, using the same herregistratie worked example with
its optimistic begin/confirm/rollback calls. Both use draftSync.submit,
the call the two herregistratie wizards make today.

State the two properties the old idiom lacked, since they are the reason
for the change: entering a state runs its effect, so a dispatch cannot
skip it; and double-submit protection is structural, because the effect
fires only on a tag transition. Add one sentence on the Seed exemption: a
mount or restore message must not trigger a submit.

Fix the one runIfSubmitting() hop in the write walkthrough at
ARCHITECTURE.md's line 574. The rest of section 6a stays stale on
purpose — RD-31 owns it, including its line citations and dead paths.
fp-tea-atomic-design.md's broken pre-monorepo paths stay stale too —
RD-32 owns those.

Set RD-09's Status to done and its README row to done in the same
commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 17:26:52 +02:00
ehoandClaude Sonnet 5 b8aced75e3 refactor: migrate the 3 wizards to the effect map and Primary (RD-08)
The three wizards paired a dispatch with a hand-written effect call
(onPrimary/onRetry + runIfSubmitting/runIfIndienen). A missed call failed
silently. RD-05 added the effect map and RD-07 added the Primary message;
this ticket moves each wizard onto both.

Each wizard now registers its submit effect on createStore, keyed by its
own submitting tag (Submitting for herregistratie and intake, Indienen for
registratie — the type catches a wrong key at compile time). The optimistic
begin/confirm/rollback calls stay inside the effect body, unchanged. The
template dispatches Primary and Retry directly, matching how Back already
worked. onPrimary, onRetry, and runIfSubmitting/runIfIndienen are deleted
from all three components.

herregistratie-wizard drops under the 250-rule-line budget, so its
eslint-disable max-lines header is removed in this same commit (RD-02's
self-cleaning mechanism). intake-wizard and registratie-wizard stay over
budget and keep theirs, both already flagged for RD-22/RD-23.

Three doc comments (in the three machine files, plus one in store.ts) named
the deleted onPrimary()/runIfSubmitting() identifiers in prose. Reworded
them so the "idiom is gone from the repo" grep check is not defeated by its
own explanatory comments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 17:20:52 +02:00
ehoandClaude Opus 5 d0d6a3764b docs: correct the A5 claim about the form-machine generator
PLAN.md stated that plop-templates/form-machine.hbs generates
runIfSubmitting, and that .claude/skills/form-machine/SKILL.md teaches
it, so the next scaffolded form would recreate the bug RD-06 fixed.

Both are false. The plop template is machine-only: 74 lines, no
@Component, no runIfSubmitting. The skill never mentions it.

Two real sites remain, both teaching the deleted idiom in a code block:
ARCHITECTURE.md:314 and :574, and fp-tea-atomic-design.md:342. RD-09 is
therefore a two-document fix, still worth doing, but not the urgent
generator fix this plan claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:14:29 +02:00
ehoandClaude Sonnet 5 be1fcb4b40 feat: add Primary message to the 3 wizard machines (RD-07)
Each wizard component re-derives the step-boundary decision the reducer
already owns: advance on a middle step, submit on the last step. This
ticket moves that decision into the machine, so RD-08 can replace the
component's guard with one dispatch.

Add a `Primary` message to each Msg union, and export a `primary(s)`
function next to the existing `next`/`submit` pair. `primary` is a
three-line branch that delegates to `next`/`submit` and writes no new
validation. Each machine tests "last step" in its own vocabulary, per
the ticket's Decisions block: `herregistratie` checks `step === 3`,
`intake` checks `currentStep(s) === 'review'`, `registratie` checks
`currentStep(s) === 'controle'`. `Next` and `Submit` stay in every
union and every reducer — `Primary` is purely additive.

Add 3 spec cases per machine (9 total): Primary advances from a
non-final step, Primary submits from the final step, and Primary is a
no-op outside the editing state. Each case also asserts the
equivalence the ticket requires for RD-08's migration:
`reduce(s, Primary)` equals `reduce(s, Next)` at a non-final step, and
equals `reduce(s, Submit)` at the final step.

Regenerate `behaviour-spec.mdx` for the 9 new `it()` titles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 17:06:02 +02:00
ehoandClaude Opus 5 d9c56b0c24 fix(ci): make npm run ci --full actually run the full gate
scripts/ci-local.sh gated its two storybook + axe steps on
`[[ "${1:-}" == "--full" ]]`, so the flag had to arrive as a positional
argument. CLAUDE.md documents `npm run ci --full`, but npm parses that
flag itself and exports `npm_config_full=true` instead of passing it
through. Proven with `npm run env --full`.

The documented command therefore skipped both steps and still printed
"local CI passed" — a gate that reported success without running. Anyone
following CLAUDE.md has been skipping storybook-a11y.

Accept either form. Fixing the script makes every existing document
correct, instead of changing four of them and re-teaching the reader.

Found while verifying RD-06, which adds stories and so depends on those
steps actually running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:57:37 +02:00
ehoandClaude Sonnet 5 0c6fd37ed8 fix: give Failed its own branch in the two single-step forms (RD-06)
Before this fix, a failed submit on the besluit-form or the
change-request-form left the user stuck. Both templates rendered `Failed`
through the same `@else` branch as the editable form. In besluit-form the
fields read from `Editing` only, so they went blank. In change-request-form
the fields still showed the sent value, but `SetField` only applies to
`Editing`, so typing did nothing. In both forms the submit button stayed
enabled, but `Submit` is a no-op outside `Editing`. The only escape was a
page reload.

After this fix, `Failed` gets its own template branch: an error message, a
read-only summary of what was sent (an `app-data-block`, reused from the
existing BRP-address pattern), and a "Opnieuw proberen" button that
dispatches `Retry`. Both machines already handle `Retry` (`Failed ->
Submitting` with the preserved data), so no machine change was needed.

Both components also move to `createStore`'s effect map (RD-05): the
`Submitting` handler replaces the hand-called `runIfSubmitting`, so
`onSubmit` is now a single `dispatch`. `runIfSubmitting`/`runIfIndienen`
now remain only in the three wizards, migrated later by RD-08.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 16:55:20 +02:00
ehoandClaude Sonnet 5 a71887a843 feat: add an optional effect map to createStore (RD-05)
createStore now takes a third, optional StoreEffects map. Each key is a
Model tag. The store runs that tag's handler after update() returns, and
only when the store enters the tag: the previous tag differs from the new
tag, and the message is not Seed (the mount/restore message in every
machine that has one).

This closes the gap where a component had to call dispatch(msg) and then
a private runIfSubmitting() by hand, or state got silently stuck. No call
site changes here. RD-06 and RD-08 migrate the 5 components that
duplicate that pattern today.

The effect map is a conditional type, not a generic constraint, so a
tagless Model (store.spec.ts's plain number store) still resolves it to
never and needs no third argument. Both tag checks use a typeof/in guard
for the same reason.

Regenerated libs/shared/docs/behaviour-spec.mdx for the 5 new spec titles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 16:34:28 +02:00
ehoandClaude Opus 5 0697afb737 docs: correct the stale cross-feature edge comment (RD-03 follow-up)
RD-03 added `overzicht: ['registratie']`, so the comment claiming
`herregistratie: ['registratie']` is "the one sanctioned cross-feature
edge" became false. There are two, and both point at registratie.

State what each edge is for instead of counting them, so the next
context added does not make the comment wrong again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:27:10 +02:00
ehoandClaude Sonnet 5 b9fd411283 refactor: move the portal home into its own overzicht context (RD-03)
The dashboard page lived inside registratie, a context with no permitted
outbound edges. Two of its sections do cross-context navigation: action
links to herregistratie/intake/brief/concepts, and an admin links section
that imported ADMIN_LINKS directly from the app shell.

Move the page and these two navigation sections into a new overzicht
context. Declare overzicht: ['registratie'] as the one sanctioned edge, so
the four data sections that stay in registratie/ui/dashboard/ remain
reachable. The route stays at /dashboard: it is a user-visible URL and four
e2e specs assert it, so only the import path and class name change.

beheer-links.section.ts now injects HEADER_ADMIN_LINKS, the same token the
site header uses, instead of importing the app shell's nav config directly.

Used npm run gen:context and corrected its three known-wrong outputs: the
overzicht: [] edge, the scaffolded overzicht.page.ts (the real page is the
moved dashboard.page.ts), and the scaffolded path: 'overzicht' route.

Verified the boundary is enforced, not decorative: a temporary import of a
herregistratie file into overzicht.page.ts makes dep:check fail, as
expected, then reverted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 16:26:11 +02:00
ehoandClaude Sonnet 5 b9d572cfdc feat: add max-lines guard with self-cleaning exemptions (RD-02)
The dashboard refactor proved a page can be 42 lines. This rule holds
the rest of the app to that budget, before the split work in RD-20
through RD-26 begins.

Add max-lines at 250 (skipBlankLines, skipComments), scoped to
{apps,libs}/**/*.{page,component,section,step}.ts. The glob includes
section and step, the file kinds the dashboard refactor invented, so
new files from this arc do not escape the guard.

Add linterOptions.reportUnusedDisableDirectives: 'error' repo-wide.
ESLint 9 only warns on an unused disable by default, so a stale
exemption would not fail the build. At 'error', every later file
split must delete its own exemption or the build goes red.

Add a dated /* eslint-disable max-lines */ header to each of the
seven files that exceed the budget today, each with a reason and the
ticket that removes it. letter-canvas keeps its disable after RD-26,
because most of its lines are CSS and the rest is one letter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 16:18:10 +02:00
ehoandClaude Opus 5 97d9697a84 docs(readable-codebase): scaffold the arc backlog (RD-01)
Convert the approved readable-codebase plan into the ticket protocol this
repo already uses for long arcs (docs/project/backlog/README.md, 75 work
packages driven to done through it).

Adds docs/project/readable-codebase/ with:
- README.md — the ledger: session protocol, GREEN definition, restart
  recovery, the agent loop, and an Order table of 35 tickets with their
  dependencies and status.
- PLAN.md — the design record, committed so the repository holds the
  complete state. A fresh session needs nothing outside it.

Two properties make the arc restart-safe. Each ticket updates its own
Status line and its README row in the same commit as its code, so the
ledger and git log cannot desync. And the plan lives in the repository,
so recovery is three commands and needs no prior context.

Records one correction found while scaffolding: the 22 abandoned agent
checkouts under .claude/worktrees/ are live registered git worktrees, not
orphaned directories. RD-15 must use `git worktree remove` and verify each
branch tip is an ancestor of main first. Spot-checked four; all are
already merged via 637d500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:09:00 +02:00
ehoandClaude Sonnet 5 c8029d23e2 docs: regenerate behaviour-spec.mdx
CI / changes (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 1m14s
CI / frontend (pull_request) Successful in 2m19s
CI / backend (pull_request) Successful in 2m9s
CI / e2e (pull_request) Successful in 3m0s
CI / semgrep (pull_request) Successful in 1m5s
CI / api-client-drift (pull_request) Successful in 1m48s
CI / changes (push) Successful in 7s
CI / lint (push) Successful in 1m15s
CI / frontend (push) Successful in 2m15s
CI / storybook-a11y (pull_request) Successful in 13m11s
CI / backend (push) Successful in 2m10s
CI / e2e (push) Successful in 2m58s
CI / semgrep (push) Successful in 1m2s
CI / api-client-drift (push) Successful in 1m49s
CI / storybook-a11y (push) Successful in 10m36s
Catches drift accumulated since the Application → Aanvraag rename
(Step 1/8, Step 2/8) plus the new tests from this branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 15:17:10 +02:00
ehoandClaude Sonnet 5 c7aed8d308 fix: narrow parseBrpAddress's return type instead of leaking the DTO
BrpAddressDto's fields are generated as optional, so returning it
directly from parseBrpAddress lost the narrowing the runtime check
already did. This broke the build once registratie-lookup.store.ts
assigned the parsed address into a stricter local type. Map to a
proper BrpAddress domain shape at the trust boundary instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 15:17:06 +02:00
ehoandClaude Sonnet 5 5977efe044 refactor: split dashboard.page.ts into per-concern sections
Each dashboard section (Mijn aanvragen, Wat moet ik regelen, Mijn
registratie, Specialismen, Wat wilt u doen, Beheer) now owns its own
store access, async state, and template. DashboardPage becomes pure
composition.

Extract the repeated RemoteData Success-narrowing pattern into
successOf() and the dashboard sort/split logic into
sortForDashboard/concepten/ingediend, both with tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 15:17:02 +02:00
ehoandClaude Opus 5 42e7a1e927 refactor: delete shadow contracts DTOs, parse the generated shape for real (Step 2/8)
dashboard-view.dto.ts and brp-address.dto.ts each shadowed a generated
type: DashboardViewDto was declared twice (hand-written with required
fields, generated with everything optional), reconciled only by
structural typing. Both are gone.

dashboard-view.adapter.ts now imports the generated DashboardViewDto/
RegistrationDto/PersonDto/RegistrationStatusDto directly. Its parse
does real work now instead of an identity copy: parseRegistrationStatus
validates each status variant's required fields per-tag (the generated
type flattens the union, so a Geregistreerd row missing
herregistratieDatum previously passed the boundary unnoticed — it no
longer does). HerregistratieDecisions moves from contracts/ to
domain/registration.ts, so no contracts-typed value reaches a page.

brp.adapter.ts drops its own BrpAddressDto shadow the same way.

Hand-written contracts/*.dto.ts count: 4 -> 2 (duo-diplomas.dto.ts and
stamdata.dto.ts remain — both parse fields codegen can't type at all).

Part of the dashboard-readability refactor (see the approved plan).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:36:30 +02:00
ehoandClaude Opus 5 194cccfd02 refactor: rename Application → Aanvraag across the wire (Step 1/8)
The wire said Application, the domain said Aanvraag — one aggregate with
two names at every hop. Rename the backend DTOs and the /applications
route to /aanvragen, regenerate the typed client, and rename the frontend
adapter/store to match.

Renamed: ApplicationSummaryDto/DetailDto, CreateApplicationRequest,
SubmitApplicationRequest/Response → Aanvraag* equivalents;
ApplicationsAdapter/Store → AanvragenAdapter/Store;
applications.adapter.ts/applications.store.ts → aanvragen.*.

Left untouched: the admin Case/Zaak vocabulary (/admin/cases,
AdminCasesStore) — a separate read model, not part of this rename; the
internal BigRegister.Domain.Applications namespace and the Applications
EF table (renaming those needs a new EF migration, out of scope here).

Part of the dashboard-readability refactor (see the approved plan).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:33:16 +02:00
ehoandClaude Opus 5 faad772f85 docs: explain how to check OpenZaak is running
CI / changes (push) Successful in 9s
CI / storybook-a11y (push) Successful in 16s
CI / lint (push) Successful in 11s
CI / frontend (push) Successful in 14s
CI / backend (push) Successful in 2m7s
CI / e2e (push) Successful in 2m42s
CI / semgrep (push) Successful in 1m4s
CI / api-client-drift (push) Successful in 1m44s
docker compose up at the repo root does not start OpenZaak. A new reader
could easily assume it does. Add a short section to
backend/openzaak/README.md that shows how to check the four containers and
how to curl OpenZaak directly, using the same probe
bootstrap-catalogus.sh already relies on. Add one line to the root README
pointing there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 07:45:01 +02:00
ehoandClaude Opus 5 637d500c96 Merge refactor/adr-c-006-shared-route-guards — RB-01..RB-33 + 4 ADR-fixes
CI / changes (push) Successful in 12s
CI / lint (push) Successful in 2m45s
CI / frontend (push) Failing after 11m9s
CI / backend (push) Successful in 2m22s
CI / e2e (push) Successful in 3m25s
CI / semgrep (push) Successful in 1m11s
CI / api-client-drift (push) Successful in 1m55s
CI / storybook-a11y (push) Failing after 15m10s
Closes the CD refactor backlog (docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md).
All 33 code tickets and the four gated ADR-fixes (ADR-C-001, ADR-C-003,
ADR-C-007, ADR-C-009) are merged, one commit per ticket, across six CD
batches plus the ADR-fix batch. npm run ci is green after every merge in
the arc, each verified independently.

Highlights: RB-01/02 fixed a BSN leak in the persisted audit trail and an
unauthorized document-content endpoint. RB-09/13 landed Session -> Principal
per ADR-0002. RB-12 added a route-table authz gate as a CI safety net.
RB-19 reordered the backend's 940-line Program.cs into reads-then-writes,
verified as a pure move by comparing every (route, gate, handler) triple
before and after. RB-24..30 moved libs/shared/upload into its proper
layers and made every layer testable. RB-31 found and fixed a real
ADR-0006 violation: two tests asserted a wizard state the real reducer
cannot produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:48:10 +02:00
ehoandClaude Opus 5 c30d5ec5a5 docs(backlog): CD batch 6 complete, close the refactor backlog arc
All three tickets RB-31 to RB-33 merged, one commit per ticket. RB-31 found a
genuine ADR-0006 violation: two registratie-wizard tests asserted a state the
real reducer cannot produce. RB-32 closed ADR-0003's own predicted failure
mode with a permanent CI drift guard rather than a one-time fix. RB-33 chose
deletion over adoption for an unused test helper, since manufacturing a first
caller would have removed no real duplication.

This closes the CD implementation phase. All 33 code tickets and the four
gated ADR-fixes are merged; npm run ci is green after every merge in this
arc, each verified independently rather than trusting an agent's own report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:33:09 +02:00
eho 3441dd4c4e Merge RB-31 — replay real messages in 4 machine specs
ADR-C-010: intake, registratie-wizard, besluit and brief machine specs
hand-rolled a state literal, three of them hardcoding errors: {} by hand
instead of running real Msgs through the real reduce (ADR-0006 §2). intake
now shares the existing intake.testing.ts with the acceptance spec instead
of ignoring it; the other three each get a *.testing.ts one-liner.

Found real drift: two registratie-wizard tests asserted a cursor-2 state
reached before any diploma was chosen, which the real reducer cannot
produce (advancing past beroep requires a diploma already set). Replayed at
cursor 1 instead; submit() validates the whole draft regardless of cursor,
so the assertions are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
2026-08-28 13:27:16 +02:00
ehoandClaude Opus 5 dfc6c419f4 refactor(specs): replay real messages in 4 machine specs (RB-31)
Four machine specs built their starting state with a hand-rolled object
literal instead of replaying real Msgs through the real reduce, the
exact anti-pattern ADR-0006 section 2 forbids. Three of the four also
hardcoded errors: {}, a shape the reducer might never actually produce.

intake.machine.spec.ts now imports the existing givenIntake from
intake.testing.ts (previously used only by intake.acceptance.spec.ts).
Three new one-line *.testing.ts files export the same given(reduce,
initial) wrapper for registratie-wizard, besluit, and brief. Every old
literal helper (answering, invullen, editingWith, loaded) is replaced
by a message replay that reaches the same state.

Two tests in registratie-wizard.machine.spec.ts asserted a cursor value
the real reducer cannot reach (cursor 2 with no diploma chosen yet,
which requires a diploma to already be set). Both are re-pointed at the
reachable cursor-1 equivalent; submit() validates the whole draft
regardless of cursor, so no assertion changed. Recorded in
implementation/rb-31.md, not worked around.

No *.machine.ts reducer was touched. All four specs pass; npm run ci
is green (lint, typecheck, dep:check, format, tokens, seam, all four
test suites, both app builds, backend 293/293, api-client drift).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:26:12 +02:00
eho 1a6652b417 Merge RB-32 — add the missing language-switcher row to the CIBG gap register
ADR-C-008: 9 files carried a CIBG-GAP EXTENSION marker against 8 register
rows. language-switcher had a well-formed marker and no row. Adds the row,
plus an optional ~14-line drift guard in check-tokens.sh that diffs the
marker set against the register and fails naming any missing row, so this
drift cannot silently reoccur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
2026-08-28 13:20:31 +02:00
ehoandClaude Opus 5 dd5fd66fb8 docs(backlog): mark RB-33 done after merge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:20:14 +02:00
ehoandClaude Opus 5 6cfba81a39 docs(shared): add the missing language-switcher row to the CIBG gap register (RB-32)
The register at libs/shared/docs/cibg-gaps.mdx had 8 rows for 9
CIBG-GAP EXTENSION markers in code. language-switcher carries a
well-formed marker with no matching row, exactly as ADR-C-008 and
adr-c-007.md's handoff note flag. Add the row from the component's
own marker comment.

Also add a small guard to check-tokens.sh (folded into check:tokens,
as ADR-C-008 suggests as an optional step): it diffs the marker set
in code against the register's rows and fails CI on drift. Verified
working with a scratch marker file before removing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:15:31 +02:00
ehoandClaude Opus 5 0714b2af34 Merge RB-33 — delete unwrapOk, the unadopted test value-object helper
ADR-C-011: unwrapOk had zero consumers anywhere in apps/ or libs/ since it
shipped, and the one candidate call site (submit-change-request.spec.ts's
inline parse-and-throw guard) already satisfies ADR-0006 section 3's real
requirement, never a cast. Manufacturing a first caller to justify keeping
the helper would remove no real duplication. Deleted rather than adopted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:14:52 +02:00
ehoandClaude Opus 5 531817259e refactor(shared): delete unwrapOk, the unadopted test value-object helper (RB-33)
unwrapOk had zero consumers in apps/ or libs/ since ADR-0006 shipped it.
The one call site the finding named already satisfies the ADR's real
rule (call the real parser, never a cast) with an inline guard, so
adding a manufactured first caller was not the better fix. This commit
deletes the helper and its file, and updates the one doc sentence that
named it. The finding's call site is unchanged. See rb-33.md for the
full adopt-or-delete reasoning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:14:21 +02:00
ehoandClaude Opus 5 03c6e09306 docs(backlog): CD batch 5 complete
All seven tickets RB-24 to RB-30 merged, one commit per ticket. Records the
actual wave split, since the backlog's own depends-on column missed that
RB-24 rewrites imports in two of RB-28's target files.

RB-24 expanded its own scope to fix a second, real boundary violation that
deleting its acceptance criterion exposed, reviewed and accepted. Two more
findings were shown stale or overstated, on top of the nine from earlier
batches. RB-26 and RB-27 both correctly declined part of their own ticket's
proposed shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:55:39 +02:00
ehoandClaude Opus 5 c4a5d20202 docs(backlog): mark RB-27 done after merge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:53:00 +02:00
ehoandClaude Opus 5 a260af6120 Merge RB-27 — extract uploadOutcome from the XHR closure
TE-005: xhrUpload buried the 2xx-vs-not check, JSON.parse-with-fallback and
ProblemDetails mapping inside XHR listener bodies, unreachable without
stubbing the XHR global. uploadOutcome(status, responseText) is now a pure
function with no DOM and no XHR stub in its spec. Abort-vs-error
disambiguation stays where it is: it fires on a different event with no
status or responseText, so it cannot fit the extracted signature. The
optional currentScenario() move into KeepaliveTransport.send() was not
taken, since it would cross into upload-shell.service.ts, outside this
ticket's scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:52:50 +02:00
ehoandClaude Opus 5 e63db509ef refactor(shared): extract uploadOutcome from the XHR load closure (RB-27)
UploadAdapter.xhrUpload built new XMLHttpRequest() directly and put the
actual decisions inside its load listener: 2xx-vs-not, JSON.parse of the
body with a fallback, and ProblemDetails mapping via parseError. None of
it was reachable without stubbing the XHR global, so it had no spec
(TE-005; file LH 5/64, BRH 3/57).

Extract uploadOutcome(status, responseText): Result<string, {
documentId }>, a pure function next to genericError/parseError. It holds
the 2xx check, the JSON.parse-with-fallback, and the ProblemDetails
mapping. The load listener is now a two-line dispatch into it.

Abort-vs-error disambiguation stays where it is: it decides whether a
response exists at all, before uploadOutcome would even run, and the
proposed signature has no field for "aborted". It is already a one-line
ternary with no DOM-only logic to extract.

Add upload.adapter.spec.ts: plain describe/it, no DOM, no XHR stub,
covering a 2xx success, a 2xx unparseable body, a non-2xx ProblemDetails
body, a non-2xx non-ProblemDetails body, and the 200/300 boundary.
Verified red by editing uploadOutcome down to one line (an Edit, not
git checkout): 4 of 5 new specs failed. Re-applied with a second Edit.
Coverage for upload.adapter.ts: LH 5/64 -> 12/65, BRH 3/57 -> 7/59.

Skip TE-005's optional half (moving the currentScenario() branch into
KeepaliveTransport.send()): it needs a second file, upload-shell.
service.ts, and this ticket's own scope fences it to upload.adapter.ts
and its spec. The dev simulator's behaviour is unchanged.

Mark RB-27 implemented in 99-backlog.md and add its implementation note,
including a batch 5 close-out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:52:17 +02:00
eho 6372d452a4 Merge RB-28 — add BLOB_PRESENTER, unlock the blob-to-browser success paths
TE-006: StamdataStore.download(), BriefStore.previewLetter() and
OrgTemplateStore.proefbrief() each ended in raw DOM blob calls jsdom cannot
meaningfully execute, so their success paths were unassertable and
download()'s two-clause guard true-branch was permanently dark.
BLOB_PRESENTER mirrors the SESSION_PORT shape; all three commands go through
it. download()'s branch coverage goes from 40.5% to 67.6%, and
org-template.store.ts gets its first spec at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-28 08:39:09 +02:00
ehoandClaude Opus 5 ce952941bb refactor(shared): add BLOB_PRESENTER, unlock the blob-to-browser success paths (RB-28)
Three application-layer commands ended in raw DOM calls (URL.createObjectURL,
window.open, document.createElement('a').click(), URL.revokeObjectURL) as
their last statement. jsdom cannot assert a call that is also the end of the
function, so each command's success path stayed unassertable, and
StamdataStore.download()'s two-clause guard stayed permanently dark on its
true branch (TE-006).

Add BLOB_PRESENTER (libs/shared/src/application/blob-presenter.ts), an
InjectionToken mirroring SESSION_PORT's shape: an interface with open()/
download(), a real implementation preserving the existing open()-never-
revokes vs download()-always-revokes asymmetry, provided in root. Route
StamdataStore.download(), BriefStore.previewLetter(), and
OrgTemplateStore.proefbrief() through it.

Add specs with a recording fake presenter: StamdataStore.download()'s guard
(both clauses) and its success path, asserting toJson(...)'s exact output
reaches the file; BriefStore.previewLetter()'s existing success test now
goes through the seam instead of spying on window/URL directly; a new
org-template.store.spec.ts (none existed before) covers proefbrief()'s
success and failure paths.

Verified red without the fix by editing the download() filename to the
wrong extension, watching the success-path spec fail, then restoring it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:38:14 +02:00
eho a0e2985fb3 Merge RB-25 — add the UPLOAD_TRANSPORT injection token
TE-003: UploadShellService documented UploadTransport as the swap seam, then
bound the concrete, unexported KeepaliveTransport class directly, so a spec
could not fake it. UPLOAD_TRANSPORT copies the SESSION_PORT shape; the
default factory returns the same instance, so runtime behaviour is
unchanged. upload-shell.service.ts goes from 0% to 88.57% line coverage
across 16 new specs for upload(), cancel(), delete() and pollReturning().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-28 08:33:36 +02:00
ehoandClaude Opus 5 adad4513d0 refactor(shared): add UPLOAD_TRANSPORT injection token (RB-25)
UploadShellService injected the concrete KeepaliveTransport class instead
of a token. The class was not exported, so a spec could not fake it, and
could not provide against the UploadTransport interface either, since an
interface is not a DI token. The port existed only on paper.

Add UPLOAD_TRANSPORT, an InjectionToken with a default factory that
resolves the same KeepaliveTransport singleton, following the
SessionPort/SESSION_PORT shape. UploadShellService now injects the token.
Runtime behaviour is unchanged.

Add upload-shell.service.spec.ts: a recording fake transport plus a fake
UploadAdapter exercise upload(), delete(), cancel() and pollReturning(),
the four methods the missing seam left unreachable. Coverage for
upload-shell.service.ts goes from 0% to 88.6% line / 85% branch.

Mark RB-25 done in 99-backlog.md and add its implementation note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:31:34 +02:00
eho 3162c755d6 Merge RB-26 — extract planFileSelection from the upload controller
TE-004: createUploadController performed three inject() calls, an effect()
registration and a window listener before returning, so the real policy
buried inside it — deciding per file whether to reject or start an upload —
was reachable only through a TestBed. planFileSelection in upload.machine.ts
is now that decision as a pure function taking plain {name, type, size}
objects; the controller executes the plan and keeps the one impure step
(crypto.randomUUID()) it can't move. No change to the controller's public
surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-28 08:29:36 +02:00
ehoandClaude Opus 5 95bb77395e refactor(shared): move the accept/reject decision into planFileSelection (RB-26)
createUploadController required inject(), an effect(), and a window listener
before a test could reach it. The file-selection policy trapped behind that
cost now lives in a pure function, planFileSelection, in upload.machine.ts.

planFileSelection takes plain { name, type, size } objects, not File, and
decides per file whether to reject it or accept it, with no I/O. The
controller executes the plan: it dispatches a rejection as-is, and starts the
upload for an accepted file (the one step that needs crypto.randomUUID()).

A new spec covers the three outcomes: the 'multiple' batch rejection, a
rejectReason-based rejection, and the accept case, plus order in a mixed
batch. Verified red-then-green with a temporary stub, undone by a second edit.

No change to the controller's public surface or to the calling organism.
previewUrlFor (added by RB-24) is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:28:30 +02:00
eho e304211715 Merge RB-30 — extract BriefStore's guards into Domain/Letters/BriefRules.cs
TE-008: five guard decisions in BriefStore (Save, Submit, Send, the shared
Approve/Reject review path) were pure functions of status tag, actor role and
entity completeness, but each sat inside a lock-held, DB-opening method, so a
spec could not exercise a decision without a booted host and a real SQLite
file. BriefRules.cs holds the five pure statics; BriefStore keeps its lock,
its Db.Create(), its static shape and every method signature. 29 new
free-running unit assertions in BriefRuleTests.cs; the existing host-booting
brief endpoint tests are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-28 08:08:00 +02:00
eho 80b792caa9 Merge RB-29 — resolve the body datum placeholder from at, not UtcNow
TE-007: Render already accepts the letter's instant and uses it correctly for
the letterhead, but the body's datum placeholder resolved through ResolveAuto,
which ignored at and read DateTimeOffset.UtcNow. Threaded at through
RenderParagraphs and RenderNode, both already in Render's call chain with at
in scope. Zero public API change, zero call-site change. The bug this
prevents: re-rendering an archive or back-dating a letter would otherwise make
the letterhead and body dates disagree within a single document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
2026-08-28 08:01:39 +02:00
ehoandClaude Opus 5 ddd02f65bc fix(backend): resolve the body datum placeholder from at, not UtcNow (RB-29)
LetterHtml.Render already receives the letter's instant and uses it
for the letterhead date. The body's "datum" placeholder resolved
through ResolveAuto, which ignored that instant and read the wall
clock instead. This is not a shipped bug today, because every current
caller passes Now() at render time. It becomes one the moment Render
runs with a historical instant (an archive re-render, a back-dated
letter): the letterhead and the body would then disagree within one
document.

Thread the existing "at" parameter down through RenderParagraphs and
RenderNode into ResolveAuto's "datum" case. Render's own signature,
and every call site, stays unchanged.

Add two tests with a fixed historical "at": one pins the body's
rendered date to the expected Dutch string, the other asserts the
letterhead date and the body date agree. Both fail red against the
old code, showing today's date instead of the pinned one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:42:58 +02:00
ehoandClaude Opus 5 07bb6277c0 refactor(backend): extract brief guards into Domain/Letters/BriefRules.cs (RB-30)
BriefStore's five guard decisions (Save, Submit, Send, and the shared
Approve/Reject review path) were pure functions of status tag, actor role,
and entity completeness, but each sat inside a lock-held, DB-opening
method. A spec could not exercise the decision without a booted host and
a real SQLite file.

Extract the guards into a pure Domain/Letters/BriefRules.cs. BriefStore
keeps its lock, its Db.Create(), its static shape, and every method
signature — only the if cascades move. Add BriefRuleTests.cs (29
assertions, ~120 ms, no host boot) covering every branch, including the
rejected-to-draft reopen on save, the required-filled gate on submit,
and the non-drafter and self-review denials. The existing host-booting
brief endpoint tests are unchanged and still pass, proving the
extraction preserved behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:42:00 +02:00
ehoandClaude Opus 5 693016445b Merge RB-24 — move upload/ into its proper layers, delete the carve-out
ADR-C-002: libs/shared/src/upload/ held a network adapter outside
infrastructure/ and the only Elm machine outside a domain/ folder, and the
dependency-cruiser rule was written around the violation rather than the
violation being fixed. The five files move to infrastructure/, domain/ and
application/, and the ^libs/shared/src/upload/ carve-out is gone.

Deleting the carve-out exposed a second, real violation that the old path had
hidden from the ui-not-infrastructure rule: three UI components injected
UploadAdapter for nothing but a one-line wrapper over its own exported pure
uploadContentUrl. They now read previewUrlFor from their application-layer
collaborator. dep:check passes for both apps with the clause removed, which is
the ticket's acceptance criterion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:41:54 +02:00
ehoandClaude Opus 5 9520d6c24e refactor(shared): move upload/ into infrastructure/domain/application (RB-24)
libs/shared/src/upload/ held a network adapter, an Elm machine, and two
application-layer coordinators outside the folder-per-layer convention every
other context follows. The dependency-cruiser rule carved an exception around
the misplaced adapter instead of the violation being fixed.

Move all five files to the layer each belongs to (git mv), update every
import across 24 consumer files, then delete the carve-out clause from
.dependency-cruiser.base.js. No export renamed, no file split, no spec
content changed.

Deleting the carve-out exposed a second, pre-existing rule violation:
ui-not-infrastructure had never fired against upload.adapter.ts because its
old path did not match /infrastructure/. Three UI components injected
UploadAdapter directly for its one-line contentUrl() wrapper. Route each
through the existing pure uploadContentUrl() function via the application
layer (upload-controller's new previewUrlFor, OrgTemplateStore's new
previewUrlFor) instead — the same idiom brief.store.ts already used.

npm run ci passes; dep:check is clean for both apps with the carve-out gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:40:41 +02:00
ehoandClaude Opus 5 424ceb604b docs(backlog): CD batch 4 complete
All six tickets RB-18 to RB-23 merged, one commit per ticket. Records the two
incomplete tickets that the agents reported, RB-22's deliberate departure from
the runResult idiom, and how RB-19 was verified as a pure reorder.

Adds five dispatch lessons. The stale worktree base is now the rule at 11 of 13
agent-runs. A spend limit killed four agents mid-flight and a message resumed
each one from its own transcript, so no work was redone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:21:01 +02:00
ehoandClaude Opus 5 dc096d98e2 Merge RB-19 — reorder Program.cs sections into reads then writes
CQ-006: the file declared direction as its organising principle, then switched
to feature grouping without saying so, and five sections interleaved reads and
writes. Each section now orders reads first, with the WP-65 sub-banner pair.
DELETE /admin/cases/{id} and GET /admin/audit move up beside GET /admin/cases,
129 lines closer. The org-template preview moves to the org-template section.

Pure reordering. Verified centrally: the sorted list of all 47 route strings is
identical before and after, and so is every (route, .Gate marker, wrapper called
in the handler) triple. The swagger.json and api-client.ts diffs are ordering
only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:20:11 +02:00
ehoandClaude Opus 5 e270b8612f refactor(backend): reorder Program.cs sections into reads-then-writes (RB-19)
CQ-006 found that Program.cs states a reads-then-writes principle at the
top of the file, then abandons it for five feature sections that mix GET
and mutating endpoints in mapping order. This is a pure reorder: within
Document upload, Applications, Admin cases, Brief, and Organization
templates, every GET now precedes every POST/PUT/DELETE, each split by a
`--- reads ---`/`--- writes ---` sub-banner in the style WP-65 already
established for Beoordeling/Besluit.

DELETE /admin/cases/{id} and GET /admin/audit move up beside GET
/admin/cases, closing the 129-line gap CQ-006 measured. GET
/admin/org-template/{subOrgId}/preview moves from the Brief section to
the Organization-templates section it actually belongs to.

No route, signature, DTO, or handler body changed. Every block was cut
by exact line-range slicing, never retyped. The sorted list of mapped
HTTP-method-plus-path strings is byte-identical before and after; every
.Gate(...) count is unchanged; the three routes that moved with a gate
were checked by eye against the wrapper their handler actually calls,
per RB-12's stated limitation that the route-table test only proves a
marker is present, not that it still matches the handler.

npm run gen:api regenerated backend/swagger.json and
libs/shared/src/infrastructure/api-client.ts; both diffs are ordering
only (sorted-file diff is empty), committed alongside per the ticket's
own guidance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:16:05 +02:00
ehoandClaude Opus 5 edd20c06df docs(backlog): mark RB-23 done after merge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:02:09 +02:00
ehoandClaude Opus 5 9f3814d8b9 Merge RB-23 — make GET /brief a pure query that 404s when absent
CQ-007 contract half. GET /brief was the only backend endpoint where a GET
performed a persisted write, and the FE retries GETs automatically, so a
transient failure could enter the create path. BriefStore.GetOrCreate splits
into Get plus the existing ResetAndCreate. GET /brief/preview shared the same
call site and gets the same treatment. RB-22 already made the FE tolerate the
404, so the pair is complete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:01:42 +02:00
ehoandClaude Opus 5 d0fda08bcc fix(brief): make GET /brief a pure query, 404 when absent (RB-23)
GET /brief allocated a row on first call (BriefStore.GetOrCreate) — the
one endpoint in the backend where a read performed a persisted write.
The FE retries GETs automatically, so a transient failure could enter
the create path more than once; a lock prevented a duplicate row, but
the safety depended on the lock, not on the endpoint being a query.

Split GetOrCreate into Get (a pure query) and the already-existing
ResetAndCreate (POST /brief/reset owns creation). GET /brief now 404s
when the owner has no brief yet. GET /brief/preview used GetOrCreate
too, so it gets the same Get + 404 treatment, forced by the split.

RB-22 already made BriefStore.load() on the FE tolerate a 404 by
calling reset() once; this ticket is what makes that branch live.

Updated the brief/preview/org-template backend tests that assumed
GET seeded a brief on first call to create one explicitly first, and
added a test that GET 404s and writes no row without the fix (verified
red beforehand). Regenerated the API client (npm run gen:api).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:01:06 +02:00
eho 05dff974bf Merge RB-22 — tolerate a 404 on GET /brief with a one-shot reset
CQ-007 expand half. BriefStore.load() treats a 404 as 'no brief yet' and calls
the existing reset() command once. load()'s error channel becomes the
BriefLoadFailure union, because runResult folds the HTTP status away and the
store needs it. Today's backend never 404s, so the branch is a no-op until
RB-23 lands the contract half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 18:44:20 +02:00
ehoandClaude Opus 5 7a29f5facc feat(brief): tolerate a 404 on GET /brief with a one-shot reset (RB-22)
BriefStore.load() now treats a 404 from GET /brief as "no brief exists
yet" and calls the existing reset() command once, instead of showing
the generic load-failed error. BriefAdapter.load() gains a
BriefLoadFailure error channel (notFound | error) so the store can
tell a 404 apart from every other failure; every other adapter method
stays on runSubmit, unchanged.

The once-only bound is a field on the store, not a comment: a second
404 (from a later load() call) always falls through to the ordinary
error path, and the recovery path never calls load() again, so no
loop can form.

This is the expand half of CQ-007's split (04-cqrs-light.md). Today's
backend never 404s GET /brief, so the new branch is dead code until
RB-23 (the backend contract half) ships in a later merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:43:27 +02:00
eho 6c5c4cb249 Merge RB-20 — route cancel and delete through runSubmit, surface the error
CQ-002: ApplicationsStore.cancel and AdminCasesStore.delete reached the raw
ApiClient and swallowed the failure in a bare catch, so a failed cancel made the
row reappear with no message. Both now fold through runSubmit and expose
lastError, which the two pages render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 18:33:38 +02:00
eho 9666790d65 Merge RB-18 — key IdempotencyStore on caller plus idem key
BIO-018: the store was a process-global dictionary keyed on the client-supplied
Idempotency-Key alone, so one caller could replay another caller's key and
receive their cached response. The key is now scoped with the caller SubjectId.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 18:33:12 +02:00
ehoandClaude Opus 5 7def4a7552 fix(ssp): route cancel/delete through runSubmit, surface the error (RB-20)
ApplicationsStore.cancel and AdminCasesStore.delete rolled an optimistic
write back on failure but showed no message — a bare catch with no
Result and no error channel (CQ-002). Both now call runSubmit and set a
lastError signal on failure, mirroring createSubmitChangeRequest in the
same folder. Each page renders the error with the existing app-alert
atom, the same pattern brief.page.ts already uses for lastError.

Added a spec file for ApplicationsStore (none existed) and extended
AdminCasesStore's spec, each asserting the rollback AND the surfaced
error. Verified both new assertions fail without the fix (an Edit
undo/redo of the store method, not git checkout).

Regenerated libs/shared/docs/behaviour-spec.mdx (gen:behaviour-spec) to
pick up the new/renamed test names. Marked RB-20 done in 99-backlog.md
and recorded the change in implementation/rb-20.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:32:51 +02:00
ehoandClaude Opus 5 0adf831fb9 Merge RB-21 — extract the read half of createDraftSync into find-concept.ts
CQ-001: createDraftSync was the longest function in the repo and owned three
query paths next to its write path. findConcept and loadConcept are now free
functions that take the adapter, so they have a direct spec without TestBed.
The closure state (id, ensuring, resumeGate) stays where it was, because the
coupling is load-bearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:29:20 +02:00
ehoandClaude Opus 5 25a5d415a5 docs(adr): land ADR-C-001, ADR-C-003, ADR-C-007 and ADR-C-009
The architect approved the four ADR-fix tickets. All four change what the
architecture documents claim. No code changes.

ADR-0001, ADR-C-001: the worked example claimed the POC has no real backend.
It rewrites against `backend/src/BigRegister.Api`. Every path it named is
repointed. The out-of-scope list drops two discharged bullets: 33 `parse*`
boundaries exist, and `npm run gen:api` is real.

ADR-0001, ADR-C-003: a new section states that the generated client is the wire
contract. A hand-written `contracts/*.dto.ts` is the exception for two cases
only. The four survivors stay, because NSwag emits every property as optional
and flattens `RegistrationStatusDto` into five optional strings. The `parse*`
trust boundary stays mandatory, because a generated type is a compile-time
claim about the wire and not a runtime guarantee.

ADR-0003, ADR-C-007: four paths moved in WP-67 and are repointed. Point 4 kept
the principle and changed its example to `skeleton` and `spinner`. Two of its
claims were false and the amendment says so: `app-alert` wraps the vendored
`.feedback` classes, and `site-header` composes the vendored `.titlebar`.

ADR-0004, ADR-C-009: the exception section states a four-part test instead of
one named exception. `OrgTemplateStore` and `FeatureFlagStore` both pass it. RB-07
gated this ticket, because clause 4 needs an audited allow path. RB-07 landed
that, so the ADR does not ratify a control that the code lacks.

Three tickets need a matching CLAUDE.md correction in the same diff. CLAUDE.md
section 2 loses the false `alert` example. Section 4 gets the generated-client
rule and the four-part test.

Two findings were wrong. ADR-C-001 asked to keep an out-of-scope bullet that
reads "SessionStore is in-memory". The session persists to `localStorage` now,
so the bullet covers multi-tab sync only. ADR-C-007 flagged one half of point 4
and missed that the other half is equally false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:29:05 +02:00
ehoandClaude Opus 5 4631556e68 fix(backend): key IdempotencyStore on caller + idem key (RB-18)
IdempotencyStore keyed a replayed submission on the raw Idempotency-Key
header alone. Two different callers who send the same header value
shared one cache slot: the second caller received the first caller's
cached reference instead of running its own submission.

Program.cs now composes the key as "{SubjectId}:{idemKey}" in the
Submit helper, so the cache is scoped per caller. Add a test that
proves a caller cannot replay another caller's idempotency key and
receive their cached result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:27:52 +02:00
ehoandClaude Opus 5 d518a1466c refactor(registratie): extract the read half of createDraftSync (RB-21)
createDraftSync mixed a read path (findConcept, load, the read half of
resume) with its write path (ensureId, flush, submit, reset) in one
187-line function -- CQ-001's finding. Move findConcept and loadConcept
into a new application/find-concept.ts as free functions that take the
adapter, so they get a direct spec with no Angular TestBed.

createDraftSync keeps the closure state (id, ensuring, resumeGate) and
the whole write path unchanged -- this is a move, not a redesign. The
resumeGate coupling that lets the write path wait for the read path
stays exactly where it was.

createDraftSync shrinks from 187 to 169 lines. draft-sync.spec.ts is
unchanged -- it never called resume()/load() directly, and its 409
recovery test for submit() still exercises the extracted findConcept
through ensureId's catch branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:23:13 +02:00
ehoandClaude Opus 5 7fbac8fca5 docs: write English prose in Simplified Technical English
Adds a Conventions rule for Simplified Technical English (ASD-STE100). It
covers documentation, code comments, commit messages, ADRs, and the backlog
notes. STE is a controlled language. It makes text easy to read for people
who do not have English as a first language, and easy to translate. The
readers of this project are mostly non-native English readers.

The rule states that STE governs form, not content. Split a long sentence.
Never remove a caveat, a measurement, or a precise term to make text shorter.

The rule does not apply to Dutch identifiers, $localize copy, quoted output,
or existing documents that you are not already editing. It therefore does not
change the Naming convention above it, which keeps domain contexts in Dutch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:03:37 +02:00
ehoandClaude Opus 5 bc5b2c4b2d docs(backlog): CD batch 3 complete
All six merged, gate green (14 steps, backend 260/260). Records the three
tickets that could not be built as written — RB-12's wrapper/public binary
does not fit the route table, RB-14's command exits 0 on a High advisory, and
RB-15 needed a third environment name because RB-09 makes Production fail to
boot — plus RB-13's measured duplication drop (168 -> 32 lines per side).

Adds a section on dispatching implementation agents. Four of six agent-runs
were handed a worktree branched from a stale ancestor; batch 3 was three for
three. That, the background-task parking, and the git-checkout-destroys-work
trap are all cheap to prevent in the prompt and expensive to discover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:01:41 +02:00
eho 2a28db4aac Merge RB-12 + RB-15 + RB-16 — route-table authz gate, Swagger dev-only, peildatum 400
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 16:58:41 +02:00
eho ab0ec62f6a Merge RB-13 — land Session -> Principal, add MedewerkerAdapter
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 16:58:34 +02:00
ehoandClaude Opus 5 f19185ed81 refactor(auth): land Session -> Principal, add MedewerkerAdapter (RB-13)
ADR-0002 SS3 models Zorgverlener/Medewerker as different Principal
variants with different login flows. Actor #2 (apps/behandelportal)
landed in WP-61/67 and the union never followed: grep -rn "Principal"
returned one hit, a comment. Both apps' auth/domain/session.ts stayed
byte-identical (`{ bsn, naam }`), so the backoffice's Behandelaar
carried a BSN and logged into the backoffice as a citizen, by DigiD,
under a fabricated citizen's name (login.page.ts). The divergence
ADR-0002 predicted took an orthogonal side door instead
(medewerker.interceptor.ts's X-Medewerker/X-Rollen stamp, which never
touches SessionStore) -- which is why ssp/auth and bhp/auth still
measured as 100%/84% duplicated after ADR-C-006 shared the route
guards. RB-09 (landed the day before) made the backend's
IIdentityProvider able to say "no identity" and fail closed; this
ticket is its named FE half.

Each app's auth/domain/session.ts becomes principal.ts, holding the
one Principal variant that app actually has an actor for: ssp keeps
`{ kind: 'zorgverlener', bsn, naam }` (G1 still strips the BSN before
persisting); behandelportal gets `{ kind: 'medewerker', medewerkerId,
naam, rollen }` (no BSN to strip -- G2 shape validation only). A new
MedewerkerAdapter replaces DigidAdapter in behandelportal, resolving
the existing MEDEWERKER_ID/currentRollen() dev stand-in into a
Principal; because there is no credential to check, it returns
Principal directly rather than a Result whose error variant could
never occur. login.page.ts stops being a BSN/wachtwoord form -- one
explainer line and an "Inloggen met SSO" button -- and its dead
error-handling branch goes with the Result wrapper that justified it.

Measured with tools/baseline-scan.mjs --dup: auth duplication drops
from 168/168 (ssp) and 168/200 (bhp) to 32/179 and 32/259 -- under the
backlog's <40 target. What remains is the ADR-C-006 route-guard
re-export (deliberately identical), generic test/story-file
boilerplate, and one shared fragment of the root-singleton-store
idiom -- not re-converged identity or login-flow logic. SS3's
prediction that the two actors would authenticate differently enough
to justify not sharing auth has now actually been tested, not just
asserted, and held.

Also: renamed Session.bsn to Principal.bsn in two doc comments
(libs/shared/src/infrastructure/subject.ts, subject.interceptor.ts)
that cited the old type name; regenerated
libs/shared/docs/behaviour-spec.mdx (generated file, per its own
banner); recorded the resolution in ADR-0002 as a new amendment,
replacing its "Known debt" section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:54:26 +02:00
ehoandClaude Opus 5 b617d2f09a docs: regenerate behaviour-spec for RB-12/RB-15/RB-16
New backend test classes (RouteInventoryTests, SwaggerGateTests) plus
one added case to StamdataEndpointTests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:53:52 +02:00
ehoandClaude Opus 5 2627799284 fix(backend): 400 instead of 500 on an unparseable peildatum (RB-16)
BIO-019: GET /stamdata/{table}?peildatum= called DateOnly.Parse
directly, which throws FormatException on anything unparseable — an
unhandled 500 (leaking exception detail in Development) instead of
the 400-with-problem-details every other bad-input check in this file
returns. §3c named backend/Stamdata's 71.7% branch coverage (BL-005)
as the weak spot this bug lived in.

Switched to DateOnly.TryParse; an unparseable value now returns
Results.Problem(detail: ..., statusCode: 400), matching the shape the
upload/change-request endpoints already use. Endpoint doc gained
.ProducesProblem(400), so the OpenAPI doc + generated client were
regenerated and committed in this same diff (RB-09's note records a
prior incident where a response-shape change shipped without this and
the drift went unnoticed).

No FE change needed: libs/beheer's stamdata adapter already funnels
every call through runSubmit, which folds any thrown ApiException
(now including this 400) into a generic Result error — ADR-0001's
"the FE renders the decision" already covers "the server rejected
this input".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:53:29 +02:00
ehoandClaude Opus 5 a93218e8ac fix(backend): gate Swagger + the OpenAPI doc behind IsDevelopment (RB-15)
BIO-015: app.UseSwagger()/app.UseSwaggerUI() ran unconditionally, so
the full OpenAPI document (every route + request/response shape) and
SwaggerUI's interactive "Try it out" were reachable in every
environment, including a real deployment.

Both now run only inside `if (app.Environment.IsDevelopment())`.
AddSwaggerGen/AddEndpointsApiExplorer stay unconditional — DI
registration only, no HTTP surface by itself.

RB-09 already made a non-Development environment throw at startup,
which broke `npm run gen:api` until that script pinned
ASPNETCORE_ENVIRONMENT=Development for its one CLI invocation. This
change sits in the same pipeline, so it was verified rather than
assumed: `dotnet swagger tofile` resolves ISwaggerProvider straight
out of DI and never sends an HTTP request through this middleware, so
gating it can't affect that tool by construction. Ran the real
`npm run gen:api` to confirm — exit 0, regenerated files byte-identical
to what's committed.

New tests exercise the gate on a third ("Staging") environment name,
not Production — Production already can't boot at all post-RB-09, so
a Production-environment test would only re-prove that unrelated
startup throw, not this gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:40:23 +02:00
ehoandClaude Opus 5 ee0d449510 test(backend): assert every route is authz-gated (RB-12)
BL-006: the backend has zero automated architecture enforcement.
BIO-016 names the concrete consequence for authorization — nothing
asserted the *set* of gated endpoints, so BIO-003's X-Admin gate
(outside Authz) and BIO-004's two ungated endpoints were caught only
by a human reading Program.cs, not by CI.

Adds RouteInventoryTests: walks the real app's EndpointDataSource and
asserts every mapped route either carries a .Gate("XAdmin") metadata
marker (added at the 16 call sites that already call one of the five
admin wrappers — OrgAdmin/StamdataAdmin/CasesAdmin/Beoordelen/
FlagsAdmin) or appears in a written-down, reasoned allow-list. Proved
it's hard to fool by adding a throwaway unguarded route, watching the
test go red, and reverting.

The allow-list is not "public routes" as the ticket's shorthand put
it — 19 of its 31 entries are ownership-scoped inline (ctx.Zorgverlener()/
ctx.Caller()) endpoints, not public ones, and labelling them public
would misrepresent the exact property BIO-004 was about. Each entry
instead carries its own reason. Implementation note has the full
route-by-route breakdown and judgement calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:36:30 +02:00
ehoandClaude Opus 5 1c5442d797 Merge RB-17 — split runResult out of runSubmit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:34:16 +02:00
ehoandClaude Opus 5 80de261299 refactor(shared): split runResult out of runSubmit (RB-17)
runSubmit did two things at once: fold a call into a Result, and mint
an Idempotency-Key for it. Five call sites are reads and had no
business minting one — brief.adapter.ts:load, org-template.adapter.ts
:list/:load, and stamdata.adapter.ts:list/:load. stamdata.adapter.ts's
own docstring already said "Both endpoints are reads … There is no
write method" while both called runSubmit; that mismatch is the
sharpest evidence, and the reason the baseline's original "~13
mutations" count (derived from the helper's name, not the code) was
wrong by five in one direction.

Split submit.ts in place: runResult is the try/catch + problemDetail
fold with no mint; runSubmit is runResult wrapping
withIdempotencyKey. Zero behaviour change for the 8 real mutations
(brief save/submit/approve/reject/send/reset, org-template
save/publish/rollback) — same fold, same mint, same timing. The five
reads now run the fold with no pendingIdempotencyKey touched.

submit.spec.ts asserts the split behaviourally via
currentIdempotencyKey() (two reads inside the same call agree only
when a key was minted and reused) rather than mocking a relative
import, matching this repo's existing vitest convention. Verified red
without the fix by temporarily reintroducing the mint into runResult.

ApplicationsStore.cancel/AdminCasesStore.delete (RB-20) and
FeatureFlagStore.set are out of scope and untouched — the latter
already calls runSubmit correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:31:13 +02:00
ehoandClaude Opus 5 adfaa32a42 ci: gate on known advisories in the .NET dependency tree (RB-14)
npm audit --omit=dev gates the shipped frontend bundle; nothing equivalent
existed for the backend, so the entire .NET dependency tree — direct and
transitive — was unscanned (BIO-016 lists it first under "Absent").

The ticket's literal wording would not have worked. `dotnet list package
--vulnerable` is a reporting command: it prints the advisory table and exits
0 regardless. Verified with a throwaway project on System.Net.Http 4.3.0 —
severity High, GHSA-7jgj-8wvc-jh57, exit code 0. A bare `- run: dotnet list
package --vulnerable` would have added a line that reads like coverage in a
compliance review and enforces nothing, which is worse than leaving the gap
visible.

scripts/dotnet-audit.sh runs the scan and matches "has the following
vulnerable packages" — the exact sentence dotnet prints per project on a hit.
One script, two callers (ci.yml and ci-local.sh), so the workflow and the
local gate cannot drift apart.

No severity threshold and no suppression list: picking either before a real
advisory forces the question would be guessing at a policy nobody needs yet.
Secret scanning, BIO-016's other named absence, stays on the checklist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:24:16 +02:00
ehoandClaude Opus 5 988612cd7e docs(backlog): CD batch 2 complete
All five tickets merged and green on the fixed gate (13/13 steps, exit 0).
RB-07 unblocks ADR-C-009; RB-09 unblocks RB-13 in batch 3.

Adds a "Gate integrity" section recording that every earlier "ci green" in
this file predates the ci-local.sh errexit fix and is weaker than it reads.
Batch 1 has not been re-verified under the honest gate, and the note says so
rather than leaving a reader to assume it was.

Also records what batch 2 leaves open: RB-01's residual is NOT solved by
RB-09 (the upload-content link is still a plain browser navigation with no
credential), and a non-Development non-Production environment fails fast at
GetRequiredService rather than at RB-09's deliberate throw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:37:20 +02:00
ehoandClaude Opus 5 6bdfa35abb build: stop ci-local.sh swallowing the first half of every paired step
Under `set -e` bash exempts every command of an AND-OR list except the last,
so `npm run gen:api && git diff --exit-code ...` silently swallowed a CRASH
in gen:api: the diff never ran and the script sailed on to print "local CI
passed". Verified directly — `bash -c 'set -e; false && true; echo hi'`
prints hi and exits 0, while `false; true` exits 1.

This was not hypothetical. It hid a real gen:api crash introduced by RB-09
(dotnet swagger's design-time host defaults to Production, which that ticket
made throw at startup). .github/workflows/ci.yml would have caught it, since
it runs each step as its own `- run:` — so the local gate was strictly weaker
than the remote one, which is the opposite of its stated purpose.

Six steps were affected. The worst was `ng build ssp --localize && ng build
behandelportal --localize`: a missing English translation in ssp — the exact
thing the second-locale gate exists to catch — could not fail the run.

The one `( cd backend && ... )` step is safe as-is and left alone: a subshell
propagates its own non-zero status, so errexit sees it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:31:50 +02:00
eho 2fa96c300c Merge RB-08 + RB-09 — CasesAdmin on the admin upload delete; no-identity representable
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 14:30:41 +02:00
ehoandClaude Opus 5 c6bc6dd4c3 docs(backlog): RB-11 done
Also records what RB-11 turned up: BIO-012 was factually wrong that the
proefbrief error mapping was already a separate function (it was inlined in a
try/catch), and the step-up literal is still a literal, moved one layer up to
the only caller rather than eliminated — BIO-006(c) stays a production gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:29:50 +02:00
ehoandClaude Opus 5 d089151dbd docs: record the gen:api regression found while verifying RB-09
Documents the dotnet swagger tofile crash discovered by actually
running the affected command (not just trusting ci-local.sh's local
"passed" line, which turned out to mask this exact failure via a
set -e && short-circuit gotcha), its root cause, and the two follow-up
fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:22:26 +02:00
eho c336328cff Merge RB-11 — keep the dev hatches out of production builds
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 14:21:51 +02:00
ehoandClaude Opus 5 436e18421b fix(tooling): keep gen:api working under RB-09's Development-only stub
RB-09 registers StubIdentityProvider only under IsDevelopment() and
throws for Production. `dotnet swagger tofile` (npm run gen:api) loads
the same Program.cs through .NET's design-time HostFactoryResolver,
which executes the app's startup code (including the unconditional
app.Services.GetRequiredService<IIdentityProvider>() the identity
middleware already relied on) without ever setting
ASPNETCORE_ENVIRONMENT - so it now defaults to Production and crashes
(dotnet swagger tofile exited 134), breaking `npm run gen:api`
entirely, including the "api-client drift" job in
.github/workflows/ci.yml (a separate `- run:` step there, so this
would fail real CI even though ci-local.sh's `cmd1 && cmd2` step
shape happens to swallow a cmd1 failure under `set -e` and reports
"passed" - a separate, pre-existing script fragility, not touched
here).

Real usage is unaffected: dotnet run already sets
ASPNETCORE_ENVIRONMENT=Development via launchSettings.json, and
docker-compose.yml/docker-compose.prod.yml already set
Development/Production explicitly - gen:api's bare CLI invocation was
the one place with no environment variable at all. Set it to
Development inline, the same value launchSettings.json already uses
for the real app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:21:34 +02:00
ehoandClaude Opus 5 1fad740606 fix(api): regenerate client for RB-08's 403 response shape
RB-08 changed DELETE /admin/uploads/{documentId}'s 403 mapping from
.Produces to .ProducesProblem, matching CasesAdmin's actual
Results.Problem() response - but the generated OpenAPI doc and typed
client were never regenerated alongside it, so CI's api-client-drift
check (npm run gen:api && git diff --exit-code) was left red. No
frontend consumes this admin-only endpoint (confirmed by grep), so
this is a pure regeneration with no consumer impact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:21:26 +02:00
ehoandClaude Opus 5 772c47ea43 fix(brief): keep the dev hatches out of production builds (RB-11)
BIO-012: roleInterceptor/subjectInterceptor are correctly registered
only under isDevMode(), but three hand-written fetch adapters
(reveal-bignummer, letter-preview, org-template's proefbrief) bypass
HttpClient and set X-Role/X-Subject themselves with no guard. The
readers underneath, role.ts and subject.ts, were ungated too: they
read ?role=/?subject= and wrote it into sessionStorage on any
navigation, in any build -- for ?subject= that value is a BSN, which
is exactly what SessionStore's G1 comment promises never happens.

Gate both layers: currentRole()/currentSubject() return their safe
default immediately outside isDevMode() (no query-param read, no
sessionStorage write), and the three adapters additionally wrap their
headers in isDevMode() so a production request carries neither header
at all, matching what an HttpClient request already does once the
interceptors aren't registered.

TE-002: reveal-bignummer's response-shape validation was a "Trust
boundary" a spec could only reach by stubbing globalThis.fetch.
Exported it as parseRevealed(body), matching the other 30 parse*
boundaries in the repo. Same treatment for letter-preview's
errorMessage and org-template's proefbrief error mapping (extracted
from an inline try/catch into a named, exported function first, since
it wasn't already separate).

BIO-006(a): reveal-bignummer sent X-Step-Up: 'true' unconditionally,
so the backend's step-up precondition constrained nothing. reveal()
now takes a stepUp flag; BriefStore.revealBigNummer() -- reachable
only after the UI's confirm() gesture -- is the one that supplies it,
so the literal no longer lives in the transport adapter.

BIO-006(b): documented in roles-and-access.md that drafter is also
the backend's fallback identity (StubIdentityProvider's catch-all
arm), not just the dev switcher's initial choice -- so the
least-privilege consequence of it also being the only role that may
reveal a BSN is visible.

Doc correction, same diff: roles-and-access.md's "wired only under
isDevMode()" claim was false for the three hand-written fetch paths;
it now says where the gate lives (interceptor registration and the
reader functions) so it doesn't go stale the same way again.
CLAUDE.md's dev-only claims needed no correction -- they already
noted these three calls bypass the interceptor.

Every fix has a test confirmed red by temporarily reverting the
source change and rerunning the suite before restoring it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:20:47 +02:00
ehoandClaude Opus 5 b5432d2c63 docs(backlog): RB-07 and RB-10 done, batch 2 in progress
RB-07 unblocks signing ADR-C-009 (its clause 4, "writes are
admin-capability-gated and audited", now holds) and closes CQ-004's
outstanding half. RB-10 landed parseStoredSession twice, once per app,
deliberately — recorded so a later reader does not file it as duplication.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:15:43 +02:00
ehoandClaude Opus 5 4ac13f6cb5 docs: regenerate behaviour spec (RB-07 drift, RB-08, RB-09)
`npm run gen:behaviour-spec`'s drift check (part of `npm run ci`)
caught two things: RB-07 had already left this generated doc stale
(three of its new AuthzAuditTests cases were never picked up), and
RB-08/RB-09 added more test names since. Regenerated so the doc
matches the suite it claims to mirror.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:11:51 +02:00
ehoandClaude Opus 5 9bff19a3e6 build: keep agent worktrees out of prettier and git
Running implementation agents with worktree isolation puts full checkouts of
this repo under .claude/worktrees/. `prettier --check .` walks into them, so
`npm run ci` went red on 70 files that belong to another checkout — including
the vendored CIBG design system, which the top-level ignore already excludes.

Ignored in both .prettierignore and .gitignore; the latter so a worktree can
never be committed into the repo it is a checkout of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:11:18 +02:00
eho 5118b0da95 Merge RB-10 — extract and spec the stored-session parse boundary
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 14:08:48 +02:00
ehoandClaude Opus 5 3545023af8 docs: regenerate the behaviour spec for RB-07's new tests
The RB-07 commit added three AuthzAuditTests cases and did not regenerate
libs/shared/docs/behaviour-spec.mdx, so the "behaviour spec drift" CI step
was left red on that branch. My mistake: I committed RB-07 and answered a
question before running the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:08:27 +02:00
ehoandClaude Opus 5 de349e702e test(auth): extract and spec the stored-session parse boundary (RB-10)
SessionStore.restore() — identical in both apps — read localStorage itself
and did the parse plus shape validation in the same module-private function,
invoked from a field initializer, so the storage read happened the instant
the singleton was constructed and no spec could feed it a raw string. The
logic it guards is a trust boundary, not incidental validation: the comment
above it names G1 (never persist the BSN) and G2 (validate the shape before
trusting it), and CLAUDE.md mandates a spec for boundary parse* adapters.
ssp/auth and bhp/auth were jointly the worst-covered frontend modules.

parseStoredSession(raw) moves into each app's auth/domain/session.ts, which
is pure TS and already had a spec, so no new scaffolding was needed;
restore() collapses to one line. Four cases: absent, non-JSON, wrong shape,
and — BIO-017's addition — a stored {"bsn":…,"naam":…} restoring with bsn
'', which makes the G1 guarantee executable rather than merely commented.
Verified red without the fix.

Landed twice, once per app, deliberately. TE-001 and BL-002 both say an
extract-to-shared here would contradict ADR-0002, which models the two
actors as different Principal variants and expects the two auth contexts to
diverge; RB-13 is what differentiates them.

Also specs redactProfile (BIO-017's second half) — a pure exported
PII-redaction function that had none.

behaviour-spec.mdx is regenerated, which also picks up the test names RB-07
added; that commit should have carried them and did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:08:06 +02:00
ehoandClaude Opus 5 8b8b522052 fix(auth): make no-identity representable; stub dev-only (RB-09)
IIdentityProvider.Resolve returned a non-nullable CallerIdentity, so
the interface could not express "no identity" - StubIdentityProvider
was forced to invent one for any request carrying no credential at
all. Consequence: a production behandelportal build sends no
X-Medewerker header (medewerkerInterceptor is dev-only), so it used
to authenticate as the seeded citizen, role drafter - failing closed
on backoffice capabilities but open on every citizen-scoped endpoint,
including CanRevealBigNummer.

Resolve now returns CallerIdentity?. StubIdentityProvider keeps a
non-nullable return type (a valid narrower override) since it never
itself has "no identity" to report - it is registered only under
IsDevelopment() now. Production registers nothing and throws an
InvalidOperationException immediately during startup instead: there
is no real DigiD/employee-SSO provider in this POC yet, so a
misconfigured Production deploy must fail before serving a single
request, not resolve one per request. The identity-resolution
middleware turns a null resolution into a 401 rather than passing it
downstream.

Added StubIdentityProviderTests.Never_returns_null_even_with_no_headers_at_all
and ProductionIdentityProviderTests, which builds its own
WebApplicationFactory<Program> with UseEnvironment("Production") and
asserts startup throws. Verified both new tests fail red against the
pre-fix code.

RB-01's residual (GET /uploads/{id}/content reached via plain browser
navigation, no identity header) is confirmed unchanged in Development
and its Production consequence is written up in
implementation/rb-09.md for whoever lands the real identity provider -
no signed-URL/cookie scheme was designed here, per scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:06:49 +02:00
ehoandClaude Opus 5 494cee9d08 fix(uploads): route the admin delete through CasesAdmin (RB-08)
DELETE /admin/uploads/{documentId} was gated by a standalone
`X-Admin: true` header check (`IsAdmin`), outside the `Authz` module
entirely and outside the `CasesAdmin`/`StamdataAdmin`/`OrgAdmin`/
`FlagsAdmin` wrappers the four sibling admin surfaces use. It wrote no
AuthzAuditStore row, so a destructive cross-owner document delete never
appeared on /beheer/audit. A repo-wide grep confirmed the only sender of
X-Admin was the backend test itself — no frontend or e2e path depends on
it — so the gate was safe to delete outright.

Routed the endpoint through CasesAdmin (Authz.CanManageCases), the same
wrapper the other admin-cases endpoints use. RB-07 already moved
AuditAuthz onto every *Admin wrapper's allow path, so this gets the
missing audit row for free with no second AuditAuthz call. Deleted the
now-unused IsAdmin function and updated the two comments that referenced
the old X-Admin seam.

Updated EndpointTests.cs's Admin_delete_requires_admin_role to send
X-Role: admin instead of X-Admin: true, and added
AuthzAuditTests.An_admin_upload_delete_is_recorded, which asserts the
cases:manage/allow row count increases by exactly one (a plain
Contains would already be satisfied by this test class's other
cases:manage calls). Verified both tests fail red against the
pre-fix gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:55:41 +02:00
ehoandClaude Opus 5 e89525eef6 feat(audit): record the allow path, not just the denial (RB-07)
All five authorization gates audited only their deny branch, so /beheer/audit
could answer "who was turned away" but never "who changed this" — for a
register whose integrity is the product, the wrong half. Nothing recorded the
flag toggle, either org-template write, the admin case or upload delete, the
three brief transitions, or the besluit; the comment claiming endpoints log
their own effect held for two of the eight.

Each gate now computes the decision once, audits it, and then acts. The row
is written by the gate rather than the endpoint, so a new admin endpoint
cannot be added that forgets to audit itself. Same reasoning for the brief:
every transition already funnelled through LogBrief for its log line, so the
audit row goes there too — submit/approve/reject/send in one place, with the
transition's own outcome as the decision, so a 403 or 409 is as visible as a
success.

FlagsAdmin gained a per-call resource, the one deviation from BIO-007's
minimal remediation: the toggle endpoint writes no log line of its own, so a
constant "feature-flags" row would say a flag changed without saying which.
It now records feature-flags/<key>=<value>. OrgAdmin and CasesAdmin keep
coarse refs because those endpoints do log the specific object.

The besluit gets a second row: the gate records that a behandelaar was
allowed to act, aanvraag:besluit records what they decided.

Row volume goes up — StamdataAdmin gates read endpoints, so admin page loads
now write rows. That is what auditing the allow path means; it is also what
would make retention on AuthzAuditStore necessary later.

Closes CQ-004's outstanding half and unblocks signing ADR-C-009.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:12:22 +02:00
ehoandClaude Opus 5 d682f91b54 docs(backlog): lift the HALT, record CD batch 1 as complete
The operator approved 99-backlog.md, so Phase 3 started. RB-01..RB-06 are
marked done in the table, _status.md gains a Phase 3 section tracking all six
batches, and the halt notice is replaced by what batch 1 leaves for later
batches to pick up:

- RB-01's residual is RB-09's problem — the document-content endpoint is
  reached by a plain browser navigation with no identity header, so it
  resolves to the seeded citizen and works only because one citizen owns
  every document in the POC. That is BIO-002.
- Pii.MaskTail now lives in Domain/People/Pii.cs; RB-11 should use it rather
  than hand-rolling a second masker.
- RB-06 additionally deleted SubmissionRules.RejectRegistratie, which its row
  did not ask for.

Also records the standing OpenZaakIntegrationTests failure, which needs a
live container and is unrelated to any of these tickets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:06:10 +02:00
ehoandClaude Opus 5 0298ecc506 fix(uploads): delete the dead POST /registrations (RB-06)
POST /registrations passed its Documents list straight to Submit, which calls
DocumentStore.Link on every digital documentId in it — and linking a document
blocks its owner from ever deleting it (DeleteOwned returns 409 Linked). That
path had no ForeignIds ownership check, so any authenticated citizen could
post another citizen's document id and permanently block them from deleting
their own diploma scan. POST /applications/{id}/submit, the endpoint actually
in use, has had that guard since it was written.

Deleted rather than guarded: the endpoint is dead. No frontend caller, and
the whole registratie flow goes through /applications/{id}/submit.
RegistratieRequest went with it, and so did SubmissionRules.RejectRegistratie
— reachable only from here, and contradicted by the live path, which treats a
handmatig diploma as "does not auto-approve" rather than a 422 rejection. Its
own message said as much while being returned as a rejection. That last part
is a judgement call beyond the ticket's wording; reverting the two
SubmissionRules hunks restores it in isolation.

Coverage moved rather than vanished: the problem+json shape assertion is now
on /change-requests (the other endpoint on the same Submit helper), and the
linked-delete 409 test goes through the real submit path.

swagger.json, the generated client and the behaviour spec regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:04:03 +02:00
ehoandClaude Opus 5 5187bfa19a fix(zgw): keep the BSN out of the recorded ZGW failure message (RB-05)
ZgwHttpClient interpolated the full request uri and up to 500 characters of
the response body into its failure message. That message is persisted as
Aanvraag.ZgwError in SQLite and written to the log, and both halves can carry
a BSN: ZGW filters travel as query parameters (the citizen-scoped zaken list
filters on rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn), and
OpenZaak echoes the offending request in its error bodies, so a rejected POST
/rollen comes back holding the owner BSN it was sent.

All three interpolation sites now use Redact(url) — the path without its
query — and the body snippet is replaced by the reason phrase. Status plus
path still routes a failure to the right endpoint; the lost detail already
has a deliberate home in ZGW_DEBUG_HTTP=1 (ZgwDiagnosticHandler), which is
opt-in, dev-only and not persisted.

The new test fails the one call in the fixture whose url carries a query
string and asserts the persisted ZgwError has neither the body snippet nor a
"?", while keeping the path and the 503. Verified red without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:57:09 +02:00
ehoandClaude Opus 5 fbd27ed641 fix(privacy): mask the BSN recorded as the document audit Actor (RB-04)
DocumentStore wrote one audit row per upload and per user delete carrying the
acting citizen's raw BSN as AuditEntry.Actor, persisted to SQLite — on a
store whose own doc comment says it holds metadata only, never file content
"or other PII". Same shape as RB-02, in a second store.

Masked at the two citizen call sites rather than inside Audit, because the
third actor is the literal "admin" and MaskTail("admin", 3) is "**min";
masking centrally would mean guessing which actors are BSNs and which are
role names. Audit's doc comment now states that actors arrive redacted.

StoredDocument.Owner is untouched: it is the authorization key that
DeleteOwned, ForeignIds and RB-01's content check all compare against, so the
BSN stays where it is load-bearing and leaves the trail where it was only
decoration. No endpoint exposes AuditLog, so no response shape changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:54:07 +02:00
ehoandClaude Opus 5 487818e67a fix(privacy): mask the owner BSN on the cross-owner case lists (RB-03)
Mappers.ToAdminSummaryDto set Owner to the raw BSN. Both consumers are
cross-owner lists read by someone who is not the subject — GET /admin/cases
and GET /werkvoorraad — while GET /beoordeling/{id}, the detail view of the
same data, already masked it. The detail screen showed ******782 and the list
one click earlier showed the whole thing.

Masked in the mapper rather than at each endpoint, so a third cross-owner
list cannot be added that forgets to.

MaskTail moves out of Program.cs into Domain/People/Pii.cs: it now has
callers in Contracts, Program.cs and (once RB-04 lands) Data, and a second
hand-rolled copy is how one of them drifts into leaking. Documented as
idempotent, which is what lets /beoordeling/{id} keep its own call —
IZaakSource has a second implementation whose Owner is mapped from the
OpenZaak zaak identificatie, so that endpoint should not depend on which
source answered.

No frontend change: all three consumers display the value, and the parse
boundaries only require a non-empty string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:52:29 +02:00
ehoandClaude Opus 5 6ffd3643b1 fix(audit): stop writing a BSN into the authz audit Resource (RB-02)
Program.cs built the BIG-nummer reveal's audit resource ref as
"brief/" + ctx.Zorgverlener().Bsn. AuditAuthz persists that to the
AuthzAudit.Resource column in SQLite and /admin/audit renders it, so a BSN
reached durable storage and a UI on the one trail four documents describe as
data-minimised and PII-free — on the endpoint whose own comment promises the
audit carries no PII.

The ref is now "brief". Nothing is lost: BriefStore keys one brief per owner,
so the id named what the row's acting principal already implies.

The existing guard, The_audit_schema_carries_no_pii, asserts on column names,
so a BSN inside a column called Resource could never fail it. Added
No_audit_row_carries_a_subjects_bsn, which drives a denied reveal as a
non-default subject and scans every string field of every row for that BSN
and for DemoOwner — asserting on the two BSNs actually in play rather than a
\d{9} shape, since a hex correlation id can hold nine digits by chance.
Verified it goes red when only the Program.cs line is reverted.

AuditEntry.Actor on document audit rows holds a raw BSN too; that is a
different store and stays with RB-04.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:49:57 +02:00
ehoandClaude Opus 5 a2e935d1d8 fix(uploads): authorize the document-content and status endpoints (RB-01)
GET /uploads/{documentId}/content took only (string documentId) — no
HttpContext, so no authorization was possible. It streams diploma and
identity scans, protected by GUID unguessability alone, while DELETE on the
same resource has always been owner-scoped. GET /uploads/status had the same
shape and confirmed whether any client-chosen localId exists, plus its
documentId.

Both now take HttpContext. Content is readable by the owning
ZorgverlenerCaller or a caller passing Authz.CanBeoordelen — matched on the
caller kind rather than branched on a boolean, because ctx.Zorgverlener()
throws for a MedewerkerCaller and the behandelportal's beoordeling screen is
a legitimate reader. Status is scoped to ctx.Zorgverlener().Bsn via a new
owner parameter on DocumentStore.ByLocalIds (one call site).

404, not 403, on both: a foreign document id must not be distinguishable
from one that never existed, and a foreign localId reads back as "unknown".

Residual, recorded in the implementation note: both callers reach the URL as
a plain browser navigation (<a href> / previewUrl), which carries no identity
header and no interceptor, so StubIdentityProvider resolves it to the seeded
citizen. That is BIO-002 and belongs to RB-09; the links keep working today
only because one citizen owns every document in the POC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:48:04 +02:00
ehoandClaude Opus 5 176e5baef8 docs: BIO2 compliance pass + consolidated backlog (agents 07, 08)
Completes the pipeline's analysis phase. Agent 07 (BIO2/ISO 27002:2022,
control set stated as an assumption since none was supplied) produced 20
findings — 12 "defect now", 8 "production gate" — and agent 08 consolidated
all 47 findings across 00/02/04/06/07 into 33 tickets, 5 ADR-fixes and a
release checklist.

Two findings are live defects rather than refactoring candidates, both
verified directly:

- RB-01/BIO-004: GET /uploads/{documentId}/content takes only (string
  documentId) — no HttpContext, so no authorization is possible. It streams
  diploma and identity scans, protected by GUID unguessability alone, while
  DELETE on the same resource is owner-scoped.
- RB-02/BIO-008: Program.cs:674 concatenates the caller's BSN into the authz
  audit Resource column, which is persisted to SQLite and rendered by the
  admin audit page. Four doc comments claim that store holds no PII; the test
  cited as enforcing it asserts on column names, so a BSN inside a column
  called Resource is invisible to it.

07 also answered the handoff from 06: in a production behandelportal build no
X-Medewerker is sent, so StubIdentityProvider returns the seeded citizen. It
fails closed on backoffice capabilities but open on citizen-scoped ones,
including CanRevealBigNummer. Root cause is IIdentityProvider.Resolve
returning a non-nullable CallerIdentity — the interface cannot express "no
identity", so any provider must invent one.

08's gate was relaxed from all-seven to the four agents that ran; _status.md
records why 01/03/05 were skipped, and the backlog carries a "Coverage"
note naming what those skips leave unowned. It caught two errors in the
orchestrator's handoff: CQ-002 is not fixed (ApplicationsStore.cancel and
AdminCasesStore.delete still swallow errors -> RB-20), and CQ-004 shipped
with half its compliance criterion unmet (PUT /admin/flags/{key} writes no
audit row -> RB-07, which blocks signing ADR-C-009).

Both agents preserved a "verified clean — do not fix" list, so a later pass
does not re-spend effort on the controls that already hold.

Consolidation halted for human approval per its spec. No source file changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 09:52:13 +02:00
ehoandClaude Opus 5 8dbfa83cd6 build: make coverageExclude actually exclude the generated API client
The entry was a bare workspace-relative path ("libs/shared/src/
infrastructure/api-client.ts" in the app projects, "src/infrastructure/
api-client.ts" in the libraries) while every sibling in the same list is a
glob. It matched nothing, so the 2372-line NSwag client was instrumented in
all four projects: 987 mostly-uncovered lines that dragged the reported
libs/shared/infrastructure figure from 94.7% down to 6.9%.

Normalizes all four to "**/infrastructure/api-client.ts" and adds the entry
to the beheer project, which was missing it entirely. api-client.provider.ts
is hand-written and stays covered.

Effect on the shared project's reported coverage: lines 96.2% -> 90.5% and
branches 85.1% -> 80.2%, because the denominator is now real source instead
of generated code inflating it.

Found by the metrics baseline (BL-008).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:13:48 +02:00
ehoandClaude Opus 5 9440ce1345 fix(stamdata): evaluate the profession validity window per call, not at type-load
Professions.ByProgram was a `static readonly` field filtered on
DateTime.Today, so it evaluated once when the type first loaded. Two
consequences, both real:

- A long-running process kept serving the answer it computed at startup. A
  mapping whose geldigVan fell after boot never appeared; one whose geldigTot
  passed never disappeared.
- Both branches of StamdataFile.ActiveOn were unreachable from this caller,
  which is why this table's validity window had no test at all. It is the
  cleanest single explanation for Stamdata's 71.7% branch coverage (BL-005).

Adds ByProgramOn(DateOnly) — the peildatum as an argument, matching
StamdataTable.RowsOn which already parameterizes it — and makes ByProgram a
property delegating to it with today's date. Call sites (DiplomaRules) are
unchanged and keep the same behaviour, now with a current date.

ProfessionsTests covers both ActiveOn branches plus the regression itself:
the same date must give the same answer, a different date a different one.

Found by the testability pass (TE-009).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:13:38 +02:00
ehoandClaude Opus 5 4b94f8edb5 fix(flags): surface a failed admin toggle instead of swallowing it
FeatureFlagStore.set() was try/finally with no catch. A rejected
PUT /admin/flags/{key} escaped into the `void this.store.set(...)` call site
as an unhandled promise rejection; the finally-block reload then snapped the
control back to its old value. The admin saw a toggle that silently refused
to move, with no error rendered anywhere and nothing in the state.

set() now folds through the existing runSubmit helper and returns
Result<string, void>, reloading either way so the state still reflects the
server. The page awaits it and renders the failure in an app-alert.

Found by the CQRS-light pass (CQ-002/CQ-004) as one of three mutations that
reach the raw ApiClient without producing a Result — the baseline's BL-007
inventory had missed all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:13:29 +02:00
ehoandClaude Opus 5 f2d4c900b4 refactor(auth): share the actor-agnostic route guards (ADR-C-006)
authGuard and capabilityGuard were duplicated byte-for-byte across both
apps, along with their specs — 57 of the 211 duplicated lines BL-002
measured in the two auth contexts, the largest block after session.store.ts.

They are not actor-specific. They ask "is anyone logged in" and "may they do
X", never "who are you or how did you get here". ADR-0002 §3's non-sharing
decision scopes to identity and login flow — Principal, DigiD vs employee
SSO — and a route guard is neither; §Consequences names auth.guard.ts only
as a seam that localises the change, not as something that must be
duplicated.

Moves both to libs/shared/src/application/auth.guard.ts, reading SESSION_PORT
instead of an app-local SessionStore. The port gains one member,
isAuthenticated: Signal<boolean> — free, because both SessionStores already
expose exactly that (session.store.ts:40) and both apps already register
{ provide: SESSION_PORT, useExisting: SessionStore }. The seam existed; it
was just narrower than what it already carried.

Each app keeps a re-export at @auth/auth.guard so app.routes.ts is untouched
— routing asks the auth context for its guards, which is the direction the
boundary should read. The two identical specs collapse into one, plus a case
asserting the guard resolves through the port.

Deliberately NOT merged: session.store.ts, session.ts, digid.adapter.ts,
login-form.component.ts, login.page.ts. Those are identical only because
ADR-C-004 (Session -> Principal) was never executed. Merging them would make
a citizen DigiD/BSN login the backoffice's shared login.

Measured with tools/baseline-scan.mjs: ssp/auth duplicated lines 211 -> 151,
bhp/auth 86.8% -> 82.5%, repo-wide 7.1% -> 6.6%. Both guard clone pairs drop
out of the top-clones list. What remains is exactly the three files
ADR-C-004 should differentiate.

behaviour-spec.mdx regenerated (the spec moved libraries).

Verified: lint, typecheck, dep:check (0 violations, 224 modules), prettier,
ng build --localize for both apps, and 407 tests passing across all four
projects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:49:04 +02:00
ehoandClaude Opus 5 4debf6614f docs(adr-0002): accept, and record the unbuilt Principal union as debt
ADR-C-005 from the ADR-conformance pass.

Status Proposed -> Accepted. Two apps have shipped against this ADR and its
structural rulings run in CI at severity: error with 0 violations; the other
five ADRs are all Accepted. A decision CI enforces is not "Proposed".

Drops two "out of scope, not built" bullets that have since shipped
(WP-61..67) — the Behandeling backoffice, and the backend status lifecycle +
authz DTOs: AanvraagStatusTag, GET /me (Program.cs:578), Domain/
Authorization/Authz.cs. Real authentication is the one that genuinely stays.

Replaces the `Session -> Principal` deferral with a Known debt section. The
deferral was conditional on the backoffice not existing yet; it does now, and
the union did not follow. `grep -rn "Principal" apps libs` returns one hit,
a comment. Consequently the two auth contexts are byte-identical (diff -rq:
zero content differences), and behandelportal's Behandelaar still carries a
bsn and logs in through DigiD — a backoffice user authenticating as a
citizen, which is what §3 was written to prevent. The divergence that did
happen took an orthogonal side door (medewerker.interceptor.ts) that never
touches Session.

The section says explicitly that the WP-67 amendment's "expected to diverge"
reasoning still holds but has never been tested, so the identical copies are
evidence §3 is unexecuted — not evidence §3 was wrong. Without that, a future
reader is likely to "simplify" the duplication away and cement the citizen
login into the backoffice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:48:48 +02:00
ehoandClaude Opus 5 664a43bf2d docs: refactoring-backlog workspace — baseline + 3 Phase 1 agents
Runs the multi-agent refactoring-backlog pipeline in docs/project/
refactor-backlog-setup/ up to and including three of the seven Phase 1
agents.

00-baseline.md establishes the metrics every later agent must cite, using
only tooling already in the repo (vitest lcov, coverlet cobertura, ESLint's
core `complexity` rule at threshold 0 for a full distribution, depcruise
--metrics). Duplication and C# complexity had no tooling, so
tools/baseline-scan.mjs adds a deterministic ~200-line text scan rather
than a new dependency; the approximations are labelled as such.

Headline: FE 75.1% line coverage but only over the 98 of 220 source files a
spec loads; BE 97.6% line / 79.6% branch; 0 layering violations; 7.1%
duplication; 25 of 2085 TS functions over CC 10.

Then 02-testability, 04-cqrs-light and 06-adr-conformance (27 findings).
01/03/05 were skipped deliberately — the baseline shows little for them to
find; 07 (BIO2) and 08 (consolidation) are still open.

Each agent corrected a baseline observation of mine, and in every case the
error was in something derived rather than measured:

- BL-007 counted ~13 adapter "mutations" from the `runSubmit` helper name;
  5 of those call sites are reads. It also missed 3 real mutations that
  reach the raw ApiClient and never return a Result.
- BL-002 diagnosed the 100%-duplicated auth folders as ADR-0002's
  divergence prediction failing. It never had a chance to fail: §3's
  `Principal` union was never built.
- BL-004 named libs/shared/domain and libs/beheer/contracts as coverage
  gaps; both are pure type declarations where 0% is unimprovable.

All three corrections are recorded inline in 00-baseline.md §10, so agent
08 does not inherit the bad numbers.

.prettierignore excludes the agent prompt directories — reflowing their
markdown would edit the prompt text itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:44:32 +02:00
ehoandClaude Sonnet 5 0ad02e651a docs: rewrite the README as the entry point to all documentation
The repo had good docs that nobody could find. The root README linked to
exactly two documents, while learning-path.mdx — a 375-line paced three-day
onboarding curriculum — had zero inbound links and was reachable only by
running Storybook and spotting it in the sidebar. docs/README.md indexes
~20 documents and nothing at the root pointed at it either.

The README was also describing the pre-monorepo repo. Its centrepiece
atomic-design table was fictional: it claimed the folder structure IS the
hierarchy, with atoms/molecules/organisms/templates/pages directories that
exist nowhere. The truth is a better story and now replaces it — two
orthogonal axes, DDD on disk (context, then layer) and the atomic ladder in
the Storybook sidebar, which comes from story titles. Every other path was
stale too (src/app/, src/styles.scss, src/locale/, proxy.conf.json), the
second app was entirely absent, and 6 of 36 npm scripts were documented,
omitting `npm run ci` — the pre-push gate.

Adds a signpost table organised by what you are trying to do, a repo map,
the commands that matter, and keeps one corrected showcase section so the
repo still makes its case. Also fixes the index it now points at:
docs/README.md cited Foundations pages at src/docs/*.mdx, claimed the
backlog ran to WP-48 (it is at 75 — the range is dropped so it cannot go
stale again), and was missing ADR-0006 and the OpenZaak harness.

Verified rather than assumed: all 12 README links and every docs/README.md
link resolve, every named npm script exists, no stale path survives, and
the quick start was executed — backend serves swagger and the API on :5000,
behandelportal serves on :4201.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 14:08:40 +02:00
ehoandClaude Sonnet 5 ae7781efef docs: close WP-72..75, regenerate behaviour spec
CI / changes (push) Successful in 8s
CI / lint (push) Successful in 1m9s
CI / frontend (push) Successful in 2m27s
CI / backend (push) Successful in 1m56s
CI / e2e (push) Successful in 3m16s
CI / semgrep (push) Successful in 1m7s
CI / api-client-drift (push) Successful in 1m50s
CI / storybook-a11y (push) Successful in 11m4s
Four close-outs and their README rows. The behaviour spec is regenerated
once here rather than per-track — it derives from every test name in the
repo, so any track running it would have conflicted with the other three.

Records two findings the arc surfaced but did not cause: the /brief/preview
staleness for non-DemoOwner identities (blocking per-spec identity isolation
in brief-v2.spec.ts), and that WP-72/73 had to share a commit because both
edit Program.cs — separate execution waves prevented build collisions but
did not produce separable diffs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:34:54 +02:00
ehoandClaude Sonnet 5 6fa27d1c53 test: close the remaining FE/BE seams (WP-75)
Three seams WP-71 documented but left unguarded.

Deletes the FE's isHerregistratieEligible and isStatusConsistent — both
uncalled, the first dead by its own doc-comment. Their tests used fixtures
completely disjoint from the backend's (the backend even had an exact-window
boundary case the FE lacked), so the two sides could diverge indefinitely
without failing anything. CLAUDE.md's policy of keeping server-owned rules
as FE "reference impls" is what kept them alive, so it is amended: the FE may
mirror a server-supplied value for instant feedback, never reimplement the
algorithm. registration.policy.ts keeps its three live exports.

check-seam.sh now also guards the Besluit tag list — the C# enum and the TS
BESLUIT_TAGS array are identical ordered name lists with nothing linking
them, and Enum.TryParse fails at request time rather than build time. Anchored
on the full declaration so it avoids the "greps all matches" trap WP-69 hit.

The phone-format divergence turned out to be real, not latent as recorded:
the backend returned 422 for +31612345678 and (06) 12345678, both of which
the FE's own parseTelefoonnummer accepts. A grep check would have compared
the shared ^0\d{9}$ regex and reported all clear — the difference was in
stripping. RejectPhoneChange now strips what the FE strips, pinned by a
contract test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:32:22 +02:00
ehoandClaude Sonnet 5 42f7bd651d test(e2e): isolate runs and identities without a new backend endpoint (WP-74)
The three specs shared one mutable backend and said so in their own
comments ("Restart the backend between CI runs"). WP-70 recorded the fix as
a dev-only seed endpoint; it isn't needed. The DB path already routes
through IConfiguration, so playwright.config's webServer hands the backend a
throwaway SQLite file per invocation — the same trick TestWebApplicationFactory
already uses, with zero backend change. And StubIdentityProvider already
honoured X-Subject; the only gap was that nothing sent it. That matters
because the backend has no IsDevelopment() gate anywhere, so a seed endpoint
would have had to invent the codebase's first environment gate.

subjectInterceptor mirrors the existing roleInterceptor and is wired into the
same isDevMode()-only list. Interceptors alone were not enough: the raw XHR
upload and the hand-written letter-preview fetch bypass Angular's chain (as
CLAUDE.md documents), so both now stamp X-Subject explicitly — without that,
every uploaded document still landed under DemoOwner.

reuseExistingServer stays on: flipping it would break local runs for anyone
already serving the docker stack. Each run gets a unique DB filename and
global-setup sweeps only prior runs' leftovers — deleting a fixed path
mid-run risks SQLite silently recreating an empty, unmigrated file under
fullyParallel.

Verified: e2e passes twice back-to-back with no backend restart, and
X-Subject was observed on a real request, not merely wired.

brief-v2.spec.ts keeps the shared identity for now — see the KNOWN GAP note;
a backend staleness bug makes /brief/preview return a sent letter with the
draft watermark for any non-DemoOwner BSN. actors.ts reserves the actor for
whoever fixes it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:32:07 +02:00
ehoandClaude Sonnet 5 6bc00a917c refactor(backend): delete dead legacy endpoints, make domain types unions (WP-72 + WP-73)
Two work packages in one commit because both edit Program.cs and splitting
them would leave a commit that does not build.

WP-72 — deletes POST /api/v1/intakes and /herregistraties. Both were dead
from the UI (the wizard submits via /applications/{id}/submit) and strictly
less capable: they minted a bare reference and wrote no Aanvraag, made no
ZGW call, and did no document-ownership check. The shared Submit(...) helper
survives — /registrations and /change-requests still use it. WP-69 hardened
/intakes with a 400 last session; removing the surface is the stronger fix,
and WP-69's /applications/{id}/submit enforcement is untouched.

WP-73 — RegistrationStatus becomes an abstract record with three sealed
variants behind a private base ctor, so only Geregistreerd carries a
herregistratie deadline and reden is required on Geschorst/Doorgehaald
(matching the FE union, which was already right). HerregistratieRule
.IsStatusConsistent and its test are deleted: the type now guarantees what
the runtime check was for, and the test could no longer construct the
illegal state it existed to catch.

Aanvraag splits into a Concept | Submitted | Decided union with the EF row
demoted to AanvraagEntity behind a two-way mapper. Submitted carries a
non-null Referentie and SubmittedAt, and Decided.Afgewezen/MeerInfoGevraagd
require a Toelichting — so the five Referentie! null-forgiving derefs in
StatusAt are gone, not merely suppressed. IZaakSource.CreateZaak narrows to
Aanvraag.Submitted, removing the same class of deref in both zaak sources.

Draft is now cleared on submit rather than lingering: ApplicationStore's
doc-comment claimed "Concept only" but Submit never cleared it. Verified
nothing reads a submitted aanvraag's draft (draft-sync's applyResume only
resumes unsubmitted wizards), so the comment is now true instead of
aspirational.

No migration, no schema change, no wire change — RegistrationStatusDto and
the application DTOs are byte-identical, confirmed against a live swagger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 16:31:38 +02:00
ehoandClaude Sonnet 5 edaf1360c5 docs: close WP-69, record verification result
CI / changes (push) Successful in 8s
CI / lint (push) Successful in 1m6s
CI / frontend (push) Successful in 2m19s
CI / backend (push) Successful in 1m53s
CI / e2e (push) Successful in 3m13s
CI / semgrep (push) Successful in 1m4s
CI / api-client-drift (push) Successful in 1m51s
CI / storybook-a11y (push) Successful in 10m43s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 22:44:36 +02:00
ehoandClaude Sonnet 5 5d73ca21f6 feat(backend): enforce the scholing threshold server-side (WP-69)
ADR-0001's own canonical "config value" example was unenforced: GET
/intake/policy echoed ScholingThreshold, but no request DTO carried a
scholing answer, so the server had nothing to re-validate. A crafted
POST could skip a requirement the wizard presents as mandatory.

IntakePolicy.RejectIncompleteScholing is the authority — three-valued
completeness (below threshold an answer is required; "nee" is legal and
still submits; punten only belong to a followed scholing), living in the
class that owns the constant so scripts/check-seam.sh keeps guarding the
FE/BE literal pair. Both submit paths call it; a violation 400s with
ProblemDetails and leaves the aanvraag a Concept. Gated on
Type == "intake" (the endpoint's switch lumps herregistratie with
intake, which has no scholing question), and guarded by `reject is null`
so a zero-uren submission is still decided on its merits.

Also fixes a live FE bug in the same rule: validateStep required punten
whenever scholingGevolgd was 'ja' regardless of lageUren, while the
template renders those fields only when lageUren — so answering 'ja'
then raising uren either blocked the user on an invisible field or
emitted aanvullendeScholing: undefined alongside punten. punten now
derives from aanvullendeScholing, so that combination is unrepresentable
in ValidIntake.

Note: EndpointTests' Worked_hours_submission_succeeds was itself
asserting the vulnerable payload ({ uren: 40 }, no answer) and needed a
complete answer added; the zero-hours rows are the ordering regression
net and are unmodified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 22:42:14 +02:00
540 changed files with 27275 additions and 5432 deletions
+25 -2
View File
@@ -100,15 +100,38 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName)
{
name: 'apiclient-infrastructure-only',
comment:
'The generated ApiClient is a value only inside infrastructure/ (+ shared/upload); elsewhere type-only.',
'The generated ApiClient is a value only inside infrastructure/; elsewhere type-only.',
severity: 'error',
from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' },
from: { pathNot: '/infrastructure/' },
to: {
path: '^libs/shared/src/infrastructure/api-client\\.ts$',
dependencyTypesNot: ['type-only'],
},
},
// --- Atomic ladder within libs/shared/src/ui (folder = layer, CLAUDE.md decision 2) ---
{
name: 'atoms-compose-nothing-above',
comment: 'An atom composes nothing above it — no molecule or organism. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/atoms/' },
to: { path: '^libs/shared/src/ui/(molecules|organisms)/' },
},
{
name: 'molecules-below-organisms',
comment: 'A molecule composes nothing above it — no organism. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/molecules/' },
to: { path: '^libs/shared/src/ui/organisms/' },
},
{
name: 'design-system-not-layout',
comment: 'The design system (ui/) does not depend on layout/ templates. See CLAUDE.md §2.',
severity: 'error',
from: { path: '^libs/shared/src/ui/' },
to: { path: '^libs/shared/src/layout/' },
},
{
name: 'no-testing-in-production',
comment:
+5 -1
View File
@@ -2,9 +2,13 @@
// scaffolding one (see `gen:context`, WP-44).
module.exports = require('./.dependency-cruiser.base.js')(
{
// Two sanctioned cross-feature edges, both pointing at registratie: the portal home
// composes registratie's dashboard sections (RD-03), and herregistratie builds on a
// registration. Every other context imports only libs/shared and libs/beheer.
overzicht: ['registratie'],
auth: [],
registratie: [],
herregistratie: ['registratie'], // the one sanctioned cross-feature edge
herregistratie: ['registratie'],
brief: [],
showcase: null, // unrestricted — the sanctioned teaching page; nothing imports it
},
+5
View File
@@ -202,6 +202,11 @@ jobs:
# run manually against backend/openzaak/ (see its README), never in CI.
- run: dotnet test backend/BigRegister.slnx --filter "Category!=Integration"
if: needs.changes.outputs.backend == 'true'
# RB-14/BIO-016: `npm audit --omit=dev` covers only the frontend; the .NET dependency
# tree was entirely unscanned. The script — not a bare `dotnet list` — is the gate,
# because `dotnet list package --vulnerable` exits 0 even on a High advisory.
- run: ./scripts/dotnet-audit.sh
if: needs.changes.outputs.backend == 'true'
e2e:
needs: changes
+4
View File
@@ -58,3 +58,7 @@ backend/openzaak/seeded.env
# WP-55: render-prod-secrets.sh's output — the real client secret, never committed
backend/openzaak/setup_configuration/data.prod.yaml
# Agent git worktrees (Claude Code `isolation: "worktree"`) — full checkouts of
# this repo nested inside it; never commit one.
.claude/worktrees/
+9
View File
@@ -4,6 +4,11 @@ storybook-static*/
coverage/
.angular/
# Agent git worktrees — full checkouts of this repo nested inside it, so an
# unignored `prettier --check .` walks into every one of them (and reports the
# vendored CIBG files that the top-level ignore already excludes).
.claude/worktrees/
# Lockfile
package-lock.json
@@ -21,3 +26,7 @@ plop-templates/
# Backend is formatted by `dotnet format`, not prettier
backend/
# Agent prompts — their exact wording is the input, reflowing markdown edits the prompt
docs/project/refactor-backlog-setup/agents/
docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/
+36 -12
View File
@@ -124,8 +124,10 @@ than hardcoding one app's content — the two apps' primary nav genuinely differ
should be **composition of existing blocks** — adding building blocks is the
exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2)
CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API,
the design system does the visuals. (Where CIBG lacks a class — e.g. `alert` — the atom is a
small hand-rolled surface built from the token bridge; see ADR-0003.)
the design system does the visuals. (Where CIBG lacks a class — e.g. `skeleton`,
`spinner` — the atom is a small hand-rolled surface built from the token bridge and carries a
`// CIBG-GAP EXTENSION:` marker; see ADR-0003. `alert` is **not** such a case: it wraps the
vendored `.feedback feedback-*` classes.)
### 3. State: make illegal states unrepresentable
@@ -175,17 +177,28 @@ herregistratie eligibility) or _config value_ (server sends threshold, FE applie
for instant feedback, server re-validates as authority — e.g. scholing threshold).
FE keeps only **format** validation, never as authority.
DTO lives in `contracts/`; a hand-written `parse*`/`toDomain` in `infrastructure/`
validates the untrusted shape and maps DTO → domain. Wiring a real .NET backend
The generated client
(`libs/shared/src/infrastructure/api-client.ts`, `npm run gen:api`, drift-checked in CI) **is**
the wire contract — consume its types directly, as 19 of the 20 adapters do. A hand-written
`contracts/*.dto.ts` is the exception, only where codegen does not reach the endpoint or types
it too loosely (the four survivors are all the latter — the generator emits every property as
optional and flattens unions); such a file must still import nothing. Either way a hand-written
`parse*`/`toDomain` in `infrastructure/` validates the untrusted shape and maps DTO → domain —
**a generated type is a compile-time claim about the wire, not a runtime guarantee.** Wiring a real .NET backend
touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned
rules stay in `domain/*.policy.ts` as reference impl + unit test, marked server-owned,
but the FE doesn't call them.
rules live **only** on the server, with no FE mirror to drift from it — the FE may
mirror a server-supplied _value_ (a threshold, a bound) for instant feedback, but
never reimplements the _algorithm_.
**Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the
business controls (profession↔diploma map, thresholds, policy-question text) live as typed
C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit
fails CI, never prod) — never runtime-editable. Org-templates are the deliberate exception
(operational per-org config in SQLite). UI copy is `$localize`. See ADR-0004.
fails CI, never prod) — never runtime-editable. Operational configuration is the deliberate
exception, and ADR-0004 states it as a four-part test rather than a list: the catalog lives in
code, an unknown key fails closed, the value is operational rather than a shared business rule,
and writes are admin-capability-gated **and** audited. Two surfaces pass it today —
`OrgTemplateStore` (per-org letterhead) and `FeatureFlagStore` (rollout switches), both in
SQLite. A third surface must pass the same test, not argue by analogy. UI copy is `$localize`. See ADR-0004.
### 5. Testing
@@ -197,10 +210,10 @@ each app has its **own Storybook instance** (`.storybook-ssp/`, `.storybook-beha
WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing
its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's
Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout`
or `libs/beheer/ui` component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`;
a component in an app 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).
component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`;
a component in an app context's `ui/`, or in `libs/beheer/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
@@ -213,6 +226,17 @@ regardless of which atomic layer it is (a context organism doesn't get its own
- **Naming:** shared/reusable UI is **English** (language-agnostic: `button`,
`wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`,
`*.machine.ts`). Pick the language by which side of the seam the code is on.
- **English prose uses Simplified Technical English (STE).** This covers documentation,
code comments, commit messages, ADRs, and the backlog notes. One idea per sentence;
20 words or fewer in a procedure, 25 in a description. Active voice, present tense.
One word for one meaning — pick a term and repeat it, do not vary it for style. Keep
articles ("the test fails"). Three nouns together at most. No idioms and no humour.
Six sentences per paragraph at most. Write a procedure as numbered steps, one action
per step.
**STE governs form, not content.** Split a long sentence; never drop a caveat, a
measurement, or a precise term to make it shorter.
**STE does not apply to** Dutch identifiers, `$localize` copy, quoted output, or
existing documents you are not already editing.
- **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's
first-party `$localize` (no third-party i18n lib), with a stable custom id
(`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
+143 -172
View File
@@ -1,205 +1,176 @@
# BIG-register Self-Service Portal — Atomic Design POC
# BIG-register Portals — Atomic Design POC
A small Angular app that shows how **atomic design** makes a frontend cheap to build,
reuse and extend. The domain is the **BIG-register** self-service portal (the Dutch
register of healthcare professionals, run by CIBG). It is styled with the **CIBG
Huisstijl** design system (a customized Bootstrap 5.2 build, vendored — see ADR-0003),
and demonstrates a robust **async-state pattern** where the UI can never reach an
inconsistent state.
A two-app Angular monorepo showing how **atomic design** plus **domain-driven boundaries** make
a frontend cheap to build, reuse and extend. The domain is the **BIG-register** (the Dutch
register of healthcare professionals, run by CIBG): a citizen self-service portal and a
case-handler backoffice, sharing one design system and one backend.
> Demo / POC — **no real login** (DigiD is faked) and synthetic seed data. The
> business rules and data _are_ served by a real **ASP.NET Core backend**
> (`backend/`) consumed through a generated typed client, so the BFF + DDD design
> is demonstrable, not hand-waved. A system-font stack stands in for the licensed
> Rijksoverheid font and a text wordmark for the logo.
It is styled with the **CIBG Huisstijl** (a customized Bootstrap 5.2 build, vendored — ADR-0003)
and built around one idea: **make illegal states unrepresentable** — in the UI's async states, in
the domain types, and in the tests.
> **Demo / POC** — **no real login** (DigiD is faked) and synthetic seed data. But the business
> rules and data _are_ served by a real **ASP.NET Core backend** (`backend/`) through a generated
> typed client, so the BFF + DDD design is demonstrable rather than hand-waved. A system-font
> stack stands in for the licensed Rijksoverheid font, and a text wordmark for the logo.
> **New here?** Run `npm run storybook` and open **Foundations → Learning Path** — a paced,
> hands-on three-day route through the codebase, written for a strong programmer who is new to
> frontend functional programming. For everything else, **[`docs/README.md`](docs/README.md)** is
> the documentation index.
---
## Run it
## Quick start
Everything at once — API, both portals:
```bash
docker compose up # frontend + backend together → app http://localhost:4200, Swagger http://localhost:5000/swagger
docker compose up
# self-service portal → http://localhost:4200
# behandelportal → http://localhost:4201
# API + Swagger → http://localhost:5000/swagger
```
Or run the two halves separately:
This does not start OpenZaak. The BFF uses a local, in-memory case store instead. To run
against a real OpenZaak, see [backend/openzaak/README.md](backend/openzaak/README.md).
Or run the pieces yourself:
```bash
npm install
npm start # app → http://localhost:4200 (proxies /api → backend, proxy.conf.json)
# in another terminal:
cd backend && dotnet run --project src/BigRegister.Api # API → http://localhost:5000/swagger
npm start # self-service portal → :4200 (proxies /api → backend)
npm run start:behandelportal # case-handler portal → :4201
npm run storybook # component library, organized by atomic layer
npm run gen:api # regenerate the typed API client from the backend OpenAPI doc
npm run e2e # Playwright smoke tests against the running app + backend (both must be up)
# in another terminal:
cd backend && dotnet run --project src/BigRegister.Api # API → :5000/swagger
npm run storybook # the design system + Foundations curriculum
npm run e2e # Playwright — starts the backend and app itself
```
Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie → Intake**.
The backend hosts the business rules (profession derivation, policy questions,
eligibility, thresholds); see **[backend/README.md](backend/README.md)**.
**Self-service flow:** Login → Dashboard → Mijn gegevens → Registreren → Herregistratie → Intake
→ Brief. **Behandelportal:** Login → Werkvoorraad → Beoordeling (approve / reject / ask for more).
Admin pages (`/beheer/*`, `/brief/huisstijl`) need the `admin` role — see
[roles and access](docs/reference/roles-and-access.md).
> **New here:** a **branching intake questionnaire** (`/intake`) where later questions
> appear based on earlier answers and progress survives a page reload, plus a visual
> walkthrough of the state-management ideas. See
> **[docs/reference/architecture/ARCHITECTURE.md](docs/reference/architecture/ARCHITECTURE.md)** for diagrams (atomic-design pyramid,
> the dispatch→reduce→view loop, RemoteData states, and "why not just signals") and a
> section on **connecting to a .NET backend**.
---
### See every data state (scenario toggle)
## Where to find things
Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state:
| I want to… | Go to |
| --------------------------------- | -------------------------------------------------------------------------------------------------- |
| learn the codebase from scratch | `npm run storybook`**Foundations → Learning Path** |
| find any document | **[docs/README.md](docs/README.md)** — the full index |
| understand the architecture | [ARCHITECTURE.md](docs/reference/architecture/ARCHITECTURE.md) |
| know _why_ a decision was made | [the ADRs](docs/reference/architecture/) — BFF-lite, contexts, huisstijl, stamdata, ZGW, test data |
| work on the backend / BFF | [backend/README.md](backend/README.md) |
| run OpenZaak locally | [backend/openzaak/README.md](backend/openzaak/README.md) |
| see what shipped, or pick up work | [docs/project/backlog/README.md](docs/project/backlog/README.md) |
| build a feature the house way | [`.claude/skills/`](.claude/skills/) — invocable recipes (`new-feature`, `form-machine`, …) |
| know the import rules | [dependencies.md](docs/reference/architecture/dependencies.md) — enforced by `dep:check` |
| work on this repo as an AI agent | [CLAUDE.md](CLAUDE.md) |
## Repo map
| Path | What lives there |
| ---------------------- | ----------------------------------------------------------------------- |
| `apps/ssp/` | Zorgverlener self-service portal (:4200) |
| `apps/behandelportal/` | Behandelaar backoffice (:4201) |
| `libs/shared/` | Design system, kernel, generated API client, Storybook Foundations docs |
| `libs/beheer/` | Admin/stamdata context, used identically by both apps |
| `backend/` | ASP.NET Core BFF (.NET 10, EF Core/SQLite), OpenZaak seam |
| `e2e/` | Playwright specs |
| `docs/` | `reference/` (how + why) and `project/` (backlog, PRDs) |
| `public/` | Vendored CIBG Huisstijl + assets |
| `scripts/` | CI gate, drift checks, generators |
---
## Commands
```bash
npm run ci # ← run this before pushing: the whole gate, exactly what CI runs
```
| Task | Command |
| -------- | ------------------------------------------------------------------------------------------- |
| Run | `npm start`, `npm run start:behandelportal`, `docker compose up` |
| Test | `npm test`, `npm run e2e`, `npm run test-storybook` (axe on every story) |
| Check | `npm run lint`, `typecheck`, `dep:check`, `check:tokens`, `check:seam` |
| Build | `npm run build`, `npm run build-storybook` |
| Generate | `npm run gen:api` (typed client), `npm run gen` (plop: value object, form machine, context) |
| Docs | `npm run storybook`, `npm run dep:graph`, `npm run gen:behaviour-spec` |
`npm run ci` chains lint, typecheck, dependency boundaries, formatting, token and seam drift
checks, all four test projects, both localized builds, the backend suite, and the generated-artifact
drift gates. The full script list is in `package.json`; `CLAUDE.md` explains the traps.
---
## Where atomic design pays off
**Two orthogonal axes, and that is the point.** On disk, code is grouped by **ownership**
bounded context, then layer (`domain/``application/``infrastructure/``ui/`, dependencies
pointing inward). In Storybook, the same components are grouped by **atomic level** — story titles
put them under `Design System/Atoms|Molecules|Organisms|Templates`. So `libs/shared/src/ui/` is a
flat folder of 26 components, and the atomic ladder lives in the sidebar where you actually browse
it. See [Atomic design](libs/shared/docs/atomic-design.mdx) and
[Domain-driven design](libs/shared/docs/layers.mdx) in Foundations.
**Reuse is the payoff.** `button`, `form-field`, `async`, `page-shell`, `site-header` appear on
essentially every screen across _both_ apps. Change one, every screen follows.
**A new page is composition, not new components.** The branching intake wizard — the most complex
flow in the app — needed exactly **one** new atom (`radio-group`) and one new organism
(`intake-wizard`). Everything else was already there.
**Theming is one stylesheet and a token bridge.** `libs/shared/styles.scss` maps the app's
semantic `--rhc-*` vocabulary onto CIBG/`--bs-*` values, so components reference tokens, never
colours. Re-point the bridge to re-theme both apps with no component changes (ADR-0003).
`npm run check:tokens` fails the build on a hardcoded colour.
---
## Try it: dev affordances
Append `?scenario=` to any data page to force an async state — the states are mutually exclusive
by construction, via the `<app-async>` molecule:
| URL | What you see |
| ----------------------------- | -------------------------------------------- |
| `/dashboard` | real data (fast) |
| `/dashboard` | real data |
| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data |
| `/dashboard?scenario=loading` | the loading state, held open |
| `/dashboard?scenario=empty` | "geen gegevens" empty state |
| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) |
---
## How atomic design works here (folder = layer)
Atomic design organizes UI into five layers, each built from the one below. In this repo
the folder structure _is_ the hierarchy (`src/app/`):
| Layer | What it is | Examples here |
| -------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| **atoms/** | smallest building blocks; wrap one design-system element | `button`, `text-input`, `heading`, `link`, `alert`, `status-badge`, `spinner`, `skeleton` |
| **molecules/** | a few atoms combined into a unit | `form-field` (label + input + error), `data-row`, `async` (state wrapper) |
| **organisms/** | larger, self-contained sections | `site-header`, `site-footer`, `login-form`, `registration-summary`, `registration-table`, `change-request-form` |
| **templates/** | page skeletons that define layout; content is projected in | `page-layout` (header/content/footer chrome), `page-shell` (back-link + heading + intro + content) |
| **pages/** | a template filled with real data | `login`, `dashboard`, `registration-detail`, `herregistratie` |
Each atom is a thin Angular standalone component that applies CIBG Huisstijl
(Bootstrap 5.2) CSS classes (`btn`, `form-control`, `card`, …) — so the design system
does the visual work and we only own a small, typed component API.
Append `?role=drafter|approver|admin` to switch the dev role stand-in and unlock the admin pages.
Both toggles are **dev-only** — neither interceptor is wired into production builds.
---
## Where you actually notice the benefit
**1. Reuse — the same blocks appear everywhere.**
| Component | Appears in |
| ----------------------------- | ------------------------------------------------------------- |
| `button` | login, change-request, herregistratie, async retry, Storybook |
| `form-field` + `text-input` | login form _and_ change-request _and_ herregistratie |
| `status-badge` | dashboard summary, detail summary |
| `page-shell` / `page-layout` | all four pages |
| `site-header` / `site-footer` | every page |
| `async` + `skeleton` | dashboard, detail |
Change a component once and every screen that uses it updates.
**2. A whole new page = composition, no new components.**
`pages/herregistratie/herregistratie.page.ts` is a complete new flow assembled entirely
from existing atoms/molecules/templates — zero new building blocks. The branching
**intake wizard** went further: it needed only **one new atom** (`radio-group`) and **one
new organism** (`intake-wizard`); the form fields, buttons, alerts, spinner and page shell
were all reused. That's the payoff: new screens cost almost nothing.
**3. Templates remove per-page boilerplate.**
Every page used to repeat its own back-link + heading + intro markup. `page-shell`
captures that once; pages now read like `<app-page-shell heading="…" backLink="…">…`.
**4. Theming is one stylesheet + a token bridge.**
The look comes from **CIBG Huisstijl**, vendored under `public/cibg-huisstijl/` and
loaded via a `<link>` in `index.html`; `body.brand--cibg` activates CIBG's
robijn/lintblauw palette. `src/styles.scss` is a **token bridge** mapping the app's
semantic `--rhc-*` token vocabulary onto CIBG/`--bs-*` values, so components keep
referencing tokens — swap the vendored CSS and re-point the bridge to re-theme the
whole app, no component changes (ADR-0003). `npm run check:tokens` fails the build
on any hardcoded colour outside that bridge.
---
## State management (no impossible states)
Data fetching uses Angular's native, signal-based **`resource`** over the generated
typed client (no NgRx, no extra dependency). Each context's `infrastructure/*.adapter.ts`
exposes a resource that carries `status()`, `value()`, `error()` and `reload()` as
signals, and a `parse*` function validates the response at the trust boundary
(DTO → domain). The screen-shaped ("BFF-lite") endpoints return server-computed
decisions the FE renders rather than recomputes (see ADR-0001).
The molecule **`<app-async>`** turns those signals into UI. It renders **exactly one** of
four slots, chosen by a single `computed` — so loading, empty, error and loaded are
mutually exclusive _by construction_. You cannot render data and an error at the same
time, or show stale content during a hard failure: those states are unrepresentable.
```html
<app-async [resource]="reg" [isEmpty]="regEmpty">
<ng-template appAsyncLoaded let-r> <app-registration-summary [reg]="r" /> </ng-template>
<ng-template appAsyncLoading> <app-skeleton [count]="6" /> </ng-template>
<!-- appAsyncEmpty / appAsyncError are optional → sensible defaults -->
</app-async>
```
- **Loaded** — your content, with the value.
- **Loading** — your skeleton, or a default **delayed spinner** (only appears after
~250ms, so fast connections never flash a spinner; slow ones get feedback). Skeletons
are also delay-gated. → _handles slow vs fast connections._
- **Empty** — your message, or a default "Geen gegevens gevonden" (driven by an
`isEmpty` predicate).
- **Error** — your template, or a default alert + a **retry** button that calls
`resource.reload()`.
Because each data-fetching page wraps its content in `<app-async>`, correct
loading/empty/error handling is automatic and consistent across the app.
---
## Page transitions
The chrome (`templates/shell` — header + footer) is **persistent**: it mounts once and
hosts the `<router-outlet>`, so navigating doesn't re-create it (no white flash). Only
the routed content cross-fades, via Angular's native **`withViewTransitions()`** — the
header/footer get a stable `view-transition-name` in `styles.scss` so they're excluded
from the fade. `prefers-reduced-motion` disables the animation; non-Chromium browsers
degrade to an instant navigation.
## Tech notes
- Angular 22 (standalone components, signals, `httpResource`, view transitions,
control flow `@if/@for`).
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
- Data: ASP.NET Core backend (`backend/`, EF Core/SQLite-persisted; BRP/DUO
reference data stays in-memory-seeded) exposed via an OpenAPI contract; the FE
consumes an **NSwag-generated** typed client (`npm run gen:api`).
The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
**dev-only** — it is not wired into production builds.
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
Angular 22; the builder runs fine (build verified).
- **i18n**: every user-facing string is `$localize`-wrapped with a stable `@@id`
(source locale `nl`). `npx ng build --localize` (CI runs this) builds both `nl` and
`en` — a genuine second-locale build, not just an unexercised claim — into
`dist/atomic-design-poc/browser/{nl,en}/`; `ng serve --configuration=en` serves the
English build locally. `src/locale/messages.en.xlf` is real (if demo-quality)
English, not machine-untranslated placeholders; `angular.json`'s
`i18nMissingTranslation: "error"` fails the build if a new `$localize` string ships
without a translation. `npm run extract-i18n` regenerates the `nl` reference file.
- **Angular 22** — standalone components, signals, `resource()`, native control flow, view
transitions. No NgRx: shared state is a root singleton store with a pure reducer.
- **Backend** — ASP.NET Core (.NET 10), EF Core/SQLite for applications, documents, the brief and
the audit trail; BRP/DUO reference data stays in-memory seeded. Screen-shaped ("BFF-lite")
endpoints return server-computed decisions the frontend renders rather than recomputes
(ADR-0001). Cases can be sourced from **OpenZaak/ZGW** behind the same seam (ADR-0005).
- **Typed client** — NSwag-generated from the backend's OpenAPI doc (`npm run gen:api`); CI fails
on drift.
- **Boundaries are enforced, not hoped for** — `dep:check` fails the build if `domain/` imports
Angular, a context imports upward, or an app reaches into the other app.
- **i18n** — every user-facing string is `$localize`-wrapped with a stable id (source locale `nl`).
`ng build --localize` builds both `nl` and `en`, and `i18nMissingTranslation: "error"` fails the
build if a string ships untranslated.
- **Dependencies** — the shipped bundle audits clean (`npm audit --omit=dev`: 0 vulnerabilities).
`.npmrc` sets `legacy-peer-deps=true` because Storybook's peer range lags Angular 22. Never run
`npm audit fix --force` — it downgrades Angular 22 → 21.
### Dependency security
### Deliberately out of scope
The **shipped app has 0 known vulnerabilities** (`npm audit --omit=dev`) — and, since the
`@babel/core` pin below, the **full dev audit is 0 too**. All advisories live(d) in
dev/build tooling (Storybook + the Angular build chain) and never reach the bundle.
`package.json` `overrides` pin patched transitive versions; the last remaining cluster
cascaded from `@babel/core`'s low-severity sourceMappingURL issue, closed by pinning
`@babel/core` to a patched **7.x** (`^7.29.7`) — no jump to Babel 8, no breaking change.
We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21.
### Deliberately out of scope (POC)
Real auth/DigiD, real BRP/DUO upstreams, a production-grade database (Postgres/SQL
Server — SQLite persists applications/documents/the brief + a real audit table,
see `backend/README.md`, WP-22), NgRx, licensed RO/Rijks fonts + logo (system-font
stack; text wordmark). (The backend itself _is_ implemented.) i18n's build seam is
proven (see above) but
production-quality translation, a runtime locale switcher, and RTL/pluralization
edge cases are not — the `en` file is demo-quality, and locale is a build-time
choice, not a switch in the running app.
Real auth/DigiD, real BRP/DUO upstreams, a production-grade database, NgRx, licensed RO/Rijks
fonts and logo. The i18n build seam is proven, but the `en` translation is demo-quality and locale
is a build-time choice, not a runtime switch.
+4 -3
View File
@@ -106,7 +106,7 @@
"**/*.spec.ts",
"**/*.stories.ts",
"**/contracts/**",
"libs/shared/src/infrastructure/api-client.ts",
"**/infrastructure/api-client.ts",
"apps/ssp/src/main.ts",
"**/*.testing.ts",
"**/*.d.ts"
@@ -235,7 +235,7 @@
"**/*.spec.ts",
"**/*.stories.ts",
"**/contracts/**",
"libs/shared/src/infrastructure/api-client.ts",
"**/infrastructure/api-client.ts",
"apps/behandelportal/src/main.ts",
"**/*.testing.ts",
"**/*.d.ts"
@@ -293,7 +293,7 @@
"**/*.spec.ts",
"**/*.stories.ts",
"**/contracts/**",
"src/infrastructure/api-client.ts",
"**/infrastructure/api-client.ts",
"src/test-entry.ts",
"**/*.testing.ts",
"**/*.d.ts"
@@ -332,6 +332,7 @@
"**/*.spec.ts",
"**/*.stories.ts",
"**/contracts/**",
"**/infrastructure/api-client.ts",
"src/test-entry.ts",
"**/*.testing.ts",
"**/*.d.ts"
+4 -1
View File
@@ -14,6 +14,7 @@ import localeEn from '@angular/common/locales/en';
import { routes } from './app.routes';
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
import { subjectInterceptor } from '@shared/infrastructure/subject.interceptor';
import { medewerkerInterceptor } from '@auth/infrastructure/medewerker.interceptor';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { SESSION_PORT } from '@shared/application/session.port';
@@ -55,7 +56,9 @@ export const appConfig: ApplicationConfig = {
// a query param could otherwise force errors on the live app.
provideHttpClient(
withInterceptors(
isDevMode() ? [scenarioInterceptor, roleInterceptor, medewerkerInterceptor] : [],
isDevMode()
? [scenarioInterceptor, roleInterceptor, subjectInterceptor, medewerkerInterceptor]
: [],
),
),
provideApiClient(),
+3 -3
View File
@@ -20,7 +20,7 @@ export const routes: Routes = [
},
{
path: 'aanvraag/:id',
// Same capability the werkvoorraad list itself is gated by (WP-64/65) — the
// Same capability the werkvoorraad list itself is gated by — the
// detail page is reachable only from a row already filtered to that capability.
canActivate: [capabilityGuard('aanvraag:beoordelen')],
loadComponent: () =>
@@ -36,14 +36,14 @@ export const routes: Routes = [
},
{
path: 'beheer/audit',
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default
// Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
canActivate: [capabilityGuard('cases:manage')],
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
},
{
path: 'beheer/functies',
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`.
// Admin-only feature-flag toggles, gated by `flags:manage`.
canActivate: [capabilityGuard('flags:manage')],
loadComponent: () =>
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
@@ -1,58 +1,50 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Session } from '../domain/session';
import { DigidAdapter } from '../infrastructure/digid.adapter';
import { Principal, parseStoredPrincipal } from '../domain/principal';
import { MedewerkerAdapter } from '../infrastructure/medewerker.adapter';
const STORAGE_KEY = 'session-v1';
/** Restore a persisted session (best-effort; corrupt entry → logged out).
G2: validate the shape before trusting it. G1: the BSN is never persisted
(see the effect below), so a restored session carries an empty one — it is
unused after login; only `naam` is shown in the chrome. */
function restore(): Session | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Session>;
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
} catch {
return null;
}
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
The shape validation (G2 — there is no BSN here, so no G1 to enforce) lives in
`parseStoredPrincipal` (`../domain/principal`) — pure, spec'd, and testable
without stubbing `localStorage`; this just supplies the raw value. */
function restore(): Principal | null {
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
}
/**
* Holds the current session for the whole app. Because it is providedIn:'root'
* there is exactly one instance — every component that injects it sees the same
* session signal, so logging in is instantly visible everywhere (the guard, the
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
* in an httpOnly cookie/token, not web storage.
* Holds the current medewerker principal for the whole backoffice app. One
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
* (the guard, the header). Persisted to localStorage — a refresh or the
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
* which is safe to do verbatim here: a medewerker principal carries no BSN or
* other national identifier, unlike the SSP's `SessionStore`, whose equivalent
* comment explains why *that* app strips a field before writing. A real
* deployment keeps auth in an httpOnly cookie/token, not web storage, regardless.
*/
@Injectable({ providedIn: 'root' })
export class SessionStore {
private digid = inject(DigidAdapter);
private _session = signal<Session | null>(restore());
private medewerker = inject(MedewerkerAdapter);
private _session = signal<Principal | null>(restore());
readonly session = this._session.asReadonly();
readonly isAuthenticated = computed(() => this._session() !== null);
constructor() {
effect(() => {
const s = this._session();
// G1: persist only `naam` — never write the BSN (national ID) to storage.
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
const p = this._session();
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
else localStorage.removeItem(STORAGE_KEY);
});
}
/** Effectful command: authenticate, then store the session on success. */
async login(bsn: string): Promise<Result<string, Session>> {
const r = await this.digid.authenticate(bsn);
if (r.ok) this._session.set(r.value);
return r;
/** Effectful command: authenticate via the SSO stand-in, then store the
resulting principal. No credential to pass in, and nothing that can fail
today — see `MedewerkerAdapter`. */
async login(): Promise<Principal> {
const p = await this.medewerker.authenticate();
this._session.set(p);
return p;
}
logout() {
+7 -31
View File
@@ -1,34 +1,10 @@
import { inject } from '@angular/core';
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';
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
export const authGuard: CanActivateFn = () => {
const store = inject(SessionStore);
const router = inject(Router);
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
};
/**
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
* The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
* only `SESSION_PORT` and `AccessStore`, so both apps share one copy and one spec.
* Re-exported here so `app.routes.ts` keeps importing them from `@auth/auth.guard`:
* routing asks the auth context for its guards, which is the right direction to read.
*
* **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me`
* is still loading — it would deny an entitled admin and bounce them. We await
* `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user
* goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're
* logged in, just not allowed here — no re-login loop). The backend re-enforces
* regardless (403); this guard is the UX pre-gate.
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
* `Principal`, the login flow, `SessionStore`. A guard is neither.
*/
export function capabilityGuard(capability: Capability): CanActivateFn {
return async () => {
const session = inject(SessionStore);
const access = inject(AccessStore);
const router = inject(Router);
if (!session.isAuthenticated()) return router.createUrlTree(['/login']);
await access.whenReady();
return access.can(capability) ? true : router.createUrlTree(['/dashboard']);
};
}
export { authGuard, capabilityGuard } from '@shared/application/auth.guard';
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { isAuthenticated, parseRollen, parseStoredPrincipal, Principal } from './principal';
const principal: Principal = {
kind: 'medewerker',
medewerkerId: 'medewerker-1',
naam: 'Test',
rollen: ['behandelaar'],
};
describe('isAuthenticated', () => {
it('narrows a present principal to Principal', () => {
expect(isAuthenticated(principal)).toBe(true);
});
it('reports no principal as not authenticated', () => {
expect(isAuthenticated(null)).toBe(false);
});
});
describe('parseStoredPrincipal', () => {
it('returns null when nothing is stored', () => {
expect(parseStoredPrincipal(null)).toBeNull();
});
it('returns null for a non-JSON string', () => {
expect(parseStoredPrincipal('not json')).toBeNull();
});
it('returns null when the stored shape is wrong (no naam)', () => {
expect(
parseStoredPrincipal(JSON.stringify({ kind: 'medewerker', medewerkerId: 'medewerker-1' })),
).toBeNull();
});
it('returns null when kind is not medewerker', () => {
expect(
parseStoredPrincipal(
JSON.stringify({
kind: 'zorgverlener',
medewerkerId: 'medewerker-1',
naam: 'Test',
rollen: [],
}),
),
).toBeNull();
});
it('returns null when rollen holds an unrecognized token', () => {
expect(
parseStoredPrincipal(
JSON.stringify({
kind: 'medewerker',
medewerkerId: 'medewerker-1',
naam: 'Test',
rollen: ['geen'],
}),
),
).toBeNull();
});
it('restores a well-shaped stored principal as-is (no BSN to strip)', () => {
const restored = parseStoredPrincipal(JSON.stringify(principal));
expect(restored).toEqual(principal);
});
});
describe('parseRollen', () => {
it('parses a single recognized rol', () => {
expect(parseRollen('behandelaar')).toEqual(['behandelaar']);
});
it('is case-insensitive and trims whitespace', () => {
expect(parseRollen(' Behandelaar , behandelaar ')).toEqual(['behandelaar', 'behandelaar']);
});
it('drops unrecognized tokens (the deny-path toggle, e.g. ?rollen=geen)', () => {
expect(parseRollen('geen')).toEqual([]);
});
it('returns an empty list for an empty string', () => {
expect(parseRollen('')).toEqual([]);
});
});
@@ -0,0 +1,71 @@
/**
* Who is logged in. Framework-free domain type.
*
* The `medewerker` variant of ADR-0002 §3's `Principal` union — the backoffice has
* exactly one actor kind (an employee, authenticated via SSO), so this app's own copy
* of the union only ever holds this one member. Unlike the SSP's `zorgverlener`
* variant, there is no BSN: a Behandelaar is not a citizen, and §3 names this
* unrepresentable-by-construction distinction as the whole point of the union.
* `rollen` is the FE-visible echo of the same dev stand-in `medewerker.interceptor.ts`
* already stamps onto every backend request — it does not itself grant anything;
* `AccessStore`/`GET /me` (server-resolved capabilities) is still the sole authority
* on what this principal may do (ADR-0001, ADR-0002 §3).
*/
export type Rol = 'behandelaar';
const ROLLEN: readonly Rol[] = ['behandelaar'];
export const isRol = (v: unknown): v is Rol => typeof v === 'string' && ROLLEN.includes(v as Rol);
export interface Principal {
readonly kind: 'medewerker';
readonly medewerkerId: string;
readonly naam: string;
readonly rollen: readonly Rol[];
}
export function isAuthenticated(p: Principal | null): p is Principal {
return p !== null;
}
/**
* Turn the raw `X-Rollen` stand-in value (`medewerker.ts`'s `currentRollen()`) into
* typed `Rol[]`, mirroring the backend's own `StubIdentityProvider.ParseRollen`:
* comma-separated, case-insensitive, unrecognized tokens dropped — so
* `?rollen=geen` (the deny-path toggle) yields an empty list here too, rather than
* a fabricated recognized role. Pure so `MedewerkerAdapter` (infrastructure) can
* stay a thin wire-up instead of holding logic of its own.
*/
export function parseRollen(raw: string): Rol[] {
return raw
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(isRol);
}
/**
* Parse a persisted principal out of a raw `localStorage` string (best-effort;
* anything that isn't a well-shaped record → logged out). G2: validate the shape
* before trusting it. Unlike the zorgverlener variant there is no G1 field to strip
* — a medewerker carries no national identifier — so a well-shaped record is
* restored as-is rather than reconstructed field-by-field.
*/
export function parseStoredPrincipal(raw: string | null): Principal | null {
try {
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Principal>;
return parsed?.kind === 'medewerker' &&
typeof parsed.medewerkerId === 'string' &&
typeof parsed.naam === 'string' &&
Array.isArray(parsed.rollen) &&
parsed.rollen.every(isRol)
? {
kind: 'medewerker',
medewerkerId: parsed.medewerkerId,
naam: parsed.naam,
rollen: parsed.rollen,
}
: null;
} catch {
return null;
}
}
@@ -1,14 +0,0 @@
import { describe, it, expect } from 'vitest';
import { isAuthenticated, Session } from './session';
const session: Session = { bsn: '19012345601', naam: 'Test' };
describe('isAuthenticated', () => {
it('narrows a present session to Session', () => {
expect(isAuthenticated(session)).toBe(true);
});
it('reports no session as not authenticated', () => {
expect(isAuthenticated(null)).toBe(false);
});
});
@@ -1,9 +0,0 @@
/** Who is logged in. Framework-free domain type. */
export interface Session {
readonly bsn: string;
readonly naam: string;
}
export function isAuthenticated(s: Session | null): s is Session {
return s !== null;
}
@@ -1,16 +0,0 @@
import { Injectable } from '@angular/core';
import { Result, ok } from '@shared/kernel/fp';
import { parseBsn } from '@shared/kernel/bsn';
import { Session } from '../domain/session';
/** Infrastructure: talks to the (mock) DigiD identity provider. */
@Injectable({ providedIn: 'root' })
export class DigidAdapter {
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
// for a real OIDC redirect flow when there's an IdP.
async authenticate(bsn: string): Promise<Result<string, Session>> {
const r = parseBsn(bsn);
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
}
}
@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { Principal, parseRollen } from '../domain/principal';
import { MEDEWERKER_ID, currentRollen } from './medewerker';
/**
* Infrastructure: resolves the current medewerker identity into a `Principal`
* (ADR-C-004). Stands in for a real employee-SSO redirect flow (ADR-0002 §3,
* "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s
* BSN check, no format to reject, so `authenticate()` takes no input and returns the
* `Principal` directly rather than a `Result` with an error variant that can never
* actually occur. A real SSO callback (which *can* fail — session expired, access
* denied) swaps in behind this same method; that is the point where this return
* type would gain a `Result`, not before.
*
* Resolves the same `MEDEWERKER_ID` + `currentRollen()` the dev-only
* `medewerkerInterceptor` already stamps onto every backend request as
* `X-Medewerker`/`X-Rollen` — this only makes that identity visible on the
* frontend (the guard, the header, `SessionStore`'s persisted principal), it does
* not change what the backend resolves or authorizes.
*/
@Injectable({ providedIn: 'root' })
export class MedewerkerAdapter {
// ponytail: fake employee SSO — a fixed medewerker, no credential exchange.
async authenticate(): Promise<Principal> {
return {
kind: 'medewerker',
medewerkerId: MEDEWERKER_ID,
naam: 'H. (Hassan) Bakker',
rollen: parseRollen(currentRollen()),
};
}
}
@@ -1,50 +1,27 @@
import { Component, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
/**
* Organism: employee-SSO-style mock login (ADR-C-004). No real auth — and,
* unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no
* BSN, and this app has no password of its own to check either way. There is
* nothing to compose beyond one button, which is itself evidence for the ADR — the
* two apps' login flows are meant to look this different.
*/
@Component({
selector: 'app-login-form',
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
imports: [ButtonComponent],
template: `
<form (ngSubmit)="submitted.emit(bsn)" class="form-horizontal">
<div class="form-header">
<div class="form-action">
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
</div>
</div>
<app-form-field
i18n-label="@@login.bsnLabel"
label="BSN"
fieldId="bsn"
required
i18n-description="@@login.bsnDescription"
description="9-cijferig BSN, elfproef-geldig (demo: 123456782)"
>
<app-text-input
inputId="bsn"
hasDescription
[(ngModel)]="bsn"
name="bsn"
placeholder="123456782"
/>
</app-form-field>
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
</app-form-field>
<app-button type="submit" variant="primary" i18n="@@login.submit"
>Inloggen met DigiD</app-button
>
</form>
<div class="form-horizontal">
<p i18n="@@login.ssoExplainer">
U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.
</p>
<app-button type="button" variant="primary" (click)="submitted.emit()" i18n="@@login.submit">
Inloggen met SSO
</app-button>
</div>
`,
})
export class LoginFormComponent {
bsn = '';
password = '';
submitted = output<string>();
submitted = output<void>();
}
@@ -1,36 +1,35 @@
import { Component, inject, signal } from '@angular/core';
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
import { SessionStore } from '@auth/application/session.store';
/**
* No error alert here — unlike the SSP's DigiD form, `SessionStore.login()` has
* nothing to fail on (see `MedewerkerAdapter`). A real SSO integration is where
* this page would grow one back.
*/
@Component({
selector: 'app-login-page',
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
imports: [PageShellComponent, LoginFormComponent],
template: `
<app-page-shell
i18n-heading="@@login.heading"
heading="Inloggen"
heading="Inloggen bij het behandelportal"
width="narrow"
i18n-intro="@@login.intro"
intro="Log in op uw persoonlijke BIG-register omgeving."
intro="Voor medewerkers die aanvragen beoordelen."
>
@if (error()) {
<app-alert type="error">{{ error() }}</app-alert>
}
<app-login-form (submitted)="login($event)" />
<app-login-form (submitted)="login()" />
</app-page-shell>
`,
})
export class LoginPage {
private store = inject(SessionStore);
private router = inject(Router);
error = signal('');
async login(bsn: string) {
const r = await this.store.login(bsn);
if (r.ok) this.router.navigate(['/dashboard']);
else this.error.set(r.error);
async login() {
await this.store.login();
this.router.navigate(['/dashboard']);
}
}
@@ -8,7 +8,7 @@ import {
type Err = Error | undefined;
/** One aanvraag's beoordeling detail (WP-65) — a root singleton like `WerkvoorraadStore`.
/** One aanvraag's beoordeling detail — a root singleton like `WerkvoorraadStore`.
Keyed by id: navigating to a different case resets to Loading. */
@Injectable({ providedIn: 'root' })
export class BeoordelingStore {
@@ -8,7 +8,7 @@ import {
type Err = Error | undefined;
/** The behandelaar's queue (WP-64) — a root singleton like `AdminCasesStore`'s ssp
/** The behandelaar's queue — a root singleton like `AdminCasesStore`'s ssp
counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */
@Injectable({ providedIn: 'root' })
export class WerkvoorraadStore {
@@ -2,8 +2,8 @@ import { formatDatumNl } from '@shared/kernel/datum';
import { AanvraagType } from './werkvoorraad-item';
import { BeoordelingStatus, BeoordelingView } from './beoordeling';
/** View-model mapping shared by the werkvoorraad list (WP-64) and the beoordeling
detail screen (WP-65): type/status → labels. Pure, no Angular. Lives here (not in
/** View-model mapping shared by the werkvoorraad list and the beoordeling
detail screen: type/status → labels. Pure, no Angular. Lives here (not in
`werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two
status unions — `werkvoorraad-item-view.ts` re-exports these for its own use. */
@@ -1,8 +1,8 @@
import { AanvraagType } from './werkvoorraad-item';
/**
* A case's full status lifecycle as the beoordeling detail screen sees it (WP-65)
* wider than `WerkvoorraadStatus` (WP-64), which only ever sees the two "still open"
* A case's full status lifecycle as the beoordeling detail screen sees it —
* wider than `WerkvoorraadStatus`, which only ever sees the two "still open"
* tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept`
* — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet).
*/
@@ -1,12 +1,7 @@
import { describe, it, expect } from 'vitest';
import { expectTag } from '@shared/testing/expect-tag';
import { BesluitState, reduce, initial } from './besluit.machine';
const editingWith = (besluit: string, toelichting = ''): BesluitState => ({
tag: 'Editing',
draft: { besluit, toelichting },
errors: {},
});
import { reduce, initial } from './besluit.machine';
import { givenBesluit } from './besluit.testing';
describe('besluit reduce', () => {
it('SetField updates the draft while editing', () => {
@@ -15,17 +10,23 @@ describe('besluit reduce', () => {
});
it('Submit with no besluit chosen stays Editing and reports a field error', () => {
const s = reduce(editingWith(''), { tag: 'Submit' });
const s = reduce(initial, { tag: 'Submit' });
expect(expectTag(s, 'Editing').errors.besluit).toBeTruthy();
});
it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => {
const s = reduce(editingWith('Afwijzen'), { tag: 'Submit' });
const editingAfwijzen = givenBesluit({ tag: 'SetField', key: 'besluit', value: 'Afwijzen' });
const s = reduce(editingAfwijzen, { tag: 'Submit' });
expect(expectTag(s, 'Editing').errors.toelichting).toBeTruthy();
});
it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => {
const s = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const editingGoedkeuren = givenBesluit({
tag: 'SetField',
key: 'besluit',
value: 'Goedkeuren',
});
const s = reduce(editingGoedkeuren, { tag: 'Submit' });
expect(expectTag(s, 'Submitting').data).toEqual({
besluit: 'Goedkeuren',
toelichting: undefined,
@@ -33,7 +34,11 @@ describe('besluit reduce', () => {
});
it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => {
const s = reduce(editingWith('Afwijzen', ' niet erkend '), { tag: 'Submit' });
const editingAfwijzenWithToelichting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Afwijzen' },
{ tag: 'SetField', key: 'toelichting', value: ' niet erkend ' },
);
const s = reduce(editingAfwijzenWithToelichting, { tag: 'Submit' });
expect(expectTag(s, 'Submitting').data).toEqual({
besluit: 'Afwijzen',
toelichting: 'niet erkend',
@@ -41,24 +46,36 @@ describe('besluit reduce', () => {
});
it('SubmitConfirmed maps Submitting to Submitted', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const submitting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
{ tag: 'Submit' },
);
expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted');
});
it('SubmitFailed maps Submitting to Failed with the error', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const submitting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
{ tag: 'Submit' },
);
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
});
it('Retry re-submits a failure', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const submitting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
{ tag: 'Submit' },
);
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
});
it('Reset returns to the initial editing state', () => {
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
const submitting = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
{ tag: 'Submit' },
);
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
});
});
@@ -1,6 +1,6 @@
import { Result, assertNever } from '@shared/kernel/fp';
/** The three actions the beoordeling screen offers a behandelaar (WP-65b) — mirrors the
/** The three actions the beoordeling screen offers a behandelaar — mirrors the
backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw
enum — see `RecordBesluitRequest`). */
const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const;
@@ -0,0 +1,7 @@
import { given } from '@shared/testing/machine';
import { reduce, initial } from './besluit.machine';
/** Replay real `BesluitMsg`s through the real `reduce`, starting from `initial`.
Pure TS only (no Angular) — domain/ stays framework-free (dependency-cruiser
`domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */
export const givenBesluit = given(reduce, initial);
@@ -1,5 +1,5 @@
/**
* A queue entry as the behandelportal sees it (WP-64) — the parsed, domain-side view
* A queue entry as the behandelportal sees it — the parsed, domain-side view
* of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular.
*
* The status union is narrower than the SSP's full `AanvraagStatus` (ssp's
@@ -13,7 +13,7 @@ import {
import { AanvraagType } from '@behandeling/domain/werkvoorraad-item';
/**
* Infrastructure adapter for the beoordeling detail read (WP-65) — the only place its
* Infrastructure adapter for the beoordeling detail read — the only place its
* HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated +
* mapped to domain by the parse* boundary below.
*/
@@ -3,7 +3,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
import { Valid } from '@behandeling/domain/besluit.machine';
/**
* Infrastructure adapter for recording a behandelaar's decision (WP-65b) — the single
* Infrastructure adapter for recording a behandelaar's decision — the single
* place its HTTP lives. No return value: a successful call means the server accepted
* the transition; the caller reloads `BeoordelingStore` to see the new status (the
* server, not this adapter, re-validates and is the authority).
@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { ApiClient, ApplicationSummaryDto } from '@shared/infrastructure/api-client';
import { ApiClient, AanvraagSummaryDto } from '@shared/infrastructure/api-client';
import {
WerkvoorraadItem,
WerkvoorraadStatus,
@@ -8,7 +8,7 @@ import {
} from '@behandeling/domain/werkvoorraad-item';
/**
* Infrastructure adapter for the behandelportal's queue read (WP-64) — the only
* Infrastructure adapter for the behandelportal's queue read — the only
* place its HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response
* is validated + mapped to the (narrower) queue domain shape by the parse* boundary
* below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not
@@ -18,7 +18,7 @@ import {
export class WerkvoorraadAdapter {
private client = inject(ApiClient);
list(): Promise<ApplicationSummaryDto[]> {
list(): Promise<AanvraagSummaryDto[]> {
return this.client.werkvoorraad();
}
}
@@ -26,7 +26,7 @@ export class WerkvoorraadAdapter {
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
function parseWerkvoorraadStatus(
s: ApplicationSummaryDto['status'] | undefined,
s: AanvraagSummaryDto['status'] | undefined,
): Result<string, WerkvoorraadStatus> {
if (!s || typeof s.tag !== 'string') return err('werkvoorraad: missing status');
switch (s.tag) {
@@ -44,7 +44,7 @@ function parseWerkvoorraadStatus(
export function parseWerkvoorraadItem(json: unknown): Result<string, WerkvoorraadItem> {
if (typeof json !== 'object' || json === null) return err('werkvoorraad: not an object');
const dto = json as ApplicationSummaryDto;
const dto = json as AanvraagSummaryDto;
if (typeof dto.id !== 'string') return err('werkvoorraad: missing id');
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type))
return err(`werkvoorraad: bad type ${dto.type}`);
@@ -1,7 +1,7 @@
import { Component, input } from '@angular/core';
import { BeoordelingDocument } from '@behandeling/domain/beoordeling';
/** Organism: the documents linked to an aanvraag (WP-65) — plain links to the existing
/** Organism: the documents linked to an aanvraag — plain links to the existing
(pre-existing, unauthenticated — same as ssp's own document previews) content
endpoint. No new shared atom: a context-local list, not a reusable building block. */
@Component({
@@ -1,20 +1,21 @@
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { successOf } from '@shared/application/remote-data';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BeoordelingStore } from '@behandeling/application/beoordeling.store';
import { detailRows } from '@behandeling/domain/beoordeling-view';
import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component';
import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component';
/**
* Page: one aanvraag's beoordeling detail (WP-65). The werkvoorraad list (WP-64) links
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form (WP-65b)
* Page: one aanvraag's beoordeling detail. The werkvoorraad list links
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form —
* the page never recomputes the lifecycle itself. On a recorded decision the form emits
* `decided`, and the page just reloads (the server is the authority on the new status).
*/
@@ -73,10 +74,7 @@ export class BeoordelingPage {
protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`;
protected rows = detailRows;
protected readonly view = computed(() => {
const rd = this.store.view();
return rd.tag === 'Success' ? rd.value : undefined;
});
protected readonly view = computed(() => successOf(this.store.view()));
constructor() {
void this.store.load(this.id);
@@ -1,18 +1,23 @@
import { Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import {
RadioGroupComponent,
RadioOption,
} from '@shared/ui/atoms/radio-group/radio-group.component';
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine';
import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
/**
* Organism: the decision form (WP-65b) — goedkeuren/afwijzen/meer-info-opvragen. Same
* Organism: the decision form — goedkeuren/afwijzen/meer-info-opvragen. Same
* idiom as every other form in this house (`change-request-form`): all state in one
* signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*`
* command returning `Result`. The server re-validates the transition and is the
@@ -29,10 +34,35 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
DataBlockComponent,
DataRowComponent,
],
template: `
@if (state().tag === 'Submitted') {
<app-alert type="ok" i18n="@@besluit.success">Het besluit is vastgelegd.</app-alert>
} @else if (state().tag === 'Failed') {
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
<app-alert type="error"
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
<app-data-block class="app-section" [ariaLabel]="besluitLabelText">
<div app-data-row [key]="besluitLabelText" [value]="besluitOptieLabel()"></div>
@if (toelichting()) {
<div app-data-row [key]="toelichtingLabelText" [value]="toelichting()"></div>
}
</app-data-block>
<div class="app-section">
<app-button
variant="secondary"
(click)="dispatch({ tag: 'Retry' })"
i18n="@@wizard.opnieuwProberen"
>Opnieuw proberen</app-button
>
</div>
} @else {
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
@@ -70,13 +100,6 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
/>
</app-form-field>
@if (failedError()) {
<app-alert type="error"
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
}
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
</app-button>
@@ -86,7 +109,19 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
})
export class BesluitFormComponent {
private submit = createSubmitBesluit();
private store = createStore<BesluitState, BesluitMsg>(initial, reduce);
// Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is
// exempt, so a story mounting straight into `Submitting` does not call the network).
private store = createStore<BesluitState, BesluitMsg>(initial, reduce, {
Submitting: async (s, store) => {
const r = await this.submit(this.id(), s.data);
if (r.ok) {
store.dispatch({ tag: 'SubmitConfirmed' });
this.decided.emit();
} else {
store.dispatch({ tag: 'SubmitFailed', error: r.error });
}
},
});
id = input.required<string>();
decided = output<void>();
@@ -109,12 +144,33 @@ export class BesluitFormComponent {
protected readonly submitLabel = $localize`:@@besluit.submit:Besluit vastleggen`;
protected readonly submitBezigLabel = $localize`:@@besluit.submitBezig:Bezig met vastleggen…`;
// Same ids as the form-field labels above, reused for the Failed data-block's row
// keys (the pattern change-request-form already uses for its read-only BRP rows).
protected readonly besluitLabelText = $localize`:@@besluit.besluitLabel:Besluit`;
protected readonly toelichtingLabelText = $localize`:@@besluit.toelichtingLabel:Toelichting`;
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed(() => this.editing()?.errors ?? {});
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected besluit = computed(() => this.editing()?.draft.besluit ?? '');
protected toelichting = computed(() => this.editing()?.draft.toelichting ?? '');
/** The value shown in the field — the live draft while editing, the parsed value
while submitting/failed (so the user sees what they sent, same idiom as
change-request-form.telefoon()). */
protected besluit = computed(() => {
const s = this.state();
if (s.tag === 'Editing') return s.draft.besluit;
if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.besluit;
return '';
});
protected toelichting = computed(() => {
const s = this.state();
if (s.tag === 'Editing') return s.draft.toelichting;
if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.toelichting ?? '';
return '';
});
protected besluitOptieLabel = computed(
() => this.BESLUIT_OPTIONS.find((o) => o.value === this.besluit())?.label ?? '',
);
constructor() {
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
@@ -122,19 +178,5 @@ export class BesluitFormComponent {
onSubmit() {
this.dispatch({ tag: 'Submit' });
this.runIfSubmitting();
}
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
const r = await this.submit(this.id(), s.data);
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.decided.emit();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
}
}
@@ -1,12 +1,12 @@
import { Component, input } from '@angular/core';
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
/** Organism: the behandelaar's queue as CIBG "aanvragen" rows (WP-64) — composition
/** Organism: the behandelaar's queue as CIBG "aanvragen" rows — composition
of the two existing shared/ui molecules, no new atom. Each row links to the
beoordeling detail page (WP-65). */
beoordeling detail page. */
@Component({
selector: 'app-werkvoorraad-list',
imports: [ApplicationListComponent, ApplicationLinkComponent],
@@ -1,18 +1,19 @@
import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { successOr } from '@shared/application/remote-data';
import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store';
import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component';
/**
* Page: the behandelaar's werkvoorraad (WP-64) — the behandelportal's landing page.
* Page: the behandelaar's werkvoorraad — the behandelportal's landing page.
* Deny-by-default capability gate (`aanvraag:beoordelen`), same idiom as ssp's
* AdminCasesPage: a denial alert for a non-behandelaar, the queue for one. Opening
* a case's detail is out of scope here (WP-65).
* a case's detail is out of scope here.
*/
@Component({
selector: 'app-werkvoorraad-page',
@@ -56,10 +57,7 @@ export class WerkvoorraadPage {
protected access = inject(AccessStore);
protected canBeoordelen = computed(() => this.access.can('aanvraag:beoordelen'));
protected items = computed(() => {
const rd = this.store.items();
return rd.tag === 'Success' ? rd.value : [];
});
protected items = computed(() => successOr(this.store.items(), []));
protected heading = $localize`:@@werkvoorraad.heading:Werkvoorraad`;
protected intro = $localize`:@@werkvoorraad.intro:Aanvragen die op beoordeling wachten.`;
@@ -71,7 +69,7 @@ export class WerkvoorraadPage {
private loadRequested = false;
constructor() {
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) —
// same guard-against-the-loop idiom as AdminCasesPage (WP-26 lesson).
// same guard-against-the-loop idiom as AdminCasesPage.
effect(() => {
if (this.canBeoordelen() && !this.loadRequested) {
this.loadRequested = true;
@@ -8,7 +8,7 @@ export const NAV_ITEMS: readonly HeaderNavItem[] = [
/** This app's admin pages — provided to the shared site header via HEADER_ADMIN_LINKS.
No huisstijl (that's the SSP's brief context) or zaken entry — inherited as-is from
WP-61's bootstrap trim, not revisited by this migration. */
the bootstrap trim, not revisited by this migration. */
export const ADMIN_LINKS: readonly AdminLink[] = [
{
label: $localize`:@@header.nav.stamdata:Stamdata`,
+15 -43
View File
@@ -26,65 +26,33 @@
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="form.verplichteVelden" datatype="html">
<source>* verplichte velden</source>
<target datatype="html">* required fields</target>
<trans-unit id="login.ssoExplainer" datatype="html">
<source>U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.</source>
<target datatype="html">You sign in through your organization's SSO — no password is needed.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">15,18</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/registratie/ui/change-request-form/change-request-form.component.ts</context>
<context context-type="linenumber">44,46</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/wizard-shell/wizard-shell.component.ts</context>
<context context-type="linenumber">90,92</context>
</context-group>
</trans-unit>
<trans-unit id="login.bsnLabel" datatype="html">
<source>BSN</source>
<target datatype="html">BSN</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">22,23</context>
</context-group>
</trans-unit>
<trans-unit id="login.bsnDescription" datatype="html">
<source>9-cijferig BSN, elfproef-geldig (demo: 123456782)</source>
<target datatype="html">9-digit BSN, valid eleven-test checksum (demo: 123456782)</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">25,28</context>
</context-group>
</trans-unit>
<trans-unit id="login.wachtwoordLabel" datatype="html">
<source>Wachtwoord</source>
<target datatype="html">Password</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">36,37</context>
<context context-type="linenumber">17,19</context>
</context-group>
</trans-unit>
<trans-unit id="login.submit" datatype="html">
<source>Inloggen met DigiD</source>
<target datatype="html">Log in with DigiD</target>
<source>Inloggen met SSO</source>
<target datatype="html">Log in with SSO</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">41,43</context>
<context context-type="linenumber">20,21</context>
</context-group>
</trans-unit>
<trans-unit id="login.heading" datatype="html">
<source>Inloggen</source>
<target datatype="html">Log in</target>
<source>Inloggen bij het behandelportal</source>
<target datatype="html">Log in to the treatment portal</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
<context context-type="linenumber">14,16</context>
</context-group>
</trans-unit>
<trans-unit id="login.intro" datatype="html">
<source>Log in op uw persoonlijke BIG-register omgeving.</source>
<target datatype="html">Log in to your personal BIG register environment.</target>
<source>Voor medewerkers die aanvragen beoordelen.</source>
<target datatype="html">For staff who assess applications.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
<context context-type="linenumber">17,19</context>
@@ -3878,6 +3846,10 @@
<source>De functievlaggen konden niet worden geladen.</source>
<target datatype="html">The feature flags could not be loaded.</target>
</trans-unit>
<trans-unit id="flags.set.failed" datatype="html">
<source>De functievlag kon niet worden opgeslagen.</source>
<target datatype="html">The feature flag could not be saved.</target>
</trans-unit>
<trans-unit id="flags.retry" datatype="html">
<source>Opnieuw proberen</source>
<target datatype="html">Try again</target>
+6 -1
View File
@@ -14,6 +14,7 @@ import localeEn from '@angular/common/locales/en';
import { routes } from './app.routes';
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
import { subjectInterceptor } from '@shared/infrastructure/subject.interceptor';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { SESSION_PORT } from '@shared/application/session.port';
import { SessionStore } from '@auth/application/session.store';
@@ -54,7 +55,11 @@ export const appConfig: ApplicationConfig = {
),
// Dev-only: the ?scenario= toggle must never reach a production build, where
// a query param could otherwise force errors on the live app.
provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])),
provideHttpClient(
withInterceptors(
isDevMode() ? [scenarioInterceptor, roleInterceptor, subjectInterceptor] : [],
),
),
provideApiClient(),
{ provide: SESSION_PORT, useExisting: SessionStore },
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
+7 -5
View File
@@ -14,9 +14,11 @@ export const routes: Routes = [
loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage),
},
{
// Path stays 'dashboard' on purpose: it is a user-visible URL and four e2e
// specs assert it. The context is `overzicht`; only the path string differs.
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage),
loadComponent: () => import('@overzicht/ui/overzicht.page').then((m) => m.OverzichtPage),
},
{
path: 'registratie',
@@ -59,7 +61,7 @@ export const routes: Routes = [
},
{
path: 'brief/huisstijl',
// Admin-only org-template editor (WP-26): capabilityGuard denies-by-default
// Admin-only org-template editor: capabilityGuard denies-by-default
// unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces
// via the OrgAdmin gate — the guard just avoids loading a page that would 403.
canActivate: [capabilityGuard('orgtemplate:edit')],
@@ -76,7 +78,7 @@ export const routes: Routes = [
},
{
path: 'beheer/zaken',
// Admin-only cases overview + delete (WP-36): capabilityGuard denies-by-default
// Admin-only cases overview + delete: capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (Admin role). Backend re-enforces via the
// CasesAdmin gate — the guard just avoids loading a page that would 403. The page
// lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer.
@@ -86,14 +88,14 @@ export const routes: Routes = [
},
{
path: 'beheer/audit',
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default
// Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
canActivate: [capabilityGuard('cases:manage')],
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
},
{
path: 'beheer/functies',
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`.
// Admin-only feature-flag toggles, gated by `flags:manage`.
canActivate: [capabilityGuard('flags:manage')],
loadComponent: () =>
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
@@ -1,55 +1,50 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Session } from '../domain/session';
import { Principal, parseStoredPrincipal } from '../domain/principal';
import { DigidAdapter } from '../infrastructure/digid.adapter';
const STORAGE_KEY = 'session-v1';
/** Restore a persisted session (best-effort; corrupt entry → logged out).
G2: validate the shape before trusting it. G1: the BSN is never persisted
(see the effect below), so a restored session carries an empty one — it is
unused after login; only `naam` is shown in the chrome. */
function restore(): Session | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Session>;
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
} catch {
return null;
}
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
The parse + shape validation (G1/G2) lives in `parseStoredPrincipal`
(`../domain/principal`) — pure, spec'd, and testable without stubbing
`localStorage`; this just supplies the raw value. */
function restore(): Principal | null {
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
}
/**
* Holds the current session for the whole app. Because it is providedIn:'root'
* there is exactly one instance — every component that injects it sees the same
* session signal, so logging in is instantly visible everywhere (the guard, the
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
* in an httpOnly cookie/token, not web storage.
* Holds the current zorgverlener principal for the whole SSP. One
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
* (the guard, the header). Persisted to localStorage — a refresh or the
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
* but never the BSN itself (G1 in the `effect` below): this principal carries a
* citizen's national identifier, which the behandelportal's equivalent store does
* not have to guard against, because its `medewerker` principal has no BSN.
* ponytail: localStorage, not sessionStorage — sessionStorage's per-tab clearing
* dropped the login on the cross-bundle language switch. Trade-off: the demo
* session now survives tab close; a real portal keeps auth in an httpOnly
* cookie/token, not web storage.
*/
@Injectable({ providedIn: 'root' })
export class SessionStore {
private digid = inject(DigidAdapter);
private _session = signal<Session | null>(restore());
private _session = signal<Principal | null>(restore());
readonly session = this._session.asReadonly();
readonly isAuthenticated = computed(() => this._session() !== null);
constructor() {
effect(() => {
const s = this._session();
const p = this._session();
// G1: persist only `naam` — never write the BSN (national ID) to storage.
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: p.naam }));
else localStorage.removeItem(STORAGE_KEY);
});
}
/** Effectful command: authenticate, then store the session on success. */
async login(bsn: string): Promise<Result<string, Session>> {
/** Effectful command: authenticate, then store the principal on success. */
async login(bsn: string): Promise<Result<string, Principal>> {
const r = await this.digid.authenticate(bsn);
if (r.ok) this._session.set(r.value);
return r;
-62
View File
@@ -1,62 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { describe, it, expect, vi } from 'vitest';
import { AccessStore } from '@shared/application/access.store';
import { SessionStore } from './application/session.store';
import { authGuard, capabilityGuard } from './auth.guard';
type Opts = {
authed: boolean;
can?: (c: string) => boolean;
whenReady?: () => Promise<void>;
};
function setup({ authed, can = () => false, whenReady = () => Promise.resolve() }: Opts) {
const createUrlTree = vi.fn((cmds: string[]) => ({ tree: cmds }));
const readySpy = vi.fn(whenReady);
TestBed.configureTestingModule({
providers: [
{ provide: SessionStore, useValue: { isAuthenticated: () => authed } },
{ provide: AccessStore, useValue: { whenReady: readySpy, can } },
{ provide: Router, useValue: { createUrlTree } },
],
});
return { createUrlTree, readySpy };
}
// The guards ignore their (route, state) args; cast to call with none.
const call = <T>(fn: unknown) => TestBed.runInInjectionContext(() => (fn as () => T)());
describe('authGuard', () => {
it('allows an authenticated user', () => {
setup({ authed: true });
expect(call(authGuard)).toBe(true);
});
it('redirects an anonymous user to /login', () => {
const { createUrlTree } = setup({ authed: false });
expect(call(authGuard)).toEqual({ tree: ['/login'] });
expect(createUrlTree).toHaveBeenCalledWith(['/login']);
});
});
describe('capabilityGuard', () => {
const guard = () => capabilityGuard('stamdata:edit');
it('waits for /me, then allows an entitled admin', async () => {
const { readySpy } = setup({ authed: true, can: (c) => c === 'stamdata:edit' });
await expect(call<Promise<unknown>>(guard())).resolves.toBe(true);
expect(readySpy).toHaveBeenCalledOnce(); // it awaited caps before deciding
});
it('sends an authenticated-but-unentitled user to /dashboard (not a login loop)', async () => {
setup({ authed: true, can: () => false });
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/dashboard'] });
});
it('redirects an anonymous user to /login without waiting for caps', async () => {
const { readySpy } = setup({ authed: false, can: () => true });
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/login'] });
expect(readySpy).not.toHaveBeenCalled();
});
});
+7 -31
View File
@@ -1,34 +1,10 @@
import { inject } from '@angular/core';
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';
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
export const authGuard: CanActivateFn = () => {
const store = inject(SessionStore);
const router = inject(Router);
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
};
/**
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
* The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
* only `SESSION_PORT` and `AccessStore`, so both apps share one copy and one spec.
* Re-exported here so `app.routes.ts` keeps importing them from `@auth/auth.guard`:
* routing asks the auth context for its guards, which is the right direction to read.
*
* **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me`
* is still loading — it would deny an entitled admin and bounce them. We await
* `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user
* goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're
* logged in, just not allowed here — no re-login loop). The backend re-enforces
* regardless (403); this guard is the UX pre-gate.
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
* `Principal`, the login flow, `SessionStore`. A guard is neither.
*/
export function capabilityGuard(capability: Capability): CanActivateFn {
return async () => {
const session = inject(SessionStore);
const access = inject(AccessStore);
const router = inject(Router);
if (!session.isAuthenticated()) return router.createUrlTree(['/login']);
await access.whenReady();
return access.can(capability) ? true : router.createUrlTree(['/dashboard']);
};
}
export { authGuard, capabilityGuard } from '@shared/application/auth.guard';
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { isAuthenticated, parseStoredPrincipal, Principal } from './principal';
const principal: Principal = { kind: 'zorgverlener', bsn: '19012345601', naam: 'Test' };
describe('isAuthenticated', () => {
it('narrows a present principal to Principal', () => {
expect(isAuthenticated(principal)).toBe(true);
});
it('reports no principal as not authenticated', () => {
expect(isAuthenticated(null)).toBe(false);
});
});
describe('parseStoredPrincipal', () => {
it('returns null when nothing is stored', () => {
expect(parseStoredPrincipal(null)).toBeNull();
});
it('returns null for a non-JSON string', () => {
expect(parseStoredPrincipal('not json')).toBeNull();
});
it('returns null when the stored shape is wrong (no naam)', () => {
expect(parseStoredPrincipal(JSON.stringify({ bsn: '19012345601' }))).toBeNull();
});
it('G1: a stored bsn is never restored, even if present in the raw value', () => {
const restored = parseStoredPrincipal(JSON.stringify({ bsn: '19012345601', naam: 'Test' }));
expect(restored).toEqual({ kind: 'zorgverlener', bsn: '', naam: 'Test' });
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* Who is logged in. Framework-free domain type.
*
* The `zorgverlener` variant of ADR-0002 §3's `Principal` union — the SSP has exactly
* one actor kind (a citizen, authenticated via DigiD/BSN), so this app's own copy of
* the union only ever holds this one member. `kind` is still a discriminant, not
* decoration: it is what makes `apps/behandelportal`'s `medewerker` variant a
* genuinely different type rather than a same-shaped coincidence, and what a future
* third actor (§4 — admin/auditor/institution-rep) would add a member to.
*/
export interface Principal {
readonly kind: 'zorgverlener';
readonly bsn: string;
readonly naam: string;
}
export function isAuthenticated(p: Principal | null): p is Principal {
return p !== null;
}
/**
* Parse a persisted principal out of a raw `localStorage` string (best-effort;
* anything that isn't a well-shaped record → logged out). G2: validate the
* shape before trusting it. G1: even if a stored entry carries a `bsn`, the
* restored principal's `bsn` is always `''` — the BSN is never persisted (see
* the `SessionStore` effect that writes it), so a legacy or tampered entry
* cannot resurrect one.
*/
export function parseStoredPrincipal(raw: string | null): Principal | null {
try {
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Principal>;
return typeof parsed?.naam === 'string'
? { kind: 'zorgverlener', bsn: '', naam: parsed.naam }
: null;
} catch {
return null;
}
}
@@ -1,14 +0,0 @@
import { describe, it, expect } from 'vitest';
import { isAuthenticated, Session } from './session';
const session: Session = { bsn: '19012345601', naam: 'Test' };
describe('isAuthenticated', () => {
it('narrows a present session to Session', () => {
expect(isAuthenticated(session)).toBe(true);
});
it('reports no session as not authenticated', () => {
expect(isAuthenticated(null)).toBe(false);
});
});
-9
View File
@@ -1,9 +0,0 @@
/** Who is logged in. Framework-free domain type. */
export interface Session {
readonly bsn: string;
readonly naam: string;
}
export function isAuthenticated(s: Session | null): s is Session {
return s !== null;
}
@@ -1,16 +1,16 @@
import { Injectable } from '@angular/core';
import { Result, ok } from '@shared/kernel/fp';
import { parseBsn } from '@shared/kernel/bsn';
import { Session } from '../domain/session';
import { Principal } from '../domain/principal';
/** Infrastructure: talks to the (mock) DigiD identity provider. */
@Injectable({ providedIn: 'root' })
export class DigidAdapter {
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
// Real BSN validation (parseBsn) is the trust boundary; swap the fixed identity
// for a real OIDC redirect flow when there's an IdP.
async authenticate(bsn: string): Promise<Result<string, Session>> {
async authenticate(bsn: string): Promise<Result<string, Principal>> {
const r = parseBsn(bsn);
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
return r.ok ? ok({ kind: 'zorgverlener', bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
}
}
@@ -1,8 +1,8 @@
import { Component, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
@Component({
+1 -1
View File
@@ -1,7 +1,7 @@
import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
import { SessionStore } from '@auth/application/session.store';
@@ -1,9 +1,15 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { Result } from '@shared/kernel/fp';
import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter';
import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import {
BRIEF_LOAD_FAILED,
BriefAdapter,
BriefLoadFailure,
BriefView,
} from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter, PREVIEW_FAILED } from '@brief/infrastructure/letter-preview.adapter';
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
import { BriefStore } from './brief.store';
@@ -48,8 +54,26 @@ const caseContext: CaseContext = {
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
function setup(adapter: Partial<BriefAdapter>): BriefStore {
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
/** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
touching the DOM, so a spec can assert a command's success path directly. */
function fakeBlobPresenter() {
const opened: Blob[] = [];
const presenter: BlobPresenter = {
open: (blob) => opened.push(blob),
download: () => {
throw new Error('not used by BriefStore');
},
};
return { presenter, opened };
}
function setup(adapter: Partial<BriefAdapter>, blobPresenter?: BlobPresenter): BriefStore {
TestBed.configureTestingModule({
providers: [
{ provide: BriefAdapter, useValue: adapter },
...(blobPresenter ? [{ provide: BLOB_PRESENTER, useValue: blobPresenter }] : []),
],
});
return TestBed.inject(BriefStore);
}
@@ -60,7 +84,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
};
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
load: (): Promise<Result<BriefLoadFailure, 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 }),
@@ -79,7 +104,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
};
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
load: (): Promise<Result<BriefLoadFailure, 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 }),
@@ -93,7 +119,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
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 }),
load: (): Promise<Result<BriefLoadFailure, 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' }),
@@ -108,7 +135,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
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 }),
load: (): Promise<Result<BriefLoadFailure, 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),
});
@@ -130,7 +158,7 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
});
});
// --- WP-27: undo/redo history + rejection diff ---
// --- Undo/redo history + rejection diff ---
function block(id: string, text: string): LetterBlock {
return {
@@ -151,13 +179,15 @@ const filledView: BriefView = { ...view, brief: filledBrief };
function loadedBrief(store: BriefStore): Brief {
const s = store.model();
if (s.tag !== 'loaded') throw new Error('not loaded');
if (s.tag !== 'Loaded') throw new Error('not loaded');
return s.brief;
}
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: v });
// Untyped return (inferred as the narrow `{ ok: true; value }` literal) so this one
// helper satisfies both `load` (error channel `BriefLoadFailure`) and `save` (error
// channel `string`) — it only ever produces the `ok: true` branch.
const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
await store.load();
return store;
@@ -255,8 +285,7 @@ describe('BriefStore rejection diff', () => {
...filledBrief,
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
};
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: v });
const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
const store = setup({
load: () => ok({ ...filledView, brief: submitted }),
save: () => ok(filledView),
@@ -277,41 +306,46 @@ describe('BriefStore rejection diff', () => {
});
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 }),
});
it('opens the composed letter via BLOB_PRESENTER on success', async () => {
const { presenter, opened } = fakeBlobPresenter();
const store = setup(
{
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
},
presenter,
);
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(opened).toEqual([blob]);
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 }),
});
const { presenter, opened } = fakeBlobPresenter();
const store = setup(
{
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
},
presenter,
);
await store.load();
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
ok: false,
error: PREVIEW_FAILED,
});
await store.previewLetter();
expect(open).not.toHaveBeenCalled();
expect(opened).toHaveLength(0);
expect(store.lastError()).toBe(PREVIEW_FAILED);
});
});
@@ -377,3 +411,42 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
expect(save).not.toHaveBeenCalled();
});
});
// --- CQ-007's expand half: a 404 from GET /brief tolerates by calling the
// existing reset() command, exactly once. Today's backend never 404s yet;
// this fake adapter is what exercises the branch until then. ---
describe('BriefStore.load — 404 tolerance', () => {
const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } };
const resetOk: Result<string, BriefView> = { ok: true, value: view };
it('a 404 drives exactly one reset(), which populates the store', async () => {
// Given GET /brief 404s (no brief exists yet) and reset() succeeds.
const load = vi.fn(() => Promise.resolve(notFound));
const reset = vi.fn(() => Promise.resolve(resetOk));
const store = setup({ load, reset });
// When the store loads...
await store.load();
// Then reset() ran exactly once, and the store ends up loaded from its result.
expect(reset).toHaveBeenCalledTimes(1);
expect(store.model().tag).toBe('Loaded');
});
it('a second 404 does not drive a second reset()', async () => {
// Given every load() attempt 404s (e.g. the brief still fails to appear).
const load = vi.fn(() => Promise.resolve(notFound));
const reset = vi.fn(() => Promise.resolve(resetOk));
const store = setup({ load, reset });
// When the store loads twice...
await store.load();
await store.load();
// Then reset() ran exactly once — the once-only bound holds across calls, not
// just within one — and the second 404 surfaces as an ordinary load failure.
expect(reset).toHaveBeenCalledTimes(1);
expect(store.model()).toEqual({ tag: 'Failed', reason: BRIEF_LOAD_FAILED });
});
});
@@ -1,10 +1,9 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state';
import { createHistory } from '@shared/application/history';
import { createDebouncedSave } from '@shared/application/debounced-save';
import { machineRemoteData } from '@shared/application/machine-remote-data';
import { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
import { fromLoadLifecycle } from '@shared/application/remote-data';
import {
Brief,
CaseContext,
@@ -16,11 +15,12 @@ import {
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { BRIEF_LOAD_FAILED, BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
import { uploadContentUrl } from '@shared/upload/upload.adapter';
import { uploadContentUrl } from '@shared/infrastructure/upload.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
/**
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
@@ -28,29 +28,35 @@ import { PendingSave, registerPendingSave } from '@shared/application/pending-sa
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
* `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.
* P1) via `BriefState.Loaded.decisions` — this store never computes them itself.
*/
@Injectable({ providedIn: 'root' })
export class BriefStore implements PendingSave {
private adapter = inject(BriefAdapter);
private previewAdapter = inject(LetterPreviewAdapter);
private revealAdapter = inject(RevealBigNummerAdapter);
private blobPresenter = inject(BLOB_PRESENTER);
private store = createStore<BriefState, BriefMsg>(initial, reduce);
readonly model = this.store.model;
private actionState = signal<ActionState>({ tag: 'Idle' });
readonly busy = computed(() => this.actionState().tag === 'Busy');
/** The one-shot action lifecycle now lives on the machine's `Loaded.action` (RD-12);
these stay as plain `computed`s so the render seam (four `busy = input(...)`
components, two page templates) keeps a byte-identical boolean/string API. */
readonly busy = computed(() => {
const s = this.model();
return s.tag === 'Loaded' && s.action.tag === 'Busy';
});
readonly lastError = computed(() => {
const s = this.actionState();
return s.tag === 'Failed' ? s.error : null;
const s = this.model();
return s.tag === 'Loaded' && s.action.tag === 'Failed' ? s.action.error : null;
});
/** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of
`Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded
/** Undo/redo is SHELL state, not machine state: a `createHistory` stack of
`Brief` snapshots (the mechanics live in a shared helper). Only CONTENT edits are recorded
(they flow through `edit()`); status transitions never enter history, or undo would
replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine
changes. */
@@ -58,7 +64,7 @@ export class BriefStore implements PendingSave {
readonly canUndo = this.history.canUndo;
readonly canRedo = this.history.canRedo;
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The
/** The letter as it stood when it was REJECTED, captured shell-side. The
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
full page reload loses it — a real system would persist the rejected revision. */
private rejectionSnapshot = signal<Brief | null>(null);
@@ -75,7 +81,7 @@ export class BriefStore implements PendingSave {
);
readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
/** The org template the letter renders with (WP-24). Server-owned appearance data,
/** The org template the letter renders with. 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);
@@ -93,11 +99,11 @@ export class BriefStore implements PendingSave {
/** 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(() => machineRemoteData(this.model()));
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
private brief = computed<Brief | null>(() => {
const s = this.model();
return s.tag === 'loaded' ? s.brief : null;
return s.tag === 'Loaded' ? s.brief : null;
});
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
@@ -109,7 +115,7 @@ export class BriefStore implements PendingSave {
private decisions = computed(() => {
const s = this.model();
return s.tag === 'loaded' ? s.decisions : null;
return s.tag === 'Loaded' ? s.decisions : null;
});
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
@@ -119,13 +125,40 @@ export class BriefStore implements PendingSave {
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
});
/** True once a 404-triggered recovery has been attempted (CQ-007's expand
half — see `recoverFromMissingBrief`). This is the structural once-only bound:
a repeated 404 falls straight to the `error` branch below and can never reach
`adapter.reset()` a second time, regardless of how many times `load()` runs. */
private hasRecoveredFromMissingBrief = false;
async load() {
const r = await this.adapter.load();
if (r.ok) {
this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext);
this.history.clear();
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
this.applyLoadedView(r.value);
} else if (r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief) {
this.hasRecoveredFromMissingBrief = true;
await this.recoverFromMissingBrief();
} else {
const reason = r.error.tag === 'notFound' ? BRIEF_LOAD_FAILED : r.error.reason;
this.store.dispatch({ tag: 'BriefLoadFailed', reason });
}
}
private applyLoadedView(view: BriefView) {
this.orgTemplate.set(view.orgTemplate);
this.caseContext.set(view.caseContext);
this.history.clear();
this.store.dispatch({ tag: 'BriefLoaded', ...view });
}
/** `GET /brief` 404'd — no brief exists yet for this owner. Recover by calling the
existing `reset()` command directly (the same POST `resetDemo()` uses) and
applying whatever it returns; this NEVER calls `load()` again, so a second 404
(e.g. `reset()` itself failing) cannot loop back into this method. */
private async recoverFromMissingBrief() {
const r = await this.adapter.reset();
if (r.ok) {
this.applyLoadedView(r.value);
} else {
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
}
@@ -153,7 +186,7 @@ export class BriefStore implements PendingSave {
}
private restore(step: (current: Brief) => Brief | undefined) {
const s = this.model();
if (s.tag !== 'loaded') return;
if (s.tag !== 'Loaded') return;
const target = step(s.brief);
if (target === undefined) return;
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
@@ -167,7 +200,7 @@ export class BriefStore implements PendingSave {
}
// 600ms debounced autosave (the server is the store of record). Timer mechanics live in
// the shared helper; `flushSave` below is the store-specific write + save-state (WP-31).
// the shared helper; `flushSave` below is the store-specific write + save-state.
private debouncedSave = createDebouncedSave({
canSave: () => this.canEdit(),
flush: () => this.flushSave(),
@@ -183,31 +216,33 @@ export class BriefStore implements PendingSave {
if (r.ok) {
this.saveState.set({ tag: 'Saved' });
} else {
this.actionState.set({ tag: 'Failed', error: r.error });
// The autosave failure legitimately surfaces in two places: the small save
// indicator below (kept as-is) and the action error line (RD-12).
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
this.saveState.set({ tag: 'Error' });
}
}
/** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */
/** Retry a failed autosave — reuses the existing flush path, no new state. */
retrySave() {
void this.flushSave();
}
/** Demo "start over": recreate the brief server-side and load the fresh view. */
async resetDemo() {
this.actionState.set({ tag: 'Busy' });
this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel();
const r = await this.adapter.reset();
this.saveState.set({ tag: 'Idle' });
if (r.ok) {
this.actionState.set({ tag: 'Idle' });
this.store.dispatch({ tag: 'ActionFinished' });
this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext);
this.history.clear();
this.rejectionSnapshot.set(null);
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else {
this.actionState.set({ tag: 'Failed', error: r.error });
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
}
}
@@ -217,27 +252,31 @@ export class BriefStore implements PendingSave {
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. */
letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the
object URL is never revoked. */
async previewLetter() {
this.actionState.set({ tag: 'Busy' });
this.store.dispatch({ tag: 'ActionStarted' });
const r = await this.previewAdapter.preview();
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
window.open(URL.createObjectURL(r.value), '_blank');
this.store.dispatch({ tag: 'ActionFinished' });
this.blobPresenter.open(r.value);
}
/** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability
+ step-up and audits the attempt; on success we swap the masked value in the
already-loaded caseContext (a field update, not a reload). The step-up gesture
itself is the UI's concern — this command just runs the audited server call. */
itself is the UI's concern (`behandel-scherm.component.ts`'s `onReveal()` confirm)
— this command is only reachable once that gesture has happened, so it is the one
that tells the adapter to send `X-Step-Up` (BIO-006a: the adapter itself no longer
hardcodes the header). */
async revealBigNummer() {
const r = await this.revealAdapter.reveal();
const r = await this.revealAdapter.reveal(true);
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
// Never sets Busy — an existing asymmetry (RD-12), not fixed here.
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return;
}
this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));
@@ -246,15 +285,15 @@ export class BriefStore implements PendingSave {
// A transition: flush any pending save, call the server (authoritative), then mirror
// the returned status through the pure reducer's guarded transition.
private async transition(action: () => Promise<Result<string, BriefView>>) {
this.actionState.set({ tag: 'Busy' });
this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel();
await this.flushSave();
const r = await action();
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
this.store.dispatch({ tag: 'ActionFinished' });
this.applyServerStatus(r.value);
}
@@ -272,7 +311,7 @@ export class BriefStore implements PendingSave {
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
break;
case 'rejected':
// Capture the letter as-rejected for the resubmission diff (WP-27). This is the
// Capture the letter as-rejected for the resubmission diff. This is the
// "before" snapshot the approver later compares against.
this.rejectionSnapshot.set(brief);
this.store.dispatch({
@@ -0,0 +1,120 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect } from 'vitest';
import { Result, ok } from '@shared/kernel/fp';
import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter';
import { UploadAdapter } from '@shared/infrastructure/upload.adapter';
import { UploadShellService } from '@shared/application/upload-shell.service';
import { OrgTemplate, OrgTemplateAdminView, SubOrgSummary } from '@brief/domain/org-template';
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
import { OrgTemplateStore } from './org-template.store';
const template: 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: OrgTemplateAdminView = {
draft: template,
publishedVersion: 1,
history: [],
unsentBriefs: 0,
};
const subOrgs: SubOrgSummary[] = [
{ subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 },
];
/** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
touching the DOM, so a spec can assert a command's success path directly. */
function fakeBlobPresenter() {
const opened: Blob[] = [];
const presenter: BlobPresenter = {
open: (blob) => opened.push(blob),
download: () => {
throw new Error('not used by OrgTemplateStore');
},
};
return { presenter, opened };
}
/** A no-op categories resource: the logo-upload sub-state is untouched by these
tests, so 'idle' (never resolved) keeps the constructor effect from dispatching. */
function fakeCategoriesResource(): ReturnType<UploadAdapter['categoriesResource']> {
const fake = { status: () => 'idle' as const, value: () => undefined };
return fake as unknown as ReturnType<UploadAdapter['categoriesResource']>;
}
function setup(
adapter: Partial<OrgTemplateAdapter>,
blobPresenter: BlobPresenter,
): OrgTemplateStore {
const uploadAdapter: Partial<UploadAdapter> = {
categoriesResource: () => fakeCategoriesResource(),
};
TestBed.configureTestingModule({
providers: [
{ provide: OrgTemplateAdapter, useValue: adapter },
{ provide: UploadAdapter, useValue: uploadAdapter },
{ provide: UploadShellService, useValue: {} },
{ provide: BLOB_PRESENTER, useValue: blobPresenter },
],
});
return TestBed.inject(OrgTemplateStore);
}
// --- TE-006: proefbrief() ends in BLOB_PRESENTER.open, not a raw
// window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. ---
describe('OrgTemplateStore.proefbrief', () => {
it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => {
// Given a loaded sub-org template.
const { presenter, opened } = fakeBlobPresenter();
const blob = new Blob(['<html></html>'], { type: 'text/html' });
const store = setup(
{
list: (): Promise<Result<string, SubOrgSummary[]>> => Promise.resolve(ok(subOrgs)),
load: (): Promise<Result<string, OrgTemplateAdminView>> => Promise.resolve(ok(view)),
proefbrief: (): Promise<Result<string, Blob>> => Promise.resolve(ok(blob)),
},
presenter,
);
await store.load();
// When proefbrief() is called...
await store.proefbrief();
// Then the presenter receives exactly the rendered blob, and no error surfaces.
expect(opened).toEqual([blob]);
expect(store.lastError()).toBeNull();
});
it('surfaces the error without opening a tab on failure', async () => {
// Given a loaded sub-org template whose proefbrief call fails server-side.
const { presenter, opened } = fakeBlobPresenter();
const store = setup(
{
list: (): Promise<Result<string, SubOrgSummary[]>> => Promise.resolve(ok(subOrgs)),
load: (): Promise<Result<string, OrgTemplateAdminView>> => Promise.resolve(ok(view)),
proefbrief: (): Promise<Result<string, Blob>> =>
Promise.resolve({ ok: false, error: 'mislukt' }),
},
presenter,
);
await store.load();
// When proefbrief() is called...
await store.proefbrief();
// Then the presenter is never reached and the error is surfaced.
expect(opened).toHaveLength(0);
expect(store.lastError()).toBe('mislukt');
});
});
@@ -1,11 +1,10 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state';
import { createDebouncedSave } from '@shared/application/debounced-save';
import { machineRemoteData } from '@shared/application/machine-remote-data';
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 { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
import { fromLoadLifecycle } from '@shared/application/remote-data';
import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
import { UploadShellService } from '@shared/application/upload-shell.service';
import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
import {
MARGIN_MAX_MM,
MARGIN_MIN_MM,
@@ -13,6 +12,7 @@ import {
SubOrgSummary,
} from '@brief/domain/org-template';
import {
OrgTemplateActionState,
OrgTemplateMsg,
OrgTemplateState,
initial,
@@ -20,14 +20,15 @@ import {
} from '@brief/domain/org-template.machine';
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
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
* Root singleton for the admin org-template editor. 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
@@ -38,6 +39,7 @@ export class OrgTemplateStore implements PendingSave {
private adapter = inject(OrgTemplateAdapter);
private uploadAdapter = inject(UploadAdapter);
private shell = inject(UploadShellService);
private blobPresenter = inject(BLOB_PRESENTER);
private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
readonly model = this.store.model;
@@ -45,22 +47,28 @@ export class OrgTemplateStore implements PendingSave {
readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
readonly selectedSubOrgId = signal<string | null>(null);
private actionState = signal<ActionState>({ tag: 'Idle' });
readonly busy = computed(() => this.actionState().tag === 'Busy');
/** The one-shot action lifecycle and the publish impact-confirm gate now live on
the machine's `Loaded.action` as one four-variant union (RD-13); these stay as
plain `computed`s so the render seam (the editor organism's `input()`s, the
page template) keeps a byte-identical boolean/string API. */
private action = computed<OrgTemplateActionState>(() => this.loaded()?.action ?? { tag: 'Idle' });
readonly busy = computed(() => this.action().tag === 'Busy');
readonly lastError = computed(() => {
const s = this.actionState();
return s.tag === 'Failed' ? s.error : null;
const a = this.action();
return a.tag === 'Failed' ? a.error : null;
});
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST).
Before RD-13 this was an independent boolean, so it could be `true` at the same
time `busy` was `true` — representable and meaningless. It is now derived from
the same union `busy` reads, so the two are mutually exclusive by construction. */
readonly pendingPublish = computed(() => this.action().tag === 'ConfirmingPublish');
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(() => machineRemoteData(this.model()));
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
private loaded = computed<LoadedState | null>(() => {
const s = this.model();
return s.tag === 'loaded' ? s : null;
return s.tag === 'Loaded' ? s : null;
});
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
@@ -72,6 +80,9 @@ export class OrgTemplateStore implements PendingSave {
return id ? this.uploadAdapter.contentUrl(id) : null;
});
/** Preview/download link for any completed upload in the editor's document list. */
readonly previewUrlFor = (documentId: string): string | undefined => uploadContentUrl(documentId);
/** 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(() => {
@@ -96,7 +107,7 @@ export class OrgTemplateStore implements PendingSave {
// the length guard makes it idempotent (no dispatch loop).
effect(() => {
const s = this.model();
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;
if (s.tag !== 'Loaded' || s.upload.categories.length > 0) return;
const status = this.categoriesRes.status();
if (status === 'resolved' || status === 'local')
this.dispatchUpload({
@@ -140,7 +151,7 @@ export class OrgTemplateStore implements PendingSave {
this.debouncedSave.schedule();
}
// 600ms debounced autosave (same idiom as BriefStore, WP-31). Timer mechanics live in the
// 600ms debounced autosave (same idiom as BriefStore). Timer mechanics live in the
// shared helper; `flushSave` below is the store-specific write + save-state.
private debouncedSave = createDebouncedSave({
canSave: () => this.loaded() !== null,
@@ -160,61 +171,65 @@ export class OrgTemplateStore implements PendingSave {
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
} else {
this.saveState.set({ tag: 'Error' });
this.actionState.set({ tag: 'Failed', error: r.error });
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
}
}
// --- publish (impact-confirm) / rollback / proefbrief ---
// RD-13: `requestPublish`/`cancelPublish` are the only two commands here that do
// NOT guard on `loaded()` — as dispatches they no-op outside `Loaded` by
// construction (the reducer's own guard), so behaviour is unchanged.
requestPublish() {
this.pendingPublish.set(true);
this.store.dispatch({ tag: 'PublishRequested' });
}
cancelPublish() {
this.pendingPublish.set(false);
this.store.dispatch({ tag: 'PublishCancelled' });
}
async confirmPublish() {
const s = this.loaded();
if (!s) return;
this.pendingPublish.set(false);
this.actionState.set({ tag: 'Busy' });
// ActionStarted overwrites `action` straight to Busy, so ConfirmingPublish and
// Busy are never simultaneously true (RD-13).
this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel();
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 });
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
this.store.dispatch({ tag: 'ActionFinished' });
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' });
this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel();
const r = await this.adapter.rollback(s.subOrgId, version);
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
this.store.dispatch({ tag: 'ActionFinished' });
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' });
this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel();
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 });
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
window.open(URL.createObjectURL(r.value), '_blank');
this.store.dispatch({ tag: 'ActionFinished' });
this.blobPresenter.open(r.value);
}
// --- logo upload (reuses the shared upload transport; single `org-logo` file) ---
+1 -1
View File
@@ -2,7 +2,7 @@ import { Brief, LetterBlock, allBlocks } from './brief';
/**
* The rejection diff as a PURE function over two immutable `Brief` values — the whole
* teaching payload of WP-27: because state is one value, "what changed since the letter
* teaching payload here: because state is one value, "what changed since the letter
* was rejected" is just a fold over two snapshots, no change-tracking bookkeeping.
*
* Blocks are matched by `blockId` (stable `local-N`/seed ids):
@@ -3,6 +3,7 @@ import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './b
import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderDef } from './placeholders';
import { BriefState, reduce } from './brief.machine';
import { givenBrief } from './brief.testing';
const placeholders: PlaceholderDef[] = [
{ key: 'naam', label: 'Naam', autoResolvable: true },
@@ -64,18 +65,18 @@ const decisions: BriefDecisions = {
canRevealBigNummer: true,
};
const loaded = (
status: BriefStatus = { tag: 'draft' },
sections?: Brief['sections'],
): BriefState => ({
tag: 'loaded',
brief: briefWith(status, sections),
availablePassages: lib,
decisions,
});
// Replays a real `BriefLoaded` message through the real `reduce` (ADR-0006 §2)
// instead of hand-assembling the 'loaded' state directly.
const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState =>
givenBrief({
tag: 'BriefLoaded',
brief: briefWith(status, sections),
availablePassages: lib,
decisions,
});
const sectionBlocks = (s: BriefState, key: string) =>
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
s.tag === 'Loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
const passageIds = (s: BriefState, key: string) =>
sectionBlocks(s, key)
@@ -91,12 +92,12 @@ describe('brief.machine reduce', () => {
availablePassages: [],
decisions,
}).tag,
).toBe('loaded');
).toBe('Loaded');
});
it('BriefLoadFailed moves loading to failed with the reason', () => {
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
tag: 'failed',
tag: 'Failed',
reason: 'x',
});
});
@@ -128,12 +129,12 @@ describe('brief.machine reduce', () => {
it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
const st: BriefState = {
tag: 'loaded',
const st = givenBrief({
tag: 'BriefLoaded',
brief: briefWith({ tag: 'draft' }),
availablePassages: [passage],
decisions,
};
});
const s = reduce(st, besluit('positief'));
// Mutate the source passage object after composition.
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
@@ -209,7 +210,7 @@ describe('brief.machine reduce', () => {
comments: 'graag aanpassen',
});
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft');
expect(next.tag === 'Loaded' && next.brief.status.tag).toBe('draft');
expect(sectionBlocks(next, 'slot')).toHaveLength(1);
});
@@ -219,7 +220,7 @@ describe('brief.machine reduce', () => {
// fill the required section via the besluit, then submit
const filled = reduce(loaded(), besluit('positief'));
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',
submittedBy: 'u1',
submittedAt: 't',
@@ -231,7 +232,7 @@ describe('brief.machine reduce', () => {
// approve from draft is a no-op
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
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',
approvedBy: 'u2',
approvedAt: 't2',
@@ -247,7 +248,7 @@ describe('brief.machine reduce', () => {
comments: 'nee',
decisions,
});
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
expect(rejected.tag === 'Loaded' && rejected.brief.status).toEqual({
tag: 'rejected',
rejectedBy: 'u2',
rejectedAt: 't2',
@@ -261,7 +262,40 @@ describe('brief.machine reduce', () => {
// send from submitted is a no-op
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
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' });
});
// --- RD-12: the action lifecycle lives on `Loaded.action`, driven by three msgs ---
it('ActionStarted moves a loaded brief to Busy', () => {
const s = reduce(loaded(), { tag: 'ActionStarted' });
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Busy' });
});
it('ActionFailed carries the error', () => {
const s = reduce(loaded(), { tag: 'ActionFailed', error: 'niet gelukt' });
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Failed', error: 'niet gelukt' });
});
it('ActionFinished returns to Idle', () => {
const busy = reduce(loaded(), { tag: 'ActionStarted' });
const s = reduce(busy, { tag: 'ActionFinished' });
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Idle' });
});
it('BriefLoaded resets a stale action error to Idle', () => {
const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'niet gelukt' });
const reloaded = reduce(failed, {
tag: 'BriefLoaded',
brief: briefWith({ tag: 'draft' }),
availablePassages: lib,
decisions,
});
expect(reloaded.tag === 'Loaded' && reloaded.action).toEqual({ tag: 'Idle' });
});
it('an action message is a no-op when the brief is not loaded', () => {
expect(reduce(initialLoading(), { tag: 'ActionStarted' })).toEqual(initialLoading());
});
it('a status transition replaces decisions with the fresh server value', () => {
@@ -279,10 +313,10 @@ describe('brief.machine reduce', () => {
at: 't2',
decisions: staleApprover,
});
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover);
expect(approved.tag === 'Loaded' && approved.decisions).toEqual(staleApprover);
});
});
function initialLoading(): BriefState {
return { tag: 'loading' };
return { tag: 'Loading' };
}
+30 -10
View File
@@ -36,17 +36,22 @@ import { passagesForBesluit } from './besluit';
* structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`).
*/
/** The one-shot action lifecycle (submit/approve/reject/send/preview/reveal/reset),
owned by the reducer instead of an imperative store-level signal (RD-12). */
export type BriefActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
export type BriefState =
| { tag: 'loading' }
| { tag: 'Loading' }
| {
tag: 'loaded';
tag: 'Loaded';
brief: Brief;
availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions;
action: BriefActionState;
}
| { tag: 'failed'; reason: string };
| { tag: 'Failed'; reason: string };
export const initial: BriefState = { tag: 'loading' };
export const initial: BriefState = { tag: 'Loading' };
export type BriefMsg =
| {
@@ -65,7 +70,10 @@ export type BriefMsg =
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
| { tag: 'Seed'; state: BriefState };
| { tag: 'Seed'; state: BriefState }
| { tag: 'ActionStarted' } // a one-shot action (submit/approve/preview/…) began
| { tag: 'ActionFinished' } // it completed successfully
| { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
function isEditable(status: BriefStatus): boolean {
@@ -110,7 +118,7 @@ function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBl
/** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
if (s.tag !== 'loaded' || !isEditable(s.brief.status)) return s;
if (s.tag !== 'Loaded' || !isEditable(s.brief.status)) return s;
let brief = f(s.brief);
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
return { ...s, brief };
@@ -189,13 +197,16 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
switch (m.tag) {
case 'BriefLoaded':
return {
tag: 'loaded',
tag: 'Loaded',
brief: m.brief,
availablePassages: m.availablePassages,
decisions: m.decisions,
// A fresh load clears a stale action error rather than letting it outlive
// the reload (RD-12, decision 4).
action: { tag: 'Idle' },
};
case 'BriefLoadFailed':
return { tag: 'failed', reason: m.reason };
return { tag: 'Failed', reason: m.reason };
case 'Seed':
return m.state;
@@ -203,7 +214,7 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
// drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
case 'BesluitSelected':
return withEdit(s, (b) =>
s.tag === 'loaded' && isSectionEditable(b, 'kern')
s.tag === 'Loaded' && isSectionEditable(b, 'kern')
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
: b,
);
@@ -260,6 +271,15 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
case 'Sent':
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
// The action lifecycle (RD-12): a no-op unless a brief is loaded, since there is
// nothing to attach the action state to otherwise.
case 'ActionStarted':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s;
case 'ActionFinished':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
case 'ActionFailed':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s;
default:
return assertNever(m);
}
@@ -275,6 +295,6 @@ function transition(
decisions: BriefDecisions,
guard: (b: Brief) => boolean = () => true,
): 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() }, decisions };
}
@@ -0,0 +1,7 @@
import { given } from '@shared/testing/machine';
import { reduce, initial } from './brief.machine';
/** Replay real `BriefMsg`s through the real `reduce`, starting from `initial`.
Pure TS only (no Angular) — domain/ stays framework-free (dependency-cruiser
`domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */
export const givenBrief = given(reduce, initial);
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { expectTag } from '@shared/testing/expect-tag';
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
import { OrgTemplateState, reduce } from './org-template.machine';
import { DocumentCategory } from '@shared/upload/upload.machine';
import { DocumentCategory } from '@shared/domain/upload.machine';
const template: OrgTemplate = {
subOrgId: 'cibg-registers',
@@ -26,7 +26,7 @@ const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView =>
});
const loaded = (): OrgTemplateState =>
reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
reduce({ tag: 'Loading' }, { tag: 'DraftLoaded', view: view() });
const logoCategory: DocumentCategory = {
categoryId: 'org-logo',
@@ -41,7 +41,7 @@ const logoCategory: DocumentCategory = {
describe('org-template.machine', () => {
it('DraftLoaded moves to loaded with the draft, clean', () => {
const s = expectTag(loaded(), 'loaded');
const s = expectTag(loaded(), 'Loaded');
expect(s.draft.orgName).toBe('CIBG');
expect(s.subOrgId).toBe('cibg-registers');
expect(s.unsentBriefs).toBe(2);
@@ -49,14 +49,14 @@ describe('org-template.machine', () => {
});
it('LoadFailed carries the reason', () => {
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' });
expect(s).toEqual({ tag: 'failed', reason: 'boom' });
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 = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }),
'loaded',
'Loaded',
);
expect(s.draft.orgName).toBe('CIBG Nieuw');
expect(s.dirty).toBe(true);
@@ -65,7 +65,7 @@ describe('org-template.machine', () => {
it('MarginEdited edits one edge and marks dirty', () => {
const s = expectTag(
reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }),
'loaded',
'Loaded',
);
expect(s.draft.margins.topMm).toBe(40);
expect(s.draft.margins.leftMm).toBe(20);
@@ -75,9 +75,9 @@ describe('org-template.machine', () => {
it('DraftSaved clears dirty when the saved draft is the current one', () => {
const edited = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
'loaded',
'Loaded',
);
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'loaded');
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'Loaded');
expect(s.dirty).toBe(false);
expect(s.draft.orgName).toBe('X');
});
@@ -85,20 +85,20 @@ describe('org-template.machine', () => {
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
const editing = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
'loaded',
'Loaded',
);
const savedDraft = editing.draft;
// a further edit changes the draft reference before the save resolves
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'loaded');
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'Loaded');
expect(s.dirty).toBe(true);
});
it('edits are no-ops in non-loaded states', () => {
expect(
reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
reduce({ tag: 'Loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
).toEqual({
tag: 'loading',
tag: 'Loading',
});
});
@@ -122,7 +122,7 @@ describe('org-template.machine', () => {
tag: 'Upload',
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
}),
'loaded',
'Loaded',
);
expect(done.draft.logoDocumentId).toBe('doc-1');
expect(done.dirty).toBe(true);
@@ -138,7 +138,7 @@ describe('org-template.machine', () => {
tag: 'Upload',
msg: { type: 'UploadRemoved', localId: 'a' },
}),
'loaded',
'Loaded',
);
expect(removed.draft.logoDocumentId).toBeUndefined();
expect(removed.dirty).toBe(true);
@@ -154,10 +154,50 @@ describe('org-template.machine', () => {
tag: 'DraftLoaded',
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
}),
'loaded',
'Loaded',
);
expect(switched.upload.categories).toHaveLength(1);
expect(switched.upload.uploads).toHaveLength(0);
expect(switched.subOrgId).toBe('cibg-vakbekwaamheid');
});
// --- the action lifecycle + publish impact-confirm gate, folded into one union (RD-13) ---
it('PublishRequested moves a loaded template to ConfirmingPublish', () => {
const s = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded');
expect(s.action).toEqual({ tag: 'ConfirmingPublish' });
});
it('PublishCancelled returns to Idle', () => {
const confirming = reduce(loaded(), { tag: 'PublishRequested' });
const s = expectTag(reduce(confirming, { tag: 'PublishCancelled' }), 'Loaded');
expect(s.action).toEqual({ tag: 'Idle' });
});
it('ActionStarted from ConfirmingPublish goes to Busy, so confirming and busy cannot coexist', () => {
const confirming = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded');
expect(confirming.action.tag).toBe('ConfirmingPublish');
const s = expectTag(reduce(confirming, { tag: 'ActionStarted' }), 'Loaded');
expect(s.action).toEqual({ tag: 'Busy' });
});
it('ActionFailed carries the error', () => {
const busy = reduce(loaded(), { tag: 'ActionStarted' });
const s = expectTag(reduce(busy, { tag: 'ActionFailed', error: 'mislukt' }), 'Loaded');
expect(s.action).toEqual({ tag: 'Failed', error: 'mislukt' });
});
it('DraftLoaded resets a stale action error to Idle', () => {
const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'mislukt' });
const s = expectTag(reduce(failed, { tag: 'DraftLoaded', view: view() }), 'Loaded');
expect(s.action).toEqual({ tag: 'Idle' });
});
it('an action message is a no-op when the template is not loaded', () => {
expect(reduce({ tag: 'Loading' }, { tag: 'PublishRequested' })).toEqual({ tag: 'Loading' });
expect(reduce({ tag: 'Loading' }, { tag: 'ActionStarted' })).toEqual({ tag: 'Loading' });
expect(reduce({ tag: 'Loading' }, { tag: 'ActionFailed', error: 'x' })).toEqual({
tag: 'Loading',
});
});
});
@@ -1,14 +1,21 @@
import { assertNever } from '@shared/kernel/fp';
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine';
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine';
/**
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5)
* The admin org-template editor as one Elm-style machine (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`.
*
* `action` (RD-13) owns the one-shot action lifecycle AND the publish impact-confirm
* gate as ONE four-variant union, replacing two independent store-level signals
* (`actionState` + `pendingPublish`). Before RD-13, `pendingPublish === true && busy
* === true` was representable and meaningless — the confirm dialog could show while a
* publish was already in flight. A single field with one tag at a time makes that
* combination unrepresentable.
*/
/** The org-identity text fields editable directly on the letter canvas. */
@@ -21,11 +28,22 @@ export type OrgTemplateTextField =
| 'signatureRole'
| 'signatureClosing';
/** The one-shot action lifecycle (publish/rollback/proefbrief), plus the publish
impact-confirm gate, owned by the reducer instead of two independent store-level
signals (RD-13). `ConfirmingPublish` is a variant of this SAME union, so
"confirming a publish while one is already in flight" is unrepresentable no
state can ever carry both at once. */
export type OrgTemplateActionState =
| { tag: 'Idle' }
| { tag: 'ConfirmingPublish' }
| { tag: 'Busy' }
| { tag: 'Failed'; error: string };
export type OrgTemplateState =
| { tag: 'loading' }
| { tag: 'failed'; reason: string }
| { tag: 'Loading' }
| { tag: 'Failed'; reason: string }
| {
tag: 'loaded';
tag: 'Loaded';
subOrgId: string;
draft: OrgTemplate;
publishedVersion: number;
@@ -34,9 +52,10 @@ export type OrgTemplateState =
dirty: boolean;
/** Logo upload sub-state (single file, `org-logo` category). */
upload: UploadState;
action: OrgTemplateActionState;
};
export const initial: OrgTemplateState = { tag: 'loading' };
export const initial: OrgTemplateState = { tag: 'Loading' };
export type OrgTemplateMsg =
| { tag: 'Loading' }
@@ -47,22 +66,27 @@ export type OrgTemplateMsg =
/** 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 };
| { tag: 'Upload'; msg: UploadMsg }
| { tag: 'PublishRequested' } // opens the publish impact-confirm gate
| { tag: 'PublishCancelled' } // closes it without publishing
| { tag: 'ActionStarted' } // a one-shot action (publish/rollback/proefbrief) began
| { tag: 'ActionFinished' } // it completed successfully
| { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show
/** 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;
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' };
return { tag: 'Loading' };
case 'LoadFailed':
return { tag: 'failed', reason: m.reason };
return { tag: 'Failed', reason: m.reason };
case 'DraftLoaded':
return {
tag: 'loaded',
tag: 'Loaded',
subOrgId: m.view.draft.subOrgId,
draft: m.view.draft,
publishedVersion: m.view.publishedVersion,
@@ -71,16 +95,19 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
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,
upload: s.tag === 'Loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
// A fresh load clears a stale action error rather than letting it outlive
// the reload (RD-13, same as brief's RD-12).
action: { tag: 'Idle' },
};
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;
return s.tag === 'Loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
case 'Upload': {
if (s.tag !== 'loaded') return s;
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')
@@ -96,6 +123,22 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
}
return { ...s, upload };
}
// The action lifecycle (RD-13): a no-op unless a template is loaded, since there
// is nothing to attach the action state to otherwise. `ConfirmingPublish` and
// `Busy` are variants of one field, so ActionStarted overwriting it to `Busy` is
// what makes the two mutually exclusive by construction — not by convention.
case 'PublishRequested':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'ConfirmingPublish' } } : s;
case 'PublishCancelled':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
case 'ActionStarted':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s;
case 'ActionFinished':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
case 'ActionFailed':
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s;
default:
return assertNever(m);
}
@@ -1,9 +1,9 @@
/**
* The organization template (Brief v2 PRD §3, WP-23/24): the SECOND template axis
* The organization template (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, on the letter canvas. Server-owned: the FE renders it verbatim,
* never edits it here (the admin editor is WP-26).
* never edits it here (the admin editor does).
*/
export interface Margins {
@@ -30,7 +30,7 @@ export interface OrgTemplate {
readonly version: number;
}
// --- admin editor (WP-26) ---
// --- admin editor ---
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */
export interface OrgTemplateVersion {
@@ -0,0 +1,51 @@
import { Brief } from './brief';
/** A minimal read-only sample letter, so the admin sees the org identity in context
while editing (content itself is not the admin's to change). Production content
the letter the org-template editor previews not a test fixture, so it lives here
rather than in `brief.testing.ts` (dependency-cruiser's no-testing-in-production
rule forbids production code from reaching any `*.testing.ts`). */
export const SAMPLE_LETTER_BRIEF: Brief = {
briefId: 'VOORBEELD-0001',
beroep: 'arts',
templateId: 'sample',
drafterId: 'sample',
status: { tag: 'draft' },
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'datum', label: 'Datum', autoResolvable: true },
],
sections: [
{
sectionKey: 'body',
title: 'Voorbeeldinhoud',
required: true,
locked: true,
blocks: [
{
type: 'freeText',
blockId: 'sample-1',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
{
nodes: [
{
type: 'text',
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
},
],
},
],
},
},
],
},
],
};
@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit';
import { problemDetail } from '@shared/infrastructure/api-error';
import {
ApiClient,
BriefDecisionsDto,
@@ -33,8 +34,13 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
* The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire
* uses FLAT unions (a `type`/`tag` string + nullable fields, the repo convention);
* the `parse*` boundary narrows them into the domain's proper discriminated unions
* and rejects malformed shapes. Mutations go through `runSubmit` (ProblemDetails
* error string), then parse the returned brief.
* and rejects malformed shapes. Every mutation folds through `runSubmit`
* (ProblemDetails error string, plus the Idempotency-Key mint), then parses the
* returned brief. `load` (the only read) does its own try/catch instead of the
* shared `runResult` fold, because it needs one extra bit `runResult` throws away:
* whether the failure was an HTTP 404 (see `BriefLoadFailure` CQ-007's
* expand half). Today's backend never 404s `GET /brief`, so the
* `notFound` branch is unreached until it does; this adapter is ready in advance.
*/
export interface BriefView {
@@ -45,16 +51,39 @@ export interface BriefView {
readonly caseContext: CaseContext;
}
/**
* Why `load()` did not return a brief. `notFound` is a bare HTTP 404 kept
* distinct from every other failure so `BriefStore.load()` can tolerate it (call
* `reset()` instead of showing an error banner) without conflating it with a real
* failure. See the class docstring above.
*/
export type BriefLoadFailure =
{ readonly tag: 'notFound' } | { readonly tag: 'error'; readonly reason: string };
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
/** True when the thrown value carries an HTTP 404 status matches both the
generic `SwaggerException` (today's shape, since `GET /brief` declares no 404
response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once
the endpoint gets a documented 404 response). */
function isHttpNotFound(e: unknown): boolean {
return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404;
}
@Injectable({ providedIn: 'root' })
export class BriefAdapter {
private client = inject(ApiClient);
async load(): Promise<Result<string, BriefView>> {
const r = await runSubmit(() => this.client.briefGET(), BRIEF_LOAD_FAILED);
return r.ok ? parseBriefView(r.value) : r;
async load(): Promise<Result<BriefLoadFailure, BriefView>> {
try {
const dto = await this.client.briefGET();
const parsed = parseBriefView(dto);
return parsed.ok ? ok(parsed.value) : err({ tag: 'error', reason: parsed.error });
} catch (e) {
if (isHttpNotFound(e)) return err({ tag: 'notFound' });
return err({ tag: 'error', reason: problemDetail(e, BRIEF_LOAD_FAILED) });
}
}
async save(sections: readonly LetterSection[]): Promise<Result<string, BriefView>> {
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { errorMessage, PREVIEW_FAILED, LetterPreviewAdapter } from './letter-preview.adapter';
// Minimal Response stand-in — errorMessage only calls `.json()`. Avoids stubbing
// globalThis.fetch to reach this trust boundary (TE-002).
const fakeResponse = (body: unknown): Response =>
({ json: () => Promise.resolve(body) }) as unknown as Response;
describe('errorMessage (TE-002 trust boundary)', () => {
it('surfaces the ProblemDetails detail when present', async () => {
expect(await errorMessage(fakeResponse({ detail: 'Geen toegang.', status: 403 }))).toBe(
'Geen toegang.',
);
});
it('falls back to PREVIEW_FAILED when the body has no detail', async () => {
expect(await errorMessage(fakeResponse({ status: 500 }))).toBe(PREVIEW_FAILED);
});
it('falls back to PREVIEW_FAILED when the body is not JSON', async () => {
const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response;
expect(await errorMessage(res)).toBe(PREVIEW_FAILED);
});
});
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
// production build. There is no ambient type for it in app code, so this is
// accessed through an untyped bag rather than a `declare const`.
const globals = globalThis as Record<string, unknown>;
const originalNgDevMode = globals['ngDevMode'];
const setDevMode = (on: boolean) => {
globals['ngDevMode'] = on;
};
describe('LetterPreviewAdapter.preview (BIO-012)', () => {
const okResponse = () =>
({ ok: true, blob: () => Promise.resolve(new Blob()) }) as unknown as Response;
afterEach(() => {
globals['ngDevMode'] = originalNgDevMode;
vi.unstubAllGlobals();
history.pushState({}, '', '/');
sessionStorage.clear();
});
it('sends no X-Role/X-Subject headers outside isDevMode()', async () => {
setDevMode(false);
history.pushState({}, '', '/?subject=111222333');
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal('fetch', fetchSpy);
await new LetterPreviewAdapter().preview();
expect(fetchSpy.mock.calls[0][1].headers).toEqual({});
});
it('sends X-Role (and X-Subject when known) under isDevMode()', async () => {
setDevMode(true);
history.pushState({}, '', '/?subject=111222333');
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal('fetch', fetchSpy);
await new LetterPreviewAdapter().preview();
const headers = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
expect(headers['X-Role']).toBeDefined();
expect(headers['X-Subject']).toBe('111222333');
});
});
@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { Injectable, isDevMode } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role';
import { currentSubject } from '@shared/infrastructure/subject';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '@shared/environments/environment';
@@ -12,15 +13,42 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
* `/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.
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
* here explicitly (without `X-Subject` this always previewed
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are
* dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under
* `isDevMode()`, mirroring how the interceptors themselves are only registered in dev
* (`app.config.ts`) a production build sends neither header from this call (BIO-012).
*
* `cache: 'no-store'`: the endpoint has no `Cache-Control`, only a CORS-driven
* `Vary: Origin`, and its content changes at the SAME URL as the letter moves
* draft sent. Explicitly bypassing the HTTP cache is the correct default for any
* mutable resource served under one unversioned URL independent of the
* identity work above, and not a complete fix by itself: see the KNOWN GAP note below.
*
* KNOWN GAP (not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`,
* this repo's own e2e run against a real backend observed this endpoint's SENT
* response still carrying the draft watermark, even though (a) the outgoing request
* carried the correct `X-Subject`, and (b) `curl` against the same backend at the
* same moment correctly returned the frozen, unwatermarked archive. `cache: 'no-store'`
* did not change the outcome, so it is very unlikely a client-side caching artifact
* it looks like a genuine backend-side staleness/race in `BriefStore`'s SQLite-backed
* read path, reproducible for MULTIPLE distinct owners and NOT reproducible for
* `DemoOwner`, which needs backend-side investigation (out of this file's scope
* see `e2e/brief-v2.spec.ts`'s header comment, which keeps that spec on the shared
* `zorgverlener` identity until this is root-caused).
*/
@Injectable({ providedIn: 'root' })
export class LetterPreviewAdapter {
async preview(): Promise<Result<string, Blob>> {
let res: Response;
try {
const subject = currentSubject();
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
headers: { 'X-Role': currentRole() },
cache: 'no-store',
headers: isDevMode()
? { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }
: {},
});
} catch {
return err(PREVIEW_FAILED);
@@ -30,7 +58,9 @@ export class LetterPreviewAdapter {
}
}
async function errorMessage(res: Response): Promise<string> {
/** Trust boundary (TE-002): maps a non-OK response to a message. Exported so a spec
can call it directly instead of stubbing `globalThis.fetch`. */
export async function errorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), PREVIEW_FAILED);
} catch {
@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest';
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
import { parseOrgTemplateAdminView } from './org-template.adapter';
import {
parseOrgTemplateAdminView,
proefbriefErrorMessage,
PROEFBRIEF_FAILED,
} from './org-template.adapter';
const draft: OrgTemplateDto = {
subOrgId: 'cibg-registers',
@@ -54,3 +58,25 @@ describe('parseOrgTemplateAdminView', () => {
expect(r.ok).toBe(false);
});
});
// Minimal Response stand-in — proefbriefErrorMessage only calls `.json()`. Avoids
// stubbing globalThis.fetch to reach this trust boundary (TE-002).
const fakeResponse = (body: unknown): Response =>
({ json: () => Promise.resolve(body) }) as unknown as Response;
describe('proefbriefErrorMessage (TE-002 trust boundary)', () => {
it('surfaces the ProblemDetails detail when present', async () => {
expect(
await proefbriefErrorMessage(fakeResponse({ detail: 'Niet gevonden.', status: 404 })),
).toBe('Niet gevonden.');
});
it('falls back to PROEFBRIEF_FAILED when the body has no detail', async () => {
expect(await proefbriefErrorMessage(fakeResponse({ status: 500 }))).toBe(PROEFBRIEF_FAILED);
});
it('falls back to PROEFBRIEF_FAILED when the body is not JSON', async () => {
const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response;
expect(await proefbriefErrorMessage(res)).toBe(PROEFBRIEF_FAILED);
});
});
@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core';
import { Injectable, inject, isDevMode } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit';
import { runResult, runSubmit } from '@shared/application/submit';
import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '@shared/environments/environment';
@@ -26,17 +26,20 @@ import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';
* 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`.
* `X-Role` there is a dev-only identity stand-in (`role.ts`) and is sent only under
* `isDevMode()`, mirroring `roleInterceptor`'s own dev-only registration a production
* build never sends it from this hand-written call either (BIO-012).
*/
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.`;
export 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);
const r = await runResult(() => this.client.orgTemplates(), FAILED);
if (!r.ok) return r;
const out: SubOrgSummary[] = [];
for (const s of r.value ?? []) {
@@ -48,7 +51,7 @@ export class OrgTemplateAdapter {
}
async load(subOrgId: string): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED);
const r = await runResult(() => this.client.orgTemplateGET(subOrgId), FAILED);
return r.ok ? parseAdminView(r.value) : r;
}
@@ -76,22 +79,26 @@ export class OrgTemplateAdapter {
try {
res = await fetch(
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
{ headers: { 'X-Role': currentRole() } },
{ headers: isDevMode() ? { '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);
}
}
if (!res.ok) return err(await proefbriefErrorMessage(res));
return ok(await res.blob());
}
}
/** Trust boundary (TE-002): maps a non-OK proefbrief response to a message. Exported
so a spec can call it directly instead of stubbing `globalThis.fetch`. */
export async function proefbriefErrorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), PROEFBRIEF_FAILED);
} catch {
return PROEFBRIEF_FAILED;
}
}
// --- parse: wire → domain, validating at the boundary ---
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { parseRevealed, REVEAL_FAILED, RevealBigNummerAdapter } from './reveal-bignummer.adapter';
describe('parseRevealed (TE-002 trust boundary)', () => {
it('accepts a well-formed body', () => {
const r = parseRevealed({ bigNummer: '12345678' });
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe('12345678');
});
// The finding's own named case: a numeric bigNummer must be rejected, not
// coerced — this is a PII reveal, not a display formatter.
it('rejects a bigNummer sent as a number', () => {
const r = parseRevealed({ bigNummer: 42 });
expect(r).toEqual({ ok: false, error: REVEAL_FAILED });
});
it('rejects a missing bigNummer field', () => {
expect(parseRevealed({}).ok).toBe(false);
});
it('rejects null and non-object bodies', () => {
expect(parseRevealed(null).ok).toBe(false);
expect(parseRevealed(undefined).ok).toBe(false);
expect(parseRevealed('12345678').ok).toBe(false);
expect(parseRevealed(42).ok).toBe(false);
});
});
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
// production build. There is no ambient type for it in app code, so this is
// accessed through an untyped bag rather than a `declare const`.
const globals = globalThis as Record<string, unknown>;
const originalNgDevMode = globals['ngDevMode'];
const setDevMode = (on: boolean) => {
globals['ngDevMode'] = on;
};
describe('RevealBigNummerAdapter.reveal (BIO-006a + BIO-012)', () => {
const okResponse = () =>
({ ok: true, json: () => Promise.resolve({ bigNummer: '12345678' }) }) as unknown as Response;
beforeEach(() => setDevMode(true));
afterEach(() => {
globals['ngDevMode'] = originalNgDevMode;
vi.unstubAllGlobals();
});
it('sends X-Step-Up only when the caller passes stepUp: true', async () => {
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal('fetch', fetchSpy);
await new RevealBigNummerAdapter().reveal(false);
const headersWithoutStepUp = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
expect(headersWithoutStepUp['X-Step-Up']).toBeUndefined();
await new RevealBigNummerAdapter().reveal(true);
const headersWithStepUp = fetchSpy.mock.calls[1][1].headers as Record<string, string>;
expect(headersWithStepUp['X-Step-Up']).toBe('true');
});
it('sends X-Role only under isDevMode()', async () => {
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal('fetch', fetchSpy);
setDevMode(false);
await new RevealBigNummerAdapter().reveal(true);
const prodHeaders = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
expect(prodHeaders['X-Role']).toBeUndefined();
expect(prodHeaders['X-Step-Up']).toBe('true'); // step-up is not a dev-only hatch
setDevMode(true);
await new RevealBigNummerAdapter().reveal(true);
const devHeaders = fetchSpy.mock.calls[1][1].headers as Record<string, string>;
expect(devHeaders['X-Role']).toBeDefined();
});
});
@@ -1,47 +1,62 @@
import { Injectable } from '@angular/core';
import { Injectable, isDevMode } 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 '@shared/environments/environment';
const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
/** Exported so specs can assert against the same message id instead of retyping the
Dutch sentence (matches `letter-preview.adapter.ts`'s `PREVIEW_FAILED`). */
export const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
/**
* Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;
* this unmasks it, gated server-side by the reveal capability AND a step-up. The
* step-up is stubbed as the `X-Step-Up` header the caller sends it only after the
* user's confirm gesture, so a plain call (or a role without the capability) 403s.
* step-up is stubbed as the `X-Step-Up` header, sent only when the caller passes
* `stepUp: true` `BriefStore.revealBigNummer()` is the only caller and it is only
* ever reachable after `behandel-scherm.component.ts`'s `onReveal()` confirm gesture,
* so the header now reflects that gesture instead of being a constant baked into this
* adapter (BIO-006a a call that skips confirmation sends no step-up at all).
*
* Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;
* `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the
* same seam as `/brief/preview` and uploads which also means `X-Role` is set here.
* same seam as `/brief/preview` and uploads. `X-Role` is a dev-only identity stand-in
* (see `role.ts`) and is therefore only sent under `isDevMode()`, mirroring the
* `roleInterceptor` registration in `app.config.ts` a production build never sends it
* from this hand-written call either (BIO-012).
*/
@Injectable({ providedIn: 'root' })
export class RevealBigNummerAdapter {
async reveal(): Promise<Result<string, string>> {
async reveal(stepUp: boolean): Promise<Result<string, string>> {
let res: Response;
try {
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, {
method: 'POST',
headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' },
headers: {
...(isDevMode() ? { 'X-Role': currentRole() } : {}),
...(stepUp ? { 'X-Step-Up': 'true' } : {}),
},
});
} catch {
return err(REVEAL_FAILED);
}
if (!res.ok) return err(await errorMessage(res));
const body: unknown = await res.json().catch(() => null);
// Trust boundary: validate the shape before handing back a plain string.
if (
typeof body === 'object' &&
body !== null &&
typeof (body as { bigNummer?: unknown }).bigNummer === 'string'
) {
return ok((body as { bigNummer: string }).bigNummer);
}
return err(REVEAL_FAILED);
return parseRevealed(await res.json().catch(() => null));
}
}
/** Trust boundary: validate the untrusted response shape before handing back a plain
string (TE-002) exported so a spec can call it without stubbing `globalThis.fetch`. */
export function parseRevealed(body: unknown): Result<string, string> {
if (
typeof body === 'object' &&
body !== null &&
typeof (body as { bigNummer?: unknown }).bigNummer === 'string'
) {
return ok((body as { bigNummer: string }).bigNummer);
}
return err(REVEAL_FAILED);
}
async function errorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), REVEAL_FAILED);
@@ -1,10 +1,10 @@
import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { MaskedValueComponent } from '@shared/ui/atoms/masked-value/masked-value.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { StepperComponent } from '@shared/ui/molecules/stepper/stepper.component';
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
import { besluitGuidance, inferSelection } from '@brief/domain/besluit';
import { Diagnostic } from '@brief/domain/placeholders';
@@ -1,8 +1,11 @@
import { Component, computed, input, linkedSignal, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { CheckboxComponent } from '@shared/ui/atoms/checkbox/checkbox.component';
import {
RadioGroupComponent,
RadioOption,
} from '@shared/ui/atoms/radio-group/radio-group.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { Besluit, LibraryPassage } from '@brief/domain/brief';
import { redenenFor } from '@brief/domain/besluit';
+6 -6
View File
@@ -1,8 +1,8 @@
import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { BriefStore } from '@brief/application/brief.store';
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component';
@@ -167,19 +167,19 @@ export class BriefPage {
void this.store.resetDemo();
}
/** Typed narrowing for the `<app-async>` loaded slot see WP-06: a structural
/** Typed narrowing for the `<app-async>` loaded slot: a structural
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();
return s.tag === 'loaded' ? s : undefined;
return s.tag === 'Loaded' ? s : undefined;
});
protected reload() {
void this.store.load();
}
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo (WP-27). Ignored while focus is in the
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo. Ignored while focus is in the
rich-text editor or a form control, so the browser's own text undo keeps working
there our shell-level undo is for structural edits (add/remove/reorder blocks). */
protected onKey(e: KeyboardEvent) {
@@ -1,5 +1,5 @@
import { Component, computed, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { Diagnostic } from '@brief/domain/placeholders';
/** Molecule: lists all letter diagnostics grouped by severity. Errors block
@@ -3,8 +3,8 @@ import { RichTextBlock } from '@shared/kernel/rich-text';
import {
RichTextEditorComponent,
PlaceholderOption,
} from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
} from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { LetterBlock } from '@brief/domain/brief';
/** Molecule: one block in a section its editor plus provenance + block controls.
@@ -1,3 +1,6 @@
/* eslint-disable max-lines */ // 77 lines of CSS + one letter's markup; splitting it into
// letterhead/body/signature/footer makes "what does the letter look like" a five-file
// question for no behavioural seam. Deliberate, not deferred.
import {
Component,
DestroyRef,
@@ -11,9 +14,7 @@ import {
signal,
viewChild,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock } from '@brief/domain/brief';
@@ -21,6 +22,7 @@ import { OrgTemplate } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { Diagnostic } from '@brief/domain/placeholders';
import { BlockDiffKind } from '@brief/domain/brief-diff';
import { LetterLineComponent } from './letter-line.component';
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
type PreviewSegment = {
@@ -39,12 +41,6 @@ function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
return out;
}
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
const SAMPLE_VALUES: Record<string, string> = {
naam_zorgverlener: 'J. Jansen',
big_nummer: '12345678901',
};
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
const A4_HEIGHT_PX = (297 * 96) / 25.4;
@@ -52,12 +48,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
footer around the case-type template's sections. `editableRegions` picks who edits
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
renders everything read-only (approver/locked, absorbs the old letter-preview),
`'template'` reserves the org-identity regions for the admin editor (WP-26).
`'template'` reserves the org-identity regions for the admin editor.
Letter typography/geometry come from the shared `public/letter.css` contract
the same file the backend preview renderer inlines (WP-25). */
the same file the backend preview renderer inlines. Each rendered line is its own
`app-letter-line` (RD-26), replacing the outlet-template indirection that stood
in for it. */
@Component({
selector: 'app-letter-canvas',
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
imports: [ButtonComponent, LetterLineComponent],
styles: [
`
:host {
@@ -81,7 +79,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
color: var(--rhc-color-foreground-subtle);
font-variant-numeric: tabular-nums;
}
/* Rejection-diff badge (WP-27): a small pill above a changed/added block. */
/* Rejection-diff badge: a small pill above a changed/added block. */
.diff-block.diff-changed {
border-inline-start: 3px solid var(--rhc-color-oranje-500);
padding-inline-start: var(--rhc-space-max-sm);
@@ -97,7 +95,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
background: var(--rhc-color-oranje-500);
}
.diff-badge.added {
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (axe). */
color: var(--rhc-color-wit);
background: var(--rhc-color-groen-700);
}
@@ -136,37 +134,19 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
`,
],
template: `
<ng-template #line let-nodes>
@for (node of nodes; track $index) {
@switch (node.type) {
@case ('text') {
<span>{{ node.text }}</span>
}
@case ('lineBreak') {
<br />
}
@case ('placeholder') {
@if (showSample() && autoFor(node.key)) {
<span>{{ sampleFor(node.key) }}</span>
} @else {
<app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)"
/>
}
}
}
}
</ng-template>
@if (editableRegions() !== 'template') {
<div class="toolbar">
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()">
<div
class="zoom"
role="group"
aria-label="Zoomniveau"
i18n-aria-label="@@brief.canvas.zoom"
>
<app-button
variant="subtle"
[disabled]="zoomLevel() <= 0.5"
[attr.aria-label]="zoomOutLabel()"
aria-label="Uitzoomen"
i18n-aria-label="@@brief.canvas.zoomOut"
(click)="zoomBy(-0.1)"
></app-button
>
@@ -174,13 +154,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<app-button
variant="subtle"
[disabled]="zoomLevel() >= 1.5"
[attr.aria-label]="zoomInLabel()"
aria-label="Inzoomen"
i18n-aria-label="@@brief.canvas.zoomIn"
(click)="zoomBy(0.1)"
>+</app-button
>
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{
zoomResetLabel()
}}</app-button>
<app-button variant="subtle" (click)="zoomLevel.set(1)" i18n="@@brief.canvas.zoomReset"
>100%</app-button
>
</div>
@if (editableRegions() === 'none') {
<app-button
@@ -188,7 +169,13 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
(click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()"
>
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
@if (showSample()) {
<ng-container i18n="@@brief.preview.hideSample">Testwaarden verbergen</ng-container>
} @else {
<ng-container i18n="@@brief.preview.showSample"
>Voorbeeld met testwaarden</ng-container
>
}
</app-button>
}
</div>
@@ -200,20 +187,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
(robijn footer background) the letter surface must stay letter.css-only. -->
<div class="letter__letterhead">
@if (logoUrl()) {
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
<img
class="org-logo"
[src]="logoUrl()"
alt="Logo van de organisatie"
i18n-alt="@@brief.canvas.logoAlt"
/>
}
@if (editing()) {
<input
class="tmpl-input org-wordmark"
[value]="orgTemplate().orgName"
[attr.aria-label]="orgNameLabel()"
aria-label="Organisatienaam"
i18n-aria-label="@@brief.canvas.orgName"
(input)="emitEdit('orgName', $event)"
/>
<textarea
class="tmpl-textarea return-address"
rows="2"
[value]="orgTemplate().returnAddress"
[attr.aria-label]="returnAddressLabel()"
aria-label="Retouradres"
i18n-aria-label="@@brief.canvas.returnAddress"
(input)="emitEdit('returnAddress', $event)"
></textarea>
} @else {
@@ -223,11 +217,11 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<address class="address-window">{{ recipientText() }}</address>
<dl class="reference">
<div>
<dt>{{ referenceLabel() }}</dt>
<dt i18n="@@brief.canvas.reference">Ons kenmerk</dt>
<dd>{{ brief().briefId }}</dd>
</div>
<div>
<dt>{{ dateLabel() }}</dt>
<dt i18n="@@brief.canvas.date">Datum</dt>
<dd>{{ letterDate }}</dd>
</div>
</dl>
@@ -241,18 +235,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
<div class="diff-block" [class.diff-changed]="!!diffKind">
@if (diffKind) {
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
diffLabel(diffKind)
}}</span>
<span class="diff-badge" [class.added]="diffKind === 'added'">
@if (diffKind === 'added') {
<ng-container i18n="@@brief.diff.added">nieuw</ng-container>
} @else {
<ng-container i18n="@@brief.diff.changed"
>gewijzigd sinds afwijzing</ng-container
>
}
</span>
}
@for (seg of segmentsOf(block); track $index) {
@if (seg.list === 'bullet') {
<ul>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
<app-letter-line
[nodes]="para.nodes"
[showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/>
</li>
}
@@ -261,18 +264,24 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<ol>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
<app-letter-line
[nodes]="para.nodes"
[showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/>
</li>
}
</ol>
} @else {
<p>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
<app-letter-line
[nodes]="seg.items[0].nodes"
[showSample]="showSample()"
[placeholders]="brief().placeholders"
[diagnostics]="diagnostics()"
[sampleDate]="letterDate"
/>
</p>
}
@@ -288,19 +297,22 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<input
class="tmpl-input"
[value]="orgTemplate().signatureClosing"
[attr.aria-label]="signatureClosingLabel()"
aria-label="Afsluiting"
i18n-aria-label="@@brief.canvas.signatureClosing"
(input)="emitEdit('signatureClosing', $event)"
/>
<input
class="tmpl-input signature-name"
[value]="orgTemplate().signatureName"
[attr.aria-label]="signatureNameLabel()"
aria-label="Naam ondertekenaar"
i18n-aria-label="@@brief.canvas.signatureName"
(input)="emitEdit('signatureName', $event)"
/>
<input
class="tmpl-input"
[value]="orgTemplate().signatureRole"
[attr.aria-label]="signatureRoleLabel()"
aria-label="Functie ondertekenaar"
i18n-aria-label="@@brief.canvas.signatureRole"
(input)="emitEdit('signatureRole', $event)"
/>
} @else {
@@ -316,13 +328,15 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
class="tmpl-textarea footer-contact"
rows="2"
[value]="orgTemplate().footerContact"
[attr.aria-label]="footerContactLabel()"
aria-label="Contactgegevens (voettekst)"
i18n-aria-label="@@brief.canvas.footerContact"
(input)="emitEdit('footerContact', $event)"
></textarea>
<input
class="tmpl-input footer-legal"
[value]="orgTemplate().footerLegal"
[attr.aria-label]="footerLegalLabel()"
aria-label="Juridische voettekst"
i18n-aria-label="@@brief.canvas.footerLegal"
(input)="emitEdit('footerLegal', $event)"
/>
} @else {
@@ -333,7 +347,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
@for (top of pageBreaks(); track $index) {
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
<span>{{ pageBreakCaption() }}</span>
<span i18n="@@brief.canvas.pageBreak">±pagina-einde afdrukvoorbeeld is leidend</span>
</div>
}
</div>
@@ -344,12 +358,12 @@ export class LetterCanvasComponent {
brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>();
/** Who edits what on the surface: read-only ('none', the drafter preview + approver
view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */
view) or admin editor ('template'). Authoring moved to letter-editor. */
editableRegions = input<'template' | 'none'>('none');
diagnostics = input<readonly Diagnostic[]>([]);
/** Initial zoom; the in-canvas controls take over from here (WP-27). */
/** Initial zoom; the in-canvas controls take over from here. */
zoom = input(1);
/** Blocks changed/added/removed since the letter was rejected (WP-27); badged when
/** Blocks changed/added/removed since the letter was rejected; badged when
`showDiff` is on. Removed blocks aren't in the map's rendered set they no longer
exist in the letter the composer surfaces them as a count. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
@@ -359,30 +373,13 @@ export class LetterCanvasComponent {
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
pageBreakCaption = input(
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
);
/** The one label kept as an `input()` rather than inlined `i18n` (RD-26, decision 2):
its message embeds a literal `\n`. As template text that `\n` becomes a source
line break, a different string to Angular's extractor so inlining it would
change the extracted source text, unlike the other 19 labels this ticket inlines. */
recipientText = input(
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`,
);
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
dateLabel = input($localize`:@@brief.canvas.date:Datum`);
logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);
orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);
returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);
signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);
signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`);
zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`);
zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`);
zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`);
addedLabel = input($localize`:@@brief.diff.added:nieuw`);
changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`);
protected showSample = signal(false);
protected letterDate = formatDatumNl(new Date());
@@ -394,9 +391,6 @@ export class LetterCanvasComponent {
// clamp 0.51.5; round to avoid float drift accumulating on repeated clicks.
this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10);
}
protected diffLabel = (kind: BlockDiffKind) =>
kind === 'added' ? this.addedLabel() : this.changedLabel();
/** Admin edit-in-place: the org-identity regions render as controls. */
protected editing = computed(() => this.editableRegions() === 'template');
@@ -417,25 +411,9 @@ export class LetterCanvasComponent {
};
});
// --- read-only rendering helpers (migrated from the superseded letter-preview) ---
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
private worst = computed(() => {
const m = new Map<string, 'error' | 'warning'>();
for (const d of this.diagnostics()) {
if (!d.placeholderKey) continue;
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
}
return m;
});
// --- read-only rendering helper (migrated from the superseded letter-preview) ---
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
protected sampleFor = (key: string) =>
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key));
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
@@ -444,7 +422,7 @@ export class LetterCanvasComponent {
constructor() {
// ponytail: whole-surface height / A4-interval — ignores that a break never truly
// falls mid-line; the caption says "±" and WP-25's server preview is authoritative.
// falls mid-line; the caption says "±" and the server preview is authoritative.
const observer = new ResizeObserver(([entry]) => {
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.
const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
@@ -127,12 +127,12 @@ export const ReadOnlyZonderBevindingen: Story = {
args: { editableRegions: 'none', diagnostics: [] },
};
/** Admin editor focus (consumer arrives in WP-26): body read-only, no "not yours" tint. */
/** Admin editor focus: body read-only, no "not yours" tint. */
export const TemplateMode: Story = { args: { editableRegions: 'template' } };
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged (WP-27). */
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged. */
export const WithDiff: Story = {
args: {
editableRegions: 'none',
@@ -150,14 +150,14 @@ export const PageBreak: Story = {
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
};
// Inline SVG so the story needs no backend/upload round-trip (WP-26 logo upload).
// Inline SVG so the story needs no backend/upload round-trip (the logo upload).
const sampleLogo =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
);
/** Published org logo (WP-26 AC2): the letterhead shows it above the org name. */
/** Published org logo: the letterhead shows it above the org name. */
export const MetLogo: Story = {
args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo },
};
@@ -0,0 +1,104 @@
import { Component, computed, input } from '@angular/core';
import { PlaceholderChipComponent } from '@shared/ui/atoms/placeholder-chip/placeholder-chip.component';
import { RichTextNode } from '@shared/kernel/rich-text';
import { Diagnostic, PlaceholderDef } from '@brief/domain/placeholders';
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
const SAMPLE_VALUES: Record<string, string> = {
naam_zorgverlener: 'J. Jansen',
big_nummer: '12345678901',
};
/** `brief().placeholders` keyed by `key` for O(1) lookup from a node. */
export function placeholderDefs(
placeholders: readonly PlaceholderDef[],
): Map<string, PlaceholderDef> {
return new Map(placeholders.map((p) => [p.key, p]));
}
/** The worst (error over warning) diagnostic severity per placeholder key. */
export function worstSeverities(
diagnostics: readonly Diagnostic[],
): Map<string, 'error' | 'warning'> {
const m = new Map<string, 'error' | 'warning'>();
for (const d of diagnostics) {
if (!d.placeholderKey) continue;
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
}
return m;
}
export function resolveLabel(defs: ReadonlyMap<string, PlaceholderDef>, key: string): string {
return defs.get(key)?.label ?? key;
}
export function resolveAuto(defs: ReadonlyMap<string, PlaceholderDef>, key: string): boolean {
return defs.get(key)?.autoResolvable ?? false;
}
export function resolveState(
worst: ReadonlyMap<string, 'error' | 'warning'>,
key: string,
): 'ok' | 'warning' | 'error' {
return worst.get(key) ?? 'ok';
}
/** The "Voorbeeld" toggle's stand-in for an auto-resolvable placeholder: a canned
sample, the caller's sample date for `datum`, or the field's own label. */
export function resolveSample(
defs: ReadonlyMap<string, PlaceholderDef>,
sampleDate: string,
key: string,
): string {
return SAMPLE_VALUES[key] ?? (key === 'datum' ? sampleDate : resolveLabel(defs, key));
}
/** Organism: one rendered line of letter content text runs, line breaks and
placeholder chips (an auto-resolvable one swaps to a sample value when
`showSample` is on). Extracted from `letter-canvas` (RD-26): the `#line`
template plus the label/auto/state/sample helpers it needs, so the canvas's
three `ngTemplateOutlet` incantations become one tag each. */
@Component({
selector: 'app-letter-line',
imports: [PlaceholderChipComponent],
template: `
@for (node of nodes(); track $index) {
@switch (node.type) {
@case ('text') {
<span>{{ node.text }}</span>
}
@case ('lineBreak') {
<br />
}
@case ('placeholder') {
@if (showSample() && autoFor(node.key)) {
<span>{{ sampleFor(node.key) }}</span>
} @else {
<app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)"
/>
}
}
}
}
`,
})
export class LetterLineComponent {
nodes = input.required<readonly RichTextNode[]>();
showSample = input(false);
placeholders = input<readonly PlaceholderDef[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
/** The canvas's `formatDatumNl(new Date())`, passed down so every line agrees. */
sampleDate = input('');
private defs = computed(() => placeholderDefs(this.placeholders()));
private worst = computed(() => worstSeverities(this.diagnostics()));
protected labelFor = (key: string) => resolveLabel(this.defs(), key);
protected autoFor = (key: string) => resolveAuto(this.defs(), key);
protected stateFor = (key: string) => resolveState(this.worst(), key);
protected sampleFor = (key: string) => resolveSample(this.defs(), this.sampleDate(), key);
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import { PlaceholderDef, Diagnostic } from '@brief/domain/placeholders';
import {
placeholderDefs,
resolveAuto,
resolveLabel,
resolveSample,
resolveState,
worstSeverities,
} from './letter-line.component';
const DEFS: readonly PlaceholderDef[] = [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
];
const LOCATION = { blockId: 'b1', paragraphIndex: 0, nodeIndex: 0 };
function diagnostic(placeholderKey: string, severity: 'error' | 'warning'): Diagnostic {
return { severity, code: 'unresolved-at-send', message: 'x', placeholderKey, location: LOCATION };
}
describe('placeholderDefs', () => {
it('keys the placeholder list by its key', () => {
const defs = placeholderDefs(DEFS);
expect(defs.get('naam_zorgverlener')?.label).toBe('Naam zorgverlener');
expect(defs.get('unknown')).toBeUndefined();
});
});
describe('worstSeverities', () => {
it('ignores a diagnostic with no placeholder key', () => {
const worst = worstSeverities([
{ severity: 'error', code: 'malformed', message: 'x', location: LOCATION },
]);
expect(worst.size).toBe(0);
});
it('keeps error over a warning already recorded for the same key', () => {
const worst = worstSeverities([
diagnostic('naam_zorgverlener', 'warning'),
diagnostic('naam_zorgverlener', 'error'),
]);
expect(worst.get('naam_zorgverlener')).toBe('error');
});
it('does not let a later warning downgrade an error', () => {
const worst = worstSeverities([
diagnostic('naam_zorgverlener', 'error'),
diagnostic('naam_zorgverlener', 'warning'),
]);
expect(worst.get('naam_zorgverlener')).toBe('error');
});
});
describe('resolveLabel', () => {
it('returns the field label for a known key', () => {
expect(resolveLabel(placeholderDefs(DEFS), 'reden_besluit')).toBe('Reden besluit');
});
it('falls back to the bare key when the field is unknown', () => {
expect(resolveLabel(placeholderDefs(DEFS), 'unknown')).toBe('unknown');
});
});
describe('resolveAuto', () => {
it('reads autoResolvable off the field', () => {
const defs = placeholderDefs(DEFS);
expect(resolveAuto(defs, 'naam_zorgverlener')).toBe(true);
expect(resolveAuto(defs, 'reden_besluit')).toBe(false);
});
it('defaults to false for an unknown key', () => {
expect(resolveAuto(placeholderDefs(DEFS), 'unknown')).toBe(false);
});
});
describe('resolveState', () => {
it('defaults to ok when the key has no diagnostic', () => {
expect(resolveState(worstSeverities([]), 'naam_zorgverlener')).toBe('ok');
});
it('surfaces the worst recorded severity', () => {
const worst = worstSeverities([diagnostic('naam_zorgverlener', 'warning')]);
expect(resolveState(worst, 'naam_zorgverlener')).toBe('warning');
});
});
describe('resolveSample', () => {
it('prefers the canned sample value over the label', () => {
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'naam_zorgverlener')).toBe(
'J. Jansen',
);
});
it('resolves datum to the caller-supplied sample date', () => {
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'datum')).toBe(
'4 september 2026',
);
});
it('falls back to the field label for anything else', () => {
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'reden_besluit')).toBe(
'Reden besluit',
);
});
});
@@ -1,8 +1,8 @@
import { Component, computed, input, output, signal } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/atoms/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { Brief } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { Diagnostic } from '@brief/domain/placeholders';
@@ -137,7 +137,7 @@ export class LetterComposerComponent {
canReject = input(false);
canSend = input(false);
busy = input(false);
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The
/** Rejection diff: the changed/added/removed blocks and their count. The
"Toon wijzigingen" toggle only appears when there's something to show. */
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
removedCount = input(0);
@@ -181,7 +181,7 @@ export const Sent: Story = {
}),
};
/** Approver's "Toon wijzigingen" (WP-27): a resubmitted letter with blocks changed,
/** Approver's "Toon wijzigingen": a resubmitted letter with blocks changed,
added and removed since the last rejection. */
export const RejectionDiff: Story = {
render: () =>
@@ -1,5 +1,5 @@
import { Component, computed, input, output } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { Brief } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
@@ -1,8 +1,8 @@
import { Component, input, output } from '@angular/core';
import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { LetterSection } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
@@ -0,0 +1,77 @@
import { Component, computed, input, output } from '@angular/core';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { FileInputComponent } from '@shared/ui/atoms/upload/file-input/file-input.component';
import { SingleUploadComponent } from '@shared/ui/molecules/upload/single-upload/single-upload.component';
import { UploadState } from '@shared/domain/upload.machine';
const LOGO_CATEGORY = 'org-logo';
/**
* Organism: the org-template editor's logo-upload block, split out of
* `org-template-editor.component.ts` (RD-25) one of its two self-contained
* mutation clusters. Presentational: every mutation is an output the parent
* re-emits unchanged.
*/
@Component({
selector: 'app-logo-upload',
imports: [HeadingComponent, AlertComponent, FileInputComponent, SingleUploadComponent],
styles: [
`
:host {
display: block;
}
.section {
margin-block-start: var(--rhc-space-max-xl);
}
`,
],
template: `
<section class="section">
<app-heading [level]="3" i18n="@@orgTemplate.logo">Logo</app-heading>
@if (logoCategory()) {
<app-file-input
inputId="org-logo-input"
[accept]="logoCategory()!.acceptedTypes"
[maxSizeMb]="logoCategory()!.maxSizeMb"
i18n-label="@@orgTemplate.logo"
label="Logo"
(filesSelected)="logoSelected.emit($event)"
/>
}
@if (logoRejection()) {
<app-alert type="error">{{ logoRejection() }}</app-alert>
}
@if (logoUploads().length) {
<ul class="file-list">
@for (u of logoUploads(); track u.localId) {
<li
app-single-upload
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="logoRemoved.emit(u.localId)"
(retry)="logoRetry.emit(u.localId)"
></li>
}
</ul>
}
</section>
`,
})
export class LogoUploadComponent {
logoUrl = input<string | null>(null);
uploadState = input.required<UploadState>();
previewUrlFor = input<(documentId: string) => string | undefined>();
logoSelected = output<File[]>();
logoRemoved = output<string>();
logoRetry = output<string>();
protected logoCategory = computed(() =>
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
);
protected logoUploads = computed(() =>
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
);
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
}
@@ -1,12 +1,9 @@
import { Component, computed, input, output } from '@angular/core';
import { DatePipe } from '@angular/common';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
import { UploadState } from '@shared/upload/upload.machine';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { UploadState } from '@shared/domain/upload.machine';
import { Brief } from '@brief/domain/brief';
import { SAMPLE_LETTER_BRIEF } from '@brief/domain/sample-letter';
import {
MARGIN_MAX_MM,
MARGIN_MIN_MM,
@@ -17,74 +14,29 @@ import {
} from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
import { LogoUploadComponent } from './logo-upload.component';
import { VersionHistoryComponent } from './version-history.component';
const LOGO_CATEGORY = 'org-logo';
const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];
/** A minimal read-only sample letter, so the admin sees the org identity in context
while editing (content itself is not the admin's to change). */
export const SAMPLE_LETTER_BRIEF: Brief = {
briefId: 'VOORBEELD-0001',
beroep: 'arts',
templateId: 'sample',
drafterId: 'sample',
status: { tag: 'draft' },
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'datum', label: 'Datum', autoResolvable: true },
],
sections: [
{
sectionKey: 'body',
title: 'Voorbeeldinhoud',
required: true,
locked: true,
blocks: [
{
type: 'freeText',
blockId: 'sample-1',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
{
nodes: [
{
type: 'text',
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
},
],
},
],
},
},
],
},
],
};
/**
* Organism (WP-26): the admin org-template editor. The mirror of the drafter's
* Organism: the admin org-template editor. The mirror of the drafter's
* composer the letter canvas runs in `editableRegions='template'` so the
* letterhead/signature/footer are edited in place, while the content is a read-only
* sample. Margins, logo upload, version history and the publish bar sit around it.
* Presentational: every mutation is an output the store turns into a command.
* sample. Margins and the publish bar sit around it; the logo uploader and version
* history are their own children (`app-logo-upload`, `app-version-history`, RD-25)
* each a self-contained mutation cluster. Presentational: every mutation is an
* output the store turns into a command, whether sourced here or re-emitted from
* a child.
*/
@Component({
selector: 'app-org-template-editor',
imports: [
DatePipe,
HeadingComponent,
ButtonComponent,
AlertComponent,
FileInputComponent,
SingleUploadComponent,
LetterCanvasComponent,
LogoUploadComponent,
VersionHistoryComponent,
],
styles: [
`
@@ -122,22 +74,6 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
.margins input {
width: 6rem;
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--rhc-space-max-sm);
}
.history-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rhc-space-max-md);
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
padding-block-end: var(--rhc-space-max-sm);
}
.bar {
display: flex;
flex-wrap: wrap;
@@ -153,7 +89,7 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
template: `
<div class="toolbar">
<label class="field">
<span>{{ subOrgLabel() }}</span>
<span i18n="@@orgTemplate.subOrg">Organisatieonderdeel</span>
<select class="form-select" (change)="onSelectSubOrg($event)">
@for (o of subOrgs(); track o.subOrgId) {
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
@@ -190,80 +126,62 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
}
</fieldset>
<section class="section">
<app-heading [level]="3">{{ logoHeading() }}</app-heading>
@if (logoCategory()) {
<app-file-input
inputId="org-logo-input"
[accept]="logoCategory()!.acceptedTypes"
[maxSizeMb]="logoCategory()!.maxSizeMb"
[label]="logoHeading()"
(filesSelected)="logoSelected.emit($event)"
/>
}
@if (logoRejection()) {
<app-alert type="error">{{ logoRejection() }}</app-alert>
}
@if (logoUploads().length) {
<ul class="file-list">
@for (u of logoUploads(); track u.localId) {
<li
app-single-upload
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="logoRemoved.emit(u.localId)"
(retry)="logoRetry.emit(u.localId)"
></li>
}
</ul>
}
</section>
<app-logo-upload
[logoUrl]="logoUrl()"
[uploadState]="uploadState()"
[previewUrlFor]="previewUrlFor()"
(logoSelected)="logoSelected.emit($event)"
(logoRemoved)="logoRemoved.emit($event)"
(logoRetry)="logoRetry.emit($event)"
/>
<section class="section">
<app-heading [level]="3">{{ historyHeading() }}</app-heading>
@if (history().length === 0) {
<p class="published">{{ noHistory() }}</p>
} @else {
<ul class="history-list">
@for (v of history(); track v.version) {
<li class="history-row">
<span
>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span
>
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
{{ rollbackLabel() }}
</app-button>
</li>
}
</ul>
}
</section>
<app-version-history
[history]="history()"
[publishedVersion]="publishedVersion()"
[busy]="busy()"
(rollback)="rollback.emit($event)"
/>
<div class="bar">
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span>
<span class="published"
><span i18n="@@orgTemplate.published">Gepubliceerde versie:</span>
{{ publishedVersion() }}</span
>
@if (pendingPublish()) {
<app-alert type="warning">{{ impactText() }}</app-alert>
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()">
{{ confirmLabel() }}
</app-button>
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()">
{{ cancelLabel() }}
</app-button>
<app-button
variant="primary"
[disabled]="busy()"
(click)="confirmPublish.emit()"
i18n="@@orgTemplate.publish.confirm"
>Bevestigen</app-button
>
<app-button
variant="subtle"
[disabled]="busy()"
(click)="cancelPublish.emit()"
i18n="@@orgTemplate.publish.cancel"
>Annuleren</app-button
>
} @else {
<app-button
variant="primary"
[disabled]="!draftValid() || busy()"
(click)="requestPublish.emit()"
i18n="@@orgTemplate.publish"
>Publiceren</app-button
>
{{ publishLabel() }}
</app-button>
@if (!draftValid()) {
<span class="published">{{ invalidHint() }}</span>
}
}
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()">
{{ proefbriefLabel() }}
</app-button>
<app-button
variant="secondary"
[disabled]="busy()"
(click)="proefbrief.emit()"
i18n="@@orgTemplate.proefbrief"
>Proefbrief</app-button
>
</div>
`,
})
@@ -299,14 +217,6 @@ export class OrgTemplateEditorComponent {
protected readonly MIN = MARGIN_MIN_MM;
protected readonly MAX = MARGIN_MAX_MM;
protected logoCategory = computed(() =>
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
);
protected logoUploads = computed(() =>
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
);
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
protected onSelectSubOrg(event: Event) {
this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
}
@@ -333,20 +243,9 @@ export class OrgTemplateEditorComponent {
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
);
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
protected marginsLegend = input(
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,
);
protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);
protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);
protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);
protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);
protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);
protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);
protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);
protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);
protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);
protected invalidHint = input(
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
);
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { OrgTemplateEditorComponent } from './org-template-editor.component';
import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template';
import { UploadState, initialUpload } from '@shared/upload/upload.machine';
import { UploadState, initialUpload } from '@shared/domain/upload.machine';
const draft: OrgTemplate = {
subOrgId: 'cibg-registers',
@@ -87,12 +87,12 @@ const sampleLogo =
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
);
/** Published logo (WP-26 AC2): the letterhead canvas shows it above the org name. */
/** Published logo: the letterhead canvas shows it above the org name. */
export const MetLogo: Story = {
args: { logoUrl: sampleLogo },
};
/** Client-side upload rejection (existing `rejectReason`, WP-26 AC5) type/size caught
/** Client-side upload rejection (existing `rejectReason`) type/size caught
before the file ever reaches the backend. */
export const LogoUploadFout: Story = {
args: {
@@ -0,0 +1,78 @@
import { Component, input, output } from '@angular/core';
import { DatePipe } from '@angular/common';
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { OrgTemplateVersion } from '@brief/domain/org-template';
/**
* Organism: the org-template editor's version-history block, split out of
* `org-template-editor.component.ts` (RD-25) one of its two self-contained
* mutation clusters. Presentational: `rollback` is the parent's own output,
* re-emitted unchanged.
*/
@Component({
selector: 'app-version-history',
imports: [DatePipe, HeadingComponent, ButtonComponent],
styles: [
`
:host {
display: block;
}
.section {
margin-block-start: var(--rhc-space-max-xl);
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--rhc-space-max-sm);
}
.history-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rhc-space-max-md);
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
padding-block-end: var(--rhc-space-max-sm);
}
.published {
color: var(--rhc-color-foreground-subtle);
}
`,
],
template: `
<section class="section">
<app-heading [level]="3" i18n="@@orgTemplate.history">Versiegeschiedenis</app-heading>
@if (history().length === 0) {
<p class="published" i18n="@@orgTemplate.history.none">Nog niets gepubliceerd.</p>
} @else {
<ul class="history-list">
@for (v of history(); track v.version) {
<li class="history-row">
<span
><span i18n="@@orgTemplate.version">Versie</span> {{ v.version }} ·
{{ v.publishedAt | date: 'longDate' }}</span
>
<app-button
variant="subtle"
[disabled]="busy()"
(click)="rollback.emit(v.version)"
i18n="@@orgTemplate.rollback"
>Terugzetten in concept</app-button
>
</li>
}
</ul>
}
</section>
`,
})
export class VersionHistoryComponent {
history = input<readonly OrgTemplateVersion[]>([]);
publishedVersion = input(0);
busy = input(false);
rollback = output<number>();
}
@@ -1,14 +1,13 @@
import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { UploadAdapter } from '@shared/upload/upload.adapter';
import { OrgTemplateStore } from '@brief/application/org-template.store';
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default
/** Page: thin container for the admin org-template editor. Deny-by-default
capability gate (`orgtemplate:edit`) a denial alert for non-admins, the editor
for admins. Loads once the capability resolves; wires store commands to the organism. */
@Component({
@@ -86,10 +85,9 @@ import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-te
export class OrgTemplatePage {
protected store = inject(OrgTemplateStore);
protected access = inject(AccessStore);
private uploadAdapter = inject(UploadAdapter);
protected canEdit = computed(() => this.access.can('orgtemplate:edit'));
protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId);
protected previewUrlFor = this.store.previewUrlFor;
protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;
protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;
@@ -1,8 +1,8 @@
import { Component, computed, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { CheckboxComponent } from '@shared/ui/atoms/checkbox/checkbox.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { textOf } from '@shared/kernel/rich-text';
import { LibraryPassage } from '@brief/domain/brief';
@@ -10,8 +10,8 @@ import { LibraryPassage } from '@brief/domain/brief';
inserts ALL checked passages at once (a single message upstream) there is no
single-insert path. Presentational: emits the chosen passages in list order.
Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in
`src/app` outside its own story (WP-28 audit). Kept for now rather than deleted
Superseded by `besluit-panel`'s guided drafting: no consumer left in
`src/app` outside its own story. Kept for now rather than deleted
in-flight of an unrelated WP; a future cleanup can remove it. */
@Component({
selector: 'app-passage-picker',
@@ -87,7 +87,7 @@ export class PassagePickerComponent {
protected checked = signal<Record<string, boolean>>({});
protected query = signal('');
/** Client-side filter on label + rendered content text the library is small, so no
server search (WP-27). Placeholder keys are searchable too (see `textOf`). */
server search. Placeholder keys are searchable too (see `textOf`). */
protected filtered = computed(() => {
const q = this.query().trim().toLowerCase();
if (!q) return this.passages();
@@ -1,7 +1,7 @@
import { Component, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/** Molecule: shows the rejection comments (drafter view) or collects them from the
approver. The approver rejects WITH comments; they never edit the letter. */
@@ -8,6 +8,7 @@ import {
back,
gaNaarStap,
submit,
primary,
resolve,
reduce,
WizardState,
@@ -104,6 +105,25 @@ describe('wizard.machine', () => {
});
});
describe('primary', () => {
it('Primary advances to the next step from a non-final step', () => {
const s = toStep2('4160', '200'); // Editing, step 2 — not the final step
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Next' }));
expect(expectTag(primary(s), 'Editing').step).toBe(3);
});
it('Primary submits from the final step', () => {
const s = toStep3('4160', '200'); // Editing, step 3 — the final step
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Submit' }));
expect(primary(s).tag).toBe('Submitting');
});
it('Primary is a no-op from a non-editing state', () => {
const submitting = submit(toStep3('4160', '200'));
expect(primary(submitting)).toBe(submitting);
});
});
describe('reduce (message-driven)', () => {
it('drives the full happy path via messages', () => {
let s: WizardState = initial;
@@ -7,7 +7,8 @@ import {
reduceUpload,
requiredCategoriesSatisfied,
deliveryRefs,
} from '@shared/upload/upload.machine';
digitalDocumentIds,
} from '@shared/domain/upload.machine';
/** What the user is typing (raw, possibly invalid). */
export interface Draft {
@@ -53,7 +54,7 @@ export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolea
!!s.draft.uren ||
!!s.draft.jaren ||
!!s.draft.punten ||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
digitalDocumentIds(s.upload).length > 0
);
}
@@ -121,6 +122,13 @@ export function submit(s: WizardState): WizardState {
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
}
/** The primary button's action: advance, or submit from the last step. No-op
outside Editing this is the one decision the old component-side handler used to make. */
export function primary(s: WizardState): WizardState {
if (s.tag !== 'Editing') return s;
return s.step === 3 ? submit(s) : next(s);
}
/** Route an upload sub-message through the pure upload reducer (Editing only). */
export function upload(s: WizardState, msg: UploadMsg): WizardState {
if (s.tag !== 'Editing') return s;
@@ -152,6 +160,7 @@ export type WizardMsg =
| { tag: 'Back' }
| { tag: 'GaNaarStap'; step: 1 | 2 | 3 }
| { tag: 'Submit' }
| { tag: 'Primary' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
@@ -170,6 +179,8 @@ export function reduce(s: WizardState, m: WizardMsg): WizardState {
return gaNaarStap(s, m.step);
case 'Submit':
return submit(s);
case 'Primary':
return primary(s);
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
@@ -147,6 +147,35 @@ describe('intake acceptance journeys', () => {
});
});
it('raising uren above the threshold after answering scholing drops both fields', () => {
// Given a journey that answered the scholing question while uren was low.
const atReview = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
{ tag: 'SetAnswer', key: 'punten', value: '150' },
{ tag: 'Next' },
);
expect(atReview.tag).toBe('Answering');
expect(atReview.tag === 'Answering' && atReview.cursor).toBe(2); // review
// When the user goes back and raises uren above the threshold, then submits...
const done = given(reduce, atReview)(
{ tag: 'GaNaarStap', cursor: 1 },
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
{ tag: 'Next' }, // the now-hidden scholing question no longer blocks
{ tag: 'Submit' },
{ tag: 'SubmitConfirmed' },
);
// Then the submission succeeds, and BOTH the stale answer and its punten are gone —
// exactly the crafted-POST-shaped payload the server rule rejects.
expect(done.tag).toBe('Submitted');
expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined();
expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined();
});
it('SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing', () => {
const atWerkStep = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
@@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { expectTag } from '@shared/testing/expect-tag';
import {
Answers,
initial,
STEPS,
lageUren,
@@ -11,18 +10,12 @@ import {
back,
gaNaarStap,
submit,
primary,
resolve,
reduce,
IntakeState,
} from './intake.machine';
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({
tag: 'Answering',
answers,
cursor,
errors: {},
scholingThreshold,
});
import { givenIntake } from './intake.testing';
describe('STEPS (fixed) and inline questions', () => {
it('always has the same three steps', () => {
@@ -31,12 +24,12 @@ describe('STEPS (fixed) and inline questions', () => {
it('reveals the buitenland detail questions inline only when worked abroad', () => {
// No new step; instead these fields become required within the buitenland step.
expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked
expect(
expectTag(next(answering({ buitenlandGewerkt: 'ja' })), 'Answering').errors.land,
).toBeTruthy();
expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves)
expect(expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering').cursor).toBe(1);
const abroad = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' });
expect(next(abroad).tag).toBe('Answering'); // land/uren missing -> blocked
expect(expectTag(next(abroad), 'Answering').errors.land).toBeTruthy();
const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
expect(next(domestic).tag).toBe('Answering'); // valid, advances (cursor moves)
expect(expectTag(next(domestic), 'Answering').cursor).toBe(1);
});
it('reveals the scholing question only when NL-hours are below the threshold', () => {
@@ -49,9 +42,13 @@ describe('STEPS (fixed) and inline questions', () => {
expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
// And the threshold from state flows through submit:
const lowThreshold = submit(
answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000),
const lowThresholdState = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
{ tag: 'SetAnswer', key: 'punten', value: '200' },
{ tag: 'SetPolicy', scholingThreshold: 2000 },
);
const lowThreshold = submit(lowThresholdState);
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
expect(expectTag(lowThreshold, 'Answering').errors.scholingGevolgd).toBeTruthy();
});
@@ -65,18 +62,21 @@ describe('navigation', () => {
});
it('Next advances once the step is valid', () => {
const s = expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering');
const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
const s = expectTag(next(domestic), 'Answering');
expect(s.cursor).toBe(1);
expect(currentStep(s)).toBe('werk');
});
it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
const atWerk = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' },
{ tag: 'SetAnswer', key: 'land', value: 'België' },
{ tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' },
{ tag: 'Next' }, // buitenland step valid -> cursor 0 -> 1
);
const edited = expectTag(
reduce(answering({ buitenlandGewerkt: 'ja' }, 1), {
tag: 'SetAnswer',
key: 'buitenlandGewerkt',
value: 'nee',
}),
reduce(atWerk, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }),
'Answering',
);
expect(edited.cursor).toBe(1); // cursor untouched; only inline questions change
@@ -87,72 +87,154 @@ describe('navigation', () => {
});
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
const s = answering({ buitenlandGewerkt: 'nee' }, 2);
const s = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' }, // cursor 0 -> 1
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
{ tag: 'Next' }, // cursor 1 -> 2
);
expect(expectTag(gaNaarStap(s, 0), 'Answering').cursor).toBe(0);
});
it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => {
const s = answering({ buitenlandGewerkt: 'nee' }, 1);
const s = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' }, // cursor 0 -> 1
);
expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op
expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op
const submitting = submit(answering({ buitenlandGewerkt: 'nee', uren: '4160' }, 2));
const atReview = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' }, // cursor 0 -> 1
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
{ tag: 'Next' }, // cursor 1 -> 2
);
const submitting = submit(atReview);
expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op
});
});
describe('submit', () => {
// High hours: no scholing question, so no punten is asked or collected.
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' };
const highUren = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
);
it('reaches Submitting ONLY with valid answers', () => {
// Bad punten only blocks when scholing was followed (otherwise punten is ignored).
expect(
submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }),
).tag,
).toBe('Answering');
const good = expectTag(submit(answering(complete)), 'Submitting');
const badPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
{ tag: 'SetAnswer', key: 'punten', value: 'x' },
);
expect(submit(badPunten).tag).toBe('Answering');
const good = expectTag(submit(highUren), 'Submitting');
expect(good.data.uren).toBe(4160);
expect(good.data.punten).toBeUndefined(); // not collected without scholing
});
it('punten is required only when aanvullende scholing was gevolgd', () => {
// scholing = ja but punten missing -> blocked on punten.
const missing = expectTag(
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })),
'Answering',
const scholingJaNoPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
);
const missing = expectTag(submit(scholingJaNoPunten), 'Answering');
expect(missing.errors.punten).toBeTruthy();
// scholing = nee -> punten not required, submits without it.
expect(
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag,
).toBe('Submitting');
const scholingNee = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'nee' },
);
expect(submit(scholingNee).tag).toBe('Submitting');
});
it('low hours requires the scholing answer before submit', () => {
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
const withScholing = expectTag(
submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }),
),
'Submitting',
const lowUrenNoScholing = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
);
const noScholing = submit(lowUrenNoScholing);
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
const lowUrenWithScholing = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
{ tag: 'SetAnswer', key: 'punten', value: '200' },
);
const withScholing = expectTag(submit(lowUrenWithScholing), 'Submitting');
expect(withScholing.data.aanvullendeScholing).toBe(true);
expect(withScholing.data.punten).toBe(200);
});
it('does not require punten for a hidden question', () => {
// scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above
// threshold — the template hides the question, so punten must not be required either.
const staleScholingNoPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
);
const good = expectTag(submit(staleScholingNoPunten), 'Submitting');
expect(good.data.aanvullendeScholing).toBeUndefined();
});
it('drops punten when raising uren hides the question', () => {
// Same stale answer, but this time punten was also filled in while uren was low.
const staleScholingWithPunten = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
{ tag: 'SetAnswer', key: 'punten', value: '150' },
);
const good = expectTag(submit(staleScholingWithPunten), 'Submitting');
// ValidIntake stays honest: neither the stale 'ja' nor its punten leak through.
expect(good.data.aanvullendeScholing).toBeUndefined();
expect(good.data.punten).toBeUndefined();
});
it('resolve maps Submitting to Submitted on a successful submit', () => {
const submitting = submit(answering(complete));
const submitting = submit(highUren);
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
});
it('resolve maps Submitting to Failed on a failed submit', () => {
const submitting = submit(answering(complete));
const submitting = submit(highUren);
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
});
});
describe('primary', () => {
// Same fixture as the 'submit' describe block above: buitenland answered 'nee',
// uren high enough to skip the scholing question.
const highUren = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
);
it('Primary advances to the next step from a non-final step', () => {
expect(currentStep(expectTag(highUren, 'Answering'))).toBe('buitenland'); // not the final step
expect(reduce(highUren, { tag: 'Primary' })).toEqual(reduce(highUren, { tag: 'Next' }));
expect(expectTag(primary(highUren), 'Answering').cursor).toBe(1);
});
it('Primary submits from the final step', () => {
const atReview = reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' });
expect(currentStep(expectTag(atReview, 'Answering'))).toBe('review'); // the final step
expect(reduce(atReview, { tag: 'Primary' })).toEqual(reduce(atReview, { tag: 'Submit' }));
expect(primary(atReview).tag).toBe('Submitting');
});
it('Primary is a no-op from a non-editing state', () => {
const submitting = submit(reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' }));
expect(primary(submitting)).toBe(submitting);
});
});
describe('reduce (message-driven happy path)', () => {
it('drives abroad branch end to end', () => {
let s: IntakeState = initial;
@@ -60,7 +60,7 @@ export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
// #endregion showcase:steps
/** Per-field error map: one message per question, since a step holds several. */
type Errors = Partial<Record<keyof Answers, string>>;
export type Errors = Partial<Record<keyof Answers, string>>;
export type IntakeState =
| {
@@ -112,8 +112,12 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
if (!u.ok) errors.uren = u.error;
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd)
errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`;
// Nascholingspunten are only asked (and required) when scholing was followed.
if (a.scholingGevolgd === 'ja') {
// Nascholingspunten are only asked (and required) when the question is actually
// visible (lageUren) AND scholing was followed — matching the template's
// `@if (scholingZichtbaar())`. Without the `lageUren` guard, answering 'ja' and then
// raising uren above the threshold left an error on a field the template no longer
// renders.
if (lageUren(a, scholingThreshold) && a.scholingGevolgd === 'ja') {
const p = parseUren(a.punten ?? '');
if (!p.ok) errors.punten = p.error;
}
@@ -142,14 +146,19 @@ function validateAll(a: Answers, scholingThreshold: number): Result<Errors, Vali
const werktBuitenland = a.buitenlandGewerkt === 'ja';
const buitenland = parseUren(a.buitenlandseUren ?? '');
// Punten are only collected when aanvullende scholing was gevolgd.
const punten = a.scholingGevolgd === 'ja' ? parseUren(a.punten ?? '') : undefined;
const aanvullendeScholing = lageUren(a, scholingThreshold)
? a.scholingGevolgd === 'ja'
: undefined;
// Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer —
// a stale 'ja' left over from when uren was low, after uren was raised above the
// threshold, must not leak a punten value into the parsed, submitted ValidIntake.
const punten = aanvullendeScholing === true ? parseUren(a.punten ?? '') : undefined;
return ok({
werktBuitenland,
land: werktBuitenland ? a.land : undefined,
buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,
uren: uren.value,
aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined,
aanvullendeScholing,
punten: punten?.ok ? punten.value : undefined,
});
}
@@ -191,6 +200,13 @@ export function submit(s: IntakeState): IntakeState {
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
}
/** The primary button's action: advance, or submit from the review step. No-op
outside Answering this is the one decision the old component-side handler used to make. */
export function primary(s: IntakeState): IntakeState {
if (s.tag !== 'Answering') return s;
return currentStep(s) === 'review' ? submit(s) : next(s);
}
export function resolve(s: IntakeState, r: Result<string, void>): IntakeState {
if (s.tag !== 'Submitting') return s;
return r.ok
@@ -204,6 +220,7 @@ export type IntakeMsg =
| { tag: 'Back' }
| { tag: 'GaNaarStap'; cursor: number }
| { tag: 'Submit' }
| { tag: 'Primary' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
@@ -222,6 +239,8 @@ export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
return gaNaarStap(s, m.cursor);
case 'Submit':
return submit(s);
case 'Primary':
return primary(s);
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
@@ -1,15 +1,16 @@
import { Component, computed, inject, input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import {
WizardShellComponent,
WizardError,
WizardStatus,
WizardPhase,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -22,10 +23,9 @@ import {
hasProgress,
} from '@herregistratie/domain/herregistratie.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/upload/upload-controller';
import { UploadAdapter } from '@shared/upload/upload.adapter';
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
import { DocumentUploadComponent } from '@shared/ui/organisms/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/application/upload-controller';
import { UploadState, initialUpload, digitalDocumentIds } from '@shared/domain/upload.machine';
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
@@ -51,15 +51,14 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
[stepTitle]="stepTitle()"
i18n-processName="@@herregWizard.processName"
processName="Herregistratie aanvragen"
[status]="shellStatus()"
[phase]="phase()"
[primaryLabel]="primaryLabel()"
[canGoBack]="step() > 1"
[errors]="errorList()"
[errorMessage]="errorMessage()"
(primary)="onPrimary()"
(primary)="dispatch({ tag: 'Primary' })"
(back)="dispatch({ tag: 'Back' })"
(cancel)="restart()"
(retry)="onRetry()"
(retry)="dispatch({ tag: 'Retry' })"
(goToStep)="goToStep($event)"
>
@switch (step()) {
@@ -149,37 +148,31 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
})
export class HerregistratieWizardComponent {
private profile = inject(BigProfileStore);
private uploadAdapter = inject(UploadAdapter);
private store = createStore<WizardState, WizardMsg>(initial, reduce);
/** Preview/download link for a completed upload; dev-simulation `demo-*` ids have
no stored bytes, so they get no link. */
protected previewUrlFor = (documentId: string): string | undefined =>
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<WizardState>(initial);
// --- The store: all state in one signal, changed only by a pure reduce -----
// The effect fires once, on the `Editing -> Submitting` transition. `Seed` is exempt,
// so a story that mounts straight into `Submitting` does not call the network.
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
private store = createStore<WizardState, WizardMsg>(initial, reduce, {
Submitting: async (s, store) => {
this.profile.beginHerregistratie();
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
if (r.ok) {
store.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
store.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
},
});
readonly state = this.store.model; // public so the showcase can highlight the live state
protected dispatch = this.store.dispatch;
// Backend draft-sync (new persistence for this wizard): create a Concept on first
// progress, debounced-sync the snapshot, resume by `?aanvraag=<id>`.
private draftSync = createDraftSync({
type: 'herregistratie',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
const documentIds = deliveryRefs(s.upload)
.filter((r) => r.channel === 'digital' && r.documentId)
.map((r) => r.documentId!);
return { draft: s, stepIndex: s.step - 1, stepCount: this.stepLabels.length, documentIds };
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
enabled: () => this.seed() === initial,
});
// Stepper labels + per-step heading titles (presentational only).
// --- Static copy: stepper labels and per-step headings ---------------------
readonly stepLabels = [
$localize`:@@herregWizard.step.werkervaring:Werkervaring`,
$localize`:@@herregWizard.step.nascholing:Nascholing`,
@@ -191,6 +184,7 @@ export class HerregistratieWizardComponent {
$localize`:@@herregWizard.title.documenten:Documenten aanleveren`,
];
// --- State projections: one narrow, then read-only views of it -------------
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>(
@@ -201,12 +195,35 @@ export class HerregistratieWizardComponent {
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
// --- Controllers: persistence and uploads ----------------------------------
// Create a Concept on first progress, then debounced-sync the snapshot.
// `?aanvraag=<id>` resumes it.
private draftSync = createDraftSync({
type: 'herregistratie',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
return {
draft: s,
stepIndex: s.step - 1,
stepCount: this.stepLabels.length,
documentIds: digitalDocumentIds(s.upload),
};
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
enabled: () => this.seed() === initial,
});
protected uploadCtl = createUploadController({
wizardId: 'herregistratie',
getUpload: () => this.upload(),
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
});
/** Preview/download link for a completed upload; delegates to the upload
controller (application layer), which knows the dev-simulation `demo-*` ids
have no stored bytes and returns no link for them. */
protected previewUrlFor = (documentId: string): string | undefined =>
this.uploadCtl.previewUrlFor(documentId);
// --- Presentational wiring for the shared wizard shell ---------------------
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
@@ -216,33 +233,32 @@ export class HerregistratieWizardComponent {
? naarStapLabel(step + 1, this.stepLabels[step])
: $localize`:@@herregWizard.indienen:Herregistratie aanvragen`;
});
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
composing the localized failure prefix so the `Failed` message arrives intact. */
protected phase = computed<WizardPhase>(() => {
const s = this.state();
switch (s.tag) {
case 'Editing':
return { tag: 'Editing' };
case 'Submitting':
return { tag: 'Submitting' };
case 'Submitted':
return { tag: 'Submitted' };
case 'Failed':
return {
tag: 'Failed',
message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`,
};
}
});
/** Current step's field errors, flattened for the shell's error summary. */
protected errorList = computed<WizardError[]>(() => toWizardErrors(this.editing()?.errors ?? {}));
// --- Event handlers: narrow a child event into a message -------------------
/** Stepper emits a 0-based index for an earlier (visited) step. */
protected goToStep(index: number) {
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
}
protected errorMessage = computed(
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
);
protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) {
case 'Editing':
return 'editing';
case 'Submitting':
return 'submitting';
case 'Submitted':
return 'submitted';
case 'Failed':
return 'failed';
}
});
/** Current step's field errors, flattened for the shell's error summary. */
protected errorList = computed<WizardError[]>(() => {
const e = this.editing()?.errors ?? {};
return (Object.keys(e) as (keyof typeof e)[])
.filter((k) => e[k])
.map((k) => ({ id: k, message: e[k]! }));
});
constructor() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
@@ -253,37 +269,9 @@ export class HerregistratieWizardComponent {
);
}
onPrimary() {
const s = this.state();
if (s.tag !== 'Editing') return;
this.dispatch(s.step < 3 ? { tag: 'Next' } : { tag: 'Submit' });
this.runIfSubmitting();
}
onRetry() {
this.dispatch({ tag: 'Retry' });
this.runIfSubmitting();
}
/** Reset the wizard to a fresh, empty start. */
restart() {
this.draftSync.reset();
this.dispatch({ tag: 'Seed', state: initial });
}
/** The effect: when we entered Submitting, submit through the aanvraag lifecycle,
flip the optimistic cross-page flag, then dispatch the result (commit/rollback). */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
}
}
@@ -4,7 +4,7 @@ import { provideHttpClient } from '@angular/common/http';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
import { initialUpload } from '@shared/upload/upload.machine';
import { initialUpload } from '@shared/domain/upload.machine';
import { Uren } from '@registratie/domain/value-objects/uren';
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
@@ -1,7 +1,7 @@
import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { ASYNC } from '@shared/ui/molecules/async/async.component';
import { map } from '@shared/application/remote-data';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
@@ -0,0 +1,77 @@
import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
import { Answers, Errors } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's first screen (foreign work in the last 5 years).
Pure & presentational values in via `answers`/`errors`, every keystroke out
via `answerChange`. No store, no services, no internal state; the parent owns
the Model and decides what a change means. */
@Component({
selector: 'app-intake-buitenland-step',
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent],
template: `
<fieldset>
<app-form-field
i18n-label="@@intake.q.buitenland"
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
fieldId="buitenlandGewerkt"
required
[error]="err('buitenlandGewerkt')"
>
<app-radio-group
name="buitenlandGewerkt"
[options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''"
(ngModelChange)="answerChange.emit({ key: 'buitenlandGewerkt', value: $event })"
/>
</app-form-field>
</fieldset>
@if (answers().buitenlandGewerkt === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.land"
label="In welk land?"
fieldId="land"
required
[error]="err('land')"
>
<app-text-input
inputId="land"
[ngModel]="answers().land ?? ''"
(ngModelChange)="answerChange.emit({ key: 'land', value: $event })"
name="land"
i18n-placeholder="@@intake.q.landPlaceholder"
placeholder="bijv. België"
/>
</app-form-field>
<app-form-field
i18n-label="@@intake.q.buitenlandseUren"
label="Hoeveel uur heeft u daar gewerkt?"
fieldId="buitenlandseUren"
required
[error]="err('buitenlandseUren')"
>
<app-text-input
inputId="buitenlandseUren"
[ngModel]="answers().buitenlandseUren ?? ''"
(ngModelChange)="answerChange.emit({ key: 'buitenlandseUren', value: $event })"
name="buitenlandseUren"
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
placeholder="bijv. 800"
/>
</app-form-field>
</fieldset>
}
`,
})
export class BuitenlandStep {
answers = input.required<Answers>();
errors = input.required<Errors>();
answerChange = output<{ key: keyof Answers; value: string }>();
readonly jaNee = JA_NEE;
protected err = (k: keyof Answers) => this.errors()[k] ?? '';
}
@@ -1,19 +1,13 @@
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
import {
WizardShellComponent,
WizardError,
WizardStatus,
WizardPhase,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -21,35 +15,35 @@ import {
IntakeState,
IntakeMsg,
Answers,
Errors,
StepId,
initial,
reduce,
STEPS,
lageUren,
hasProgress,
SCHOLING_THRESHOLD_DEFAULT,
} from '@herregistratie/domain/intake.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
import { BuitenlandStep } from './buitenland.step';
import { WerkStep } from './werk.step';
import { ReviewStep } from './review.step';
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
from the answers via `visibleSteps`, never stored so editing an earlier
answer immediately changes the remaining steps. Answers are persisted to
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */
answer immediately changes the remaining steps. The draft persists to the
backend as a Concept aanvraag (createDraftSync), so a reload or a "Verder
gaan" from the dashboard via `?aanvraag=<id>` resumes progress. */
@Component({
selector: 'app-intake-wizard',
imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
ButtonComponent,
AlertComponent,
DataRowComponent,
ReviewSectionComponent,
ConfirmationComponent,
WizardShellComponent,
BuitenlandStep,
WerkStep,
ReviewStep,
],
template: `
<app-wizard-shell
@@ -58,193 +52,38 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
[stepTitle]="stepTitle()"
i18n-processName="@@intake.processName"
processName="Herregistratie-intake"
[status]="shellStatus()"
[phase]="phase()"
[primaryLabel]="primaryLabel()"
[canGoBack]="cursor() > 0"
[errors]="errorList()"
[errorMessage]="errorMessage()"
(primary)="onPrimary()"
(primary)="dispatch({ tag: 'Primary' })"
(back)="dispatch({ tag: 'Back' })"
(cancel)="restart()"
(retry)="onRetry()"
(retry)="dispatch({ tag: 'Retry' })"
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
>
@switch (step()) {
@case ('buitenland') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.buitenland"
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
fieldId="buitenlandGewerkt"
required
[error]="err('buitenlandGewerkt')"
>
<app-radio-group
name="buitenlandGewerkt"
[options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''"
(ngModelChange)="set('buitenlandGewerkt', $event)"
/>
</app-form-field>
</fieldset>
@if (answers().buitenlandGewerkt === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.land"
label="In welk land?"
fieldId="land"
required
[error]="err('land')"
>
<app-text-input
inputId="land"
[ngModel]="answers().land ?? ''"
(ngModelChange)="set('land', $event)"
name="land"
i18n-placeholder="@@intake.q.landPlaceholder"
placeholder="bijv. België"
/>
</app-form-field>
<app-form-field
i18n-label="@@intake.q.buitenlandseUren"
label="Hoeveel uur heeft u daar gewerkt?"
fieldId="buitenlandseUren"
required
[error]="err('buitenlandseUren')"
>
<app-text-input
inputId="buitenlandseUren"
[ngModel]="answers().buitenlandseUren ?? ''"
(ngModelChange)="set('buitenlandseUren', $event)"
name="buitenlandseUren"
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
placeholder="bijv. 800"
/>
</app-form-field>
</fieldset>
}
<app-intake-buitenland-step
[answers]="answers()"
[errors]="errors()"
(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
/>
}
@case ('werk') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.urenNl"
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
fieldId="uren"
required
[error]="err('uren')"
>
<app-text-input
inputId="uren"
[ngModel]="answers().uren ?? ''"
(ngModelChange)="set('uren', $event)"
name="uren"
i18n-placeholder="@@intake.q.urenNlPlaceholder"
placeholder="bijv. 4160"
/>
</app-form-field>
</fieldset>
@if (scholingZichtbaar()) {
<fieldset>
<app-form-field
i18n-label="@@intake.q.scholing"
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
fieldId="scholingGevolgd"
required
[error]="err('scholingGevolgd')"
>
<app-radio-group
name="scholingGevolgd"
[options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''"
(ngModelChange)="set('scholingGevolgd', $event)"
/>
</app-form-field>
</fieldset>
}
@if (answers().scholingGevolgd === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.punten"
label="Behaalde nascholingspunten"
fieldId="punten"
required
[error]="err('punten')"
>
<app-text-input
inputId="punten"
[ngModel]="answers().punten ?? ''"
(ngModelChange)="set('punten', $event)"
name="punten"
i18n-placeholder="@@intake.q.puntenPlaceholder"
placeholder="bijv. 200"
/>
</app-form-field>
</fieldset>
}
<app-intake-werk-step
[answers]="answers()"
[errors]="errors()"
[scholingThreshold]="scholingThreshold()"
(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
/>
}
@case ('review') {
<app-alert type="info" i18n="@@intake.review.controleer"
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
>
<app-review-section
i18n-heading="@@intake.sectie.buitenland"
heading="Buitenland"
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
editAriaLabel="Wijzigen buitenland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
>
<div
app-data-row
i18n-key="@@intake.review.buitenNl"
key="Buiten NL gewerkt"
[value]="answers().buitenlandGewerkt ?? '—'"
></div>
@if (answers().buitenlandGewerkt === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.land"
key="Land"
[value]="answers().land ?? ''"
></div>
<div
app-data-row
i18n-key="@@intake.review.buitenlandseUren"
key="Buitenlandse uren"
[value]="answers().buitenlandseUren ?? ''"
></div>
}
</app-review-section>
<app-review-section
class="app-section"
i18n-heading="@@intake.sectie.werk"
heading="Werk in Nederland"
i18n-editAriaLabel="@@intake.werkWijzigenAria"
editAriaLabel="Wijzigen werk in Nederland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
>
<div
app-data-row
i18n-key="@@intake.review.urenNl"
key="Uren NL"
[value]="answers().uren ?? ''"
></div>
@if (scholingZichtbaar()) {
<div
app-data-row
i18n-key="@@intake.review.scholing"
key="Aanvullende scholing"
[value]="answers().scholingGevolgd ?? ''"
></div>
}
@if (answers().scholingGevolgd === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.punten"
key="Nascholingspunten"
[value]="answers().punten ?? ''"
></div>
}
</app-review-section>
<app-intake-review-step
[answers]="answers()"
[scholingThreshold]="scholingThreshold()"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
/>
}
}
@@ -268,16 +107,66 @@ export class IntakeWizardComponent {
// Server-owned policy (scholing threshold): fetched from the backend via the
// application facade, not hardcoded. The backend stays the authority on submit.
private policyStore = inject(IntakePolicyStore);
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial);
readonly jaNee = JA_NEE;
// --- The store: all state in one signal, changed only by a pure reduce -----
// The effect fires once, on the `Answering -> Submitting` transition. `Seed` is exempt,
// so a story that mounts straight into `Submitting` does not call the network.
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
private store = createStore<IntakeState, IntakeMsg>(initial, reduce, {
Submitting: async (s, store) => {
this.profile.beginHerregistratie();
// The scholing answer rides along so the server can re-validate it as the
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
// JSON.stringify, so a wizard above the threshold sends neither field.
const r = await this.draftSync.submit({
uren: s.data.uren,
aanvullendeScholing: s.data.aanvullendeScholing,
scholingPunten: s.data.punten,
});
if (r.ok) {
store.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
store.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
},
});
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
// Backend draft-sync (replaces sessionStorage); the intake has no uploads.
// --- Static copy: stepper labels and per-step headings ---------------------
readonly stepLabels = [
$localize`:@@intake.step.buitenland:Buitenland`,
$localize`:@@intake.step.werk:Werk`,
$localize`:@@intake.step.controle:Controle`,
];
private stepTitles: Record<StepId, string> = {
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
review: $localize`:@@intake.title.review:Controleren en indienen`,
};
// --- State projections: one narrow, then read-only views of it -------------
private answering = computed(() => whenTag(this.state(), 'Answering'));
/** Public so the showcase can render the (fixed) step list next to the wizard. */
readonly steps = STEPS;
protected cursor = computed(() => this.answering()?.cursor ?? 0);
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
protected scholingThreshold = computed(
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
);
protected errors = computed<Errors>(() => this.answering()?.errors ?? {});
// --- Controllers: persistence and uploads ----------------------------------
// Create a Concept on first progress, then debounced-sync the snapshot.
// `?aanvraag=<id>` resumes it. The intake has no uploads.
private draftSync = createDraftSync({
type: 'intake',
snapshot: () => {
@@ -289,64 +178,36 @@ export class IntakeWizardComponent {
enabled: () => this.seed() === initial,
});
private answering = computed(() => whenTag(this.state(), 'Answering'));
/** Public so the showcase can render the (fixed) step list next to the wizard. */
readonly steps = STEPS;
protected cursor = computed(() => this.answering()?.cursor ?? 0);
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
protected scholingThreshold = computed(
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
);
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
// --- Presentational wiring for the shared wizard shell ---------------------
readonly stepLabels = [
$localize`:@@intake.step.buitenland:Buitenland`,
$localize`:@@intake.step.werk:Werk`,
$localize`:@@intake.step.controle:Controle`,
];
private stepTitles: Record<StepId, string> = {
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
review: $localize`:@@intake.title.review:Controleren en indienen`,
};
protected stepTitle = computed(() => this.stepTitles[this.step()]);
protected primaryLabel = computed(() => {
if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;
const next = this.cursor() + 1;
return naarStapLabel(next + 1, this.stepLabels[next]);
});
protected errorMessage = computed(
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
);
protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) {
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
composing the localized failure prefix so the `Failed` message arrives intact. */
protected phase = computed<WizardPhase>(() => {
const s = this.state();
switch (s.tag) {
case 'Answering':
return 'editing';
return { tag: 'Editing' };
case 'Submitting':
return 'submitting';
return { tag: 'Submitting' };
case 'Submitted':
return 'submitted';
return { tag: 'Submitted' };
case 'Failed':
return 'failed';
return {
tag: 'Failed',
message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`,
};
}
});
/** Current step's field errors, flattened for the shell's error summary. The
field ids match the answer keys, so the summary anchors jump to the field. */
protected errorList = computed<WizardError[]>(() => {
const e = this.answering()?.errors ?? {};
return (Object.keys(e) as (keyof Answers)[])
.filter((k) => e[k])
.map((k) => ({ id: k, message: e[k]! }));
});
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
protected set = (key: keyof Answers, value: string) =>
this.dispatch({ tag: 'SetAnswer', key, value });
protected errorList = computed<WizardError[]>(() =>
toWizardErrors(this.answering()?.errors ?? {}),
);
constructor() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
@@ -364,36 +225,8 @@ export class IntakeWizardComponent {
});
}
onPrimary() {
const s = this.state();
if (s.tag !== 'Answering') return;
this.dispatch(this.step() === 'review' ? { tag: 'Submit' } : { tag: 'Next' });
this.runIfSubmitting();
}
onRetry() {
this.dispatch({ tag: 'Retry' });
this.runIfSubmitting();
}
restart() {
this.draftSync.reset();
this.dispatch({ tag: 'Seed', state: initial });
}
/** The effect: when we enter Submitting, submit through the aanvraag lifecycle,
flip the optimistic cross-page flag, then dispatch the outcome (commit/rollback). */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
const r = await this.draftSync.submit({ uren: s.data.uren });
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
}
}
@@ -0,0 +1,85 @@
import { Component, computed, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/molecules/review-section/review-section.component';
import { Answers, lageUren } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's review screen. Pure & presentational values in via
`answers`/`scholingThreshold`, the cursor to jump back to out via `edit`. No
store, no services, no internal state; the parent maps the cursor onto its own
`GaNaarStap` message. */
@Component({
selector: 'app-intake-review-step',
imports: [AlertComponent, DataRowComponent, ReviewSectionComponent],
template: `
<app-alert type="info" i18n="@@intake.review.controleer"
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
>
<app-review-section
i18n-heading="@@intake.sectie.buitenland"
heading="Buitenland"
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
editAriaLabel="Wijzigen buitenland"
(edit)="edit.emit(0)"
>
<div
app-data-row
i18n-key="@@intake.review.buitenNl"
key="Buiten NL gewerkt"
[value]="answers().buitenlandGewerkt ?? '—'"
></div>
@if (answers().buitenlandGewerkt === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.land"
key="Land"
[value]="answers().land ?? ''"
></div>
<div
app-data-row
i18n-key="@@intake.review.buitenlandseUren"
key="Buitenlandse uren"
[value]="answers().buitenlandseUren ?? ''"
></div>
}
</app-review-section>
<app-review-section
class="app-section"
i18n-heading="@@intake.sectie.werk"
heading="Werk in Nederland"
i18n-editAriaLabel="@@intake.werkWijzigenAria"
editAriaLabel="Wijzigen werk in Nederland"
(edit)="edit.emit(1)"
>
<div
app-data-row
i18n-key="@@intake.review.urenNl"
key="Uren NL"
[value]="answers().uren ?? ''"
></div>
@if (scholingZichtbaar()) {
<div
app-data-row
i18n-key="@@intake.review.scholing"
key="Aanvullende scholing"
[value]="answers().scholingGevolgd ?? ''"
></div>
}
@if (answers().scholingGevolgd === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.punten"
key="Nascholingspunten"
[value]="answers().punten ?? ''"
></div>
}
</app-review-section>
`,
})
export class ReviewStep {
answers = input.required<Answers>();
scholingThreshold = input.required<number>();
edit = output<number>();
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
}

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