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
ehoandClaude Sonnet 5 9da385311d docs: close WP-71, record verification result
CI / changes (push) Successful in 8s
CI / lint (push) Successful in 1m11s
CI / frontend (push) Successful in 2m30s
CI / backend (push) Successful in 1m54s
CI / e2e (push) Successful in 3m9s
CI / semgrep (push) Successful in 1m7s
CI / api-client-drift (push) Successful in 1m53s
CI / storybook-a11y (push) Successful in 10m55s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 20:28:03 +02:00
ehoandClaude Sonnet 5 3652ff8d3f docs(test): make Given/When/Then the default BDD structure (WP-71)
bdd.mdx previously banned "Given/When/Then ceremony" outright, which
directly contradicted WP-70's own acceptance tests (Acceptance/
BesluitLifecycleTests.cs already used // Given/When/Then comments) and
the backend's organically-evolved PascalCase_snake_sentence convention,
which the doc gave zero guidance for. Reverses that rule: every test is
now structured Given -> When -> Then, with a genuinely empty phase
omitted rather than faked; present-tense declarative naming and the
one-behaviour-per-test rule are unchanged. ADR-0006 gets a cross-reference
so both documents agree everywhere, not just in acceptance tests.

Also closes out the doc's other named-but-unenforced rules found by the
audit: fixes the 5 files asserting rendered $localize copy instead of
the underlying tag/message-id (the compliant pattern already existed in
werkvoorraad-item-view.spec.ts), splits the multi-behaviour titles the
doc itself calls a smell (";", "and", "/"), and fixes bdd.mdx's own false
citation of registratie-wizard.machine.spec.ts as "one transition per
test" by actually splitting that test into one-transition-per-test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 20:25:30 +02:00
ehoandClaude Sonnet 5 306d002221 docs(test): generated living behaviour spec + FE/BE seam drift check (WP-71)
Gherkin/Cucumber was considered and rejected for business-readable BDD
scenarios: step-binding by runtime string match undoes the compile-time
guarantees WP-70 just added, and needs two frameworks for .NET+TS with
no non-technical co-author in view. Instead scripts/gen-behaviour-spec.mjs
(modeled on the existing gen-snippets.mjs) extracts every describe/it
and [Fact]/[Theory] name straight from the real suites into
libs/shared/docs/behaviour-spec.mdx, gated for drift in CI exactly like
gen-snippets/gen-api — the page can never diverge from the tests because
it's generated from them, and test names stay the single source of truth.

scripts/check-seam.sh guards the one FE/BE rule duplication most likely
to silently diverge: IntakePolicy.cs's ScholingThreshold vs
intake.machine.ts's SCHOLING_THRESHOLD_DEFAULT, two unlinked literals
pinned separately in each side's own tests but never against each other.

package.json/CI wiring for both (gen:behaviour-spec, check:seam) shipped
in the prior commit alongside the typecheck gate, since all three touch
the same few config files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 20:25:17 +02:00
ehoandClaude Sonnet 5 28c0a250e7 test(backend): split RuleTests.cs by aggregate, refresh DDD doc (WP-71)
RuleTests.cs held five aggregates' rules as nested classes in one file,
misaligned with Domain/<Aggregate>/ and with the Acceptance/Builders/
folder convention WP-70 started. Split into Domain/<Aggregate>RuleTests.cs
(pure move — same names, same bodies, same count) plus a new
ApplicationRuleTests.cs (the enum invariant moved out of the
WebApplicationFactory-booting ApplicationTests.cs, since it's a pure
Enum.GetNames check with no business needing a web host) and
OrgTemplateRuleTests.cs (RejectDraft had no direct unit test before,
only endpoint coverage).

libs/shared/docs/layers.mdx still taught the pre-WP-67 shape (six
contexts, no apps/libs split, enforcement via ESLint) — updated to the
real monorepo structure and to dependency-cruiser as the actual
enforcement mechanism. Adds specs for registration.policy.ts's
isStatusConsistent (untested; its backend mirror is) and both apps'
auth/domain/session.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 20:25:05 +02:00
ehoandClaude Sonnet 5 b937e55ad3 test: close illegal-state escape hatches in spec type-safety (WP-71)
ESLint blanket-exempted every *.spec.ts from the any ban, and no gate
type-checked spec files at all (ng test is transpile-only), so a wrong
cast in a test could never fail the build. 76 `as any` + 12 `as
Extract<>` state-narrowing casts in the three biggest wizard specs read
one variant's fields off a whole-union value: if the reducer returned
the wrong variant, the assertion silently read undefined instead of
failing.

expectTag(state, tag) (libs/shared/src/testing/expect-tag.ts) asserts
and narrows in one call, replacing every one of those casts. Removes
the spec-file any exemption, adds `npm run typecheck` (tsc --noEmit
over each project's tsconfig.spec.json) to CI, and forbids production
code from importing libs/shared/src/testing via dependency-cruiser.
Backend: AanvraagBuilder now models ZaakUrl (closing the last
post-Build() mutation) and guards AtStep; null-forgiving `!` on
endpoint assertions replaced with Assert.NotNull so a null DTO fails by
name, not NullReferenceException.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 20:24:53 +02:00
ehoandClaude Sonnet 5 66224b1644 docs: record WP-70 commit range
CI / changes (push) Successful in 10s
CI / lint (push) Successful in 1m58s
CI / frontend (push) Successful in 3m40s
CI / backend (push) Successful in 2m6s
CI / e2e (push) Successful in 3m36s
CI / semgrep (push) Successful in 1m4s
CI / api-client-drift (push) Successful in 1m48s
CI / storybook-a11y (push) Successful in 13m50s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 15:31:32 +02:00
ehoandClaude Sonnet 5 a82332fa20 docs: ADR-0006 test-data builders, close out WP-70
Writes up the principle behind WP-70's three tracks ("build test data
through the same door production code uses") as ADR-0006, with a decision
table for which fixture idiom fits which test type. Updates the
test-strategy skill (adds the Fixtures rule, fixes its stale pre-monorepo
src/app/... worked-example paths) and the shared Storybook testing.mdx page
to match. Closes WP-70 with the signatures/counts as actually shipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 15:31:20 +02:00
ehoandClaude Sonnet 5 a7bf228ca8 test(e2e): shared Actors/SeedRefs/loginAs, kill duplicated magic strings (WP-70)
The demo BSN, password, and DigiD login sequence were copy-pasted verbatim
into all three specs; the diploma id #diploma-d1 was coupled to SeedData.cs's
ordering by comment only, with no compile-time check if the seed shape
changed. e2e/support/actors.ts names both: Actors.zorgverlener + loginAs()
for the login sequence, SeedRefs.diplomaZonderPolicyVragen for the seed
coupling (with the "why d1" reasoning attached to the name, not scattered
across specs). Zero assertions changed — pure extract-and-rename of test
setup. e2e test-isolation (the shared mutable backend) is a documented
follow-up, not fixed here — see ADR-0006.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 15:31:12 +02:00
ehoandClaude Sonnet 5 a73a1c6f1e test(frontend): replay real messages instead of hand-built state literals (WP-70)
Every machine spec redefined its own throwaway fixture helper (editing1/2/3,
editingWith), hardcoding fields like errors: {} that assert against shapes
the reducer may never actually produce. given(reduce, initial)(...msgs)
(libs/shared/src/testing/machine.ts) replaces them by replaying real Msgs
through the real reduce, so a fixture is provably reachable. Adds the same
idiom for value objects (unwrapOk) and RemoteData (loading/success/failure),
plus intake.acceptance.spec.ts as a worked full-journey example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 15:31:06 +02:00
ehoandClaude Sonnet 5 2eea860efe test(backend): type-state Aanvraag builder, illegal fixtures unrepresentable (WP-70)
RuleTests/OpenZaakZaakSourceTests hand-built Aanvraag fixtures by initializer,
keeping Submitted/Referentie/SubmittedAt/BesluitStatus consistent by hand. A
type-state builder (Given.Concept().Submitted().Decided()) makes an illegal
sequence a compile error instead, and delegates the toelichting-required rule
to the real BeoordelingRules so it can't drift from production. Adds
BesluitLifecycleTests covering the WP-68 besluit invariants end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 15:30:57 +02:00
ehoandClaude Opus 5 868fb55783 docs: close WP-68, record verification result
CI / changes (push) Successful in 9s
CI / lint (push) Successful in 1m0s
CI / frontend (push) Successful in 2m43s
CI / backend (push) Successful in 2m14s
CI / e2e (push) Successful in 3m22s
CI / semgrep (push) Successful in 1m12s
CI / api-client-drift (push) Successful in 2m4s
CI / storybook-a11y (push) Successful in 11m46s
npm run ci passed fully green (lint, format, tokens, all four test suites, both
localized builds, audit, backend dotnet test at 216 passing, snippet + api-client
drift checks). npm run e2e could not be verified this session: port 4200 was
occupied by an unrelated container from a different repo, so Playwright reused it
instead of starting this app — a pre-existing local port collision, not a
regression, and not part of the local GREEN gate per CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:47:35 +02:00
ehoandClaude Opus 5 472a49f19f docs(backend): correct IntakePolicy's false re-validation claim (WP-68 F5)
The doc-comment claimed "the backend re-validates on submit as the authority" —
it doesn't. Neither SubmitApplicationRequest nor IntakeRequest carries a scholing
answer at all, so there's nothing to re-validate; both submit paths only apply
SubmissionRules.RejectZeroUren. A crafted POST can bypass the scholing requirement
entirely. States the gap and points to WP-69 (opened, not yet planned) for the
enforcement, which needs a wire change.

Also strengthens the F2 concurrency test to assert the persisted status matches
whichever request actually won the race, and updates WP-68's own Decisions/
acceptance-criteria text to reflect two implementation choices that improved on
the original write-up once real constraints surfaced: ProcessingWindow stays on
ApplicationStore (StatusAt is already in the same file), and AanvraagStatusTag is
not given a Concept member (would have broken
AanvraagStatusTag_covers_the_published_lifecycle) — AanvraagStatus.Tag is nullable
instead, null exactly for Concept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:42:21 +02:00
ehoandClaude Opus 5 31d4aa1848 test(backend): cover WP-68 F2/F6/T3 (besluit concurrency, toelichting rule, transitions)
- Concurrent_besluiten_on_the_same_aanvraag_yield_exactly_one_success: races two
  besluiten on the same open aanvraag, asserts exactly one 200 and one 409 — the
  behavior F2's in-lock guard exists to guarantee.
- Only_a_non_approval_requires_a_toelichting: unit test for
  BeoordelingRules.RequiresToelichting (F6).
- A_terminal_decision_refuses_any_further_besluit /
  MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal: the transition
  table at the aggregate level (T3) — an Aanvraag whose BesluitStatus already records
  a decision computes a terminal StatusAt, and CanDecide refuses a further besluit,
  independent of the endpoint-level equivalent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:38:30 +02:00
ehoandClaude Opus 5 fc6e73806a refactor(backend): move aanvraag status lifecycle into the domain (WP-68 F3)
The status was derived in Contracts/Mappers.ToStatusDto, not the domain; Concept was
a magic "Concept" string with no AanvraagStatusTag member; and the besluit endpoint
re-derived its own guard by reading the status back out of the DTO and Enum.Parse-ing
it. New Domain/Applications/AanvraagStatus.cs models the full status (Concept
included, via a null Tag rather than a sixth enum member) as a closed type,
constructible only through its factories. Aanvraag.StatusAt(now) carries the logic
verbatim; Mappers.ToStatusDto and ZgwZaakMapper's two status producers become
one-line projections onto the same wire DTO, so the wire shape is unchanged (gen:api
shows zero diff beyond F1's). The one remaining Enum.Parse (the beoordeling GET,
which crosses the IZaakSource wire boundary) is now non-throwing on an unrecognised
tag.

Also, WP-68 F2: the besluit transition-legality check now runs inside
ApplicationStore.RecordBesluit's write lock instead of in the endpoint beforehand —
two concurrent besluiten used to both pass the check before either wrote, letting
the second silently overwrite a terminal decision. RecordBesluit returns an
Ok/NotFound/Conflict outcome, mirroring DocumentStore.DeleteResult.

Also, WP-68 F6: the "toelichting required" rule moves from an inline endpoint check
into BeoordelingRules.RequiresToelichting, alongside CanDecide.

The three tests naming this refactor's regression net
(AanvraagStatusTag_covers_the_published_lifecycle,
AutoApprovable_flips_to_goedgekeurd_after_the_window, ZgwZaakMapperTests) pass
unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:34:10 +02:00
ehoandClaude Opus 5 fd04221d2f chore(api): regenerate client for F1's new 400 responses
npm run gen:api after WP-68 F1 — draft-sync and submit now document their
document-ownership 400 ProblemDetails response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:33:50 +02:00
ehoandClaude Opus 5 a394950a1d fix(backend): reject foreign documentIds on submit and draft-sync (WP-68 F1)
submit and draft-sync took document ids straight from the request body with no
ownership check: a caller who knew a foreign document's id could attach another
citizen's upload to their own aanvraag (surfacing on the behandelaar's beoordeling
screen, POSTed to OpenZaak as their zaakinformatieobject) and permanently block the
victim's own delete by flipping Linked=true. ADR-0001 holds the FE has no authority;
this trusted it anyway.

Adds DocumentStore.ForeignIds(ids, owner) and calls it from both write paths before
any write, 400 ProblemDetails on a mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:24:32 +02:00
ehoandClaude Opus 5 6a4a0ad435 docs: add WP-68, aggregate invariants + status modelling
Architecture review found the context boundaries, FP/TEA idioms and read/write
separation sound, and rejected explicit CQRS as the fix for anything found. It
located four real defects clustered in one place: the backend's aggregate roots
don't guard their own invariants, and the aanvraag status lifecycle is a computed
string living in the contracts layer instead of the domain. Full Decisions block
pre-made so implementation can proceed without re-litigating scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:20:55 +02:00
ehoandClaude Opus 5 6cfd70eeeb fix(backend): resolve besluit endpoint's id via Referentie, not local PK
POST /beoordeling/{id}/besluit always 404'd against a real OpenZaak: {id} is the
FE-facing case id from IZaakSource.ListCases, which under OpenZaakZaakSource is the
ZGW zaak's own uuid, not ApplicationStore's primary key. Resolve the case through
ListCases first (same seam the GET sibling already uses), then to the local Aanvraag
via its Referentie — the one identifier stable across both sources.

Adds ApplicationStore.GetByReferentie and a regression test that reproduces the
divergence with a decorating IZaakSource test double instead of a live OpenZaak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:20:36 +02:00
eho d2c2cffc1f Merge pull request 'Behandelportal: monorepo merge + WP-64..67 backoffice arc (OpenZaak write closes it out)' (#1) from monorepo-behandelportal into main
CI / changes (push) Successful in 9s
CI / lint (push) Successful in 56s
CI / frontend (push) Successful in 2m40s
CI / backend (push) Successful in 2m6s
CI / e2e (push) Successful in 3m20s
CI / semgrep (push) Successful in 1m11s
CI / api-client-drift (push) Successful in 2m10s
CI / storybook-a11y (push) Successful in 11m59s
2026-08-04 12:56:33 +00:00
ehoandClaude Sonnet 5 a2e301060e docs: record WP-66 commit hash
CI / changes (pull_request) Successful in 18s
CI / lint (pull_request) Successful in 1m9s
CI / frontend (pull_request) Successful in 3m28s
CI / backend (pull_request) Successful in 2m59s
CI / e2e (pull_request) Successful in 4m33s
CI / semgrep (pull_request) Successful in 1m23s
CI / api-client-drift (pull_request) Successful in 2m25s
CI / storybook-a11y (pull_request) Successful in 14m48s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 09:41:38 +02:00
ehoandClaude Sonnet 5 d996ca2463 feat(behandelportal): WP-66 wire the decision into OpenZaak
Extends IZaakSource with RecordBesluit, mirroring WP-50's CreateZaak write
pattern: OpenZaakZaakSource POSTs a new Statussen entry (highest-volgnummer
statustype, since the harness catalogus has no per-outcome besluittype),
carrying the besluit + toelichting in statustoelichting; LocalZaakSource
no-ops. The beoordeling endpoint calls it after the local decision commits,
flagging a failure via RecordZgwDivergence the same way submit's
create-zaak/document writes do — closing WP-60's "second write pair" gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 09:41:28 +02:00
ehoandClaude Sonnet 5 39409bdf76 ci: fix test-storybook config-dir + add missing behandelportal a11y coverage
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Successful in 56s
CI / frontend (pull_request) Successful in 2m31s
CI / backend (pull_request) Successful in 1m57s
CI / e2e (pull_request) Successful in 3m32s
CI / semgrep (pull_request) Successful in 1m13s
CI / api-client-drift (pull_request) Successful in 2m2s
CI / storybook-a11y (pull_request) Successful in 11m42s
WP-67's monorepo split renamed .storybook to .storybook-ssp/
.storybook-behandelportal, but test-storybook still defaulted to the
(now nonexistent) plain .storybook dir -- it loads <config-dir>/main.js
for test-runner hooks even in --url mode, so every invocation failed
with "Could not load main.js in .storybook".

Also: test-storybook:ci only ever built+served+tested the ssp instance.
Since the split, behandelportal's stories (werkvoorraad, beoordeling,
besluit-form, ...) were never axe-tested in CI at all. Added the
:behandelportal siblings (mirroring the existing storybook/
build-storybook naming) and wired them into ci-local.sh's --full step
and the storybook-a11y GitHub Actions job.

Verified directly: ssp 62/180 stories green, behandelportal 45/112
green (including the new besluit-form story), full `ci-local.sh --full`
green end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 10:24:49 +02:00
ehoandClaude Sonnet 5 6c6bef45f3 docs: fix stale WP-65 status in backlog README, closing the format:check gap
CI / changes (pull_request) Successful in 16s
CI / lint (pull_request) Successful in 54s
CI / frontend (pull_request) Successful in 2m27s
CI / storybook-a11y (pull_request) Failing after 3m16s
CI / backend (pull_request) Successful in 1m53s
CI / semgrep (pull_request) Successful in 1m7s
CI / e2e (pull_request) Successful in 2m45s
CI / api-client-drift (pull_request) Successful in 1m57s
WP-65's summary-table row still said "in progress (65a done)" from before
65b shipped, and that one oversized cell was forcing prettier to want to
re-pad the entire ~65-row table. Correcting it to "done" (its actual
status, per the WP-65 file's own outcome notes) removes the outlier —
npm run format:check is green again with zero other rows touched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 09:52:34 +02:00
ehoandClaude Sonnet 5 af8a011819 feat(behandelportal): WP-65b beoordeling besluit (decision write)
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 56s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m19s
CI / backend (pull_request) Failing after 1m55s
CI / api-client-drift (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 40s
CI / semgrep (pull_request) Canceled after 24s
Adds POST /beoordeling/{id}/besluit: a Besluit enum (Goedkeuren/Afwijzen/
MeerInfoOpvragen) backed by new Aanvraag.BesluitStatus/BesluitToelichting
columns, gated by the same BeoordelingRules.CanDecide the read side's
canBesluiten flag already uses (409 on an illegal transition, 400 on a
missing required toelichting). Mappers.ToStatusDto gains the "a recorded
decision wins" branch. FE: besluit.machine.ts + besluit-form organism
(same form idiom as change-request-form), wired into the beoordeling page
behind the server's canBesluiten flag.

Completes WP-65 (65a + 65b) — verified end-to-end against a running
backend (werkvoorraad -> beoordeling -> besluit -> status reflected back).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 09:46:20 +02:00
ehoandClaude Sonnet 5 4133b30e5d feat(behandelportal): WP-65a beoordeling detail (read) + fix unreachable medewerker login
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 54s
CI / frontend (pull_request) Successful in 2m38s
CI / storybook-a11y (pull_request) Failing after 3m28s
CI / backend (pull_request) Successful in 2m1s
CI / semgrep (pull_request) Successful in 1m9s
CI / e2e (pull_request) Successful in 2m55s
CI / api-client-drift (pull_request) Successful in 2m1s
New GET /beoordeling/{id} shows one aanvraag's status, linked documents, and a
canBesluiten decision flag, gated by the same CanBeoordelen capability as the
werkvoorraad list. Reads through IZaakSource.ListCases rather than a new seam
method (WP-66 needs one anyway for the real write); owner BSN is masked.

Fixes a real gap found while wiring this up: the behandelportal's login was still
WP-61's copied citizen/BSN DigiD flow, so nothing ever sent X-Medewerker and the
werkvoorraad screen (WP-64) always denied in a real browser. A dev-only
medewerkerInterceptor (mirrors the existing ?role= stand-in as ?rollen=) fixes that.

WP-65's own Risks note authorized splitting read from write across sessions given
its size; this is the read half. The decision-recording mutation is next (65b).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 09:01:09 +02:00
ehoandClaude Sonnet 5 fe69caee63 feat(behandelportal): WP-64 werkvoorraad (queue) screen
CI / changes (pull_request) Successful in 15s
CI / lint (pull_request) Successful in 57s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m14s
CI / backend (pull_request) Successful in 2m1s
CI / semgrep (pull_request) Successful in 1m10s
CI / e2e (pull_request) Successful in 3m3s
CI / api-client-drift (pull_request) Successful in 2m1s
New GET /werkvoorraad endpoint lists aanvragen still open (Ingediend/InBehandeling),
gated by the medewerker capability (CanBeoordelen) rather than the admin role — reuses
the existing ApplicationSummaryDto, no new DTO. GET /me now surfaces aanvraag:beoordelen
for a behandelaar so the FE can gate with the same AccessStore/capabilityGuard idiom
every other page uses.

FE: a behandeling domain type deliberately narrower than ssp's full AanvraagStatus
union (only the two open tags — illegal states unrepresentable), composed into a
werkvoorraad-list organism from existing shared/ui molecules. Replaces WP-61's
scaffold placeholder as the app's real landing page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 22:02:35 +02:00
ehoandClaude Sonnet 5 e7156c5132 feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 21:01:57 +02:00
750 changed files with 43024 additions and 67798 deletions
+14 -3
View File
@@ -22,6 +22,13 @@ No `TestBed` for domain. Never assert on user-facing copy.
only for wiring axe can't see. only for wiring axe can't see.
- **Never assert on `$localize` copy.** It changes per locale/edit — assert on the - **Never assert on `$localize` copy.** It changes per locale/edit — assert on the
`Result`, the value object, or the message id. `Result`, the value object, or the message id.
- **Fixtures go through the production door, never a hand-built literal** (ADR-0006).
Replay real `Msg`s through the real `reduce` (`given(reduce, initial)(...msgs)`) for a
state machine; `unwrapOk(parseX(raw))` for a value object; a type-state builder
(`Given.Concept().Submitted()...`) for a backend aggregate with an ordered lifecycle.
The one deliberate exception is a trust-boundary `parse*` spec, below — there the fixture
must be a raw, possibly-malformed literal, because the test's whole point is "what if this
shape is wrong." See ADR-0006's decision table for which idiom fits which test type.
## Skeleton ## Skeleton
@@ -54,9 +61,13 @@ describe('parseThing', () => {
## Worked examples ## Worked examples
- `src/app/registratie/domain/value-objects/postcode.spec.ts` — parser style. - `apps/ssp/src/app/registratie/domain/value-objects/postcode.spec.ts` — parser style.
- `src/app/registratie/infrastructure/brp.adapter.spec.ts` — trust boundary (`null`/`{}`). - `apps/ssp/src/app/registratie/infrastructure/brp.adapter.spec.ts` — trust boundary (`null`/`{}`).
- `src/app/registratie/domain/registratie-wizard.machine.spec.ts` — pure reducer. - `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts` — pure reducer.
- `libs/shared/src/testing/{machine,remote-data,value-object}.ts` — the shared fixture
helpers (ADR-0006); `apps/ssp/src/app/herregistratie/domain/intake.testing.ts` — a
per-context wrapper (`givenIntake = given(reduce, initial)`); `intake.acceptance.spec.ts`
— a full journey expressed as one replayed message sequence.
## Verify ## Verify
+169
View File
@@ -0,0 +1,169 @@
// Dependency-cruiser (WP-38, generalized for the WP-67 monorepo split): the single
// declarative source for the bounded-context + atomic-layer boundaries. Each app
// (apps/ssp, apps/behandelportal) is cruised SEPARATELY against its own tsconfig.json
// (see .dependency-cruiser.<app>.js) — a single merged tsconfig can't resolve both
// apps' `@auth/*` alias at once, since each points at a different physical directory.
// This file is the shared rule *factory*; it never runs standalone.
//
// libs/shared and libs/beheer are cross-app libraries, not per-app feature contexts:
// any app context may import either; neither may import an app's feature context;
// libs/shared may not import libs/beheer (shared stays the base, beheer a peer leaf).
/**
* @param {Record<string, string[] | null>} contextAllowed context name -> the OTHER
* app-local contexts it may additionally import (besides itself + libs/shared|beheer).
* `null` = unrestricted (showcase, the sanctioned teaching page).
* @param {string} appName the apps/<appName> directory this config cruises.
* @param {string} tsConfigFileName this app's own tsconfig.json (resolves its aliases).
*/
module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName) {
const FEATURES = Object.keys(contextAllowed).join('|');
const appRoot = `apps/${appName}/src/app`;
const contextRule = (from) => {
const allowed = contextAllowed[from];
if (allowed === null) return null;
const forbidden = Object.keys(contextAllowed)
.filter((name) => name !== from && !allowed.includes(name))
.join('|');
return {
name: `${appName}-${from}-scope`,
comment: `${from} may depend only on its allowed contexts (+ libs/shared|beheer). See CLAUDE.md §1.`,
severity: 'error',
from: { path: `^${appRoot}/${from}/` },
to: { path: `^${appRoot}/(${forbidden})/` },
};
};
// Atomic-layer rules apply uniformly across this app's tree AND both libraries.
const anyRoot = `(${appRoot}|libs/shared/src|libs/beheer/src)`;
return {
forbidden: [
// --- Bounded-context direction (the "dependencies point inward" spine) ---
{
name: 'shared-no-features',
comment: 'libs/shared is the base — it must not import any app feature context.',
severity: 'error',
from: { path: '^libs/shared/src/' },
to: { path: `^${appRoot}/(${FEATURES})/` },
},
{
name: 'beheer-no-features',
comment: 'libs/beheer is a cross-app library — it must not import any app feature context.',
severity: 'error',
from: { path: '^libs/beheer/src/' },
to: { path: `^${appRoot}/(${FEATURES})/` },
},
{
name: 'shared-no-beheer',
comment: 'libs/shared stays the base — it must not depend on the beheer library.',
severity: 'error',
from: { path: '^libs/shared/src/' },
to: { path: '^libs/beheer/src/' },
},
{
name: `${appName}-no-other-app`,
comment: "An app may not import another app's source directly.",
severity: 'error',
from: { path: `^${appRoot}/` },
to: { path: '^apps/(?!' + appName + '/)' },
},
...Object.keys(contextAllowed).map(contextRule).filter(Boolean),
// --- Atomic-layer rules (dependencies point inward: ui → application → domain) ---
{
name: 'domain-is-pure',
comment: 'domain/ is framework-free business logic — no Angular.',
severity: 'error',
from: { path: `^${anyRoot}/.*/domain/` },
to: { path: 'node_modules/@angular/' },
},
{
name: 'contracts-import-nothing',
comment: 'contracts/ are pure wire DTO shapes — they import nothing (ADR-0001).',
severity: 'error',
from: { path: `^${anyRoot}/.*/contracts/` },
to: { pathNot: '/contracts/', path: `^(${anyRoot}/|node_modules/@angular/)` },
},
{
name: 'ui-not-infrastructure',
comment:
'ui/ + layout/ reach data through an application store/command, never infrastructure directly (type-only DTO imports allowed).',
severity: 'error',
from: {
path: `^${anyRoot}/.*(/ui/|/layout/)`,
pathNot: '\\.stories\\.ts$|\\.spec\\.ts$',
},
to: { path: '/infrastructure/', dependencyTypesNot: ['type-only'] },
},
{
name: 'apiclient-infrastructure-only',
comment:
'The generated ApiClient is a value only inside infrastructure/; elsewhere type-only.',
severity: 'error',
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:
'Test-only fixture helpers (libs/shared/src/testing/** and any *.testing.ts) are reached from specs/stories only — production code gets its data through the real domain/application doors (ADR-0006), never the test escape hatch.',
severity: 'error',
from: {
pathNot: '\\.(spec|stories)\\.ts$|\\.testing\\.ts$|^libs/shared/src/testing/',
},
to: { path: '^libs/shared/src/testing/|\\.testing\\.ts$' },
},
// --- Hygiene (cheap wins a graph makes obvious) ---
{
name: 'no-circular',
comment: 'No cyclic dependencies.',
severity: 'error',
from: {},
to: { circular: true },
},
],
options: {
doNotFollow: { path: 'node_modules' },
tsConfig: { fileName: tsConfigFileName },
tsPreCompilationDeps: true, // needed so `type-only` imports are distinguished
enhancedResolveOptions: {
exportsFields: ['exports'],
conditionNames: ['import', 'require', 'node', 'default'],
},
reporterOptions: {
archi: { collapsePattern: '^(apps/[^/]+/src/app|libs/[^/]+/src)/[^/]+' },
},
},
};
};
+9
View File
@@ -0,0 +1,9 @@
// behandelportal's own context boundaries.
module.exports = require('./.dependency-cruiser.base.js')(
{
auth: [],
behandeling: [],
},
'behandelportal',
'apps/behandelportal/tsconfig.json',
);
-118
View File
@@ -1,118 +0,0 @@
// Dependency-cruiser (WP-38): the single declarative source for the app's bounded-context
// + atomic-layer boundaries — and the graph you can SEE (`npm run dep:graph`). Replaces the
// hand-duplicated `no-restricted-imports` blocks that had to be copied per context (and that
// left `herregistratie` without one). ESLint keeps only the rules dep-cruiser can't express
// (no-explicit-any, template a11y).
//
// Contexts: shared (base) · auth · registratie · herregistratie · brief · beheer · showcase.
// Allowed cross-context edges: everyone → shared; herregistratie → registratie; showcase → *
// (the sanctioned teaching page). Nobody imports showcase.
// Single source of truth for bounded-context boundaries: each entry maps a context name to the
// OTHER contexts it may additionally import (besides itself + shared). `showcase` maps to `null`
// — sanctioned to import every context (the teaching page); nothing else may import it. Add a
// context here — nowhere else — when scaffolding one (see `gen:context`, WP-44); FEATURES and
// every contextRule below are derived from this object.
const CONTEXT_ALLOWED = {
auth: [],
registratie: [],
herregistratie: ['registratie'], // the one sanctioned cross-feature edge
brief: [],
beheer: [],
showcase: null,
};
const FEATURES = Object.keys(CONTEXT_ALLOWED).join('|');
/** A context may import shared + itself + its allowed list; forbidden = every other context. */
const contextRule = (from) => {
const allowed = CONTEXT_ALLOWED[from];
if (allowed === null) return null; // unrestricted (showcase) — no rule to generate
const forbidden = Object.keys(CONTEXT_ALLOWED)
.filter((name) => name !== from && !allowed.includes(name))
.join('|');
return {
name: `${from}-scope`,
comment: `${from} may depend only on its allowed contexts (+ shared). See CLAUDE.md §1.`,
severity: 'error',
from: { path: `^src/app/${from}/` },
to: { path: `^src/app/(${forbidden})/` },
};
};
module.exports = {
forbidden: [
// --- Bounded-context direction (the "dependencies point inward" spine) ---
{
name: 'shared-no-features',
comment: 'shared/ is the base — it must not import any feature context.',
severity: 'error',
from: { path: '^src/app/shared/', pathNot: '^src/app/shared/ui/debug-state/' },
to: { path: `^src/app/(${FEATURES})/` },
},
...Object.keys(CONTEXT_ALLOWED).map(contextRule).filter(Boolean),
// showcase/ is exempt (reads every context by design); nothing imports it — covered by the
// rules above each forbidding `→ showcase`.
// --- Atomic-layer rules (dependencies point inward: ui → application → domain) ---
{
name: 'domain-is-pure',
comment: 'domain/ is framework-free business logic — no Angular.',
severity: 'error',
from: { path: '/domain/' },
to: { path: 'node_modules/@angular/' },
},
{
name: 'contracts-import-nothing',
comment: 'contracts/ are pure wire DTO shapes — they import nothing (ADR-0001).',
severity: 'error',
from: { path: '/contracts/' },
to: { pathNot: '/contracts/', path: '^(src/app/|node_modules/@angular/)' },
},
{
name: 'ui-not-infrastructure',
comment:
'ui/ + layout/ reach data through an application store/command, never infrastructure directly (type-only DTO imports allowed).',
severity: 'error',
from: {
path: '(/ui/|/layout/)',
pathNot: '\\.stories\\.ts$|\\.spec\\.ts$|^src/app/shared/ui/debug-state/',
},
to: { path: '/infrastructure/', dependencyTypesNot: ['type-only'] },
},
{
name: 'apiclient-infrastructure-only',
comment:
'The generated ApiClient is a value only inside infrastructure/ (+ shared/upload); elsewhere type-only.',
severity: 'error',
from: { pathNot: '/infrastructure/|^src/app/shared/upload/' },
to: {
path: '^src/app/shared/infrastructure/api-client\\.ts$',
dependencyTypesNot: ['type-only'],
},
},
// --- Hygiene (cheap wins a graph makes obvious) ---
{
name: 'no-circular',
comment: 'No cyclic dependencies.',
severity: 'error',
from: {},
to: { circular: true },
},
],
options: {
doNotFollow: { path: 'node_modules' },
tsConfig: { fileName: 'tsconfig.json' }, // resolves @shared/@registratie/… path aliases
tsPreCompilationDeps: true, // needed so `type-only` imports are distinguished
enhancedResolveOptions: {
exportsFields: ['exports'],
conditionNames: ['import', 'require', 'node', 'default'],
},
reporterOptions: {
// Context-level architecture graph for `npm run dep:graph` (mermaid — no graphviz needed).
archi: { collapsePattern: '^src/app/[^/]+/[^/]+' },
},
},
};
+17
View File
@@ -0,0 +1,17 @@
// The SSP's own context boundaries. Add a context here — nowhere else — when
// 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'],
brief: [],
showcase: null, // unrestricted — the sanctioned teaching page; nothing imports it
},
'ssp',
'apps/ssp/tsconfig.json',
);
+1 -1
View File
@@ -4,7 +4,7 @@
.git .git
node_modules node_modules
dist dist
storybook-static storybook-static*
backend/**/bin backend/**/bin
backend/**/obj backend/**/obj
backend/**/bigregister.db* backend/**/bigregister.db*
+29 -11
View File
@@ -35,16 +35,17 @@ jobs:
with: with:
filters: | filters: |
frontend: frontend:
- 'src/**' - 'apps/**'
- 'libs/**'
- 'public/**' - 'public/**'
- 'e2e/**' - 'e2e/**'
- 'scripts/**' - 'scripts/**'
- 'angular.json' - 'angular.json'
- 'package*.json' - 'package*.json'
- 'tsconfig*.json' - 'tsconfig*.json'
- '.storybook/**' - '.storybook*/**'
- 'eslint.config.mjs' - 'eslint.config.mjs'
- '.dependency-cruiser.js' - '.dependency-cruiser*.js'
- '.github/workflows/**' - '.github/workflows/**'
backend: backend:
- 'backend/**' - 'backend/**'
@@ -74,10 +75,14 @@ jobs:
if: needs.changes.outputs.frontend == 'true' && steps.node-modules-cache.outputs.cache-hit != 'true' if: needs.changes.outputs.frontend == 'true' && steps.node-modules-cache.outputs.cache-hit != 'true'
- run: npm run lint - run: npm run lint
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
- run: npm run typecheck
if: needs.changes.outputs.frontend == 'true'
- run: npm run format:check - run: npm run format:check
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
- run: npm run check:tokens - run: npm run check:tokens
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
- run: npm run check:seam
if: needs.changes.outputs.frontend == 'true'
frontend: frontend:
needs: changes needs: changes
@@ -104,16 +109,20 @@ jobs:
- run: npm run dep:check - run: npm run dep:check
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
# Showcase snippets must match their real source regions (WP-39, no drift). # Showcase snippets must match their real source regions (WP-39, no drift).
- run: npm run gen:snippets && git diff --exit-code src/app/showcase/snippets.generated.ts - run: npm run gen:snippets && git diff --exit-code apps/ssp/src/app/showcase/snippets.generated.ts
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
# Runs the full suite AND reports coverage (WP-46, report-only — no thresholds, so # Behaviour spec must match the real test names it's generated from (WP-71, no drift).
# it can't fail on coverage; it still fails on a failing test, like `npm test` did). - run: npm run gen:behaviour-spec && git diff --exit-code libs/shared/docs/behaviour-spec.mdx
if: needs.changes.outputs.frontend == 'true'
# Runs the full suite (both apps + both shared libraries, WP-67) AND reports coverage
# (WP-46, report-only — no thresholds, so it can't fail on coverage; it still fails on
# a failing test, like `npm test` did).
- run: npm run test:coverage - run: npm run test:coverage
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
# --localize builds every configured locale (nl + en, angular.json's i18n # --localize builds every configured locale (nl + en, angular.json's i18n block) in one
# block) in one pass; i18nMissingTranslation:"error" (angular.json) fails # pass per app; i18nMissingTranslation:"error" (angular.json) fails this step if either
# this step if messages.en.xlf is missing a unit the source (WP-20) gains. # app's messages.en.xlf is missing a unit its source (WP-20) or libs/shared gains.
- run: npx ng build --localize - run: npx ng build ssp --localize && npx ng build behandelportal --localize
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
# The shipped bundle must stay clean; dev-only advisories are excluded. # The shipped bundle must stay clean; dev-only advisories are excluded.
- run: npm audit --omit=dev - run: npm audit --omit=dev
@@ -166,6 +175,10 @@ jobs:
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
- run: npm run test-storybook:ci - run: npm run test-storybook:ci
if: needs.changes.outputs.frontend == 'true' if: needs.changes.outputs.frontend == 'true'
- run: npm run build-storybook:behandelportal
if: needs.changes.outputs.frontend == 'true'
- run: npm run test-storybook:ci:behandelportal
if: needs.changes.outputs.frontend == 'true'
backend: backend:
needs: changes needs: changes
@@ -189,6 +202,11 @@ jobs:
# run manually against backend/openzaak/ (see its README), never in CI. # run manually against backend/openzaak/ (see its README), never in CI.
- run: dotnet test backend/BigRegister.slnx --filter "Category!=Integration" - run: dotnet test backend/BigRegister.slnx --filter "Category!=Integration"
if: needs.changes.outputs.backend == 'true' 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: e2e:
needs: changes needs: changes
@@ -300,5 +318,5 @@ jobs:
if: (needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true') && steps.node-modules-cache.outputs.cache-hit != 'true' if: (needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true') && steps.node-modules-cache.outputs.cache-hit != 'true'
- run: npm run gen:api - run: npm run gen:api
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true'
- run: git diff --exit-code src/app/shared/infrastructure/api-client.ts backend/swagger.json - run: git diff --exit-code libs/shared/src/infrastructure/api-client.ts backend/swagger.json
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true'
+6 -1
View File
@@ -44,7 +44,8 @@ __screenshots__/
Thumbs.db Thumbs.db
*storybook.log *storybook.log
storybook-static storybook-static*
documentation.json
# Playwright e2e # Playwright e2e
/test-results /test-results
@@ -57,3 +58,7 @@ backend/openzaak/seeded.env
# WP-55: render-prod-secrets.sh's output — the real client secret, never committed # WP-55: render-prod-secrets.sh's output — the real client secret, never committed
backend/openzaak/setup_configuration/data.prod.yaml 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/
+13 -3
View File
@@ -1,16 +1,22 @@
# Build output & caches # Build output & caches
dist/ dist/
storybook-static/ storybook-static*/
coverage/ coverage/
.angular/ .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 # Lockfile
package-lock.json package-lock.json
# Generated — owned by their generators, not prettier # Generated — owned by their generators, not prettier
documentation.json documentation.json
src/app/shared/infrastructure/api-client.ts libs/shared/src/infrastructure/api-client.ts
src/app/showcase/snippets.generated.ts apps/ssp/src/app/showcase/snippets.generated.ts
libs/shared/docs/behaviour-spec.mdx
# Vendored design system (CIBG Huisstijl) # Vendored design system (CIBG Huisstijl)
public/cibg-huisstijl/ public/cibg-huisstijl/
@@ -20,3 +26,7 @@ plop-templates/
# Backend is formatted by `dotnet format`, not prettier # Backend is formatted by `dotnet format`, not prettier
backend/ 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/
+29
View File
@@ -0,0 +1,29 @@
import type { StorybookConfig } from '@storybook/angular';
import remarkGfm from 'remark-gfm';
// WP-67: see .storybook-ssp/main.ts's comment for why this is a separate config dir.
const config: StorybookConfig = {
stories: [
'../apps/behandelportal/src/**/*.mdx',
'../libs/shared/**/*.mdx',
'../libs/beheer/**/*.mdx',
'../apps/behandelportal/src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../libs/shared/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../libs/beheer/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
addons: [
'@storybook/addon-a11y',
{
name: '@storybook/addon-docs',
options: { mdxPluginOptions: { mdxCompileOptions: { remarkPlugins: [remarkGfm] } } },
},
'@storybook/addon-onboarding',
],
framework: '@storybook/angular',
staticDirs: ['../public'],
webpackFinal: async (config) => {
config.optimization = { ...config.optimization, nodeEnv: false };
return config;
},
};
export default config;
+53
View File
@@ -0,0 +1,53 @@
import type { Preview } from '@storybook/angular';
import { componentWrapperDecorator } from '@storybook/angular';
import { setCompodocJson } from '@storybook/addon-docs/angular';
import docJson from './documentation.json';
setCompodocJson(docJson);
if (typeof document !== 'undefined') document.body.classList.add('brand--cibg');
const preview: Preview = {
decorators: [componentWrapperDecorator((story) => `<div style="padding:1.5rem">${story}</div>`)],
parameters: {
layout: 'padded',
a11y: {
config: { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] } },
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
// Sidebar tells the DDD seam: reusable design system, then domain contexts. The
// "Foundations" MDX pages live in libs/shared/docs and apply to either app's Storybook.
options: {
storySort: {
order: [
'Foundations',
[
'Overview',
'Learning Path',
'Domain-Driven Design',
'Atomic Design',
'FP in the UI',
'State Machines (TEA)',
'RemoteData & Async',
"Parse, don't validate",
'Design Tokens',
'CIBG Gap Register',
'Accessibility',
'Testing strategy',
'BDD',
'Internationalization',
],
'Design System',
['Atoms', 'Molecules', 'Organisms', 'Templates', 'Devtools'],
'Domein',
],
},
},
},
};
export default preview;
@@ -0,0 +1,16 @@
// This tsconfig is used by Compodoc to generate the documentation for the project.
// If Compodoc is not used, this file can be deleted.
{
"extends": "./tsconfig.json",
"exclude": [
"../apps/behandelportal/src/test.ts",
"../apps/behandelportal/src/**/*.spec.ts",
"../apps/behandelportal/src/**/*.stories.ts",
"../libs/shared/**/*.spec.ts",
"../libs/shared/**/*.stories.ts",
"../libs/beheer/**/*.spec.ts",
"../libs/beheer/**/*.stories.ts"
],
"include": ["../apps/behandelportal/src/**/*", "../libs/shared/**/*", "../libs/beheer/**/*"],
"files": ["./typings.d.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../apps/behandelportal/tsconfig.app.json",
"compilerOptions": {
"types": ["node", "@angular/localize"],
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"moduleResolution": "bundler"
},
"exclude": ["../apps/behandelportal/src/test.ts", "../apps/behandelportal/src/**/*.spec.ts"],
"include": [
"../apps/behandelportal/src/**/*.stories.*",
"../libs/shared/**/*.stories.*",
"../libs/beheer/**/*.stories.*",
"./preview.ts"
],
"files": ["./typings.d.ts"]
}
+12 -1
View File
@@ -1,8 +1,19 @@
import type { StorybookConfig } from '@storybook/angular'; import type { StorybookConfig } from '@storybook/angular';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
// WP-67: one config dir per app — a single merged tsconfig can't resolve both apps' @auth/*
// alias at once (each points at a different physical directory), so this Storybook instance
// only ever compiles under the ssp project's own browserTarget/tsconfig (see angular.json's
// ssp:storybook target) and only globs ssp's own stories + the shared libraries'.
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], stories: [
'../apps/ssp/src/**/*.mdx',
'../libs/shared/**/*.mdx',
'../libs/beheer/**/*.mdx',
'../apps/ssp/src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../libs/shared/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../libs/beheer/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
addons: [ addons: [
'@storybook/addon-a11y', '@storybook/addon-a11y',
// remark-gfm so GFM pipe tables in *.mdx docs actually render (addon-docs // remark-gfm so GFM pipe tables in *.mdx docs actually render (addon-docs
+5
View File
@@ -0,0 +1,5 @@
<!-- CIBG Huisstijl (customized Bootstrap 5.2), served from the vendored public/ staticDir.
Mirrors src/index.html so stories match the app. System font per styles.scss. -->
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
<!-- Letter-rendering contract (WP-24), mirrors index.html. -->
<link rel="stylesheet" href="letter.css" />
@@ -1,7 +1,7 @@
import type { Preview } from '@storybook/angular'; import type { Preview } from '@storybook/angular';
import { componentWrapperDecorator } from '@storybook/angular'; import { componentWrapperDecorator } from '@storybook/angular';
import { setCompodocJson } from '@storybook/addon-docs/angular'; import { setCompodocJson } from '@storybook/addon-docs/angular';
import docJson from '../documentation.json'; import docJson from './documentation.json';
setCompodocJson(docJson); setCompodocJson(docJson);
// Activate CIBG's official palette in the story iframe. The override is `body.brand--cibg`, // Activate CIBG's official palette in the story iframe. The override is `body.brand--cibg`,
@@ -26,7 +26,7 @@ const preview: Preview = {
}, },
}, },
// Sidebar tells the DDD seam: reusable design system, then domain contexts. // Sidebar tells the DDD seam: reusable design system, then domain contexts.
// See src/docs/layers.mdx. // See libs/shared/docs/layers.mdx.
options: { options: {
storySort: { storySort: {
order: [ order: [
+21
View File
@@ -0,0 +1,21 @@
import type { TestRunnerConfig } from '@storybook/test-runner';
import { getStoryContext } from '@storybook/test-runner';
import { injectAxe, checkA11y } from 'axe-playwright';
const config: TestRunnerConfig = {
async preVisit(page) {
await injectAxe(page);
},
async postVisit(page, context) {
const storyContext = await getStoryContext(page, context);
if (storyContext.parameters?.a11y?.disable) return;
await checkA11y(page, '#storybook-root', {
axeOptions: storyContext.parameters?.a11y?.config,
detailedReport: true,
detailedReportOptions: { html: true },
});
},
};
export default config;
@@ -3,8 +3,16 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
// Exclude all files that are not needed for documentation generation. // Exclude all files that are not needed for documentation generation.
"exclude": ["../src/test.ts", "../src/**/*.spec.ts", "../src/**/*.stories.ts"], "exclude": [
"../apps/ssp/src/test.ts",
"../apps/ssp/src/**/*.spec.ts",
"../apps/ssp/src/**/*.stories.ts",
"../libs/shared/**/*.spec.ts",
"../libs/shared/**/*.stories.ts",
"../libs/beheer/**/*.spec.ts",
"../libs/beheer/**/*.stories.ts"
],
// Please make sure to include all files from which Compodoc should generate documentation. // Please make sure to include all files from which Compodoc should generate documentation.
"include": ["../src/**/*"], "include": ["../apps/ssp/src/**/*", "../libs/shared/**/*", "../libs/beheer/**/*"],
"files": ["./typings.d.ts"] "files": ["./typings.d.ts"]
} }
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../apps/ssp/tsconfig.app.json",
"compilerOptions": {
"types": ["node", "@angular/localize"],
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"moduleResolution": "bundler"
},
"exclude": ["../apps/ssp/src/test.ts", "../apps/ssp/src/**/*.spec.ts"],
"include": [
"../apps/ssp/src/**/*.stories.*",
"../libs/shared/**/*.stories.*",
"../libs/beheer/**/*.stories.*",
"./preview.ts"
],
"files": ["./typings.d.ts"]
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.md' {
const content: string;
export default content;
}
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "../tsconfig.app.json",
"compilerOptions": {
"types": ["node", "@angular/localize"],
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"moduleResolution": "bundler"
},
"exclude": ["../src/test.ts", "../src/**/*.spec.ts"],
"include": ["../src/**/*.stories.*", "./preview.ts"],
"files": ["./typings.d.ts"]
}
+116 -50
View File
@@ -14,20 +14,34 @@ typed client. The FE renders the backend's decisions. Reference data mimicking
BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief
persist to a SQLite file via EF Core (WP-22) — `docs/project/backlog/WP-22-durable-persistence.md`. persist to a SQLite file via EF Core (WP-22) — `docs/project/backlog/WP-22-durable-persistence.md`.
**Monorepo (WP-67):** two Angular projects share one backend + one shared library —
`apps/ssp` (Zorgverlener self-service, this doc's main subject) and `apps/behandelportal`
(Behandelaar backoffice, ADR-0002). Both import `libs/shared` (design system + kernel +
generated API client) and `libs/beheer` (the admin/stamdata context, used identically by
both). `backend/` is unowned by either — a genuinely shared dependency.
## Commands ## Commands
```bash ```bash
npm start # ng serve (proxies /api → backend) → http://localhost:4200 npm start # ng serve ssp (proxies /api → backend) → http://localhost:4200
npm test # vitest npm run start:behandelportal # ng serve behandelportal → http://localhost:4201
npm run lint # eslint — enforces `any`-free code + import/layer boundaries npm test # vitest — both apps + both shared libraries (ssp, behandelportal, shared, beheer)
npm run build # ng build (must stay green) npm run lint # eslint — enforces `any`-free code + import/layer boundaries
npm run storybook # component library by atomic layer npm run build # ng build ssp && ng build behandelportal (must stay green)
npm run gen:api # regenerate the typed client from the backend OpenAPI doc npm run storybook # ssp's component library by atomic layer
npm run ci # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y npm run storybook:behandelportal # behandelportal's own instance (see "Monorepo" note below)
docker compose up # run FE + backend together (Swagger at :5000/swagger) npm run gen:api # regenerate the ONE typed client (libs/shared) from the backend OpenAPI doc
cd backend && dotnet test # backend rule + endpoint tests npm run ci # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y
docker compose up # run both FE apps + backend together (Swagger at :5000/swagger)
cd backend && dotnet test # backend rule + endpoint tests
``` ```
**Two Storybook instances, not one:** `apps/ssp` and `apps/behandelportal` each have their own
`auth` context at the same `@auth/*` alias pointing at different physical directories — a single
merged tsconfig can't resolve both at once, so `.storybook-ssp/` and `.storybook-behandelportal/`
are separate config dirs (`npm run storybook[:behandelportal]` / `build-storybook[:behandelportal]`),
each globbing its own app's stories + both shared libraries'.
**Run `npm run ci` before every push** (`scripts/ci-local.sh`) — it runs the same jobs **Run `npm run ci` before every push** (`scripts/ci-local.sh`) — it runs the same jobs
Gitea CI does (lint, format:check, check:tokens, test, `ng build --localize`, audit, backend Gitea CI does (lint, format:check, check:tokens, test, `ng build --localize`, audit, backend
format+test, api-client drift), so a red build is caught locally. Two ways to make it format+test, api-client drift), so a red build is caught locally. Two ways to make it
@@ -65,9 +79,17 @@ session protocol is the worked example of this in practice.
### 1. DDD: contexts then layers, dependencies point inward ### 1. DDD: contexts then layers, dependencies point inward
`src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`, `apps/<app>/src/app/<context>/<layer>/` for an app-local context; `libs/<lib>/src/<layer>/`
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching for a cross-app library (WP-67). Two apps today: `apps/ssp` (Zorgverlener self-service —
page, not a feature; **sanctioned** to read every context — nothing imports it). contexts `auth`, `registratie`, `herregistratie`, `brief` (letter-composition teaching
slice), `showcase` (teaching page, not a feature; **sanctioned** to read every context in
its own app — nothing imports it)) and `apps/behandelportal` (Behandelaar backoffice,
ADR-0002 — contexts `auth`, `behandeling`). Two cross-app libraries: `libs/shared` (the
design system + kernel + generated API client — no business logic) and `libs/beheer` (the
admin/stamdata context, identical for both apps today — WP-67 folded a silently-diverging
duplicate copy back into one). `auth` is deliberately **not** shared even though today it's
near-identical in both apps — ADR-0002 models Zorgverlener/Medewerker as different
`Principal` variants with different login flows; the two copies are expected to diverge.
| Layer | Job | Angular allowed? | | Layer | Job | Angular allowed? |
| ----------------- | ----------------------------------------- | -------------------------------- | | ----------------- | ----------------------------------------- | -------------------------------- |
@@ -77,31 +99,44 @@ page, not a feature; **sanctioned** to read every context — nothing imports it
| `contracts/` | wire DTOs (the FE⇄BE seam) | no | | `contracts/` | wire DTOs (the FE⇄BE seam) | no |
| `ui/` | how it looks (components, pages) | yes | | `ui/` | how it looks (components, pages) | yes |
**Dependencies only point inward**: `ui → application → domain`; everyone may use **Dependencies only point inward**: `ui → application → domain`; every context in either
`shared`; never the reverse. `ui`/`layout` never import `infrastructure` directly app may use `libs/shared` and `libs/beheer`; never the reverse (`libs/shared` may not
(reach data through an application store/command) — lint-enforced. Cross-context only depend on `libs/beheer` either — it stays the base). `ui`/`layout` never import
`herregistratie → registratie → shared`, `auth → shared`, `brief → shared`. Imports use `infrastructure` directly (reach data through an application store/command) —
aliases as direction statements: `@shared/* @auth/* @registratie/* @herregistratie/* lint-enforced (per app, since each app is cruised against its own tsconfig — WP-67's
@brief/*`. `domain/` imports nothing from Angular. `.dependency-cruiser.base.js` + one thin `.dependency-cruiser.<app>.js` per app). An app
may not import the other app's source directly. Cross-context only
`herregistratie → registratie → libs/shared|beheer`, `auth → libs/shared|beheer`,
`brief → libs/shared|beheer` (ssp); `behandeling → libs/shared|beheer`, `auth →
libs/shared|beheer` (behandelportal). Imports use aliases as direction statements:
`@shared/* @beheer/* @auth/* @registratie/* @herregistratie/* @brief/*` (ssp) —
`@shared/* @beheer/* @auth/* @behandeling/*` (behandelportal); each app's own
`tsconfig.json` declares its full map (the root `tsconfig.json` intentionally has no
`paths` — see its comment). `domain/` imports nothing from Angular.
### 2. Atomic design: folder = layer ### 2. Atomic design: folder = layer
`shared/ui` atoms → molecules → organisms; `shared/layout` templates (`shell`, `libs/shared/ui` atoms → molecules → organisms; `libs/shared/layout` templates (`shell`,
`page-shell`); context `ui/` pages. Each level only uses levels below. A new page `page-shell`); each app's own context `ui/` pages. Each level only uses levels below,
and a shared component takes nav/copy as `input()`s or an injection token (e.g.
`HEADER_NAV_ITEMS`/`HEADER_ADMIN_LINKS`, `DEBUG_PANEL` in `shell.component.ts`) rather
than hardcoding one app's content — the two apps' primary nav genuinely differs. A new page
should be **composition of existing blocks** — adding building blocks is the 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) 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, 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 the design system does the visuals. (Where CIBG lacks a class — e.g. `skeleton`,
small hand-rolled surface built from the token bridge; see ADR-0003.) `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 ### 3. State: make illegal states unrepresentable
Default reflex — **if you're about to add a second/third boolean to track state, Default reflex — **if you're about to add a second/third boolean to track state,
model a discriminated union instead.** Three tools, all in `shared/application`: model a discriminated union instead.** Three tools, all in `libs/shared/src/application`:
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`. - **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success). Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success).
Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four Render it via the `<app-async>` molecule (`libs/shared/src/ui/async`) — one of four
templates, mutually exclusive by construction. Default loading spinner/skeleton templates, mutually exclusive by construction. Default loading spinner/skeleton
is delay-gated (~250ms) so fast connections don't flash. is delay-gated (~250ms) so fast connections don't flash.
- **Elm-style store** (`store.ts` → `createStore(initial, reduce)`) — all state in - **Elm-style store** (`store.ts` → `createStore(initial, reduce)`) — all state in
@@ -116,8 +151,9 @@ model a discriminated union instead.** Three tools, all in `shared/application`:
see `upload.machine.ts`) — prefixing there avoids alias noise at the see `upload.machine.ts`) — prefixing there avoids alias noise at the
composition site. composition site.
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a - **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
branded type only via a parser returning `Result` (`registratie/domain/value-objects/`: branded type only via a parser returning `Result` (ssp's
`Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it. `registratie/domain/value-objects/`: `Postcode`, `Uren`, `BigNummer`). Once you hold
the type, never re-check it.
**Derive, don't store** what you can compute — e.g. the wizard's visible steps are **Derive, don't store** what you can compute — e.g. the wizard's visible steps are
`visibleSteps(answers)`, not a stored field (`intake.machine.ts`). `visibleSteps(answers)`, not a stored field (`intake.machine.ts`).
@@ -141,29 +177,43 @@ herregistratie eligibility) or _config value_ (server sends threshold, FE applie
for instant feedback, server re-validates as authority — e.g. scholing threshold). for instant feedback, server re-validates as authority — e.g. scholing threshold).
FE keeps only **format** validation, never as authority. FE keeps only **format** validation, never as authority.
DTO lives in `contracts/`; a hand-written `parse*`/`toDomain` in `infrastructure/` The generated client
validates the untrusted shape and maps DTO → domain. Wiring a real .NET backend (`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 touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned
rules stay in `domain/*.policy.ts` as reference impl + unit test, marked server-owned, rules live **only** on the server, with no FE mirror to drift from it — the FE may
but the FE doesn't call them. 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-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 business controls (profession↔diploma map, thresholds, policy-question text) live as typed
C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit 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 fails CI, never prod) — never runtime-editable. Operational configuration is the deliberate
(operational per-org config in SQLite). UI copy is `$localize`. See ADR-0004. 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 ### 5. Testing
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters). spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
Test the pure function directly — no Angular TestBed for domain. UI is exercised via Test the pure function directly — no Angular TestBed for domain. UI is exercised via
Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests. Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests —
**Story titles mirror the sidebar's Design System/Domein split** (see each app has its **own Storybook instance** (`.storybook-ssp/`, `.storybook-behandelportal/`,
`src/docs/layers.mdx`): a `shared/ui`/`shared/layout` component is titled WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing
`Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`; a component in a its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's
context's `ui/` is titled `Domein/<Context>/<Name>` — full stop, regardless of which Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout`
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 ## Conventions
@@ -176,12 +226,23 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
- **Naming:** shared/reusable UI is **English** (language-agnostic: `button`, - **Naming:** shared/reusable UI is **English** (language-agnostic: `button`,
`wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`, `wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`,
`*.machine.ts`). Pick the language by which side of the seam the code is on. `*.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 - **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 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 (`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
translation file, not a code change (the seam). Shared/English components must **not** translation file, not a code change (the seam). Shared/English components must **not**
hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller
supplies the text (see `shared/ui/async`). Format-validation messages in supplies the text (see `libs/shared/src/ui/async`). Format-validation messages in
`domain/value-objects/` stay co-located but are still `$localize`-wrapped. `domain/value-objects/` stay co-located but are still `$localize`-wrapped.
- **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts` - **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts`
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the (Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
@@ -190,12 +251,13 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
- **Dates: `DatePipe` in templates, `formatDatumNl` in pure TS.** A template formats a - **Dates: `DatePipe` in templates, `formatDatumNl` in pure TS.** A template formats a
date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe
(a domain function, a `$localize` string) uses the one hand-written (a domain function, a `$localize` string) uses the one hand-written
`formatDatumNl` (`shared/kernel/datum.ts`). Never a third hand-rolled `formatDatumNl` (`libs/shared/src/kernel/datum.ts`). Never a third hand-rolled
`toLocaleDateString` call. `toLocaleDateString` call.
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate: - Routes: lazy `loadComponent`, persistent `ShellComponent` parent (`libs/shared`),
[authGuard]` on protected routes (`app.routes.ts`). `canActivate: [authGuard]` on protected routes (each app's own `app.routes.ts`).
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under - Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under
`public/cibg-huisstijl/` and loaded via a `<link>` in `index.html`; `src/styles.scss` holds a `public/cibg-huisstijl/` and loaded via a `<link>` in each app's `index.html`;
`libs/shared/styles.scss` (one copy, both apps' `angular.json` point at it — WP-67) holds a
**token bridge** mapping the app's `--rhc-*` token vocabulary onto CIBG/`--bs-*` values (so **token bridge** mapping the app's `--rhc-*` token vocabulary onto CIBG/`--bs-*` values (so
components keep referencing tokens). System-font stack (licensed RO/Rijks fonts not shipped). See ADR-0003. components keep referencing tokens). System-font stack (licensed RO/Rijks fonts not shipped). See ADR-0003.
- Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error` - Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error`
@@ -209,18 +271,22 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
`/beheer/stamdata`, `/beheer/zaken`, `/beheer/audit`, `/beheer/functies`. `/beheer/stamdata`, `/beheer/zaken`, `/beheer/audit`, `/beheer/functies`.
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`, - Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`. `noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`) fails the build - **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`, scoped to
on `any` and on illegal imports — `domain/` importing Angular, or a context importing `{apps,libs}/**`) fails the build on `any`; `npm run dep:check`
"upward" (the `herregistratie → registratie → shared`, `auth → shared` direction). (`.dependency-cruiser.base.js` + one `.dependency-cruiser.<app>.js` per app, WP-67) fails
CI (`.github/workflows/ci.yml`) runs lint + `check:tokens` + test + build, backend on illegal imports — `domain/` importing Angular, a context importing "upward" (the
`dotnet test`, and an API-client drift check. `herregistratie → registratie → shared`, `auth → shared` direction), an app importing the
other app's source, or `libs/shared` depending on `libs/beheer`. CI
(`.github/workflows/ci.yml`) runs lint + `dep:check` + `check:tokens` + test (both apps +
both libraries) + build (both apps), backend `dotnet test`, and an API-client drift check
(one generated client, `libs/shared/src/infrastructure/api-client.ts`).
## Adding a feature (recipe) ## Adding a feature (recipe)
Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter: Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter:
`httpResource` or command returning `Result`) → application (store if shared state; `httpResource` or command returning `Result`) → application (store if shared state;
union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in `<app-async>`, union + pure reduce) → UI last (compose `libs/shared/ui` atoms, wrap async in
dispatch messages). Worked example: the intake wizard (`herregistratie/`). `<app-async>`, dispatch messages). Worked example: the SSP's intake wizard (`herregistratie/`).
The recipes are also invocable skills in `.claude/skills/`: `new-feature`, The recipes are also invocable skills in `.claude/skills/`: `new-feature`,
`new-context`, `value-object`, `form-machine`, `bff-endpoint`, `mutation-command`, `new-context`, `value-object`, `form-machine`, `bff-endpoint`, `mutation-command`,
+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, A two-app Angular monorepo showing how **atomic design** plus **domain-driven boundaries** make
reuse and extend. The domain is the **BIG-register** self-service portal (the Dutch a frontend cheap to build, reuse and extend. The domain is the **BIG-register** (the Dutch
register of healthcare professionals, run by CIBG). It is styled with the **CIBG register of healthcare professionals, run by CIBG): a citizen self-service portal and a
Huisstijl** design system (a customized Bootstrap 5.2 build, vendored — see ADR-0003), case-handler backoffice, sharing one design system and one backend.
and demonstrates a robust **async-state pattern** where the UI can never reach an
inconsistent state.
> Demo / POC — **no real login** (DigiD is faked) and synthetic seed data. The It is styled with the **CIBG Huisstijl** (a customized Bootstrap 5.2 build, vendored — ADR-0003)
> business rules and data _are_ served by a real **ASP.NET Core backend** and built around one idea: **make illegal states unrepresentable** — in the UI's async states, in
> (`backend/`) consumed through a generated typed client, so the BFF + DDD design the domain types, and in the tests.
> is demonstrable, not hand-waved. A system-font stack stands in for the licensed
> Rijksoverheid font and a text wordmark for the logo. > **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 ```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 ```bash
npm install npm install
npm start # app → http://localhost:4200 (proxies /api → backend, proxy.conf.json) npm start # self-service portal → :4200 (proxies /api → backend)
# in another terminal: npm run start:behandelportal # case-handler portal → :4201
cd backend && dotnet run --project src/BigRegister.Api # API → http://localhost:5000/swagger
npm run storybook # component library, organized by atomic layer # in another terminal:
npm run gen:api # regenerate the typed API client from the backend OpenAPI doc cd backend && dotnet run --project src/BigRegister.Api # API → :5000/swagger
npm run e2e # Playwright smoke tests against the running app + backend (both must be up)
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**. **Self-service flow:** Login → Dashboard → Mijn gegevens → Registreren → Herregistratie → Intake
The backend hosts the business rules (profession derivation, policy questions, → Brief. **Behandelportal:** Login → Werkvoorraad → Beoordeling (approve / reject / ask for more).
eligibility, thresholds); see **[backend/README.md](backend/README.md)**. 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 | | URL | What you see |
| ----------------------------- | -------------------------------------------- | | ----------------------------- | -------------------------------------------- |
| `/dashboard` | real data (fast) | | `/dashboard` | real data |
| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data | | `/dashboard?scenario=slow` | skeletons for ~2.5s, then data |
| `/dashboard?scenario=loading` | the loading state, held open | | `/dashboard?scenario=loading` | the loading state, held open |
| `/dashboard?scenario=empty` | "geen gegevens" empty state | | `/dashboard?scenario=empty` | "geen gegevens" empty state |
| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) | | `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) |
--- 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.
## 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.
--- ---
## 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 ## Tech notes
- Angular 22 (standalone components, signals, `httpResource`, view transitions, - **Angular 22** — standalone components, signals, `resource()`, native control flow, view
control flow `@if/@for`). transitions. No NgRx: shared state is a root singleton store with a pure reducer.
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in - **Backend** — ASP.NET Core (.NET 10), EF Core/SQLite for applications, documents, the brief and
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the the audit trail; BRP/DUO reference data stays in-memory seeded. Screen-shaped ("BFF-lite")
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency. endpoints return server-computed decisions the frontend renders rather than recomputes
- Data: ASP.NET Core backend (`backend/`, EF Core/SQLite-persisted; BRP/DUO (ADR-0001). Cases can be sourced from **OpenZaak/ZGW** behind the same seam (ADR-0005).
reference data stays in-memory-seeded) exposed via an OpenAPI contract; the FE - **Typed client** — NSwag-generated from the backend's OpenAPI doc (`npm run gen:api`); CI fails
consumes an **NSwag-generated** typed client (`npm run gen:api`). on drift.
The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is - **Boundaries are enforced, not hoped for** — `dep:check` fails the build if `domain/` imports
**dev-only** — it is not wired into production builds. Angular, a context imports upward, or an app reaches into the other app.
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags - **i18n** — every user-facing string is `$localize`-wrapped with a stable id (source locale `nl`).
Angular 22; the builder runs fine (build verified). `ng build --localize` builds both `nl` and `en`, and `i18nMissingTranslation: "error"` fails the
- **i18n**: every user-facing string is `$localize`-wrapped with a stable `@@id` build if a string ships untranslated.
(source locale `nl`). `npx ng build --localize` (CI runs this) builds both `nl` and - **Dependencies** — the shipped bundle audits clean (`npm audit --omit=dev`: 0 vulnerabilities).
`en` — a genuine second-locale build, not just an unexercised claim — into `.npmrc` sets `legacy-peer-deps=true` because Storybook's peer range lags Angular 22. Never run
`dist/atomic-design-poc/browser/{nl,en}/`; `ng serve --configuration=en` serves the `npm audit fix --force` — it downgrades Angular 22 → 21.
English build locally. `src/locale/messages.en.xlf` is real (if demo-quality)
English, not machine-untranslated placeholders; `angular.json`'s
`i18nMissingTranslation: "error"` fails the build if a new `$localize` string ships
without a translation. `npm run extract-i18n` regenerates the `nl` reference file.
### Dependency security ### Deliberately out of scope
The **shipped app has 0 known vulnerabilities** (`npm audit --omit=dev`) — and, since the Real auth/DigiD, real BRP/DUO upstreams, a production-grade database, NgRx, licensed RO/Rijks
`@babel/core` pin below, the **full dev audit is 0 too**. All advisories live(d) in fonts and logo. The i18n build seam is proven, but the `en` translation is demo-quality and locale
dev/build tooling (Storybook + the Angular build chain) and never reach the bundle. is a build-time choice, not a runtime switch.
`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.
+233 -20
View File
@@ -7,21 +7,21 @@
}, },
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
"atomic-design-poc": { "ssp": {
"projectType": "application", "projectType": "application",
"schematics": { "schematics": {
"@schematics/angular:component": { "@schematics/angular:component": {
"style": "scss" "style": "scss"
} }
}, },
"root": "", "root": "apps/ssp",
"sourceRoot": "src", "sourceRoot": "apps/ssp/src",
"prefix": "app", "prefix": "app",
"i18n": { "i18n": {
"sourceLocale": { "code": "nl", "subPath": "" }, "sourceLocale": { "code": "nl", "subPath": "" },
"locales": { "locales": {
"en": { "en": {
"translation": "src/locale/messages.en.xlf" "translation": "apps/ssp/src/locale/messages.en.xlf"
} }
} }
}, },
@@ -29,8 +29,8 @@
"build": { "build": {
"builder": "@angular/build:application", "builder": "@angular/build:application",
"options": { "options": {
"browser": "src/main.ts", "browser": "apps/ssp/src/main.ts",
"tsConfig": "tsconfig.app.json", "tsConfig": "apps/ssp/tsconfig.app.json",
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
"assets": [ "assets": [
{ {
@@ -38,7 +38,7 @@
"input": "public" "input": "public"
} }
], ],
"styles": ["src/styles.scss"], "styles": ["libs/shared/styles.scss"],
"polyfills": ["@angular/localize/init"], "polyfills": ["@angular/localize/init"],
"i18nMissingTranslation": "error" "i18nMissingTranslation": "error"
}, },
@@ -59,8 +59,8 @@
"outputHashing": "all", "outputHashing": "all",
"fileReplacements": [ "fileReplacements": [
{ {
"replace": "src/environments/environment.ts", "replace": "libs/shared/src/environments/environment.ts",
"with": "src/environments/environment.prod.ts" "with": "libs/shared/src/environments/environment.prod.ts"
} }
] ]
}, },
@@ -77,21 +77,27 @@
}, },
"serve": { "serve": {
"builder": "@angular/build:dev-server", "builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "apps/ssp/proxy.conf.json"
},
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "atomic-design-poc:build:production" "buildTarget": "ssp:build:production"
}, },
"development": { "development": {
"buildTarget": "atomic-design-poc:build:development" "buildTarget": "ssp:build:development"
}, },
"en": { "en": {
"buildTarget": "atomic-design-poc:build:development,en" "buildTarget": "ssp:build:development,en"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"test": { "test": {
"builder": "@angular/build:unit-test", "builder": "@angular/build:unit-test",
"options": {
"tsConfig": "apps/ssp/tsconfig.spec.json"
},
"configurations": { "configurations": {
"coverage": { "coverage": {
"coverage": true, "coverage": true,
@@ -100,9 +106,9 @@
"**/*.spec.ts", "**/*.spec.ts",
"**/*.stories.ts", "**/*.stories.ts",
"**/contracts/**", "**/contracts/**",
"src/app/shared/infrastructure/api-client.ts", "**/infrastructure/api-client.ts",
"src/main.ts", "apps/ssp/src/main.ts",
"src/test-setup.ts", "**/*.testing.ts",
"**/*.d.ts" "**/*.d.ts"
] ]
} }
@@ -111,8 +117,8 @@
"storybook": { "storybook": {
"builder": "@storybook/angular:start-storybook", "builder": "@storybook/angular:start-storybook",
"options": { "options": {
"configDir": ".storybook", "configDir": ".storybook-ssp",
"browserTarget": "atomic-design-poc:build", "browserTarget": "ssp:build",
"compodoc": false, "compodoc": false,
"port": 6006 "port": 6006
} }
@@ -120,14 +126,221 @@
"build-storybook": { "build-storybook": {
"builder": "@storybook/angular:build-storybook", "builder": "@storybook/angular:build-storybook",
"options": { "options": {
"configDir": ".storybook", "configDir": ".storybook-ssp",
"browserTarget": "atomic-design-poc:build", "browserTarget": "ssp:build",
"compodoc": true, "compodoc": true,
"compodocArgs": ["-e", "json", "-d", "."], "compodocArgs": ["-e", "json", "-d", ".storybook-ssp"],
"outputDir": "storybook-static" "outputDir": "storybook-static"
} }
} }
} }
},
"behandelportal": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "apps/behandelportal",
"sourceRoot": "apps/behandelportal/src",
"prefix": "app",
"i18n": {
"sourceLocale": { "code": "nl", "subPath": "" },
"locales": {
"en": {
"translation": "apps/behandelportal/src/locale/messages.en.xlf"
}
}
},
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "apps/behandelportal/src/main.ts",
"tsConfig": "apps/behandelportal/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["libs/shared/styles.scss"],
"polyfills": ["@angular/localize/init"],
"i18nMissingTranslation": "error"
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1.5MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all",
"fileReplacements": [
{
"replace": "libs/shared/src/environments/environment.ts",
"with": "libs/shared/src/environments/environment.prod.ts"
}
]
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
},
"en": {
"localize": ["en"]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"port": 4201,
"proxyConfig": "apps/behandelportal/proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "behandelportal:build:production"
},
"development": {
"buildTarget": "behandelportal:build:development"
},
"en": {
"buildTarget": "behandelportal:build:development,en"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "apps/behandelportal/tsconfig.spec.json"
},
"configurations": {
"coverage": {
"coverage": true,
"coverageReporters": ["text-summary", "html", "lcov"],
"coverageExclude": [
"**/*.spec.ts",
"**/*.stories.ts",
"**/contracts/**",
"**/infrastructure/api-client.ts",
"apps/behandelportal/src/main.ts",
"**/*.testing.ts",
"**/*.d.ts"
]
}
}
},
"storybook": {
"builder": "@storybook/angular:start-storybook",
"options": {
"configDir": ".storybook-behandelportal",
"browserTarget": "behandelportal:build",
"compodoc": false,
"port": 6007
}
},
"build-storybook": {
"builder": "@storybook/angular:build-storybook",
"options": {
"configDir": ".storybook-behandelportal",
"browserTarget": "behandelportal:build",
"compodoc": true,
"compodocArgs": ["-e", "json", "-d", ".storybook-behandelportal"],
"outputDir": "storybook-static-behandelportal"
}
}
}
},
"shared": {
"projectType": "library",
"root": "libs/shared",
"sourceRoot": "libs/shared/src",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "libs/shared/src/test-entry.ts",
"tsConfig": "libs/shared/tsconfig.app.json"
},
"configurations": {
"development": {}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "libs/shared/tsconfig.spec.json"
},
"configurations": {
"coverage": {
"coverage": true,
"coverageReporters": ["text-summary", "html", "lcov"],
"coverageExclude": [
"**/*.spec.ts",
"**/*.stories.ts",
"**/contracts/**",
"**/infrastructure/api-client.ts",
"src/test-entry.ts",
"**/*.testing.ts",
"**/*.d.ts"
]
}
}
}
}
},
"beheer": {
"projectType": "library",
"root": "libs/beheer",
"sourceRoot": "libs/beheer/src",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "libs/beheer/src/test-entry.ts",
"tsConfig": "libs/beheer/tsconfig.app.json"
},
"configurations": {
"development": {}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "libs/beheer/tsconfig.spec.json"
},
"configurations": {
"coverage": {
"coverage": true,
"coverageReporters": ["text-summary", "html", "lcov"],
"coverageExclude": [
"**/*.spec.ts",
"**/*.stories.ts",
"**/contracts/**",
"**/infrastructure/api-client.ts",
"src/test-entry.ts",
"**/*.testing.ts",
"**/*.d.ts"
]
}
}
}
}
} }
} }
} }
@@ -14,11 +14,15 @@ import localeEn from '@angular/common/locales/en';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor'; import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
import { roleInterceptor } from '@shared/infrastructure/role.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 { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { SESSION_PORT } from '@shared/application/session.port'; import { SESSION_PORT } from '@shared/application/session.port';
import { SessionStore } from '@auth/application/session.store'; import { SessionStore } from '@auth/application/session.store';
import { provideRouteFocus } from '@shared/layout/route-focus'; import { provideRouteFocus } from '@shared/layout/route-focus';
import { provideUnloadFlush } from '@shared/application/pending-saves'; import { provideUnloadFlush } from '@shared/application/pending-saves';
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from '@shared/layout/site-header/nav-config';
import { ADMIN_LINKS, NAV_ITEMS } from './shell/nav.config';
// Both locales' data so DatePipe/number pipes work for whichever bundle is active. // Both locales' data so DatePipe/number pipes work for whichever bundle is active.
registerLocaleData(localeNl); registerLocaleData(localeNl);
@@ -50,7 +54,13 @@ export const appConfig: ApplicationConfig = {
), ),
// Dev-only: the ?scenario= toggle must never reach a production build, where // Dev-only: the ?scenario= toggle must never reach a production build, where
// a query param could otherwise force errors on the live app. // a query param could otherwise force errors on the live app.
provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])), provideHttpClient(
withInterceptors(
isDevMode()
? [scenarioInterceptor, roleInterceptor, subjectInterceptor, medewerkerInterceptor]
: [],
),
),
provideApiClient(), provideApiClient(),
{ provide: SESSION_PORT, useExisting: SessionStore }, { provide: SESSION_PORT, useExisting: SessionStore },
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the // Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
@@ -59,5 +69,7 @@ export const appConfig: ApplicationConfig = {
{ provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' }, { provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' },
provideRouteFocus(), provideRouteFocus(),
provideUnloadFlush(), provideUnloadFlush(),
{ provide: HEADER_NAV_ITEMS, useValue: NAV_ITEMS },
{ provide: HEADER_ADMIN_LINKS, useValue: ADMIN_LINKS },
], ],
}; };
+54
View File
@@ -0,0 +1,54 @@
import { Routes } from '@angular/router';
import { ShellComponent } from '@shared/layout/shell/shell.component';
import { authGuard, capabilityGuard } from '@auth/auth.guard';
export const routes: Routes = [
{
path: '',
component: ShellComponent, // persistent header/footer; only children swap
children: [
{ path: '', pathMatch: 'full', redirectTo: 'login' },
{
path: 'login',
loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage),
},
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () =>
import('@behandeling/ui/werkvoorraad.page').then((m) => m.WerkvoorraadPage),
},
{
path: 'aanvraag/:id',
// 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: () =>
import('@behandeling/ui/beoordeling.page').then((m) => m.BeoordelingPage),
},
{
path: 'beheer/stamdata',
// Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default
// unless GET /me resolved `stamdata:edit` (Admin role). Backend re-enforces via the
// StamdataAdmin gate — the guard just avoids loading a page that would 403.
canActivate: [capabilityGuard('stamdata:edit')],
loadComponent: () => import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage),
},
{
path: 'beheer/audit',
// 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, gated by `flags:manage`.
canActivate: [capabilityGuard('flags:manage')],
loadComponent: () =>
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
},
{ path: '**', redirectTo: 'login' },
],
},
];
@@ -0,0 +1,53 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Principal, parseStoredPrincipal } from '../domain/principal';
import { MedewerkerAdapter } from '../infrastructure/medewerker.adapter';
const STORAGE_KEY = 'session-v1';
/** 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 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 medewerker = inject(MedewerkerAdapter);
private _session = signal<Principal | null>(restore());
readonly session = this._session.asReadonly();
readonly isAuthenticated = computed(() => this._session() !== null);
constructor() {
effect(() => {
const p = this._session();
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
else localStorage.removeItem(STORAGE_KEY);
});
}
/** 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() {
this._session.set(null);
}
}
@@ -0,0 +1,10 @@
/**
* 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.
*
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
* `Principal`, the login flow, `SessionStore`. A guard is neither.
*/
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;
}
}
@@ -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()),
};
}
}
@@ -0,0 +1,19 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { MEDEWERKER_ID, currentRollen } from './medewerker';
/**
* Dev-only: stamps every API request as the fixed stand-in medewerker (`X-Medewerker`/
* `X-Rollen`), so `StubIdentityProvider` resolves a `MedewerkerCaller` instead of falling
* through to its zorgverlener default. Unlike `roleInterceptor`'s allow-listed endpoints,
* this is the app's whole identity — every request needs it, since this app has no
* citizen-scoped screens to keep separate (see `CallerIdentity.Zorgverlener()`'s guard: a
* medewerker hitting a citizen-scoped SSP endpoint would 500, but no such endpoint exists
* here). Real employee-SSO login is out of scope for this POC (ADR-0002 §3 — the two
* apps' login flows are expected to diverge; this stand-in is that flow's placeholder).
*/
export const medewerkerInterceptor: HttpInterceptorFn = (req, next) =>
req.url.includes('/api/v1/')
? next(
req.clone({ setHeaders: { 'X-Medewerker': MEDEWERKER_ID, 'X-Rollen': currentRollen() } }),
)
: next(req);
@@ -0,0 +1,23 @@
/**
* Dev-only medewerker rollen stand-in (the reading MECHANISM — mirrors
* `@shared/infrastructure/role.ts`'s `?role=` idiom, but app-local: `auth` is
* deliberately not shared between ssp and behandelportal, ADR-0002 §3). Until a real
* employee-SSO login exists, every request from this app identifies as one fixed
* medewerker; `?rollen=` lets a dev exercise the deny path (`?rollen=geen`) the same
* way `?role=` exercises ssp's role-gated pages.
*
* **Sticky within the tab (sessionStorage)**, same reasoning as `currentRole()`: a
* plain in-app navigation drops the query param, which would silently revert to the
* default and mask a deliberately-chosen `?rollen=geen`.
*/
const STORAGE_KEY = 'dev-rollen';
export const MEDEWERKER_ID = 'medewerker-1';
export function currentRollen(): string {
const fromUrl = new URLSearchParams(window.location.search).get('rollen');
if (fromUrl !== null) {
sessionStorage.setItem(STORAGE_KEY, fromUrl);
return fromUrl;
}
return sessionStorage.getItem(STORAGE_KEY) ?? 'behandelaar';
}
@@ -0,0 +1,27 @@
import { Component, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
/**
* 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: [ButtonComponent],
template: `
<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 {
submitted = output<void>();
}
@@ -0,0 +1,35 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.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, LoginFormComponent],
template: `
<app-page-shell
i18n-heading="@@login.heading"
heading="Inloggen bij het behandelportal"
width="narrow"
i18n-intro="@@login.intro"
intro="Voor medewerkers die aanvragen beoordelen."
>
<app-login-form (submitted)="login()" />
</app-page-shell>
`,
})
export class LoginPage {
private store = inject(SessionStore);
private router = inject(Router);
async login() {
await this.store.login();
this.router.navigate(['/dashboard']);
}
}
@@ -0,0 +1,42 @@
import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { BeoordelingView } from '@behandeling/domain/beoordeling';
import {
BeoordelingAdapter,
parseBeoordelingView,
} from '@behandeling/infrastructure/beoordeling.adapter';
type Err = Error | undefined;
/** 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 {
private adapter = inject(BeoordelingAdapter);
private id: string | undefined;
private state = signal<RemoteData<Err, BeoordelingView>>({ tag: 'Loading' });
readonly view = this.state.asReadonly();
async load(id: string) {
if (this.id !== id) this.state.set({ tag: 'Loading' });
this.id = id;
try {
const parsed = parseBeoordelingView(await this.adapter.get(id));
// A navigation to a different case may have started while this one was in flight.
if (this.id !== id) return;
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) },
);
} catch (e) {
if (this.id !== id) return;
this.state.set({ tag: 'Failure', error: e as Error });
}
}
reload() {
if (this.id) void this.load(this.id);
}
}
@@ -0,0 +1,16 @@
import { inject } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Valid } from '@behandeling/domain/besluit.machine';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { BesluitAdapter } from '@behandeling/infrastructure/besluit.adapter';
/**
* Command factory: binds the besluit adapter in an injection context and returns the
* submit function the decision form calls. Same field-initializer shape as
* `createStore` — the UI holds an application command, never the network client.
*/
export function createSubmitBesluit() {
const adapter = inject(BesluitAdapter);
return (id: string, data: Valid): Promise<Result<string, void>> =>
runSubmit(() => adapter.besluit(id, data), SUBMIT_FAILED);
}
@@ -0,0 +1,37 @@
import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
import {
WerkvoorraadAdapter,
parseWerkvoorraad,
} from '@behandeling/infrastructure/werkvoorraad.adapter';
type Err = Error | undefined;
/** 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 {
private adapter = inject(WerkvoorraadAdapter);
private state = signal<RemoteData<Err, WerkvoorraadItem[]>>({ tag: 'Loading' });
readonly items = this.state.asReadonly();
async load() {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try {
const parsed = parseWerkvoorraad(await this.adapter.list());
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) },
);
} catch (e) {
this.state.set({ tag: 'Failure', error: e as Error });
}
}
reload() {
void this.load();
}
}
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { statusLabel, detailRows, TYPE_LABELS } from './beoordeling-view';
import { BeoordelingView } from './beoordeling';
const base: Omit<BeoordelingView, 'status'> = {
id: '1',
type: 'herregistratie',
owner: '*****2333',
submittedAt: '2024-05-12',
documenten: [],
canBesluiten: true,
};
describe('statusLabel', () => {
it('labels every tag distinctly', () => {
const labels = [
statusLabel({ tag: 'Ingediend', referentie: 'R1' }),
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }),
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: true }),
statusLabel({ tag: 'MeerInfoGevraagd', referentie: 'R1', reden: 'x' }),
statusLabel({ tag: 'Goedgekeurd', referentie: 'R1' }),
statusLabel({ tag: 'Afgewezen', referentie: 'R1', reden: 'x' }),
];
expect(new Set(labels).size).toBe(labels.length);
});
});
describe('detailRows', () => {
it('lists soort/status/referentie/eigenaar/ingediend', () => {
// Given a case InBehandeling.
// When its detail rows are derived...
const rows = detailRows({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
});
const values = rows.map((r) => r.value);
// Then the type label (via TYPE_LABELS, not a literal), the reference, and the
// owner all appear as rows.
expect(values).toContain(TYPE_LABELS.herregistratie);
expect(values).toContain('R1');
expect(values).toContain(base.owner);
expect(rows.length).toBe(5);
});
// One behaviour ("a reden row is added exactly for the two statuses that carry a
// reden") checked as a truth table over three statuses — kept together per
// bdd.mdx's "truth-table of one rule" exception, rather than split apart.
//
// `reden` itself is raw domain data (a free-text field on the status union, not an
// enum), passed through `detailRows` unchanged and never wrapped by `$localize` —
// there is no reason-code/tag to assert on instead; the value under test IS the
// string the Given supplied, so checking it reappears in the Then is a
// pass-through check, not a translated-copy assertion.
it('a reden row is present only for Afgewezen and MeerInfoGevraagd', () => {
// Given three cases: rejected, more-info-requested, and approved.
// When their detail rows are derived...
const afgewezen = detailRows({
...base,
status: { tag: 'Afgewezen', referentie: 'R1', reden: 'Onvoldoende uren' },
});
const meerInfo = detailRows({
...base,
status: { tag: 'MeerInfoGevraagd', referentie: 'R1', reden: 'Diploma ontbreekt' },
});
const goedgekeurd = detailRows({ ...base, status: { tag: 'Goedgekeurd', referentie: 'R1' } });
// Then only the rejected and more-info-requested cases gain a reden row (carrying
// the reason through unchanged); the approved case does not.
expect(afgewezen.length).toBe(6);
expect(afgewezen.map((r) => r.value)).toContain('Onvoldoende uren');
expect(meerInfo.length).toBe(6);
expect(goedgekeurd.length).toBe(5);
});
});
@@ -0,0 +1,50 @@
import { formatDatumNl } from '@shared/kernel/datum';
import { AanvraagType } from './werkvoorraad-item';
import { BeoordelingStatus, BeoordelingView } from './beoordeling';
/** 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. */
export const TYPE_LABELS: Record<AanvraagType, string> = {
registratie: $localize`:@@werkvoorraad.type.registratie:Inschrijving`,
herregistratie: $localize`:@@werkvoorraad.type.herregistratie:Herregistratie`,
intake: $localize`:@@werkvoorraad.type.intake:Herregistratie-intake`,
};
export function statusLabel(status: BeoordelingStatus): string {
switch (status.tag) {
case 'Ingediend':
return $localize`:@@werkvoorraad.status.ingediend:Ingediend`;
case 'InBehandeling':
return status.manual
? $localize`:@@werkvoorraad.status.inBehandelingHandmatig:In behandeling (handmatig)`
: $localize`:@@werkvoorraad.status.inBehandeling:In behandeling`;
case 'MeerInfoGevraagd':
return $localize`:@@beoordeling.status.meerInfoGevraagd:Meer informatie gevraagd`;
case 'Goedgekeurd':
return $localize`:@@beoordeling.status.goedgekeurd:Goedgekeurd`;
case 'Afgewezen':
return $localize`:@@beoordeling.status.afgewezen:Afgewezen`;
}
}
/** Key/value rows for the beoordeling detail page (CIBG Datablock). */
export function detailRows(view: BeoordelingView): { key: string; value: string }[] {
const s = view.status;
const rows = [
{ key: $localize`:@@beoordeling.detail.soort:Soort aanvraag`, value: TYPE_LABELS[view.type] },
{ key: $localize`:@@beoordeling.detail.status:Status`, value: statusLabel(s) },
{ key: $localize`:@@beoordeling.detail.referentie:Referentie`, value: s.referentie },
{ key: $localize`:@@beoordeling.detail.eigenaar:Eigenaar (BSN)`, value: view.owner },
{
key: $localize`:@@beoordeling.detail.ingediend:Ingediend op`,
value: view.submittedAt ? formatDatumNl(view.submittedAt) : '—',
},
];
if (s.tag === 'Afgewezen' || s.tag === 'MeerInfoGevraagd') {
rows.push({ key: $localize`:@@beoordeling.detail.reden:Reden`, value: s.reden });
}
return rows;
}
@@ -0,0 +1,33 @@
import { AanvraagType } from './werkvoorraad-item';
/**
* 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).
*/
export type BeoordelingStatus =
| { tag: 'Ingediend'; referentie: string }
| { tag: 'InBehandeling'; referentie: string; manual: boolean }
| { tag: 'MeerInfoGevraagd'; referentie: string; reden: string }
| { tag: 'Goedgekeurd'; referentie: string }
| { tag: 'Afgewezen'; referentie: string; reden: string };
export interface BeoordelingDocument {
documentId: string;
categoryId: string;
fileName: string;
}
export interface BeoordelingView {
id: string;
type: AanvraagType;
status: BeoordelingStatus;
/** The BSN of the citizen the aanvraag belongs to — masked by the server. */
owner: string;
submittedAt?: string;
documenten: BeoordelingDocument[];
/** Decision flag (ADR-0001): the server computes whether a decision may be recorded;
the FE renders it, it never recomputes the lifecycle. */
canBesluiten: boolean;
}
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { expectTag } from '@shared/testing/expect-tag';
import { reduce, initial } from './besluit.machine';
import { givenBesluit } from './besluit.testing';
describe('besluit reduce', () => {
it('SetField updates the draft while editing', () => {
const s = reduce(initial, { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' });
expect(expectTag(s, 'Editing').draft.besluit).toBe('Goedkeuren');
});
it('Submit with no besluit chosen stays Editing and reports a field error', () => {
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 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 editingGoedkeuren = givenBesluit({
tag: 'SetField',
key: 'besluit',
value: 'Goedkeuren',
});
const s = reduce(editingGoedkeuren, { tag: 'Submit' });
expect(expectTag(s, 'Submitting').data).toEqual({
besluit: 'Goedkeuren',
toelichting: undefined,
});
});
it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => {
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',
});
});
it('SubmitConfirmed maps Submitting to Submitted', () => {
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 = 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 = 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 = givenBesluit(
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
{ tag: 'Submit' },
);
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
});
});
@@ -0,0 +1,92 @@
import { Result, assertNever } from '@shared/kernel/fp';
/** 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;
export type BesluitTag = (typeof BESLUIT_TAGS)[number];
function isBesluitTag(v: string): v is BesluitTag {
return (BESLUIT_TAGS as readonly string[]).includes(v);
}
/** What the user picked (raw, possibly empty while nothing is selected yet). */
export interface Draft {
besluit: string;
toelichting: string;
}
/** After parsing — besluit is the narrow tag; toelichting is present only when given
(required for Afwijzen/MeerInfoOpvragen, optional for Goedkeuren — enforced by validate). */
export interface Valid {
besluit: BesluitTag;
toelichting?: string;
}
export type Errors = Partial<Record<keyof Draft, string>>;
/** The decision form as one tagged union — same idiom as every other form in this
house (form-machine skill), single-step. draft/errors exist only while Editing. */
export type BesluitState =
| { tag: 'Editing'; draft: Draft; errors: Errors }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: BesluitState = {
tag: 'Editing',
draft: { besluit: '', toelichting: '' },
errors: {},
};
function validate(draft: Draft): Result<Errors, Valid> {
if (!isBesluitTag(draft.besluit)) {
return {
ok: false,
error: { besluit: $localize`:@@besluit.error.verplicht:Kies een besluit.` },
};
}
const toelichting = draft.toelichting.trim();
if (draft.besluit !== 'Goedkeuren' && toelichting === '') {
return {
ok: false,
error: {
toelichting: $localize`:@@besluit.error.toelichtingVerplicht:Geef een toelichting.`,
},
};
}
return { ok: true, value: { besluit: draft.besluit, toelichting: toelichting || undefined } };
}
export type BesluitMsg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Reset' }
| { tag: 'Seed'; state: BesluitState }; // mount a specific state (stories/tests)
export function reduce(s: BesluitState, m: BesluitMsg): BesluitState {
switch (m.tag) {
case 'SetField':
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;
case 'Submit': {
if (s.tag !== 'Editing') return s;
const r = validate(s.draft);
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
}
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Reset':
return initial;
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}
@@ -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);
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { werkvoorraadRow, statusLabel, TYPE_LABELS } from './werkvoorraad-item-view';
import { WerkvoorraadItem } from './werkvoorraad-item';
const base: Omit<WerkvoorraadItem, 'status'> = {
id: '1',
type: 'herregistratie',
owner: '111222333',
submittedAt: '2024-05-12',
};
describe('werkvoorraadRow', () => {
it('heading is the type, subtitle carries the owner BSN', () => {
const row = werkvoorraadRow({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
});
expect(row.heading).toBe(TYPE_LABELS.herregistratie);
expect(row.subtitle).toContain('111222333');
});
it('status line carries the status label, reference and submit date', () => {
const row = werkvoorraadRow({
...base,
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
});
expect(row.status).toContain(
statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }),
);
expect(row.status).toContain('R1');
expect(row.status).toContain('12 mei 2024');
});
it('manual review is called out distinctly from an automatic InBehandeling', () => {
const manual = statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: true });
const auto = statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false });
expect(manual).not.toBe(auto);
});
it('a missing submit date leaves no dangling separator', () => {
const row = werkvoorraadRow({
...base,
submittedAt: undefined,
status: { tag: 'Ingediend', referentie: 'R9' },
});
expect(row.status).toBe(`${statusLabel({ tag: 'Ingediend', referentie: 'R9' })} · R9`);
});
});
@@ -0,0 +1,31 @@
import { formatDatumNl } from '@shared/kernel/datum';
import { WerkvoorraadItem } from './werkvoorraad-item';
import { TYPE_LABELS, statusLabel } from './beoordeling-view';
/** View-model mapping for a queue row: type/status → the fields for a CIBG
"aanvragen" row. Pure, no Angular — the UI renders these, it does not derive them.
`TYPE_LABELS`/`statusLabel` live in `./beoordeling-view` (the wider status union) and
are re-exported here so existing imports of this file keep working. */
export { TYPE_LABELS, statusLabel };
export interface WerkvoorraadRow {
heading: string;
subtitle: string;
status: string;
}
/** Fields for one queue row: type as heading, owner (BSN) as subtitle, status +
reference + submit date as the status line. */
export function werkvoorraadRow(item: WerkvoorraadItem): WerkvoorraadRow {
const parts = [statusLabel(item.status), item.status.referentie];
if (item.submittedAt) {
parts.push(
$localize`:@@werkvoorraad.row.ingediend:ingediend op ${formatDatumNl(item.submittedAt)}:datum:`,
);
}
return {
heading: TYPE_LABELS[item.type],
subtitle: $localize`:@@werkvoorraad.row.bsn:BSN ${item.owner}:bsn:`,
status: parts.join(' · '),
};
}
@@ -0,0 +1,23 @@
/**
* 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
* `registratie/domain/aanvraag.ts`): the backend only ever puts a case in the queue
* while it is still open (`Ingediend`/`InBehandeling`), so a queue item literally
* cannot be `Concept`/`Goedgekeurd`/`Afgewezen` — illegal states unrepresentable.
*/
export type AanvraagType = 'registratie' | 'herregistratie' | 'intake';
export type WerkvoorraadStatus =
| { tag: 'Ingediend'; referentie: string }
| { tag: 'InBehandeling'; referentie: string; manual: boolean };
export interface WerkvoorraadItem {
id: string;
type: AanvraagType;
status: WerkvoorraadStatus;
/** The BSN of the citizen the aanvraag belongs to — always populated (cross-owner list). */
owner: string;
submittedAt?: string;
}
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { parseBeoordelingStatus, parseBeoordelingView } from './beoordeling.adapter';
const view = {
aanvraag: {
id: 'a1',
type: 'registratie',
status: { tag: 'InBehandeling', referentie: 'BIG-1', manual: true },
documentIds: ['d1'],
createdAt: '2026-07-01T10:00:00Z',
updatedAt: '2026-07-01T10:05:00Z',
submittedAt: '2026-07-01T10:05:00Z',
owner: '*****2333',
},
documenten: [{ documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' }],
decisions: { canBesluiten: true },
};
describe('parseBeoordelingStatus', () => {
it('parses each tag with its required fields', () => {
expect(parseBeoordelingStatus({ tag: 'Ingediend', referentie: 'BIG-1' }).ok).toBe(true);
expect(
parseBeoordelingStatus({ tag: 'InBehandeling', referentie: 'BIG-1', manual: false }).ok,
).toBe(true);
expect(
parseBeoordelingStatus({ tag: 'MeerInfoGevraagd', referentie: 'BIG-1', reden: 'x' }).ok,
).toBe(true);
expect(parseBeoordelingStatus({ tag: 'Goedgekeurd', referentie: 'BIG-1' }).ok).toBe(true);
expect(parseBeoordelingStatus({ tag: 'Afgewezen', referentie: 'BIG-1', reden: 'x' }).ok).toBe(
true,
);
});
// One behaviour ("rejects a malformed status") checked over several malformed
// shapes — a loop asserting one rule over many inputs, kept together per bdd.mdx.
it('rejects a malformed status', () => {
// Given a status that is missing entirely, has an unknown tag, or is missing a
// required field for its tag.
// When each is parsed...
// Then all are rejected.
expect(parseBeoordelingStatus(undefined).ok).toBe(false);
expect(parseBeoordelingStatus({ tag: 'Concept' } as never).ok).toBe(false);
expect(parseBeoordelingStatus({ tag: 'InBehandeling', referentie: 'BIG-1' }).ok).toBe(false);
});
});
describe('parseBeoordelingView', () => {
it('maps a valid DTO to domain', () => {
const r = parseBeoordelingView(view);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.type).toBe('registratie');
expect(r.value.documenten).toEqual([
{ documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' },
]);
expect(r.value.canBesluiten).toBe(true);
});
// One behaviour ("rejects a malformed view") checked over several malformed
// shapes — a loop asserting one rule over many inputs, kept together per bdd.mdx.
it('rejects a malformed view', () => {
// Given a view that is a non-object, missing the owner, has an unknown aanvraag
// type, or is missing decisions.
// When each is parsed...
// Then all are rejected.
expect(parseBeoordelingView(null).ok).toBe(false);
expect(
parseBeoordelingView({ ...view, aanvraag: { ...view.aanvraag, owner: undefined } }).ok,
).toBe(false);
expect(
parseBeoordelingView({ ...view, aanvraag: { ...view.aanvraag, type: 'onbekend' } }).ok,
).toBe(false);
expect(parseBeoordelingView({ ...view, decisions: {} }).ok).toBe(false);
});
it('defaults an absent documenten list to empty', () => {
const r = parseBeoordelingView({ ...view, documenten: undefined });
expect(r.ok && r.value.documenten).toEqual([]);
});
});
@@ -0,0 +1,99 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import {
ApiClient,
BeoordelingViewDto,
AanvraagStatusDto,
} from '@shared/infrastructure/api-client';
import {
BeoordelingView,
BeoordelingStatus,
BeoordelingDocument,
} from '@behandeling/domain/beoordeling';
import { AanvraagType } from '@behandeling/domain/werkvoorraad-item';
/**
* 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.
*/
@Injectable({ providedIn: 'root' })
export class BeoordelingAdapter {
private client = inject(ApiClient);
get(id: string): Promise<BeoordelingViewDto> {
return this.client.beoordeling(id);
}
}
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
export function parseBeoordelingStatus(
s: AanvraagStatusDto | undefined,
): Result<string, BeoordelingStatus> {
if (!s || typeof s.tag !== 'string') return err('beoordeling: missing status');
switch (s.tag) {
case 'Ingediend':
if (typeof s.referentie !== 'string') return err('beoordeling: bad Ingediend status');
return ok({ tag: 'Ingediend', referentie: s.referentie });
case 'InBehandeling':
if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean')
return err('beoordeling: bad InBehandeling status');
return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual });
case 'MeerInfoGevraagd':
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string')
return err('beoordeling: bad MeerInfoGevraagd status');
return ok({ tag: 'MeerInfoGevraagd', referentie: s.referentie, reden: s.reden });
case 'Goedgekeurd':
if (typeof s.referentie !== 'string') return err('beoordeling: bad Goedgekeurd status');
return ok({ tag: 'Goedgekeurd', referentie: s.referentie });
case 'Afgewezen':
if (typeof s.referentie !== 'string' || typeof s.reden !== 'string')
return err('beoordeling: bad Afgewezen status');
return ok({ tag: 'Afgewezen', referentie: s.referentie, reden: s.reden });
default:
return err(`beoordeling: unknown status tag ${s.tag}`);
}
}
function parseDocument(json: unknown): Result<string, BeoordelingDocument> {
if (typeof json !== 'object' || json === null) return err('beoordeling: document not an object');
const d = json as { documentId?: unknown; categoryId?: unknown; fileName?: unknown };
if (typeof d.documentId !== 'string') return err('beoordeling: document missing documentId');
if (typeof d.categoryId !== 'string') return err('beoordeling: document missing categoryId');
if (typeof d.fileName !== 'string') return err('beoordeling: document missing fileName');
return ok({ documentId: d.documentId, categoryId: d.categoryId, fileName: d.fileName });
}
export function parseBeoordelingView(json: unknown): Result<string, BeoordelingView> {
if (typeof json !== 'object' || json === null) return err('beoordeling: not an object');
const dto = json as BeoordelingViewDto;
const a = dto.aanvraag;
if (!a || typeof a.id !== 'string') return err('beoordeling: missing aanvraag.id');
if (typeof a.type !== 'string' || !AANVRAAG_TYPES.includes(a.type))
return err(`beoordeling: bad type ${a.type}`);
if (typeof a.owner !== 'string' || !a.owner) return err('beoordeling: missing owner');
const status = parseBeoordelingStatus(a.status);
if (!status.ok) return status;
const documenten: BeoordelingDocument[] = [];
for (const item of dto.documenten ?? []) {
const parsed = parseDocument(item);
if (!parsed.ok) return parsed;
documenten.push(parsed.value);
}
if (typeof dto.decisions?.canBesluiten !== 'boolean')
return err('beoordeling: missing decisions.canBesluiten');
return ok({
id: a.id,
type: a.type as AanvraagType,
status: status.value,
owner: a.owner,
submittedAt: a.submittedAt,
documenten,
canBesluiten: dto.decisions.canBesluiten,
});
}
@@ -0,0 +1,18 @@
import { Injectable, inject } from '@angular/core';
import { ApiClient } from '@shared/infrastructure/api-client';
import { Valid } from '@behandeling/domain/besluit.machine';
/**
* 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).
*/
@Injectable({ providedIn: 'root' })
export class BesluitAdapter {
private client = inject(ApiClient);
async besluit(id: string, data: Valid): Promise<void> {
await this.client.besluit(id, { besluit: data.besluit, toelichting: data.toelichting });
}
}
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { parseWerkvoorraadItem, parseWerkvoorraad } from './werkvoorraad.adapter';
const inBehandeling = {
id: 'a1',
type: 'herregistratie',
status: { tag: 'InBehandeling', referentie: 'BIG-1', manual: false },
documentIds: [],
createdAt: '2026-07-01T10:00:00Z',
updatedAt: '2026-07-01T10:05:00Z',
owner: '111222333',
};
describe('parseWerkvoorraadItem', () => {
it('parses Ingediend and InBehandeling', () => {
expect(parseWerkvoorraadItem(inBehandeling).ok).toBe(true);
expect(
parseWerkvoorraadItem({ ...inBehandeling, status: { tag: 'Ingediend', referentie: 'BIG-2' } })
.ok,
).toBe(true);
});
it('rejects a case whose status is not an open queue tag', () => {
expect(
parseWerkvoorraadItem({
...inBehandeling,
status: { tag: 'Goedgekeurd', referentie: 'BIG-1' },
}).ok,
).toBe(false);
expect(
parseWerkvoorraadItem({
...inBehandeling,
status: { tag: 'Concept', stepIndex: 0, stepCount: 1 },
}).ok,
).toBe(false);
});
it('rejects a missing owner, bad type, and non-objects', () => {
expect(parseWerkvoorraadItem({ ...inBehandeling, owner: undefined }).ok).toBe(false);
expect(parseWerkvoorraadItem({ ...inBehandeling, type: 'onbekend' }).ok).toBe(false);
expect(parseWerkvoorraadItem(null).ok).toBe(false);
});
});
describe('parseWerkvoorraad', () => {
it('parses a list and fails fast on a bad element', () => {
expect(parseWerkvoorraad([inBehandeling, inBehandeling]).ok).toBe(true);
expect(parseWerkvoorraad([inBehandeling, { ...inBehandeling, owner: undefined }]).ok).toBe(
false,
);
expect(parseWerkvoorraad({}).ok).toBe(false);
});
});
@@ -0,0 +1,72 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { ApiClient, AanvraagSummaryDto } from '@shared/infrastructure/api-client';
import {
WerkvoorraadItem,
WerkvoorraadStatus,
AanvraagType,
} from '@behandeling/domain/werkvoorraad-item';
/**
* 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
* a silently-rendered row — the endpoint's own filter is a guarantee this boundary enforces.
*/
@Injectable({ providedIn: 'root' })
export class WerkvoorraadAdapter {
private client = inject(ApiClient);
list(): Promise<AanvraagSummaryDto[]> {
return this.client.werkvoorraad();
}
}
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
function parseWerkvoorraadStatus(
s: AanvraagSummaryDto['status'] | undefined,
): Result<string, WerkvoorraadStatus> {
if (!s || typeof s.tag !== 'string') return err('werkvoorraad: missing status');
switch (s.tag) {
case 'Ingediend':
if (typeof s.referentie !== 'string') return err('werkvoorraad: bad Ingediend status');
return ok({ tag: 'Ingediend', referentie: s.referentie });
case 'InBehandeling':
if (typeof s.referentie !== 'string' || typeof s.manual !== 'boolean')
return err('werkvoorraad: bad InBehandeling status');
return ok({ tag: 'InBehandeling', referentie: s.referentie, manual: s.manual });
default:
return err(`werkvoorraad: a queue item cannot have status ${s.tag}`);
}
}
export function parseWerkvoorraadItem(json: unknown): Result<string, WerkvoorraadItem> {
if (typeof json !== 'object' || json === null) return err('werkvoorraad: not an object');
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}`);
if (typeof dto.owner !== 'string' || !dto.owner) return err('werkvoorraad: missing owner');
const status = parseWerkvoorraadStatus(dto.status);
if (!status.ok) return status;
return ok({
id: dto.id,
type: dto.type as AanvraagType,
status: status.value,
owner: dto.owner,
submittedAt: dto.submittedAt,
});
}
export function parseWerkvoorraad(json: unknown): Result<string, WerkvoorraadItem[]> {
if (!Array.isArray(json)) return err('werkvoorraad: not an array');
const out: WerkvoorraadItem[] = [];
for (const item of json) {
const parsed = parseWerkvoorraadItem(item);
if (!parsed.ok) return parsed;
out.push(parsed.value);
}
return ok(out);
}
@@ -0,0 +1,30 @@
import { Component, input } from '@angular/core';
import { BeoordelingDocument } from '@behandeling/domain/beoordeling';
/** 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({
selector: 'app-beoordeling-documenten',
template: `
@if (documenten().length === 0) {
<p class="app-text-subtle" i18n="@@beoordeling.documenten.leeg">Geen documenten.</p>
} @else {
<ul class="list-unstyled">
@for (doc of documenten(); track doc.documentId) {
<li>
<a
[href]="'/api/v1/uploads/' + doc.documentId + '/content'"
target="_blank"
rel="noopener"
>{{ doc.fileName }}</a
>
</li>
}
</ul>
}
`,
})
export class BeoordelingDocumentenComponent {
documenten = input.required<BeoordelingDocument[]>();
}
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { BeoordelingDocumentenComponent } from './beoordeling-documenten.component';
const meta: Meta<BeoordelingDocumentenComponent> = {
title: 'Domein/Behandeling/Beoordeling Documenten',
component: BeoordelingDocumentenComponent,
};
export default meta;
type Story = StoryObj<BeoordelingDocumentenComponent>;
export const Default: Story = {
args: {
documenten: [
{ documentId: 'd1', categoryId: 'diploma', fileName: 'diploma.pdf' },
{ documentId: 'd2', categoryId: 'identiteit', fileName: 'paspoort.pdf' },
],
},
};
export const Empty: Story = {
args: { documenten: [] },
};
@@ -0,0 +1,86 @@
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/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. 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).
*/
@Component({
selector: 'app-beoordeling-page',
imports: [
PageShellComponent,
AlertComponent,
ButtonComponent,
SkeletonComponent,
DataBlockComponent,
DataRowComponent,
BeoordelingDocumentenComponent,
BesluitFormComponent,
...ASYNC,
],
template: `
<app-page-shell [heading]="heading" backLink="/dashboard">
<app-async [data]="store.view()" (retryClicked)="reload()">
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="5" />
</ng-template>
<ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
</ng-template>
<ng-template appAsyncLoaded>
@if (view(); as v) {
<app-data-block [heading]="detailHeading" class="app-section">
@for (row of rows(v); track row.key) {
<div app-data-row [key]="row.key" [value]="row.value"></div>
}
</app-data-block>
<app-data-block [heading]="documentenHeading" class="app-section">
<app-beoordeling-documenten [documenten]="v.documenten" />
</app-data-block>
@if (v.canBesluiten) {
<div class="app-section">
<app-besluit-form [id]="v.id" (decided)="reload()" />
</div>
}
}
</ng-template>
</app-async>
</app-page-shell>
`,
})
export class BeoordelingPage {
protected store = inject(BeoordelingStore);
private id = inject(ActivatedRoute).snapshot.paramMap.get('id') ?? '';
protected heading = $localize`:@@beoordeling.heading:Aanvraag`;
protected detailHeading = $localize`:@@beoordeling.detail.heading:Aanvraaggegevens`;
protected documentenHeading = $localize`:@@beoordeling.documenten.heading:Documenten`;
protected failedText = $localize`:@@beoordeling.failed:De aanvraag kon niet worden geladen.`;
protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`;
protected rows = detailRows;
protected readonly view = computed(() => successOf(this.store.view()));
constructor() {
void this.store.load(this.id);
}
protected reload() {
this.store.reload();
}
}
@@ -0,0 +1,182 @@
import { Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
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 — 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
* authority; on success this only emits `decided` — the page reloads the detail
* (BeoordelingStore.reload()), it doesn't guess the new state itself.
*/
@Component({
selector: 'app-besluit-form',
imports: [
FormsModule,
ButtonComponent,
HeadingComponent,
AlertComponent,
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>
<form (ngSubmit)="onSubmit()" class="form-horizontal app-section">
<app-form-field
i18n-label="@@besluit.besluitLabel"
label="Besluit"
fieldId="besluit-keuze"
required
[error]="errors().besluit"
>
<app-radio-group
name="besluit-keuze"
[options]="BESLUIT_OPTIONS"
[invalid]="!!errors().besluit"
[ngModel]="besluit()"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'besluit', value: $event })"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field>
<app-form-field
i18n-label="@@besluit.toelichtingLabel"
label="Toelichting"
fieldId="besluit-toelichting"
[error]="errors().toelichting"
>
<app-text-input
inputId="besluit-toelichting"
[invalid]="!!errors().toelichting"
[ngModel]="toelichting()"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'toelichting', value: $event })"
name="toelichting"
[ngModelOptions]="{ standalone: true }"
/>
</app-form-field>
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
</app-button>
</form>
}
`,
})
export class BesluitFormComponent {
private submit = createSubmitBesluit();
// 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>();
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<BesluitState>(initial);
readonly state = this.store.model;
protected dispatch = this.store.dispatch;
protected readonly BESLUIT_OPTIONS: RadioOption[] = [
{ value: 'Goedkeuren', label: $localize`:@@besluit.optie.goedkeuren:Goedkeuren` },
{ value: 'Afwijzen', label: $localize`:@@besluit.optie.afwijzen:Afwijzen` },
{
value: 'MeerInfoOpvragen',
label: $localize`:@@besluit.optie.meerInfoOpvragen:Meer informatie opvragen`,
},
];
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 ?? '');
/** 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() }));
}
onSubmit() {
this.dispatch({ tag: 'Submit' });
}
}
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { BesluitFormComponent } from './besluit-form.component';
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { Valid } from '@behandeling/domain/besluit.machine';
const validData: Valid = { besluit: 'Afwijzen', toelichting: 'Diploma niet erkend' };
const meta: Meta<BesluitFormComponent> = {
title: 'Domein/Behandeling/Besluit Form',
component: BesluitFormComponent,
// The form injects ApiClient (over HttpClient) for the submit command.
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
args: { id: 'aanvraag-1' },
};
export default meta;
type Story = StoryObj<BesluitFormComponent>;
// One render per state of the machine.
export const Empty: Story = {
args: { seed: { tag: 'Editing', draft: { besluit: '', toelichting: '' }, errors: {} } },
};
export const WithErrors: Story = {
args: {
seed: {
tag: 'Editing',
draft: { besluit: 'Afwijzen', toelichting: '' },
errors: { toelichting: 'Geef een toelichting.' },
},
},
};
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
export const Failed: Story = {
args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } },
};
@@ -0,0 +1,32 @@
import { Component, input } from '@angular/core';
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 — composition
of the two existing shared/ui molecules, no new atom. Each row links to the
beoordeling detail page. */
@Component({
selector: 'app-werkvoorraad-list',
imports: [ApplicationListComponent, ApplicationLinkComponent],
template: `
<app-application-list>
@for (item of items(); track item.id) {
@let row = row_(item);
<li
app-application-link
[heading]="row.heading"
[subtitle]="row.subtitle"
[status]="row.status"
[to]="'/aanvraag/' + item.id"
></li>
}
</app-application-list>
`,
})
export class WerkvoorraadListComponent {
items = input.required<WerkvoorraadItem[]>();
protected row_ = werkvoorraadRow;
}
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { WerkvoorraadListComponent } from './werkvoorraad-list.component';
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
const items: WerkvoorraadItem[] = [
{
id: 'a1',
type: 'herregistratie',
owner: '111222333',
submittedAt: '2026-06-28T10:05:00Z',
status: { tag: 'InBehandeling', referentie: 'BIG-2026-456789', manual: false },
},
{
id: 'a2',
type: 'registratie',
owner: '444555666',
submittedAt: '2026-06-27T09:00:00Z',
status: { tag: 'InBehandeling', referentie: 'BIG-2026-456790', manual: true },
},
{
id: 'a3',
type: 'intake',
owner: '777888999',
status: { tag: 'Ingediend', referentie: 'BIG-2026-456791' },
},
];
const meta: Meta<WerkvoorraadListComponent> = {
title: 'Domein/Behandeling/Werkvoorraad List',
component: WerkvoorraadListComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
};
export default meta;
type Story = StoryObj<WerkvoorraadListComponent>;
export const Default: Story = {
args: { items },
};
export const Empty: Story = {
args: { items: [] },
};
@@ -0,0 +1,80 @@
import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.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 — 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.
*/
@Component({
selector: 'app-werkvoorraad-page',
imports: [
PageShellComponent,
AlertComponent,
ButtonComponent,
SkeletonComponent,
WerkvoorraadListComponent,
...ASYNC,
],
template: `
<app-page-shell [heading]="heading" [intro]="intro">
@if (!access.ready()) {
<!-- wait for /me before deciding — avoids flashing the denial to a behandelaar -->
} @else if (!canBeoordelen()) {
<app-alert type="error">{{ deniedText }}</app-alert>
} @else {
<app-async [data]="store.items()" (retryClicked)="store.reload()">
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="4" />
</ng-template>
<ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="store.reload()">{{ retryText }}</app-button>
</ng-template>
<ng-template appAsyncLoaded>
@if (items().length === 0) {
<app-alert type="info">{{ emptyText }}</app-alert>
} @else {
<app-werkvoorraad-list [items]="items()" />
}
</ng-template>
</app-async>
}
</app-page-shell>
`,
})
export class WerkvoorraadPage {
protected store = inject(WerkvoorraadStore);
protected access = inject(AccessStore);
protected canBeoordelen = computed(() => this.access.can('aanvraag:beoordelen'));
protected items = computed(() => successOr(this.store.items(), []));
protected heading = $localize`:@@werkvoorraad.heading:Werkvoorraad`;
protected intro = $localize`:@@werkvoorraad.intro:Aanvragen die op beoordeling wachten.`;
protected deniedText = $localize`:@@werkvoorraad.denied:U hebt geen rechten om de werkvoorraad te bekijken.`;
protected failedText = $localize`:@@werkvoorraad.failed:De werkvoorraad kon niet worden geladen.`;
protected emptyText = $localize`:@@werkvoorraad.empty:Er staan geen aanvragen open.`;
protected retryText = $localize`:@@werkvoorraad.retry:Opnieuw proberen`;
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.
effect(() => {
if (this.canBeoordelen() && !this.loadRequested) {
this.loadRequested = true;
void this.store.load();
}
});
}
}
@@ -0,0 +1,31 @@
import { AdminLink, HeaderNavItem } from '@shared/layout/site-header/nav-config';
/** This app's primary nav — provided to the shared site header via HEADER_NAV_ITEMS
(see app.config.ts). */
export const NAV_ITEMS: readonly HeaderNavItem[] = [
{ label: $localize`:@@header.nav.overzicht:Overzicht`, to: '/dashboard' },
];
/** 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
the bootstrap trim, not revisited by this migration. */
export const ADMIN_LINKS: readonly AdminLink[] = [
{
label: $localize`:@@header.nav.stamdata:Stamdata`,
description: $localize`:@@admin.link.stamdata.desc:Business-tabellen onderhouden`,
to: '/beheer/stamdata',
cap: 'stamdata:edit',
},
{
label: $localize`:@@header.nav.audit:Auditlog`,
description: $localize`:@@admin.link.audit.desc:Toegangs- en inzagebeslissingen bekijken`,
to: '/beheer/audit',
cap: 'cases:manage',
},
{
label: $localize`:@@header.nav.functies:Functievlaggen`,
description: $localize`:@@admin.link.functies.desc:Functionaliteit aan- of uitzetten`,
to: '/beheer/functies',
cap: 'flags:manage',
},
];
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Behandelportal</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<!-- CIBG Huisstijl (customized Bootstrap 5.2). Loaded as a <link> so its relative
url(../fonts|icons|images) refs resolve against the vendored folder at runtime.
Licensed Rijksoverheid fonts are not used — styles.scss overrides the stack to system-ui. -->
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
</head>
<!-- brand--cibg activates CIBG's official palette: robijn layout chrome + lintblauw accents
(without it, --ro-layout falls back to the blue default). -->
<body class="brand--cibg">
<app-root></app-root>
</body>
</html>
File diff suppressed because it is too large Load Diff
+980
View File
@@ -0,0 +1,980 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="nl" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="form.verplichteVelden" datatype="html">
<source>* verplichte velden</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">15,18</context>
</context-group>
</trans-unit>
<trans-unit id="login.bsnLabel" datatype="html">
<source>BSN</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/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>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/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>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">36,37</context>
</context-group>
</trans-unit>
<trans-unit id="login.submit" datatype="html">
<source>Inloggen met DigiD</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">41,43</context>
</context-group>
</trans-unit>
<trans-unit id="login.heading" datatype="html">
<source>Inloggen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/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>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/auth/ui/login.page.ts</context>
<context context-type="linenumber">17,19</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.type.registratie" datatype="html">
<source>Inschrijving</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">11</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.type.herregistratie" datatype="html">
<source>Herregistratie</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">12</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.type.intake" datatype="html">
<source>Herregistratie-intake</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.status.ingediend" datatype="html">
<source>Ingediend</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">19</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.status.inBehandelingHandmatig" datatype="html">
<source>In behandeling (handmatig)</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">22</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.status.inBehandeling" datatype="html">
<source>In behandeling</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.status.meerInfoGevraagd" datatype="html">
<source>Meer informatie gevraagd</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">25</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.status.goedgekeurd" datatype="html">
<source>Goedgekeurd</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.status.afgewezen" datatype="html">
<source>Afgewezen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">29</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.soort" datatype="html">
<source>Soort aanvraag</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">37</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.status" datatype="html">
<source>Status</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">38</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.referentie" datatype="html">
<source>Referentie</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">39</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.eigenaar" datatype="html">
<source>Eigenaar (BSN)</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">40</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.ingediend" datatype="html">
<source>Ingediend op</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">42</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.reden" datatype="html">
<source>Reden</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts</context>
<context context-type="linenumber">47</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.error.verplicht" datatype="html">
<source>Kies een besluit.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts</context>
<context context-type="linenumber">46</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.error.toelichtingVerplicht" datatype="html">
<source>Geef een toelichting.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts</context>
<context context-type="linenumber">54</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.row.ingediend" datatype="html">
<source>ingediend op <x id="datum" equiv-text="formatDatumNl(item.submittedAt)"/></source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.row.bsn" datatype="html">
<source>BSN <x id="bsn" equiv-text="item.owner"/></source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts</context>
<context context-type="linenumber">28</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.documenten.leeg" datatype="html">
<source>Geen documenten.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts</context>
<context context-type="linenumber">11,13</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.heading" datatype="html">
<source>Aanvraag</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">69</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.detail.heading" datatype="html">
<source>Aanvraaggegevens</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">70</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.documenten.heading" datatype="html">
<source>Documenten</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">71</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.failed" datatype="html">
<source>De aanvraag kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="beoordeling.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
<context context-type="linenumber">73</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.success" datatype="html">
<source>Het besluit is vastgelegd.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">35,37</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.heading" datatype="html">
<source>Besluit vastleggen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">37,39</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.besluitLabel" datatype="html">
<source>Besluit</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">43</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.toelichtingLabel" datatype="html">
<source>Toelichting</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">59,60</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.failed" datatype="html">
<source>Het vastleggen is niet gelukt:</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">75,76</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.optie.goedkeuren" datatype="html">
<source>Goedkeuren</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">101</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.optie.afwijzen" datatype="html">
<source>Afwijzen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">102</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.optie.meerInfoOpvragen" datatype="html">
<source>Meer informatie opvragen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">105</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.submit" datatype="html">
<source>Besluit vastleggen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">109</context>
</context-group>
</trans-unit>
<trans-unit id="besluit.submitBezig" datatype="html">
<source>Bezig met vastleggen…</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
<context context-type="linenumber">110</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.heading" datatype="html">
<source>Werkvoorraad</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">64</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.intro" datatype="html">
<source>Aanvragen die op beoordeling wachten.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">65</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.denied" datatype="html">
<source>U hebt geen rechten om de werkvoorraad te bekijken.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">66</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.failed" datatype="html">
<source>De werkvoorraad kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">67</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.empty" datatype="html">
<source>Er staan geen aanvragen open.</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">68</context>
</context-group>
</trans-unit>
<trans-unit id="werkvoorraad.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts</context>
<context context-type="linenumber">69</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.overzicht" datatype="html">
<source>Overzicht</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
<context context-type="linenumber">6</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.stamdata" datatype="html">
<source>Stamdata</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
<context context-type="linenumber">14</context>
</context-group>
</trans-unit>
<trans-unit id="admin.link.stamdata.desc" datatype="html">
<source>Business-tabellen onderhouden</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
<context context-type="linenumber">15</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.audit" datatype="html">
<source>Auditlog</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="admin.link.audit.desc" datatype="html">
<source>Toegangs- en inzagebeslissingen bekijken</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
<context context-type="linenumber">21</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.functies" datatype="html">
<source>Functievlaggen</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
<context context-type="linenumber">26</context>
</context-group>
</trans-unit>
<trans-unit id="admin.link.functies.desc" datatype="html">
<source>Functionaliteit aan- of uitzetten</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/behandelportal/src/app/shell/nav.config.ts</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.noTables" datatype="html">
<source>Er is geen stamdata om te beheren.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/application/stamdata.store.ts</context>
<context context-type="linenumber">150</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.validation.key" datatype="html">
<source>Vul de sleutelkolom in.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/domain/stamdata.ts</context>
<context context-type="linenumber">68</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.validation.van" datatype="html">
<source>Vul een &apos;geldig van&apos;-datum in.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/domain/stamdata.ts</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.validation.range" datatype="html">
<source>&apos;Geldig tot&apos; moet ná &apos;geldig van&apos; liggen.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/domain/stamdata.ts</context>
<context context-type="linenumber">74</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.load.failed" datatype="html">
<source>De stamdata kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/infrastructure/stamdata.adapter.ts</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="audit.heading" datatype="html">
<source>Auditlog</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">102</context>
</context-group>
</trans-unit>
<trans-unit id="audit.intro" datatype="html">
<source>Toegangs- en inzagebeslissingen (autorisatie en het tonen van afgeschermde gegevens). Vastgelegd zonder persoonsgegevens.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">103</context>
</context-group>
</trans-unit>
<trans-unit id="audit.denied" datatype="html">
<source>U hebt geen rechten om de auditlog te bekijken.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">104</context>
</context-group>
</trans-unit>
<trans-unit id="audit.failed" datatype="html">
<source>De auditlog kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">105</context>
</context-group>
</trans-unit>
<trans-unit id="audit.empty" datatype="html">
<source>Nog geen auditregels.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">106</context>
</context-group>
</trans-unit>
<trans-unit id="audit.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">107</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.tijd" datatype="html">
<source>Tijd</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">108</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.actie" datatype="html">
<source>Actie</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">109</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.resource" datatype="html">
<source>Resource</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">110</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.besluit" datatype="html">
<source>Besluit</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">111</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.rol" datatype="html">
<source>Rol</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">112</context>
</context-group>
</trans-unit>
<trans-unit id="audit.col.cid" datatype="html">
<source>Correlatie-id</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/audit.page.ts</context>
<context context-type="linenumber">113</context>
</context-group>
</trans-unit>
<trans-unit id="flags.heading" datatype="html">
<source>Functievlaggen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">82</context>
</context-group>
</trans-unit>
<trans-unit id="flags.intro" datatype="html">
<source>Zet functionaliteit aan of uit tijdens runtime. De catalogus staat vast in code; hier beheert u de status.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">83</context>
</context-group>
</trans-unit>
<trans-unit id="flags.denied" datatype="html">
<source>U hebt geen rechten om functievlaggen te beheren.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">84</context>
</context-group>
</trans-unit>
<trans-unit id="flags.failed" datatype="html">
<source>De functievlaggen konden niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">85</context>
</context-group>
</trans-unit>
<trans-unit id="flags.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">86</context>
</context-group>
</trans-unit>
<trans-unit id="flags.on" datatype="html">
<source>Aan</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">87</context>
</context-group>
</trans-unit>
<trans-unit id="flags.off" datatype="html">
<source>Uit</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">88</context>
</context-group>
</trans-unit>
<trans-unit id="flags.enable" datatype="html">
<source>Aanzetten</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">89</context>
</context-group>
</trans-unit>
<trans-unit id="flags.disable" datatype="html">
<source>Uitzetten</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/feature-flags.page.ts</context>
<context context-type="linenumber">90</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.added" datatype="html">
<source>toegevoegd</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">228</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.edited" datatype="html">
<source>gewijzigd</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">229</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.removed" datatype="html">
<source>verwijderd</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">230</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.table" datatype="html">
<source>Tabel</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">236</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.peildatum" datatype="html">
<source>Toon geldig op</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">237</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.showAll" datatype="html">
<source>Toon alles</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">238</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.previewNote" datatype="html">
<source>Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">239</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.actions" datatype="html">
<source>Acties</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">240</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.remove" datatype="html">
<source>Verwijderen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">241</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.expire" datatype="html">
<source>Sluiten per vandaag</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">242</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.removeConfirm" datatype="html">
<source>Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">243</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.undo" datatype="html">
<source>Ongedaan maken</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">257</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.redo" datatype="html">
<source>Opnieuw uitvoeren</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">258</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.addRow" datatype="html">
<source>Rij toevoegen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">259</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.download" datatype="html">
<source>Download JSON</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">260</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.applyHint" datatype="html">
<source>Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">261</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.heading" datatype="html">
<source>Stamdata onderhouden</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.intro" datatype="html">
<source>Beheer de business-tabellen die de registratie stuurt. Wijzigingen worden als JSON gedownload en via een pull request toegepast; de build blijft de bewaker.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
<context context-type="linenumber">73</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.denied" datatype="html">
<source>U hebt geen rechten om stamdata te onderhouden.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
<context context-type="linenumber">74</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.failed" datatype="html">
<source>De stamdata kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
<context context-type="linenumber">75</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/beheer/src/ui/stamdata.page.ts</context>
<context context-type="linenumber">76</context>
</context-group>
</trans-unit>
<trans-unit id="submit.failed" datatype="html">
<source>Het indienen is niet gelukt. Probeer het later opnieuw.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/application/submit.ts</context>
<context context-type="linenumber">28</context>
</context-group>
</trans-unit>
<trans-unit id="validation.bsn" datatype="html">
<source>Voer een geldig BSN van 9 cijfers in.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/kernel/bsn.ts</context>
<context context-type="linenumber">18</context>
</context-group>
</trans-unit>
<trans-unit id="validation.bsnElfproef" datatype="html">
<source>Dit is geen geldig BSN (klopt niet met de elfproef).</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/kernel/bsn.ts</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="crumb.dashboard" datatype="html">
<source>Mijn overzicht</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
<context context-type="linenumber">12</context>
</context-group>
</trans-unit>
<trans-unit id="crumb.registratie" datatype="html">
<source>Mijn gegevens</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="crumb.registreren" datatype="html">
<source>Inschrijven</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
<context context-type="linenumber">14</context>
</context-group>
</trans-unit>
<trans-unit id="crumb.herregistratie" datatype="html">
<source>Herregistratie</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
<context context-type="linenumber">16</context>
</context-group>
</trans-unit>
<trans-unit id="crumb.intake" datatype="html">
<source>Herregistratie-intake</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
<context context-type="linenumber">19</context>
</context-group>
</trans-unit>
<trans-unit id="crumb.concepts" datatype="html">
<source>Functionele patronen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb-trail.ts</context>
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="breadcrumb.aria" datatype="html">
<source>Kruimelpad</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb.component.ts</context>
<context context-type="linenumber">27,28</context>
</context-group>
</trans-unit>
<trans-unit id="breadcrumb.hier" datatype="html">
<source>U bevindt zich hier:</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/breadcrumb/breadcrumb.component.ts</context>
<context context-type="linenumber">28,29</context>
</context-group>
</trans-unit>
<trans-unit id="lang.navLabel" datatype="html">
<source>Taal / Language</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/language-switcher/language-switcher.component.ts</context>
<context context-type="linenumber">95</context>
</context-group>
</trans-unit>
<trans-unit id="lang.heading" datatype="html">
<source>Kies een taal</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/language-switcher/language-switcher.component.ts</context>
<context context-type="linenumber">96</context>
</context-group>
</trans-unit>
<trans-unit id="pageShell.backLabel" datatype="html">
<source>Terug naar overzicht</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/page-shell/page-shell.component.ts</context>
<context context-type="linenumber">49</context>
</context-group>
</trans-unit>
<trans-unit id="shell.skipLink" datatype="html">
<source>Naar de inhoud</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/shell/shell.component.ts</context>
<context context-type="linenumber">62,63</context>
</context-group>
</trans-unit>
<trans-unit id="footer.tagline" datatype="html">
<source>De Rijksoverheid. Voor Nederland.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">85,86</context>
</context-group>
</trans-unit>
<trans-unit id="footer.ministry" datatype="html">
<source> CIBG — Ministerie van Volksgezondheid, Welzijn en Sport </source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">87,89</context>
</context-group>
</trans-unit>
<trans-unit id="footer.overSiteAria" datatype="html">
<source>Over deze site</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">90,91</context>
</context-group>
</trans-unit>
<trans-unit id="footer.overSite" datatype="html">
<source>Over deze site</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">91,92</context>
</context-group>
</trans-unit>
<trans-unit id="footer.privacy" datatype="html">
<source>Privacy</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">99,101</context>
</context-group>
</trans-unit>
<trans-unit id="footer.cookies" datatype="html">
<source>Cookies</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">108,110</context>
</context-group>
</trans-unit>
<trans-unit id="footer.toegankelijkheid" datatype="html">
<source>Toegankelijkheid</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">117,120</context>
</context-group>
</trans-unit>
<trans-unit id="footer.demo" datatype="html">
<source>Demo / POC — geen echte gegevens.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-footer/site-footer.component.ts</context>
<context context-type="linenumber">122,124</context>
</context-group>
</trans-unit>
<trans-unit id="header.sender" datatype="html">
<source>BIG-register</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">44,45</context>
</context-group>
</trans-unit>
<trans-unit id="header.ministry" datatype="html">
<source>Ministerie van Volksgezondheid, Welzijn en Sport</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">46,48</context>
</context-group>
</trans-unit>
<trans-unit id="header.uitloggen" datatype="html">
<source> Uitloggen </source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">68,69</context>
</context-group>
</trans-unit>
<trans-unit id="header.navAria" datatype="html">
<source>Hoofdnavigatie</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">76,77</context>
</context-group>
</trans-unit>
<trans-unit id="alert.icon.info" datatype="html">
<source>Informatie</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/alert/alert.component.ts</context>
<context context-type="linenumber">7</context>
</context-group>
</trans-unit>
<trans-unit id="alert.icon.ok" datatype="html">
<source>Gelukt</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/alert/alert.component.ts</context>
<context context-type="linenumber">8</context>
</context-group>
</trans-unit>
<trans-unit id="alert.icon.warning" datatype="html">
<source>Waarschuwing</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/alert/alert.component.ts</context>
<context context-type="linenumber">9</context>
</context-group>
</trans-unit>
<trans-unit id="alert.icon.error" datatype="html">
<source>Foutmelding</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/alert/alert.component.ts</context>
<context context-type="linenumber">10</context>
</context-group>
</trans-unit>
<trans-unit id="async.error" datatype="html">
<source>Er ging iets mis bij het laden van de gegevens.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/async/async.component.ts</context>
<context context-type="linenumber">105</context>
</context-group>
</trans-unit>
<trans-unit id="async.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/async/async.component.ts</context>
<context context-type="linenumber">106</context>
</context-group>
</trans-unit>
<trans-unit id="async.empty" datatype="html">
<source>Geen gegevens gevonden.</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/async/async.component.ts</context>
<context context-type="linenumber">107</context>
</context-group>
</trans-unit>
<trans-unit id="common.ja" datatype="html">
<source>Ja</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/radio-group/radio-group.component.ts</context>
<context context-type="linenumber">12</context>
</context-group>
</trans-unit>
<trans-unit id="common.nee" datatype="html">
<source>Nee</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/radio-group/radio-group.component.ts</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="spinner.aria" datatype="html">
<source>Bezig met laden</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/shared/src/ui/spinner/spinner.component.ts</context>
<context context-type="linenumber">36,40</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>
@@ -7,5 +7,5 @@
"types": ["@angular/localize"] "types": ["@angular/localize"]
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.stories.ts"] "exclude": ["src/**/*.spec.ts", "src/**/*.stories.ts", "src/**/*.testing.ts"]
} }
+15
View File
@@ -0,0 +1,15 @@
/* Per-app base: adds this app's own alias map on top of the workspace-wide compiler
options in the root tsconfig.json. See that file's comment on why paths live here,
per-app, instead of at the root. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@shared/*": ["../../libs/shared/src/*"],
"@beheer/*": ["../../libs/beheer/src/*"],
"@auth/*": ["src/app/auth/*"],
"@behandeling/*": ["src/app/behandeling/*"]
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"/api": {
"target": "http://localhost:5000",
"secure": false,
"changeOrigin": true
}
}
+75
View File
@@ -0,0 +1,75 @@
import {
ApplicationConfig,
LOCALE_ID,
isDevMode,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter, withInMemoryScrolling, withViewTransitions } from '@angular/router';
import type { ActivatedRouteSnapshot } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { registerLocaleData } from '@angular/common';
import localeNl from '@angular/common/locales/nl';
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';
import { provideRouteFocus } from '@shared/layout/route-focus';
import { provideUnloadFlush } from '@shared/application/pending-saves';
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from '@shared/layout/site-header/nav-config';
import { DEBUG_PANEL } from '@shared/layout/shell/shell.component';
import { ADMIN_LINKS, NAV_ITEMS } from './shell/nav.config';
import { DebugStateComponent } from './shell/debug-state/debug-state.component';
// Both locales' data so DatePipe/number pipes work for whichever bundle is active.
registerLocaleData(localeNl);
registerLocaleData(localeEn);
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(
routes,
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
// Cross-fade page-to-page navigations only. A silent same-route nav — e.g.
// draft-sync stamping `?aanvraag=<id>` into the URL mid-wizard — must NOT
// animate: for the transition's duration Firefox's `::view-transition`
// overlay swallows pointer events (Chrome sets pointer-events:none, so it
// doesn't), which loses a click landing on it and makes the wizard's "next"
// button need a second click. Skip the transition when the route is unchanged.
withViewTransitions({
onViewTransitionCreated: ({ transition, from, to }) => {
// `from`/`to` are the ROOT snapshots (the shared shell), so descend to the
// leaf before comparing — otherwise every navigation looks "same route".
const leaf = (r: ActivatedRouteSnapshot) => {
while (r.firstChild) r = r.firstChild;
return r;
};
if (leaf(from).routeConfig === leaf(to).routeConfig) transition.skipTransition();
},
}),
),
// 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, subjectInterceptor] : [],
),
),
provideApiClient(),
{ provide: SESSION_PORT, useExisting: SessionStore },
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
// non-localized dev/source build leaves it undefined → fall back to 'nl'. (Was hardcoded
// 'nl', which mis-formatted dates/numbers in the en bundle.)
{ provide: LOCALE_ID, useFactory: () => $localize.locale ?? 'nl' },
provideRouteFocus(),
provideUnloadFlush(),
{ provide: HEADER_NAV_ITEMS, useValue: NAV_ITEMS },
{ provide: HEADER_ADMIN_LINKS, useValue: ADMIN_LINKS },
{ provide: DEBUG_PANEL, useValue: DebugStateComponent },
],
};
@@ -14,9 +14,11 @@ export const routes: Routes = [
loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage), 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', path: 'dashboard',
canActivate: [authGuard], canActivate: [authGuard],
loadComponent: () => import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage), loadComponent: () => import('@overzicht/ui/overzicht.page').then((m) => m.OverzichtPage),
}, },
{ {
path: 'registratie', path: 'registratie',
@@ -59,7 +61,7 @@ export const routes: Routes = [
}, },
{ {
path: 'brief/huisstijl', 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 // 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. // via the OrgAdmin gate — the guard just avoids loading a page that would 403.
canActivate: [capabilityGuard('orgtemplate:edit')], canActivate: [capabilityGuard('orgtemplate:edit')],
@@ -76,7 +78,7 @@ export const routes: Routes = [
}, },
{ {
path: 'beheer/zaken', 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 // 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 // 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. // lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer.
@@ -86,14 +88,14 @@ export const routes: Routes = [
}, },
{ {
path: 'beheer/audit', 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. // unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
canActivate: [capabilityGuard('cases:manage')], canActivate: [capabilityGuard('cases:manage')],
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage), loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
}, },
{ {
path: 'beheer/functies', 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')], canActivate: [capabilityGuard('flags:manage')],
loadComponent: () => loadComponent: () =>
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage), import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
+9
View File
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: '<router-outlet />',
})
export class App {}
@@ -0,0 +1,56 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Principal, parseStoredPrincipal } from '../domain/principal';
import { DigidAdapter } from '../infrastructure/digid.adapter';
const STORAGE_KEY = 'session-v1';
/** 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 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<Principal | null>(restore());
readonly session = this._session.asReadonly();
readonly isAuthenticated = computed(() => this._session() !== null);
constructor() {
effect(() => {
const p = this._session();
// G1: persist only `naam` — never write the BSN (national ID) to storage.
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: p.naam }));
else localStorage.removeItem(STORAGE_KEY);
});
}
/** 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;
}
logout() {
this._session.set(null);
}
}
+10
View File
@@ -0,0 +1,10 @@
/**
* 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.
*
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
* `Principal`, the login flow, `SessionStore`. A guard is neither.
*/
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,16 +1,16 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Result, ok } from '@shared/kernel/fp'; import { Result, ok } from '@shared/kernel/fp';
import { parseBsn } from '@shared/kernel/bsn'; import { parseBsn } from '@shared/kernel/bsn';
import { Session } from '../domain/session'; import { Principal } from '../domain/principal';
/** Infrastructure: talks to the (mock) DigiD identity provider. */ /** Infrastructure: talks to the (mock) DigiD identity provider. */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DigidAdapter { export class DigidAdapter {
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity. // 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. // 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); 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 { Component, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { TextInputComponent } from '@shared/ui/atoms/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: DigiD-style mock login. No real auth — just composes atoms/molecules. */
@Component({ @Component({
@@ -0,0 +1,11 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LoginFormComponent } from './login-form.component';
const meta: Meta<LoginFormComponent> = {
title: 'Domein/Auth/Login Form',
component: LoginFormComponent,
};
export default meta;
type Story = StoryObj<LoginFormComponent>;
export const Default: Story = {};
@@ -1,7 +1,7 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; 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 { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
import { SessionStore } from '@auth/application/session.store'; import { SessionStore } from '@auth/application/session.store';
@@ -1,10 +1,16 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { Result } from '@shared/kernel/fp'; 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 { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import {
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter'; 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 { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
import { BriefStore } from './brief.store'; import { BriefStore } from './brief.store';
@@ -48,8 +54,26 @@ const caseContext: CaseContext = {
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext }; const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
function setup(adapter: Partial<BriefAdapter>): BriefStore { /** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] }); 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); return TestBed.inject(BriefStore);
} }
@@ -60,7 +84,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } }, brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
}; };
const store = setup({ 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 }), save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> => approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: approved }), 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' } }, brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
}; };
const store = setup({ 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 }), save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> => approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: approved }), 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 () => { it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
const store = setup({ 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 }), save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> => approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: false, error: 'niet toegestaan' }), 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 () => { it('a subsequent successful transition clears a prior Failed state', async () => {
let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' }; let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
const store = setup({ 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 }), save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> => Promise.resolve(approveResult), 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 { function block(id: string, text: string): LetterBlock {
return { return {
@@ -151,38 +179,79 @@ const filledView: BriefView = { ...view, brief: filledBrief };
function loadedBrief(store: BriefStore): Brief { function loadedBrief(store: BriefStore): Brief {
const s = store.model(); 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; return s.brief;
} }
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> { async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
const ok = (v: BriefView): Promise<Result<string, BriefView>> => // Untyped return (inferred as the narrow `{ ok: true; value }` literal) so this one
Promise.resolve({ ok: true, value: v }); // 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 }); const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
await store.load(); await store.load();
return store; return store;
} }
describe('BriefStore undo/redo history', () => { describe('BriefStore undo/redo history', () => {
it('records an edit, undoes and redoes it; buttons mirror; a no-op edit is not recorded', async () => { it('starts with nothing to undo', async () => {
// Given a freshly loaded brief.
// When no edit has happened yet...
const store = await loadedStore(); const store = await loadedStore();
expect(store.canUndo()).toBe(false);
// Then there is nothing to undo.
expect(store.canUndo()).toBe(false);
});
it('records an edit and makes it undoable', async () => {
// Given a loaded brief with one block.
const store = await loadedStore();
// When a block is removed...
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' }); store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
// Then the block is gone and the edit becomes undoable.
expect(loadedBrief(store).sections[0].blocks.length).toBe(0); expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
expect(store.canUndo()).toBe(true); expect(store.canUndo()).toBe(true);
});
it('undo reverts the edit and enables redo', async () => {
// Given a brief with one recorded edit (a removed block).
const store = await loadedStore();
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
// When the edit is undone...
store.undo(); store.undo();
// Then the block is back, and redo becomes available.
expect(loadedBrief(store).sections[0].blocks.length).toBe(1); expect(loadedBrief(store).sections[0].blocks.length).toBe(1);
expect(store.canRedo()).toBe(true); expect(store.canRedo()).toBe(true);
});
it('redo reapplies the undone edit', async () => {
// Given an edit that was undone.
const store = await loadedStore();
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
store.undo();
// When it is redone...
store.redo(); store.redo();
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
// A no-op edit (unknown block) changes nothing → leaves no dead history step. // Then the edit is reapplied.
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
});
it('a no-op edit does not clear the redo future', async () => {
// Given an undone edit, with redo available.
const store = await loadedStore();
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
store.undo(); // back to 1 block, redo available store.undo(); // back to 1 block, redo available
// When an edit that changes nothing (an unknown block) is applied...
store.edit({ tag: 'BlockRemoved', blockId: 'does-not-exist' }); store.edit({ tag: 'BlockRemoved', blockId: 'does-not-exist' });
expect(store.canRedo()).toBe(true); // future NOT cleared by a no-op
// Then the no-op leaves no dead history step — redo is still available.
expect(store.canRedo()).toBe(true);
}); });
it('a new edit clears the redo future', async () => { it('a new edit clears the redo future', async () => {
@@ -216,8 +285,7 @@ describe('BriefStore rejection diff', () => {
...filledBrief, ...filledBrief,
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' }, status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
}; };
const ok = (v: BriefView): Promise<Result<string, BriefView>> => const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
Promise.resolve({ ok: true, value: v });
const store = setup({ const store = setup({
load: () => ok({ ...filledView, brief: submitted }), load: () => ok({ ...filledView, brief: submitted }),
save: () => ok(filledView), save: () => ok(filledView),
@@ -238,42 +306,47 @@ describe('BriefStore rejection diff', () => {
}); });
describe('BriefStore.previewLetter', () => { 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()); afterEach(() => vi.restoreAllMocks());
it('opens the composed letter in a new tab on success', async () => { it('opens the composed letter via BLOB_PRESENTER on success', async () => {
const store = setup({ const { presenter, opened } = fakeBlobPresenter();
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }), const store = setup(
}); {
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
},
presenter,
);
await store.load(); await store.load();
const blob = new Blob(['<html></html>'], { type: 'text/html' }); 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({ vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
ok: true, ok: true,
value: blob, value: blob,
}); });
await store.previewLetter(); await store.previewLetter();
expect(open).toHaveBeenCalledWith('blob:mock', '_blank'); expect(opened).toEqual([blob]);
expect(store.lastError()).toBeNull(); expect(store.lastError()).toBeNull();
}); });
it('surfaces the error without opening a tab on failure', async () => { it('surfaces the error without opening a tab on failure', async () => {
const store = setup({ const { presenter, opened } = fakeBlobPresenter();
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }), const store = setup(
}); {
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
},
presenter,
);
await store.load(); await store.load();
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({ vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
ok: false, ok: false,
error: 'De voorvertoning kon niet worden geopend.', error: PREVIEW_FAILED,
}); });
await store.previewLetter(); await store.previewLetter();
expect(open).not.toHaveBeenCalled(); expect(opened).toHaveLength(0);
expect(store.lastError()).toBe('De voorvertoning kon niet worden geopend.'); expect(store.lastError()).toBe(PREVIEW_FAILED);
}); });
}); });
@@ -338,3 +411,42 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
expect(save).not.toHaveBeenCalled(); 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 { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp'; import { Result } from '@shared/kernel/fp';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state';
import { createHistory } from '@shared/application/history'; import { createHistory } from '@shared/application/history';
import { createDebouncedSave } from '@shared/application/debounced-save'; import { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
import { machineRemoteData } from '@shared/application/machine-remote-data'; import { fromLoadLifecycle } from '@shared/application/remote-data';
import { import {
Brief, Brief,
CaseContext, CaseContext,
@@ -16,11 +15,12 @@ import {
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine'; import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff'; import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';
import { OrgTemplate } from '@brief/domain/org-template'; 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 { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.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 { 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 * 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`/ * outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never * `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase * 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' }) @Injectable({ providedIn: 'root' })
export class BriefStore implements PendingSave { export class BriefStore implements PendingSave {
private adapter = inject(BriefAdapter); private adapter = inject(BriefAdapter);
private previewAdapter = inject(LetterPreviewAdapter); private previewAdapter = inject(LetterPreviewAdapter);
private revealAdapter = inject(RevealBigNummerAdapter); private revealAdapter = inject(RevealBigNummerAdapter);
private blobPresenter = inject(BLOB_PRESENTER);
private store = createStore<BriefState, BriefMsg>(initial, reduce); private store = createStore<BriefState, BriefMsg>(initial, reduce);
readonly model = this.store.model; readonly model = this.store.model;
private actionState = signal<ActionState>({ tag: 'Idle' }); /** The one-shot action lifecycle now lives on the machine's `Loaded.action` (RD-12);
readonly busy = computed(() => this.actionState().tag === 'Busy'); 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(() => { readonly lastError = computed(() => {
const s = this.actionState(); const s = this.model();
return s.tag === 'Failed' ? s.error : null; return s.tag === 'Loaded' && s.action.tag === 'Failed' ? s.action.error : null;
}); });
/** Surfaced autosave state for the indicator + aria-live region. */ /** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<SaveState>({ tag: 'Idle' }); readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of /** Undo/redo is SHELL state, not machine state: a `createHistory` stack of
`Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded `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 (they flow through `edit()`); status transitions never enter history, or undo would
replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine
changes. */ changes. */
@@ -58,7 +64,7 @@ export class BriefStore implements PendingSave {
readonly canUndo = this.history.canUndo; readonly canUndo = this.history.canUndo;
readonly canRedo = this.history.canRedo; 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 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. */ full page reload loses it — a real system would persist the rejected revision. */
private rejectionSnapshot = signal<Brief | null>(null); private rejectionSnapshot = signal<Brief | null>(null);
@@ -75,7 +81,7 @@ export class BriefStore implements PendingSave {
); );
readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0); 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` 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. */ stays untouched by design). Set from every server view that carries it. */
readonly orgTemplate = signal<OrgTemplate | null>(null); 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 /** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is 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. */ 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>(() => { private brief = computed<Brief | null>(() => {
const s = this.model(); const s = this.model();
return s.tag === 'loaded' ? s.brief : null; return s.tag === 'Loaded' ? s.brief : null;
}); });
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false); readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
@@ -109,7 +115,7 @@ export class BriefStore implements PendingSave {
private decisions = computed(() => { private decisions = computed(() => {
const s = this.model(); 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 diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(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()); 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() { async load() {
const r = await this.adapter.load(); const r = await this.adapter.load();
if (r.ok) { if (r.ok) {
this.orgTemplate.set(r.value.orgTemplate); this.applyLoadedView(r.value);
this.caseContext.set(r.value.caseContext); } else if (r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief) {
this.history.clear(); this.hasRecoveredFromMissingBrief = true;
this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); 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 { } else {
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error }); this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
} }
@@ -153,7 +186,7 @@ export class BriefStore implements PendingSave {
} }
private restore(step: (current: Brief) => Brief | undefined) { private restore(step: (current: Brief) => Brief | undefined) {
const s = this.model(); const s = this.model();
if (s.tag !== 'loaded') return; if (s.tag !== 'Loaded') return;
const target = step(s.brief); const target = step(s.brief);
if (target === undefined) return; if (target === undefined) return;
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } }); 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 // 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({ private debouncedSave = createDebouncedSave({
canSave: () => this.canEdit(), canSave: () => this.canEdit(),
flush: () => this.flushSave(), flush: () => this.flushSave(),
@@ -183,31 +216,33 @@ export class BriefStore implements PendingSave {
if (r.ok) { if (r.ok) {
this.saveState.set({ tag: 'Saved' }); this.saveState.set({ tag: 'Saved' });
} else { } 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' }); 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() { retrySave() {
void this.flushSave(); void this.flushSave();
} }
/** Demo "start over": recreate the brief server-side and load the fresh view. */ /** Demo "start over": recreate the brief server-side and load the fresh view. */
async resetDemo() { async resetDemo() {
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
const r = await this.adapter.reset(); const r = await this.adapter.reset();
this.saveState.set({ tag: 'Idle' }); this.saveState.set({ tag: 'Idle' });
if (r.ok) { if (r.ok) {
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
this.orgTemplate.set(r.value.orgTemplate); this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext); this.caseContext.set(r.value.caseContext);
this.history.clear(); this.history.clear();
this.rejectionSnapshot.set(null); this.rejectionSnapshot.set(null);
this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else { } 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()); send = () => this.transition(() => this.adapter.send());
/** Explicit action, never a live re-render (PRD §8): opens the server-composed /** 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 letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the
the tab outlives this call; not worth a teardown hook for a POC. */ object URL is never revoked. */
async previewLetter() { async previewLetter() {
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
const r = await this.previewAdapter.preview(); const r = await this.previewAdapter.preview();
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
window.open(URL.createObjectURL(r.value), '_blank'); this.blobPresenter.open(r.value);
} }
/** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability /** 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 + 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 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() { async revealBigNummer() {
const r = await this.revealAdapter.reveal(); const r = await this.revealAdapter.reveal(true);
if (!r.ok) { 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; return;
} }
this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c)); 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 // A transition: flush any pending save, call the server (authoritative), then mirror
// the returned status through the pure reducer's guarded transition. // the returned status through the pure reducer's guarded transition.
private async transition(action: () => Promise<Result<string, BriefView>>) { private async transition(action: () => Promise<Result<string, BriefView>>) {
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
await this.flushSave(); await this.flushSave();
const r = await action(); const r = await action();
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
this.applyServerStatus(r.value); 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 }); this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
break; break;
case 'rejected': 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. // "before" snapshot the approver later compares against.
this.rejectionSnapshot.set(brief); this.rejectionSnapshot.set(brief);
this.store.dispatch({ 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 { Injectable, computed, effect, inject, signal } from '@angular/core';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state'; import { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
import { createDebouncedSave } from '@shared/application/debounced-save'; import { fromLoadLifecycle } from '@shared/application/remote-data';
import { machineRemoteData } from '@shared/application/machine-remote-data'; import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
import { UploadAdapter } from '@shared/upload/upload.adapter'; import { UploadShellService } from '@shared/application/upload-shell.service';
import { UploadShellService } from '@shared/upload/upload-shell.service'; import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';
import { import {
MARGIN_MAX_MM, MARGIN_MAX_MM,
MARGIN_MIN_MM, MARGIN_MIN_MM,
@@ -13,6 +12,7 @@ import {
SubOrgSummary, SubOrgSummary,
} from '@brief/domain/org-template'; } from '@brief/domain/org-template';
import { import {
OrgTemplateActionState,
OrgTemplateMsg, OrgTemplateMsg,
OrgTemplateState, OrgTemplateState,
initial, initial,
@@ -20,14 +20,15 @@ import {
} from '@brief/domain/org-template.machine'; } from '@brief/domain/org-template.machine';
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter'; import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; 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 LOGO_CATEGORY = 'org-logo';
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`; 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), * editable draft; commands here do the debounced save, publish (impact-confirm),
* rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The * rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The
* logo upload reuses the shared upload transport; its completion mutates the draft * 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 adapter = inject(OrgTemplateAdapter);
private uploadAdapter = inject(UploadAdapter); private uploadAdapter = inject(UploadAdapter);
private shell = inject(UploadShellService); private shell = inject(UploadShellService);
private blobPresenter = inject(BLOB_PRESENTER);
private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce); private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
readonly model = this.store.model; readonly model = this.store.model;
@@ -45,22 +47,28 @@ export class OrgTemplateStore implements PendingSave {
readonly subOrgs = signal<readonly SubOrgSummary[]>([]); readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
readonly selectedSubOrgId = signal<string | null>(null); readonly selectedSubOrgId = signal<string | null>(null);
private actionState = signal<ActionState>({ tag: 'Idle' }); /** The one-shot action lifecycle and the publish impact-confirm gate now live on
readonly busy = computed(() => this.actionState().tag === 'Busy'); 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(() => { readonly lastError = computed(() => {
const s = this.actionState(); const a = this.action();
return s.tag === 'Failed' ? s.error : null; 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' }); readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */ readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
readonly pendingPublish = signal(false);
readonly remoteData = computed(() => machineRemoteData(this.model()));
private loaded = computed<LoadedState | null>(() => { private loaded = computed<LoadedState | null>(() => {
const s = this.model(); 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 draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload); readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
@@ -72,6 +80,9 @@ export class OrgTemplateStore implements PendingSave {
return id ? this.uploadAdapter.contentUrl(id) : null; 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; /** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;
the server re-validates and stays the authority — publish is gated on this. */ the server re-validates and stays the authority — publish is gated on this. */
readonly draftValid = computed(() => { readonly draftValid = computed(() => {
@@ -96,7 +107,7 @@ export class OrgTemplateStore implements PendingSave {
// the length guard makes it idempotent (no dispatch loop). // the length guard makes it idempotent (no dispatch loop).
effect(() => { effect(() => {
const s = this.model(); 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(); const status = this.categoriesRes.status();
if (status === 'resolved' || status === 'local') if (status === 'resolved' || status === 'local')
this.dispatchUpload({ this.dispatchUpload({
@@ -140,7 +151,7 @@ export class OrgTemplateStore implements PendingSave {
this.debouncedSave.schedule(); 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. // shared helper; `flushSave` below is the store-specific write + save-state.
private debouncedSave = createDebouncedSave({ private debouncedSave = createDebouncedSave({
canSave: () => this.loaded() !== null, canSave: () => this.loaded() !== null,
@@ -160,61 +171,65 @@ export class OrgTemplateStore implements PendingSave {
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft }); this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
} else { } else {
this.saveState.set({ tag: 'Error' }); 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 --- // --- 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() { requestPublish() {
this.pendingPublish.set(true); this.store.dispatch({ tag: 'PublishRequested' });
} }
cancelPublish() { cancelPublish() {
this.pendingPublish.set(false); this.store.dispatch({ tag: 'PublishCancelled' });
} }
async confirmPublish() { async confirmPublish() {
const s = this.loaded(); const s = this.loaded();
if (!s) return; if (!s) return;
this.pendingPublish.set(false); // ActionStarted overwrites `action` straight to Busy, so ConfirmingPublish and
this.actionState.set({ tag: 'Busy' }); // Busy are never simultaneously true (RD-13).
this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
await this.flushSave(); // publish the saved draft — flush any pending edit first await this.flushSave(); // publish the saved draft — flush any pending edit first
const r = await this.adapter.publish(s.subOrgId); const r = await this.adapter.publish(s.subOrgId);
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0 await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0
} }
async rollback(version: number) { async rollback(version: number) {
const s = this.loaded(); const s = this.loaded();
if (!s) return; if (!s) return;
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
const r = await this.adapter.rollback(s.subOrgId, version); const r = await this.adapter.rollback(s.subOrgId, version);
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft
} }
async proefbrief() { async proefbrief() {
const s = this.loaded(); const s = this.loaded();
if (!s) return; if (!s) return;
this.actionState.set({ tag: 'Busy' }); this.store.dispatch({ tag: 'ActionStarted' });
this.debouncedSave.cancel(); this.debouncedSave.cancel();
await this.flushSave(); // the proefbrief renders the server's draft await this.flushSave(); // the proefbrief renders the server's draft
const r = await this.adapter.proefbrief(s.subOrgId); const r = await this.adapter.proefbrief(s.subOrgId);
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.store.dispatch({ tag: 'ActionFailed', error: r.error });
return; return;
} }
this.actionState.set({ tag: 'Idle' }); this.store.dispatch({ tag: 'ActionFinished' });
window.open(URL.createObjectURL(r.value), '_blank'); this.blobPresenter.open(r.value);
} }
// --- logo upload (reuses the shared upload transport; single `org-logo` file) --- // --- logo upload (reuses the shared upload transport; single `org-logo` 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 * 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. * was rejected" is just a fold over two snapshots, no change-tracking bookkeeping.
* *
* Blocks are matched by `blockId` (stable `local-N`/seed ids): * 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 { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderDef } from './placeholders'; import { PlaceholderDef } from './placeholders';
import { BriefState, reduce } from './brief.machine'; import { BriefState, reduce } from './brief.machine';
import { givenBrief } from './brief.testing';
const placeholders: PlaceholderDef[] = [ const placeholders: PlaceholderDef[] = [
{ key: 'naam', label: 'Naam', autoResolvable: true }, { key: 'naam', label: 'Naam', autoResolvable: true },
@@ -64,18 +65,18 @@ const decisions: BriefDecisions = {
canRevealBigNummer: true, canRevealBigNummer: true,
}; };
const loaded = ( // Replays a real `BriefLoaded` message through the real `reduce` (ADR-0006 §2)
status: BriefStatus = { tag: 'draft' }, // instead of hand-assembling the 'loaded' state directly.
sections?: Brief['sections'], const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState =>
): BriefState => ({ givenBrief({
tag: 'loaded', tag: 'BriefLoaded',
brief: briefWith(status, sections), brief: briefWith(status, sections),
availablePassages: lib, availablePassages: lib,
decisions, decisions,
}); });
const sectionBlocks = (s: BriefState, key: string) => 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) => const passageIds = (s: BriefState, key: string) =>
sectionBlocks(s, key) sectionBlocks(s, key)
@@ -91,12 +92,12 @@ describe('brief.machine reduce', () => {
availablePassages: [], availablePassages: [],
decisions, decisions,
}).tag, }).tag,
).toBe('loaded'); ).toBe('Loaded');
}); });
it('BriefLoadFailed moves loading to failed with the reason', () => { it('BriefLoadFailed moves loading to failed with the reason', () => {
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({ expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
tag: 'failed', tag: 'Failed',
reason: 'x', reason: 'x',
}); });
}); });
@@ -128,12 +129,12 @@ describe('brief.machine reduce', () => {
it('BesluitSelected deep-copies content — later library mutation does not leak in', () => { it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
const st: BriefState = { const st = givenBrief({
tag: 'loaded', tag: 'BriefLoaded',
brief: briefWith({ tag: 'draft' }), brief: briefWith({ tag: 'draft' }),
availablePassages: [passage], availablePassages: [passage],
decisions, decisions,
}; });
const s = reduce(st, besluit('positief')); const s = reduce(st, besluit('positief'));
// Mutate the source passage object after composition. // Mutate the source passage object after composition.
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED'; (passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
@@ -209,7 +210,7 @@ describe('brief.machine reduce', () => {
comments: 'graag aanpassen', comments: 'graag aanpassen',
}); });
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' }); 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); expect(sectionBlocks(next, 'slot')).toHaveLength(1);
}); });
@@ -219,7 +220,7 @@ describe('brief.machine reduce', () => {
// fill the required section via the besluit, then submit // fill the required section via the besluit, then submit
const filled = reduce(loaded(), besluit('positief')); const filled = reduce(loaded(), besluit('positief'));
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions }); const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({ expect(submitted.tag === 'Loaded' && submitted.brief.status).toEqual({
tag: 'submitted', tag: 'submitted',
submittedBy: 'u1', submittedBy: 'u1',
submittedAt: 't', submittedAt: 't',
@@ -231,7 +232,7 @@ describe('brief.machine reduce', () => {
// approve from draft is a no-op // approve from draft is a no-op
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded()); expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions }); const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({ expect(approved.tag === 'Loaded' && approved.brief.status).toEqual({
tag: 'approved', tag: 'approved',
approvedBy: 'u2', approvedBy: 'u2',
approvedAt: 't2', approvedAt: 't2',
@@ -247,7 +248,7 @@ describe('brief.machine reduce', () => {
comments: 'nee', comments: 'nee',
decisions, decisions,
}); });
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({ expect(rejected.tag === 'Loaded' && rejected.brief.status).toEqual({
tag: 'rejected', tag: 'rejected',
rejectedBy: 'u2', rejectedBy: 'u2',
rejectedAt: 't2', rejectedAt: 't2',
@@ -261,7 +262,40 @@ describe('brief.machine reduce', () => {
// send from submitted is a no-op // send from submitted is a no-op
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted); expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions }); 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', () => { it('a status transition replaces decisions with the fresh server value', () => {
@@ -279,10 +313,10 @@ describe('brief.machine reduce', () => {
at: 't2', at: 't2',
decisions: staleApprover, decisions: staleApprover,
}); });
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover); expect(approved.tag === 'Loaded' && approved.decisions).toEqual(staleApprover);
}); });
}); });
function initialLoading(): BriefState { function initialLoading(): BriefState {
return { tag: 'loading' }; return { tag: 'Loading' };
} }
@@ -36,17 +36,22 @@ import { passagesForBesluit } from './besluit';
* structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`). * 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 = export type BriefState =
| { tag: 'loading' } | { tag: 'Loading' }
| { | {
tag: 'loaded'; tag: 'Loaded';
brief: Brief; brief: Brief;
availablePassages: readonly LibraryPassage[]; availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions; 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 = export type BriefMsg =
| { | {
@@ -65,7 +70,10 @@ export type BriefMsg =
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved | { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected | { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent | { 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. */ /** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
function isEditable(status: BriefStatus): boolean { 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. */ /** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState { 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); let brief = f(s.brief);
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } }; if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
return { ...s, brief }; return { ...s, brief };
@@ -189,13 +197,16 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
switch (m.tag) { switch (m.tag) {
case 'BriefLoaded': case 'BriefLoaded':
return { return {
tag: 'loaded', tag: 'Loaded',
brief: m.brief, brief: m.brief,
availablePassages: m.availablePassages, availablePassages: m.availablePassages,
decisions: m.decisions, 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': case 'BriefLoadFailed':
return { tag: 'failed', reason: m.reason }; return { tag: 'Failed', reason: m.reason };
case 'Seed': case 'Seed':
return m.state; 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. // drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
case 'BesluitSelected': case 'BesluitSelected':
return withEdit(s, (b) => return withEdit(s, (b) =>
s.tag === 'loaded' && isSectionEditable(b, 'kern') s.tag === 'Loaded' && isSectionEditable(b, 'kern')
? composeKern(b, s.availablePassages, m.besluit, m.reasons) ? composeKern(b, s.availablePassages, m.besluit, m.reasons)
: b, : b,
); );
@@ -260,6 +271,15 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
case 'Sent': case 'Sent':
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions); 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: default:
return assertNever(m); return assertNever(m);
} }
@@ -275,6 +295,6 @@ function transition(
decisions: BriefDecisions, decisions: BriefDecisions,
guard: (b: Brief) => boolean = () => true, guard: (b: Brief) => boolean = () => true,
): BriefState { ): BriefState {
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s; if (s.tag !== 'Loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
return { ...s, brief: { ...s.brief, status: next() }, decisions }; 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);

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