Compare commits

...
7 Commits
Author SHA1 Message Date
ehoandClaude Opus 4.8 35a9d18374 docs: regenerate Storybook documentation.json
CI / frontend (push) Failing after 1m10s
CI / storybook-a11y (push) Failing after 5m8s
CI / backend (push) Successful in 1m17s
CI / codeql (csharp) (push) Has been cancelled
CI / codeql (javascript-typescript) (push) Has been cancelled
CI / api-client-drift (push) Has been cancelled
CI / e2e (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:36:06 +02:00
ehoandClaude Opus 4.8 d4e5a76873 docs: reorganize into project/ + reference/, expand Storybook Foundations
Move working docs (backlog, prd, roadmap) under docs/project/ and durable
docs (architecture ADRs, guides, audits) under docs/reference/; add a
docs/README.md index. Update every path reference in code comments, CLAUDE.md,
READMEs, and the new-ssp skill. Expand the Storybook Foundations curriculum
(Overview, BDD, i18n; rename Layers→Domain-Driven Design) and reorder the sidebar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:33:32 +02:00
ehoandClaude Opus 4.8 55a0a2d166 test: split multi-assertion specs into single-behavior tests
One behavior per test across FE machine/store specs and backend endpoint
tests, so a failure names exactly what broke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:33:25 +02:00
ehoandClaude Opus 4.8 5cae44f163 feat(security): ABAC P2/P3-lite — BIG-nummer redaction, authz audit, guard; clear dev audit
- fix(deps): pin @babel/core ^7.29.7 via overrides → npm audit 0 (dev+prod),
  no --force / no Angular downgrade; README corrected
- feat(brief): field-level PII reveal (PRD-0002 §5c) — CaseContext BIG-nummer
  ships masked; step-up-stubbed (X-Step-Up), audited POST /brief/reveal-bignummer
  unmasks it; drafter-only capability, deny-by-default. Realized on the BIG-nummer
  (no BSN on the wire)
- feat(authz): no-PII AuditAuthz log for reveal attempts + org-admin denials (§8)
- feat(routes): wire capabilityGuard('orgtemplate:edit') onto brief/huisstijl (§6)
- test: backend +5 (Authz + reveal endpoint), FE +3 (adapter boundary, store swap)
- docs: PRD-0002 §5c/§9, WP-18 follow-up, README

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:59:20 +02:00
ehoandClaude Opus 4.8 0edfbba2a9 docs(storybook): render MDX tables + add i18n & testing-strategy docs/skill
- fix: wire remark-gfm into addon-docs so GFM pipe tables in *.mdx render
  (previously raw text in cibg-gaps/layers/atomic-design docs)
- add src/docs/i18n.mdx (Foundations/Internationalization): the $localize
  locale seam + how to test languages without coupling to copy
- add src/docs/testing.mdx (Foundations/Testing strategy): per-layer spec
  matrix, house style, Storybook a11y gate, GREEN gate
- add .claude/skills/test-strategy skill

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:26:34 +02:00
ehoandClaude Opus 4.8 ba32e3dd9f feat(fp): brief v3 — besluit-driven guided drafting
Compose the herregistratie letter from the besluit instead of a library hunt:
the behandelaar picks positief/negatief (+ reden-checkboxes for a negatief) and
the kern's standaardteksten follow the selection live.

Front-end (this increment):
- Kern is recomposed reactively from the besluit selection (new BesluitSelected
  machine msg + composeKern); the "Genereer conceptbrief" button is gone. The
  drafter's free text is preserved across a selection change.
- The editor shows only the editable sections; the locked aanhef/slot render in
  the preview, not the authoring surface. Slot is a case-type template section
  (per templateId), documented as such.
- The panel re-seeds from the letter via inferSelection() — the besluit + redenen
  are read back off the kern's passage blocks, so the selection survives reload
  with no new wire fields (derive, don't store).
- letter-section drops the now-redundant per-section passage picker (besluit owns
  standaardteksten); keeps free-text + block edit/move/remove.

Fix: app-checkbox now falls back to a unique per-instance id when checkboxId is
omitted. The CIBG styled checkbox routes clicks through the label, so the shared
id="undefined" made every reason label toggle the first input — the second
checkbox could never be checked. Verified live (Playwright): each reason toggles
independently.

Backend/seam (brief v3 WIP): besluit/reason passage tags on the wire + seed,
carried through the adapter parse boundary.

Specs updated (besluit, brief.machine) and the affected stories re-pointed at the
new API. FE lint + build + 253 vitest specs green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:53:53 +02:00
ehoandClaude Opus 4.8 62ba0b98c4 fix(cibg): address fieldset padding + atomic-design audit cleanup
Fix: address-fields shipped a scoped `fieldset { padding:0; margin:0 }` that
tied on specificity with CIBG's `.form-horizontal fieldset` and, injected later,
won — flattening the grey-box padding on "Mijn gegevens" and the registratie
wizard address step. Removed the override (kept the legend rule).

Audit cleanup (minimise atoms, converge or document near-duplicates):
- delete dead `app-card` (+ story; zero consumers, superseded by app-data-block)
  and the unused `.app-info-box` utility
- review-section raw <h2> -> app-heading; registratie-wizard lone <dl> -> app-data-block
- keep application-link's raw <h3> (vendored `.applications li a h3` chain needs
  the bare element) with an inline justification
- document the deliberate "don't merge these" verdicts in a new Convergence
  decisions table in atomic-design.mdx; record the card removal in cibg-gaps.mdx

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:04:02 +02:00
115 changed files with 8212 additions and 3937 deletions
+4 -4
View File
@@ -17,8 +17,8 @@ business content. Keep the machinery, replace the domain.
`nswag.json`, `.storybook/`, `proxy.conf.json`, `.npmrc` (`legacy-peer-deps` — `nswag.json`, `.storybook/`, `proxy.conf.json`, `.npmrc` (`legacy-peer-deps` —
and never `npm audit fix --force`, it downgrades Angular). and never `npm audit fix --force`, it downgrades Angular).
- `src/app/auth/` (fake auth shell) and `src/app/shared/infrastructure/scenario.interceptor.ts` (dev-only). - `src/app/auth/` (fake auth shell) and `src/app/shared/infrastructure/scenario.interceptor.ts` (dev-only).
- `docs/architecture/` ADRs 0001–0003 — the decisions still apply; amend, don't delete. - `docs/reference/architecture/` ADRs 0001–0003 — the decisions still apply; amend, don't delete.
- `CLAUDE.md`, `docs/ARCHITECTURE.md`, `docs/fp-tea-atomic-design.md` — update names/examples as contexts change. - `CLAUDE.md`, `docs/reference/architecture/ARCHITECTURE.md`, `docs/reference/fp-tea-atomic-design.md` — update names/examples as contexts change.
- `.claude/skills/` — these recipes are the point of the template. - `.claude/skills/` — these recipes are the point of the template.
## Strip / replace ## Strip / replace
@@ -39,8 +39,8 @@ business content. Keep the machinery, replace the domain.
- Branding: `public/cibg-huisstijl/` + the token bridge in `src/styles.scss` — for a - Branding: `public/cibg-huisstijl/` + the token bridge in `src/styles.scss` — for a
different house style, swap the vendored CSS and re-point the `--rhc-*` bridge different house style, swap the vendored CSS and re-point the `--rhc-*` bridge
(ADR-0003 pattern: bridge, don't rewrite tokens). (ADR-0003 pattern: bridge, don't rewrite tokens).
- `docs/backlog/` WPs, PRDs, and memory-specific docs — new portal, new backlog - `docs/project/backlog/` WPs, PRDs, and memory-specific docs — new portal, new backlog
(keep `docs/backlog/README.md`'s WP process/template if you like the workflow). (keep `docs/project/backlog/README.md`'s WP process/template if you like the workflow).
## Verify — the GREEN gate must pass at every step ## Verify — the GREEN gate must pass at every step
+67
View File
@@ -0,0 +1,67 @@
---
name: test-strategy
description: Place tests the house way — Vitest specs co-located by layer (pure domain, no TestBed; parse* trust boundaries; thin UI via Storybook a11y). Use whenever adding a spec or deciding what to test.
---
# Test strategy (test where it's pure)
Push logic down to where it's pure, test it there directly, keep the layers above thin.
No `TestBed` for domain. Never assert on user-facing copy.
## Rules
- **`domain/` + any pure logic → required spec.** Reducers, combinators, `visibleSteps`,
policies, parsers. Import the function and call it — no Angular, no `TestBed`.
- **Value-object parser → happy path + normalisation + each rejection.** Assert on the
`Result` discriminant (`.ok`) and the parsed value, **not** the error message.
- **`infrastructure/` `parse*` (trust boundary) → required spec.** Accept a valid DTO;
reject `null`, `{}`, and malformed shapes. Name it `describe('… (trust boundary)')`.
- **`application/` stores/commands → spec** the pure reduce + optimistic
begin→confirm/rollback + the command `Result`.
- **`ui/` → Storybook story, not a component test.** Axe runs on every story; add a `play`
only for wiring axe can't see.
- **Never assert on `$localize` copy.** It changes per locale/edit — assert on the
`Result`, the value object, or the message id.
## Skeleton
Co-locate `*.spec.ts` next to the unit, in the same layer folder:
```
<context>/domain/<thing>.spec.ts # pure — no TestBed
<context>/domain/value-objects/<vo>.spec.ts # parser: ok + normalise + each reject
<context>/infrastructure/<x>.adapter.spec.ts# parse* trust boundary
<context>/application/<store|command>.spec.ts
```
Minimal parser spec:
```ts
import { describe, it, expect } from 'vitest';
import { parseThing } from './thing';
describe('parseThing', () => {
it('accepts + normalises', () => {
const r = parseThing(' raw ');
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe('RAW');
});
it('rejects malformed', () => {
expect(parseThing('').ok).toBe(false); // asserts the tag, not the copy
});
});
```
## Worked examples
- `src/app/registratie/domain/value-objects/postcode.spec.ts` — parser style.
- `src/app/registratie/infrastructure/brp.adapter.spec.ts` — trust boundary (`null`/`{}`).
- `src/app/registratie/domain/registratie-wizard.machine.spec.ts` — pure reducer.
## Verify
```bash
npm test # Vitest (ng test — no vitest.config)
npm run test-storybook # axe over every story (UI a11y gate)
cd backend && dotnet test # backend rule + endpoint + golden tests
```
+11 -1
View File
@@ -1,8 +1,18 @@
import type { StorybookConfig } from '@storybook/angular'; import type { StorybookConfig } from '@storybook/angular';
import remarkGfm from 'remark-gfm';
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: ['@storybook/addon-a11y', '@storybook/addon-docs', '@storybook/addon-onboarding'], addons: [
'@storybook/addon-a11y',
// remark-gfm so GFM pipe tables in *.mdx docs actually render (addon-docs
// doesn't parse them without it).
{
name: '@storybook/addon-docs',
options: { mdxPluginOptions: { mdxCompileOptions: { remarkPlugins: [remarkGfm] } } },
},
'@storybook/addon-onboarding',
],
framework: '@storybook/angular', framework: '@storybook/angular',
// Serve the vendored CIBG package so preview-head.html can <link> its CSS (and its // Serve the vendored CIBG package so preview-head.html can <link> its CSS (and its
// relative font/icon/image url()s resolve) — mirrors index.html for the real app. // relative font/icon/image url()s resolve) — mirrors index.html for the real app.
+15
View File
@@ -31,6 +31,21 @@ const preview: Preview = {
storySort: { storySort: {
order: [ order: [
'Foundations', 'Foundations',
[
'Overview',
'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', 'Design System',
['Atoms', 'Molecules', 'Organisms', 'Templates', 'Devtools'], ['Atoms', 'Molecules', 'Organisms', 'Templates', 'Devtools'],
'Domein', 'Domein',
+4 -4
View File
@@ -1,8 +1,8 @@
# CLAUDE.md # CLAUDE.md
Agent guide for this repo. The _why_ lives in `docs/ARCHITECTURE.md`, Agent guide for this repo. The _why_ lives in `docs/reference/architecture/ARCHITECTURE.md`,
`docs/architecture/0001-bff-lite-decision-dtos.md`, and the learning guide `docs/reference/architecture/0001-bff-lite-decision-dtos.md`, and the learning guide
`docs/fp-tea-atomic-design.md` (FP + The Elm Architecture + atomic design); this `docs/reference/fp-tea-atomic-design.md` (FP + The Elm Architecture + atomic design); this
file is the _rules_. When a decision below and those docs disagree, the docs win — file is the _rules_. When a decision below and those docs disagree, the docs win —
update this file. update this file.
@@ -12,7 +12,7 @@ signals. Auth is faked; **data and business rules are served by a minimal ASP.NE
Core backend** (`backend/`, see its README) and consumed through an NSwag-generated Core backend** (`backend/`, see its README) and consumed through an NSwag-generated
typed client. The FE renders the backend's decisions. Reference data mimicking 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/backlog/WP-22-durable-persistence.md`. persist to a SQLite file via EF Core (WP-22) — `docs/project/backlog/WP-22-durable-persistence.md`.
## Commands ## Commands
+7 -7
View File
@@ -41,7 +41,7 @@ eligibility, thresholds); see **[backend/README.md](backend/README.md)**.
> **New here:** a **branching intake questionnaire** (`/intake`) where later questions > **New here:** a **branching intake questionnaire** (`/intake`) where later questions
> appear based on earlier answers and progress survives a page reload, plus a visual > appear based on earlier answers and progress survives a page reload, plus a visual
> walkthrough of the state-management ideas. See > walkthrough of the state-management ideas. See
> **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** for diagrams (atomic-design pyramid, > **[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 > the dispatch→reduce→view loop, RemoteData states, and "why not just signals") and a
> section on **connecting to a .NET backend**. > section on **connecting to a .NET backend**.
@@ -185,12 +185,12 @@ degrade to an instant navigation.
### Dependency security ### Dependency security
The **shipped app has 0 known vulnerabilities** (`npm audit --omit=dev`). All advisories The **shipped app has 0 known vulnerabilities** (`npm audit --omit=dev`) — and, since the
live in dev/build tooling (Storybook + the Angular build chain) and never reach the `@babel/core` pin below, the **full dev audit is 0 too**. All advisories live(d) in
bundle. `package.json` `overrides` pin patched transitive versions, taking the full dev/build tooling (Storybook + the Angular build chain) and never reach the bundle.
audit from 16 (incl. 3 high) down to **5 low** — the remainder all cascade from `package.json` `overrides` pin patched transitive versions; the last remaining cluster
`@babel/core`'s low-severity sourceMappingURL issue, which only "fixes" by jumping to cascaded from `@babel/core`'s low-severity sourceMappingURL issue, closed by pinning
Babel 8 (a breaking change across the Storybook/Babel chain) and is deliberately left. `@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. We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21.
### Deliberately out of scope (POC) ### Deliberately out of scope (POC)
+2 -2
View File
@@ -2,7 +2,7 @@
The backend that hosts the **business rules** for the BIG-register portal. The The backend that hosts the **business rules** for the BIG-register portal. The
frontend renders the decisions this service computes; it does not recompute them frontend renders the decisions this service computes; it does not recompute them
(BFF-lite + decision DTOs — see `../docs/architecture/0001-bff-lite-decision-dtos.md`). (BFF-lite + decision DTOs — see `../docs/reference/architecture/0001-bff-lite-decision-dtos.md`).
No real BRP/DUO: the reference data they'd return (registration, person, diplomas, No real BRP/DUO: the reference data they'd return (registration, person, diplomas,
notes — `Data/SeedData.cs`) is in-memory and seeded, but the endpoints, DTOs, notes — `Data/SeedData.cs`) is in-memory and seeded, but the endpoints, DTOs,
@@ -15,7 +15,7 @@ status codes and error envelope are production-shaped.
covers it, see `docker-compose.yml`) does **not** lose data. Delete the file to covers it, see `docker-compose.yml`) does **not** lose data. Delete the file to
reset demo data back to empty, the same state a fresh clone starts from. This is reset demo data back to empty, the same state a fresh clone starts from. This is
a deliberate, right-sized choice for a POC (SQLite, no external DB service) — see a deliberate, right-sized choice for a POC (SQLite, no external DB service) — see
`docs/backlog/WP-22-durable-persistence.md`. `docs/project/backlog/WP-22-durable-persistence.md`.
## Run ## Run
+17 -3
View File
@@ -137,9 +137,13 @@ public sealed record BriefStatusDto(
string? RejectedBy = null, string? RejectedAt = null, string? Comments = null, string? RejectedBy = null, string? RejectedAt = null, string? Comments = null,
string? SentAt = null); string? SentAt = null);
// Besluit/Reason tag the passage for guided drafting (WP-brief-v3): the behandelaar
// picks the besluit + reden and the FE filters this library to the matching passages.
// null besluit = relevant to any besluit; null reason = not reason-specific.
public sealed record LibraryPassageDto( public sealed record LibraryPassageDto(
string PassageId, string Scope, string SectionKey, string Label, string PassageId, string Scope, string SectionKey, string Label,
RichTextBlockDto Content, int Version, string? Beroep = null, bool IsDefault = false); RichTextBlockDto Content, int Version, string? Beroep = null, bool IsDefault = false,
string? Besluit = null, string? Reason = null);
public sealed record BriefDto( public sealed record BriefDto(
string BriefId, string Beroep, string TemplateId, string BriefId, string Beroep, string TemplateId,
@@ -149,17 +153,27 @@ public sealed record BriefDto(
// Decision flags for the CURRENT acting principal + this brief's live status // Decision flags for the CURRENT acting principal + this brief's live status
// (PRD-0002 phase P1) — the FE renders these, it never recomputes them. // (PRD-0002 phase P1) — the FE renders these, it never recomputes them.
public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanReject, bool CanSend); // CanRevealBigNummer (PRD-0002 §5c): whether the acting principal may unmask the
// BIG-nummer the case screen ships masked. Status-independent, unlike the action gates.
public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanReject, bool CanSend, bool CanRevealBigNummer);
// The brief's screen DTO also carries the org template it renders with (WP-23): // The brief's screen DTO also carries the org template it renders with (WP-23):
// the sub-org's current PUBLISHED version — or, once sent, the version pinned at // the sub-org's current PUBLISHED version — or, once sent, the version pinned at
// send time (sent letters are immutable; a republish never re-renders them). // send time (sent letters are immutable; a republish never re-renders them).
// The case this letter is about — the zorgverlener + aanvraag the behandelaar is
// handling. Server-joined onto the brief's screen DTO so brief/ stays a shared-only
// leaf context (no cross-context import of registratie).
public sealed record CaseContextDto(string ZorgverlenerNaam, string BigNummer, string Beroep, string AanvraagReferentie);
public sealed record BriefViewDto( public sealed record BriefViewDto(
BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages, BriefDecisionsDto Decisions, BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages, BriefDecisionsDto Decisions,
OrgTemplateDto OrgTemplate); OrgTemplateDto OrgTemplate, CaseContextDto CaseContext);
public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections); public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
public sealed record RejectBriefRequest(string Comments); public sealed record RejectBriefRequest(string Comments);
// The unmasked BIG-nummer, returned only from the audited + step-up-gated reveal
// endpoint (PRD-0002 §5c). Never logged.
public sealed record RevealBigNummerResponse(string BigNummer);
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks. // PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks.
public sealed record MeDto(IReadOnlyList<string> Capabilities); public sealed record MeDto(IReadOnlyList<string> Capabilities);
+18 -5
View File
@@ -176,6 +176,9 @@ public static class BriefSeed
{ {
public const string TemplateId = "besluit-arts"; public const string TemplateId = "besluit-arts";
public const string Beroep = "arts"; public const string Beroep = "arts";
// Demo case reference shown in the behandel scherm header (no real aanvraag linkage
// in this POC — one demo case).
public const string AanvraagReferentie = "HER-2026-000842";
private static RichTextNodeDto T(string t) => new("text", Text: t); private static RichTextNodeDto T(string t) => new("text", Text: t);
private static RichTextNodeDto P(string key) => new("placeholder", Key: key); private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
@@ -226,12 +229,22 @@ public static class BriefSeed
{ {
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef", new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1), Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1),
// The "standaardbrief" kern starter set (IsDefault): one button drops these // Kern guidance set (WP-brief-v3). besluit=null → shown for any besluit (the
// in when the kern is still empty (WP-27). // shared intro); besluit=positief/negatief → inserted when that besluit is
new("p-kern-1", "global", "kern", "Beoordeling ontvangen", // chosen; reason!=null → only when that reden is ticked. The FE filters this
Block(T("Op "), P("datum"), T(" hebben wij uw aanvraag beoordeeld.")), 1, IsDefault: true), // with passagesForBesluit — no server endpoint for guidance.
new("p-kern-intro", "global", "kern", "Beoordeling ontvangen",
Block(T("Op "), P("datum"), T(" hebben wij uw aanvraag beoordeeld.")), 1),
new("p-kern-arts", "beroep", "kern", "Toelichting arts", new("p-kern-arts", "beroep", "kern", "Toelichting arts",
Block(T("Als arts met BIG-nummer "), P("big_nummer"), T(" delen wij u het volgende mee.")), 1, Beroep: "arts", IsDefault: true), Block(T("Als arts met BIG-nummer "), P("big_nummer"), T(" delen wij u het volgende mee.")), 1, Beroep: "arts"),
new("p-kern-positief", "global", "kern", "Toewijzing",
Block(T("Uw aanvraag tot herregistratie is toegewezen. U blijft ingeschreven in het BIG-register.")), 1, Besluit: "positief"),
new("p-kern-negatief", "global", "kern", "Afwijzing",
Block(T("Uw aanvraag tot herregistratie is afgewezen.")), 1, Besluit: "negatief"),
new("p-kern-scholing", "global", "kern", "Onvoldoende scholing",
Block(T("U voldoet niet aan de eis van voldoende recente werkervaring en scholing.")), 1, Besluit: "negatief", Reason: "onvoldoende_scholing"),
new("p-kern-gegevens", "global", "kern", "Onjuiste gegevens",
Block(T("De door u aangeleverde gegevens zijn onjuist of onvolledig gebleken.")), 1, Besluit: "negatief", Reason: "onjuiste_gegevens"),
new("p-slot-1", "global", "slot", "Standaard slot", new("p-slot-1", "global", "slot", "Standaard slot",
Block(T("Met vriendelijke groet,")), 1), Block(T("Met vriendelijke groet,")), 1),
// These reference a deprecated / not-fillable field so the linter's // These reference a deprecated / not-fillable field so the linter's
@@ -64,6 +64,14 @@ public static class Authz
/// have no per-resource state to weigh, so role IS the whole decision here. /// have no per-resource state to weigh, so role IS the whole decision here.
public static bool CanManageOrgTemplates(Principal principal) => principal.Role == PrincipalRole.Admin; public static bool CanManageOrgTemplates(Principal principal) => principal.Role == PrincipalRole.Admin;
/// Field-level PII (PRD-0002 §5c, phase P2): the case screen's BIG-nummer ships
/// masked by default; only the behandelaar (Drafter) composing the case — the actor
/// whose behandel-scherm shows the field — may reveal it. Role-based in the POC; a
/// real system resolves it from the app overlay independent of role. The reveal itself
/// is additionally step-up-gated + audited at the endpoint. (Illustrated on the
/// BIG-nummer because no BSN travels the wire — see PRD note.)
public static bool CanRevealBigNummer(Principal principal) => principal.Role == PrincipalRole.Drafter;
/// Resource-aware decision for the screen DTO: "would this action succeed right /// Resource-aware decision for the screen DTO: "would this action succeed right
/// now" — role/SoD AND the brief's current status. This is what the UI renders; /// now" — role/SoD AND the brief's current status. This is what the UI renders;
/// it never re-derives these booleans itself. /// it never re-derives these booleans itself.
@@ -71,5 +79,7 @@ public static class Authz
CanEdit: principal.Role == PrincipalRole.Drafter && status is "draft" or "rejected", CanEdit: principal.Role == PrincipalRole.Drafter && status is "draft" or "rejected",
CanApprove: CanActOn(BriefAction.Approve, principal, drafterId) && status == "submitted", CanApprove: CanActOn(BriefAction.Approve, principal, drafterId) && status == "submitted",
CanReject: CanActOn(BriefAction.Reject, principal, drafterId) && status == "submitted", CanReject: CanActOn(BriefAction.Reject, principal, drafterId) && status == "submitted",
CanSend: CanActOn(BriefAction.Send, principal, drafterId) && status == "approved"); CanSend: CanActOn(BriefAction.Send, principal, drafterId) && status == "approved",
// PII reveal is status-independent (§5c) — unlike the action gates above.
CanRevealBigNummer: CanRevealBigNummer(principal));
} }
@@ -150,7 +150,7 @@ public static class LetterHtml
private static string EncLines(string s) => Enc(s).Replace("\n", "<br>"); private static string EncLines(string s) => Enc(s).Replace("\n", "<br>");
// Walks up from the running assembly's own directory (NOT the process cwd, which // Walks up from the running assembly's own directory (NOT the process cwd, which
// varies by how `dotnet run`/docker/tests invoke it — see docs/backlog/WP-25) until // varies by how `dotnet run`/docker/tests invoke it — see docs/project/backlog/WP-25) until
// it finds `public/letter.css`. docker-compose.yml bind-mounts `./public` under the // it finds `public/letter.css`. docker-compose.yml bind-mounts `./public` under the
// api container's `/src` for exactly this walk to resolve there too. // api container's `/src` for exactly this walk to resolve there too.
private static string FindLetterCss() private static string FindLetterCss()
+58 -7
View File
@@ -346,6 +346,30 @@ api.MapPost("/brief/send", (HttpContext ctx) =>
.Produces<BriefViewDto>() .Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status409Conflict); .ProducesProblem(StatusCodes.Status409Conflict);
// Field-level PII reveal (PRD-0002 §5c/§5d, phase P2): the case screen ships the
// BIG-nummer masked (see ToView). Unmasking requires the reveal capability AND a
// step-up (stubbed here as the X-Step-Up header); every attempt — allow or deny — is
// audited with NO PII (AuditAuthz). The unmasked value is returned only on allow,
// and never written to a log line.
api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
{
var principal = Authz.ResolvePrincipal(ctx);
var canReveal = Authz.CanRevealBigNummer(principal);
var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true";
var allowed = canReveal && steppedUp;
AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + DocumentStore.DemoOwner, allowed, principal);
if (!allowed)
return Results.Problem(
detail: canReveal
? "Aanvullende verificatie vereist om het BIG-nummer te tonen."
: "U mag het BIG-nummer niet inzien.",
statusCode: StatusCodes.Status403Forbidden);
return Results.Ok(new RevealBigNummerResponse(SeedData.Registration.BigNummer));
})
// Hand-written fetch on the FE (needs a per-call X-Step-Up header) — excluded from the
// OpenAPI doc, same seam as /brief/preview and uploads.
.ExcludeFromDescription();
// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the // Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the
// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch → // same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch →
// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent // blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent
@@ -435,12 +459,34 @@ app.Run();
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true"; static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
// One gate for every org-template endpoint — the enforce twin of the // One gate for every org-template endpoint — the enforce twin of the
// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). // `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). A denial
static IResult OrgAdmin(HttpContext ctx, Func<IResult> action) => // is audited (PRD-0002 §8); the allow path is left un-logged (the endpoints log their
Authz.CanManageOrgTemplates(Authz.ResolvePrincipal(ctx)) // own effect, e.g. publish).
? action() IResult OrgAdmin(HttpContext ctx, Func<IResult> action)
: Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.", {
statusCode: StatusCodes.Status403Forbidden); var principal = Authz.ResolvePrincipal(ctx);
if (Authz.CanManageOrgTemplates(principal)) return action();
AuditAuthz(ctx, "orgtemplate:edit", "org-templates", false, principal);
return Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.",
statusCode: StatusCodes.Status403Forbidden);
}
// Authorization audit (PRD-0002 §8): access-relevant decisions recorded with NO PII —
// action, resource ref, allow/deny, acting role, correlation id. Never the value that
// was (or wasn't) revealed. Mirrors the no-PII Submit audit below.
void AuditAuthz(HttpContext ctx, string action, string resource, bool allowed, Principal principal)
{
var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
app.Logger.LogInformation(
"authz action={Action} resource={Resource} decision={Decision} role={Role} correlationId={Cid}",
action, resource, allowed ? "allow" : "deny", principal.Role, cid);
}
// Keep the last `keep` characters, mask the rest — mirrors the FE maskTail
// (src/app/shared/ui/debug-state/mask.ts) so wire redaction and the dev panel agree.
static string MaskTail(string value, int keep) =>
value.Length <= keep ? new string('*', value.Length)
: new string('*', value.Length - keep) + value[^keep..];
static string Now() => DateTimeOffset.UtcNow.ToString("o"); static string Now() => DateTimeOffset.UtcNow.ToString("o");
@@ -450,7 +496,12 @@ BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId), Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId),
// Sent letters render with the version pinned at send; everything else follows // Sent letters render with the version pinned at send; everything else follows
// the sub-org's current published template (WP-23 immutability invariant). // the sub-org's current published template (WP-23 immutability invariant).
OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null)); OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null),
// The case this letter is about — joined from the seeded zorgverlener so the
// behandel scherm can show whom/what it concerns without brief/ importing registratie.
// The BIG-nummer ships MASKED by default (PRD-0002 §5c, field-level PII); the reveal
// endpoint returns the full value, gated + audited.
new CaseContextDto(SeedData.Registration.Naam, MaskTail(SeedData.Registration.BigNummer, 3), e.Beroep, BriefSeed.AanvraagReferentie));
// Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run // Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift. // through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift.
+36
View File
@@ -1295,6 +1295,9 @@
}, },
"canSend": { "canSend": {
"type": "boolean" "type": "boolean"
},
"canRevealBigNummer": {
"type": "boolean"
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -1398,6 +1401,9 @@
}, },
"orgTemplate": { "orgTemplate": {
"$ref": "#/components/schemas/OrgTemplateDto" "$ref": "#/components/schemas/OrgTemplateDto"
},
"caseContext": {
"$ref": "#/components/schemas/CaseContextDto"
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -1414,6 +1420,28 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"CaseContextDto": {
"type": "object",
"properties": {
"zorgverlenerNaam": {
"type": "string",
"nullable": true
},
"bigNummer": {
"type": "string",
"nullable": true
},
"beroep": {
"type": "string",
"nullable": true
},
"aanvraagReferentie": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"ChangeRequestRequest": { "ChangeRequestRequest": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1723,6 +1751,14 @@
}, },
"isDefault": { "isDefault": {
"type": "boolean" "type": "boolean"
},
"besluit": {
"type": "string",
"nullable": true
},
"reason": {
"type": "string",
"nullable": true
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -65,4 +65,20 @@ public class AuthzTests
Assert.Empty(Authz.RoleCapabilities(Drafter)); Assert.Empty(Authz.RoleCapabilities(Drafter));
Assert.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, Authz.RoleCapabilities(Approver)); Assert.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, Authz.RoleCapabilities(Approver));
} }
[Fact]
public void CanRevealBigNummer_only_for_the_case_drafter_behandelaar()
{
Assert.True(Authz.CanRevealBigNummer(Drafter));
Assert.False(Authz.CanRevealBigNummer(Approver));
}
[Fact]
public void Decisions_CanRevealBigNummer_is_status_independent()
{
// Unlike the action gates, PII reveal does not depend on the brief's status.
Assert.True(Authz.Decisions(Drafter, "draft", DrafterId).CanRevealBigNummer);
Assert.True(Authz.Decisions(Drafter, "sent", DrafterId).CanRevealBigNummer);
Assert.False(Authz.Decisions(Approver, "draft", DrafterId).CanRevealBigNummer);
}
} }
@@ -44,7 +44,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
} }
[Fact] [Fact]
public async Task Get_creates_a_draft_from_the_template_with_scoped_passages() public async Task Get_creates_a_draft_with_expected_sections_locked_and_empty()
{ {
var brief = await Get(); var brief = await Get();
Assert.Equal("draft", brief.Status.Tag); Assert.Equal("draft", brief.Status.Tag);
@@ -57,11 +57,68 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
var kern = brief.Sections.Single(s => s.SectionKey == "kern"); var kern = brief.Sections.Single(s => s.SectionKey == "kern");
Assert.False(kern.Locked); Assert.False(kern.Locked);
Assert.Empty(kern.Blocks); Assert.Empty(kern.Blocks);
}
[Fact]
public async Task Get_offers_only_global_and_arts_scoped_besluit_tagged_passages()
{
await Get();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"); var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
// global passages + the arts-scoped one; no other-beroep passages leak in. // global passages + the arts-scoped one; no other-beroep passages leak in.
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts"); Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts")); Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
// Guided-drafting tags (WP-brief-v3): positief + negatief + reason-specific negatief.
Assert.Contains(view.AvailablePassages, p => p.Besluit == "positief");
Assert.Contains(view.AvailablePassages, p => p.Besluit == "negatief" && p.Reason == "onvoldoende_scholing");
}
[Fact]
public async Task Get_joins_the_case_context_with_the_BIG_nummer_masked()
{
await Get();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
// Case context is joined onto the screen DTO for the behandel scherm header.
// The BIG-nummer ships MASKED by default (PRD-0002 §5c) — reveal is a separate call.
Assert.Equal("********601", view!.CaseContext.BigNummer);
Assert.Equal("arts", view.CaseContext.Beroep);
Assert.False(string.IsNullOrWhiteSpace(view.CaseContext.ZorgverlenerNaam));
Assert.False(string.IsNullOrWhiteSpace(view.CaseContext.AanvraagReferentie));
}
// --- Field-level PII reveal (PRD-0002 §5c/§5d, phase P2) ---
[Fact]
public async Task Reveal_returns_the_unmasked_BIG_nummer_for_the_drafter_with_step_up()
{
BriefStore.Reset();
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/brief/reveal-bignummer");
req.Headers.Add("X-Step-Up", "true"); // no X-Role → drafter (the capable role)
var res = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
var body = await res.Content.ReadFromJsonAsync<RevealBigNummerResponse>();
Assert.Equal("19012345601", body!.BigNummer);
}
[Fact]
public async Task Reveal_is_forbidden_without_the_step_up()
{
BriefStore.Reset();
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/brief/reveal-bignummer"); // drafter, no step-up
var res = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Forbidden, res.StatusCode);
}
[Fact]
public async Task Reveal_is_forbidden_for_a_role_without_the_capability()
{
BriefStore.Reset();
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/brief/reveal-bignummer");
req.Headers.Add("X-Role", "approver");
req.Headers.Add("X-Step-Up", "true"); // capability missing → still denied
var res = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Forbidden, res.StatusCode);
} }
[Fact] [Fact]
@@ -79,12 +136,17 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
} }
[Fact] [Fact]
public async Task Submit_blocks_on_empty_required_section_then_succeeds_when_filled() public async Task Submit_blocks_on_empty_required_section()
{ {
await Get(); await Get();
// Nothing filled yet → required sections empty → 409. // Nothing filled yet → required sections empty → 409.
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
}
[Fact]
public async Task Submit_succeeds_when_required_sections_filled()
{
await Get();
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief; var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
@@ -110,7 +172,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
} }
[Fact] [Fact]
public async Task Reject_returns_comments_and_editing_reopens_to_draft() public async Task Reject_returns_comments()
{ {
var brief = await Get(); var brief = await Get();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
@@ -120,6 +182,16 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefViewDto>(); Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("rejected", rejected!.Brief.Status.Tag); Assert.Equal("rejected", rejected!.Brief.Status.Tag);
Assert.Equal("Graag aanvullen.", rejected.Brief.Status.Comments); Assert.Equal("Graag aanvullen.", rejected.Brief.Status.Comments);
}
[Fact]
public async Task Editing_a_rejected_letter_reopens_it_to_draft()
{
var brief = await Get();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
await _client.SendAsync(
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")));
// A drafter save on a rejected letter reopens it to draft. // A drafter save on a rejected letter reopens it to draft.
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefViewDto>(); var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefViewDto>();
@@ -55,7 +55,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
} }
[Fact] [Fact]
public async Task Publish_increments_version_appends_history_and_counts_unsent_briefs() public async Task Publish_increments_the_version()
{ {
ResetStores(); ResetStores();
// One unsent brief for this sub-org (GetOrCreate on first read). // One unsent brief for this sub-org (GetOrCreate on first read).
@@ -65,16 +65,42 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var published = await res.Content.ReadFromJsonAsync<PublishOrgTemplateResponse>(); var published = await res.Content.ReadFromJsonAsync<PublishOrgTemplateResponse>();
Assert.Equal(2, published!.Version); Assert.Equal(2, published!.Version);
Assert.Equal(1, published.AffectedUnsentBriefs);
var view = await AdminView(); var view = await AdminView();
Assert.Equal(2, view.PublishedVersion); Assert.Equal(2, view.PublishedVersion);
}
[Fact]
public async Task Publish_appends_to_the_version_history()
{
ResetStores();
await _client.GetAsync("/api/v1/brief");
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
res.EnsureSuccessStatusCode();
var view = await AdminView();
Assert.Equal(new[] { 1, 2 }, view.History.Select(h => h.Version)); Assert.Equal(new[] { 1, 2 }, view.History.Select(h => h.Version));
}
[Fact]
public async Task Publish_counts_the_unsent_briefs_it_affects()
{
ResetStores();
// One unsent brief for this sub-org (GetOrCreate on first read).
await _client.GetAsync("/api/v1/brief");
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
res.EnsureSuccessStatusCode();
var published = await res.Content.ReadFromJsonAsync<PublishOrgTemplateResponse>();
Assert.Equal(1, published!.AffectedUnsentBriefs);
var view = await AdminView();
Assert.Equal(1, view.UnsentBriefs); Assert.Equal(1, view.UnsentBriefs);
} }
[Fact] [Fact]
public async Task Save_draft_validates_margins_and_round_trips() public async Task Save_draft_validates_margins()
{ {
ResetStores(); ResetStores();
var draft = (await AdminView()).Draft; var draft = (await AdminView()).Draft;
@@ -83,6 +109,13 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
Assert.Equal(HttpStatusCode.BadRequest, (await _client.SendAsync( Assert.Equal(HttpStatusCode.BadRequest, (await _client.SendAsync(
Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin", Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
body: new SaveOrgTemplateRequest(invalid)))).StatusCode); body: new SaveOrgTemplateRequest(invalid)))).StatusCode);
}
[Fact]
public async Task Save_draft_round_trips_the_edited_values()
{
ResetStores();
var draft = (await AdminView()).Draft;
var valid = draft with { OrgName = "BIG-register (nieuw)", Margins = new MarginsDto(30, 20, 20, 25) }; var valid = draft with { OrgName = "BIG-register (nieuw)", Margins = new MarginsDto(30, 20, 20, 25) };
var res = await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin", var res = await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
@@ -115,11 +148,12 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/rollback/99", role: "admin"))).StatusCode); Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/rollback/99", role: "admin"))).StatusCode);
} }
[Fact] // Walk one brief all the way to sent under template v1, then republish the org
public async Task Sent_brief_keeps_its_pinned_template_while_an_unsent_brief_follows_a_republish() // template under a new name. Both invariant tests below share this exact
// precondition (the stores are process-global, so each sets it up).
private async Task WalkBriefToSentThenRepublish()
{ {
ResetStores(); ResetStores();
// Walk one brief to sent under template v1.
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief; var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
var filled = brief.Sections var filled = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, .Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
@@ -138,13 +172,25 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin", await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
body: new SaveOrgTemplateRequest(draft with { OrgName = "Hertitelde organisatie" }))); body: new SaveOrgTemplateRequest(draft with { OrgName = "Hertitelde organisatie" })));
await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin")); await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
}
// The sent brief still renders v1 with the old name (immutable)... [Fact]
public async Task Sent_brief_keeps_its_pinned_template_after_a_republish()
{
await WalkBriefToSentThenRepublish();
// The sent brief still renders v1 with the old name (immutable).
var sentView = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"); var sentView = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.Equal(1, sentView!.OrgTemplate.Version); Assert.Equal(1, sentView!.OrgTemplate.Version);
Assert.Equal("BIG-register", sentView.OrgTemplate.OrgName); Assert.Equal("BIG-register", sentView.OrgTemplate.OrgName);
}
// ...while a fresh (unsent) brief follows the new published version. [Fact]
public async Task Unsent_brief_follows_a_republish()
{
await WalkBriefToSentThenRepublish();
// A fresh (unsent) brief follows the new published version.
var freshView = await (await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/reset"))).Content.ReadFromJsonAsync<BriefViewDto>(); var freshView = await (await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/reset"))).Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal(2, freshView!.OrgTemplate.Version); Assert.Equal(2, freshView!.OrgTemplate.Version);
Assert.Equal("Hertitelde organisatie", freshView.OrgTemplate.OrgName); Assert.Equal("Hertitelde organisatie", freshView.OrgTemplate.OrgName);
@@ -81,12 +81,17 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix
} }
[Fact] [Fact]
public async Task Proefbrief_is_admin_only_and_renders_the_draft_template() public async Task Proefbrief_is_admin_only()
{ {
ResetStores(); ResetStores();
Assert.Equal(HttpStatusCode.Forbidden, Assert.Equal(HttpStatusCode.Forbidden,
(await _client.GetAsync($"/api/v1/admin/org-template/{Registers}/preview")).StatusCode); (await _client.GetAsync($"/api/v1/admin/org-template/{Registers}/preview")).StatusCode);
}
[Fact]
public async Task Proefbrief_renders_the_draft_template_with_a_watermark()
{
ResetStores();
var res = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{Registers}/preview", role: "admin")); var res = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{Registers}/preview", role: "admin"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var html = await res.Content.ReadAsStringAsync(); var html = await res.Content.ReadAsStringAsync();
+36
View File
@@ -0,0 +1,36 @@
# Documentation
Docs are split by **kind**, and kept out of each other's way:
- **`reference/` — information.** How the system works and why: architecture, decisions
(ADRs), the FP/TEA/atomic learning guide, accessibility and UX reference. Stable
knowledge, not tied to a sprint.
- **`project/` — administration.** Planning and tracking: the work-package backlog,
product requirements (PRDs), and the (superseded) roadmap. This is the moving,
process-facing material.
Teaching material that is best read **next to the components** lives in Storybook, not
here — see the **Foundations** section (`src/docs/*.mdx`), starting at *Foundations →
Overview*. The `reference/` docs are the long-form source; the Foundations pages are the
condensed, cross-linked curriculum.
## `reference/` — information
| Doc | What it is |
| --- | --- |
| [architecture/ARCHITECTURE.md](reference/architecture/ARCHITECTURE.md) | The architecture walkthrough: contexts/layers, state management, parse-don't-validate, the feature recipe, the .NET backend seam. |
| [architecture/0001-bff-lite-decision-dtos.md](reference/architecture/0001-bff-lite-decision-dtos.md) | ADR — BFF-lite endpoints + decision DTOs (backend decides, FE renders). |
| [architecture/0002-user-groups-and-bounded-contexts.md](reference/architecture/0002-user-groups-and-bounded-contexts.md) | ADR — user groups as actors; identity vs authorization. |
| [architecture/0003-cibg-huisstijl.md](reference/architecture/0003-cibg-huisstijl.md) | ADR — adopt CIBG Huisstijl (vendored Bootstrap 5.2) + the token bridge. |
| [fp-tea-atomic-design.md](reference/fp-tea-atomic-design.md) | Long-form learning guide: FP + The Elm Architecture + atomic design. |
| [wcag-checklist.md](reference/wcag-checklist.md) | Manual WCAG checks automation can't catch (tab order, focus traps, reflow). |
| [ui-ux-audit.md](reference/ui-ux-audit.md) | Early UI/UX audit against NL Design System (predates ADR-0003 — read in that light). |
## `project/` — administration
| Doc | What it is |
| --- | --- |
| [backlog/README.md](project/backlog/README.md) | The work-package backlog index (WP-01…WP-28) — the live tracker. |
| [prd/0001-mijn-aanvragen-en-wizardstatus.md](project/prd/0001-mijn-aanvragen-en-wizardstatus.md) | PRD — "Mijn aanvragen": running wizards, application status, document preview. |
| [prd/0002-attribute-based-access-control.md](project/prd/0002-attribute-based-access-control.md) | PRD — attribute-based access control in the UI. |
| [SHOWCASE-ROADMAP.md](project/SHOWCASE-ROADMAP.md) | Superseded roadmap (absorbed into `project/backlog/`) — kept for history. |
@@ -1,6 +1,6 @@
# Showcase roadmap — superseded # Showcase roadmap — superseded
**This roadmap is superseded by [`docs/backlog/`](backlog/README.md)** (2026-07-02). **This roadmap is superseded by [`docs/project/backlog/`](backlog/README.md)** (2026-07-02).
The backlog absorbs and corrects this document: its Storybook-as-curriculum track became The backlog absorbs and corrects this document: its Storybook-as-curriculum track became
WP-14/15 (+ per-invariant MDX pages in WP-05/07/08/13/17), its enforcement track became WP-14/15 (+ per-invariant MDX pages in WP-05/07/08/13/17), its enforcement track became
@@ -5,7 +5,7 @@ design-system fidelity, DDD/FP consistency, Storybook as curriculum, and WCAG co
with automated gates. Source: the architecture/CIBG/a11y audit of 2026-07-02 (plan: with automated gates. Source: the architecture/CIBG/a11y audit of 2026-07-02 (plan:
"Showcase hardening"). "Showcase hardening").
This backlog **supersedes `docs/SHOWCASE-ROADMAP.md`**. This backlog **supersedes `docs/project/SHOWCASE-ROADMAP.md`**.
## Session protocol ## Session protocol
@@ -55,7 +55,7 @@ build", so every story added or changed by later WPs is automatically covered.
6. Run locally against a fresh `build-storybook`; triage violations: fix trivial ones 6. Run locally against a fresh `build-storybook`; triage violations: fix trivial ones
(labels, roles, contrast via `--rhc-*` tokens); anything structural gets the escape (labels, roles, contrast via `--rhc-*` tokens); anything structural gets the escape
hatch + comment + WP cross-ref. hatch + comment + WP cross-ref.
7. Update `docs/backlog/README.md`: GREEN now includes `npm run test-storybook:ci`. 7. Update `docs/project/backlog/README.md`: GREEN now includes `npm run test-storybook:ci`.
## Acceptance criteria ## Acceptance criteria
@@ -11,7 +11,7 @@ Phase 0 — a pure move of wiring, no behavior change.
## Read first ## Read first
- `CLAUDE.md` §1, `docs/ARCHITECTURE.md` - `CLAUDE.md` §1, `docs/reference/architecture/ARCHITECTURE.md`
- `eslint.config.mjs` - `eslint.config.mjs`
- `src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts` (lines - `src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts` (lines
~18-19: `BrpAdapter`/`parseBrpAddress`, `DuoAdapter`/`parseDuoLookup`) ~18-19: `BrpAdapter`/`parseBrpAddress`, `DuoAdapter`/`parseDuoLookup`)
@@ -38,7 +38,7 @@ Phase 0 — a pure move of wiring, no behavior change.
lookups move behind a registratie `application/` facade (command or store methods) lookups move behind a registratie `application/` facade (command or store methods)
- `src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts` — policy resource - `src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts` — policy resource
moves behind **new** `src/app/herregistratie/application/` (folder doesn't exist yet) moves behind **new** `src/app/herregistratie/application/` (folder doesn't exist yet)
- `docs/ARCHITECTURE.md` — fix "three contexts, four layers" → six contexts - `docs/reference/architecture/ARCHITECTURE.md` — fix "three contexts, four layers" → six contexts
(shared, auth, registratie, herregistratie, brief, showcase), five layers (+ contracts); (shared, auth, registratie, herregistratie, brief, showcase), five layers (+ contracts);
add the showcase sanction add the showcase sanction
- `CLAUDE.md` §1 — add `brief` to the context list; note the showcase sanction - `CLAUDE.md` §1 — add `brief` to the context list; note the showcase sanction
@@ -10,7 +10,7 @@ principle (every response through a hand-written `parse*` returning `Result`).
## Read first ## Read first
- `CLAUDE.md` §3 + §4; `docs/architecture/0001-bff-lite-decision-dtos.md` - `CLAUDE.md` §3 + §4; `docs/reference/architecture/0001-bff-lite-decision-dtos.md`
- `src/app/registratie/infrastructure/applications.adapter.ts` (+ its spec — the pattern - `src/app/registratie/infrastructure/applications.adapter.ts` (+ its spec — the pattern
to copy) to copy)
- The three offenders below - The three offenders below
@@ -5,7 +5,7 @@ Phase: 1 — FP/DDD core
## Why ## Why
Docs (`docs/fp-tea-atomic-design.md`, ARCHITECTURE §2c) teach `createStore` as THE Docs (`docs/reference/fp-tea-atomic-design.md`, ARCHITECTURE §2c) teach `createStore` as THE
wiring, but the wizard pages hand-wire `signal(model)` + local `dispatch()` — juniors see wiring, but the wizard pages hand-wire `signal(model)` + local `dispatch()` — juniors see
two idioms and copy the wrong one. Machine naming also drifts: two idioms and copy the wrong one. Machine naming also drifts:
`change-request.machine.ts` exports bare `State`/`Msg`; `upload.machine.ts` exports `change-request.machine.ts` exports bare `State`/`Msg`; `upload.machine.ts` exports
@@ -14,7 +14,7 @@ two idioms and copy the wrong one. Machine naming also drifts:
## Read first ## Read first
- `src/app/shared/application/store.ts` (`createStore`) + its spec - `src/app/shared/application/store.ts` (`createStore`) + its spec
- `docs/fp-tea-atomic-design.md` Part on TEA-in-Angular - `docs/reference/fp-tea-atomic-design.md` Part on TEA-in-Angular
- All `*.machine.ts` files (6) and their UI wiring sites (wizard components/pages) - All `*.machine.ts` files (6) and their UI wiring sites (wizard components/pages)
## Decisions (pre-made, don't relitigate) ## Decisions (pre-made, don't relitigate)
@@ -59,7 +59,7 @@ no spec despite "domain and pure logic must have a spec" (CLAUDE.md §5).
(`draft-sync.spec.ts`, `submit-change-request.spec.ts`). (`draft-sync.spec.ts`, `submit-change-request.spec.ts`).
- [x] `map3` removed (found in `shared/application/remote-data.ts`, not - [x] `map3` removed (found in `shared/application/remote-data.ts`, not
`shared/kernel/fp.ts` as the WP text guessed — updated the three docs that `shared/kernel/fp.ts` as the WP text guessed — updated the three docs that
mentioned it: CLAUDE.md, `docs/ARCHITECTURE.md`, `remote-data.mdx`). The mentioned it: CLAUDE.md, `docs/reference/architecture/ARCHITECTURE.md`, `remote-data.mdx`). The
`variant` input on `confirmation.component.ts` no longer exists — already `variant` input on `confirmation.component.ts` no longer exists — already
cleaned up before this WP ran; nothing to do. cleaned up before this WP ran; nothing to do.
- [x] CLAUDE.md rule added (`Conventions` — DatePipe in templates, `formatDatumNl` in - [x] CLAUDE.md rule added (`Conventions` — DatePipe in templates, `formatDatumNl` in
@@ -35,7 +35,7 @@ the list-family rationale to document.
## Read first ## Read first
- `docs/architecture/0003-*.md` (ADR-0003 — the token-bridge rationale this extends) - `docs/reference/architecture/0003-*.md` (ADR-0003 — the token-bridge rationale this extends)
- The audit's gap list (below) - The audit's gap list (below)
## Decisions (pre-made, don't relitigate) ## Decisions (pre-made, don't relitigate)
@@ -41,7 +41,7 @@ Three app-level gaps close the WCAG story:
- `src/app/app.config.ts` + a new small `shared/` focus-on-navigation service - `src/app/app.config.ts` + a new small `shared/` focus-on-navigation service
- `eslint.config.mjs`, `package.json` (angular-eslint) - `eslint.config.mjs`, `package.json` (angular-eslint)
- Any template the new lint rules flag - Any template the new lint rules flag
- New `docs/wcag-checklist.md` - New `docs/reference/wcag-checklist.md`
- New `src/docs/a11y.mdx` — title `Foundations/Accessibility` - New `src/docs/a11y.mdx` — title `Foundations/Accessibility`
## Steps ## Steps
@@ -50,7 +50,7 @@ Three app-level gaps close the WCAG story:
focus lands on the new page's heading). focus lands on the new page's heading).
2. Add angular-eslint; enable the template rules on inline templates; plant a bad 2. Add angular-eslint; enable the template rules on inline templates; plant a bad
pattern, see it fail, remove it; fix real hits. pattern, see it fail, remove it; fix real hits.
3. `docs/wcag-checklist.md`: manual checks per page (dashboard, wizards, brief, login) — 3. `docs/reference/wcag-checklist.md`: manual checks per page (dashboard, wizards, brief, login) —
keyboard walk & focus order, no traps, 200% zoom/reflow, NVDA or VoiceOver pass, keyboard walk & focus order, no traps, 200% zoom/reflow, NVDA or VoiceOver pass,
visible focus, error announcement; status columns (page × check). visible focus, error announcement; status columns (page × check).
4. `src/docs/a11y.mdx`: the layered approach — axe gate (WP-01) + template lint + play 4. `src/docs/a11y.mdx`: the layered approach — axe gate (WP-01) + template lint + play
@@ -75,7 +75,7 @@ Three app-level gaps close the WCAG story:
setup. Less code to hand-maintain, same coverage plus more. setup. Less code to hand-maintain, same coverage plus more.
- The dashboard's checklist pass surfaced a **real bug**: `aanvraag-block`'s warning - The dashboard's checklist pass surfaced a **real bug**: `aanvraag-block`'s warning
`app-alert` (two `app-button` actions) overflows the viewport at 320px — its `app-alert` (two `app-button` actions) overflows the viewport at 320px — its
`.feedback` flex row doesn't wrap. Documented in `docs/wcag-checklist.md` with the `.feedback` flex row doesn't wrap. Documented in `docs/reference/wcag-checklist.md` with the
root cause, **not fixed** — fixing live component CSS found via the checklist is the root cause, **not fixed** — fixing live component CSS found via the checklist is the
"full manual audit" scope this WP's Out-of-scope section explicitly defers, not this "full manual audit" scope this WP's Out-of-scope section explicitly defers, not this
WP's own deliverable. Flagged here so it isn't lost. WP's own deliverable. Flagged here so it isn't lost.
@@ -3,6 +3,18 @@
Status: done (7ec13d8) Status: done (7ec13d8)
Phase: 5 — productie-volwassenheid Phase: 5 — productie-volwassenheid
> **Follow-up (P2/P3-lite delivered later).** On top of this P1 spine:
> - **P2 field-level PII (§5c):** the case screen's **BIG-nummer** now ships masked
> (`Authz.CanRevealBigNummer` + `BriefDecisionsDto.CanRevealBigNummer`); a
> step-up-stubbed (`X-Step-Up` header), audited `POST /brief/reveal-bignummer` unmasks
> it. Realized on the BIG-nummer, not the BSN, because **no BSN travels the wire** (see
> PRD-0002 §5c note).
> - **P3-lite audit + guard (§8, §6):** a no-PII `AuditAuthz` log line records reveal
> attempts (allow/deny) and org-admin denials; the already-built `capabilityGuard` is
> now wired onto the `brief/huisstijl` admin route.
>
> Still unbuilt: data-scoping (§5b), real step-up/MFA, break-glass.
## Why ## Why
The single biggest gap between this POC and a production SSP: identity carries no The single biggest gap between this POC and a production SSP: identity carries no
@@ -18,9 +30,9 @@ a real foundation to extend.
## Read first ## Read first
- `docs/architecture/0002-user-groups-and-bounded-contexts.md` (the `Principal` - `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` (the `Principal`
union, identity-vs-authorization split — see the deviation noted below) union, identity-vs-authorization split — see the deviation noted below)
- `docs/prd/0002-attribute-based-access-control.md` §5a, §6, §7, §9-P1 - `docs/project/prd/0002-attribute-based-access-control.md` §5a, §6, §7, §9-P1
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new — the single - `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (new — the single
authorization helper) authorization helper)
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`Review` — now delegates its - `backend/src/BigRegister.Api/Data/BriefStore.cs` (`Review` — now delegates its
@@ -31,7 +31,7 @@ speculatively.
`static Dictionary` + `lock` `static Dictionary` + `lock`
- `backend/src/BigRegister.Api/Data/SeedData.cs` (current in-memory seed — becomes - `backend/src/BigRegister.Api/Data/SeedData.cs` (current in-memory seed — becomes
a first-run DB seed) a first-run DB seed)
- `docs/architecture/0001-bff-lite-decision-dtos.md` (confirm this WP doesn't touch - `docs/reference/architecture/0001-bff-lite-decision-dtos.md` (confirm this WP doesn't touch
the decision-DTO contracts — persistence is purely behind the existing store the decision-DTO contracts — persistence is purely behind the existing store
interfaces) interfaces)
@@ -14,11 +14,11 @@ WP-25, editor WP-26) reads what this WP serves.
## Read first ## Read first
- `docs/prd` — the Brief v2 PRD §2a/§3 (two axes, OrgTemplate model, invariants) - `docs/project/prd` — the Brief v2 PRD §2a/§3 (two axes, OrgTemplate model, invariants)
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (store idiom + `BriefSeed`) - `backend/src/BigRegister.Api/Data/BriefStore.cs` (store idiom + `BriefSeed`)
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (emit+enforce single source) - `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (emit+enforce single source)
- `backend/src/BigRegister.Api/Data/AppDbContext.cs` (JSON-column precedent, WP-22) - `backend/src/BigRegister.Api/Data/AppDbContext.cs` (JSON-column precedent, WP-22)
- `docs/backlog/WP-18-abac-capability-spine.md` (how the capability spine works) - `docs/project/backlog/WP-18-abac-capability-spine.md` (how the capability spine works)
## Decisions (pre-made, don't relitigate) ## Decisions (pre-made, don't relitigate)
@@ -15,7 +15,7 @@ domain model, `brief.machine.ts`, and every `BriefMsg` stay byte-identical.
- PRD Brief v2 §2b (fidelity note), §4, §10; the sample `voorbeeldbrief-inschrijving.pdf` - PRD Brief v2 §2b (fidelity note), §4, §10; the sample `voorbeeldbrief-inschrijving.pdf`
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (the `canEdit` pivot) - `src/app/brief/ui/letter-composer/letter-composer.component.ts` (the `canEdit` pivot)
- `src/app/brief/ui/letter-preview/letter-preview.component.ts` (rendering that migrates in) - `src/app/brief/ui/letter-preview/letter-preview.component.ts` (rendering that migrates in)
- `docs/backlog/WP-23-org-template-backend.md` (the `orgTemplate` on `BriefViewDto`) - `docs/project/backlog/WP-23-org-template-backend.md` (the `orgTemplate` on `BriefViewDto`)
## Decisions (pre-made, don't relitigate) ## Decisions (pre-made, don't relitigate)
@@ -12,7 +12,7 @@ same composition is archived with the brief, making sent letters immutable.
## Read first ## Read first
- PRD Brief v2 §2b, §8; `docs/backlog/WP-24-letter-canvas.md` (the `letter.css` contract) - PRD Brief v2 §2b, §8; `docs/project/backlog/WP-24-letter-canvas.md` (the `letter.css` contract)
- `backend/src/BigRegister.Api/Program.cs` — upload `content` endpoint (binary house - `backend/src/BigRegister.Api/Program.cs` — upload `content` endpoint (binary house
pattern: `.ExcludeFromDescription()` + hand-written FE fetch) pattern: `.ExcludeFromDescription()` + hand-written FE fetch)
- `src/app/shared/upload/upload.adapter.ts` (hand-written transport precedent) - `src/app/shared/upload/upload.adapter.ts` (hand-written transport precedent)
@@ -12,7 +12,7 @@ content a read-only sample). PRD Brief v2 §5, §7h.
## Read first ## Read first
- PRD Brief v2 §5, §7h; `docs/backlog/WP-23/24/25` (endpoints, canvas, proefbrief) - PRD Brief v2 §5, §7h; `docs/project/backlog/WP-23/24/25` (endpoints, canvas, proefbrief)
- `src/app/shared/application/access.store.ts` (`can('orgtemplate:edit')`) - `src/app/shared/application/access.store.ts` (`can('orgtemplate:edit')`)
- `.claude/skills/form-machine` — the house form idiom this editor follows - `.claude/skills/form-machine` — the house form idiom this editor follows
- `src/app/shared/ui/upload/single-upload` (logo upload reuse) - `src/app/shared/ui/upload/single-upload` (logo upload reuse)
@@ -22,7 +22,7 @@ that keep CLAUDE.md and the backlog truthful.
already covers all `/api/` calls, the new endpoints included), and already covers all `/api/` calls, the new endpoints included), and
`POST /brief/reset`. The demo script documents the mapping; no new interceptor `POST /brief/reset`. The demo script documents the mapping; no new interceptor
cases, no scenario code. cases, no scenario code.
- Demo script lives at `docs/prd/0003-brief-v2-demo-script.md` and follows the §6 - Demo script lives at `docs/project/prd/0003-brief-v2-demo-script.md` and follows the §6
choreography (compose → preview → switch sub-org seed → "two axes, one render"). choreography (compose → preview → switch sub-org seed → "two axes, one render").
- One e2e spec, not a suite: drafter composes on canvas → submit → approve → send - One e2e spec, not a suite: drafter composes on canvas → submit → approve → send
pins the org-template version; admin publishes → drafter canvas reflects it. pins the org-template version; admin publishes → drafter canvas reflects it.
@@ -31,10 +31,10 @@ that keep CLAUDE.md and the backlog truthful.
## Files ## Files
- `docs/prd/0003-brief-v2-demo-script.md` (new) - `docs/project/prd/0003-brief-v2-demo-script.md` (new)
- `e2e/brief-v2.spec.ts` (new) - `e2e/brief-v2.spec.ts` (new)
- story gap-fill where WP-24..27 left holes - story gap-fill where WP-24..27 left holes
- `docs/backlog/README.md` (statuses), `CLAUDE.md` (roles/routes touch-up) - `docs/project/backlog/README.md` (statuses), `CLAUDE.md` (roles/routes touch-up)
## Steps ## Steps
@@ -137,6 +137,12 @@ canonical case (art. 9 / special-category data):
- A `canRevealBsn` flag gates an explicit reveal action; reveal requires **step-up** (§5d) and is - A `canRevealBsn` flag gates an explicit reveal action; reveal requires **step-up** (§5d) and is
**audited** (§8). **audited** (§8).
> **Implementation note.** No **BSN** actually travels the wire in this POC (the BSN lives only in
> the faked login and is never persisted). The sensitive identifier the backend *does* serve is the
> **BIG-nummer** on the backoffice case screen (`CaseContextDto`), so the delivered field-level reveal
> is realized there (`canRevealBigNummer`, `POST /brief/reveal-bignummer`) — the on-the-wire
> equivalent of this BSN illustration.
Precedent already in the code: the client persists **only `naam`, never the BSN**, to `sessionStorage` Precedent already in the code: the client persists **only `naam`, never the BSN**, to `sessionStorage`
(`src/app/auth/application/session.store.ts:40-47`) — this PRD generalizes that instinct to every PII (`src/app/auth/application/session.store.ts:40-47`) — this PRD generalizes that instinct to every PII
field, enforced server-side. field, enforced server-side.
@@ -224,7 +230,14 @@ action, resource, env)`), used **on every endpoint** — not merely to _emit_ fl
Convert the brief drafter/approver gate from `currentRole()` to a real `brief:approve` capability Convert the brief drafter/approver gate from `currentRole()` to a real `brief:approve` capability
(verified principal, keep the SoD `approver != drafter` check). (verified principal, keep the SoD `approver != drafter` check).
- **P2 — Data + field.** Row-level scoping on list endpoints; server-side PII redaction + `canRevealBsn`. - **P2 — Data + field.** Row-level scoping on list endpoints; server-side PII redaction + `canRevealBsn`.
- _Field-level reveal delivered_ (WP-18 follow-up): the backoffice case screen ships the
**BIG-nummer** masked (`Authz.CanRevealBigNummer` + `BriefDecisionsDto.CanRevealBigNummer`),
revealed by the step-up-gated, audited `POST /brief/reveal-bignummer`. Realized on the
BIG-nummer, **not the BSN** — see the §5c note. Row-level scoping (§5b) still unbuilt.
- **P3 — Step-up & audit.** MFA/assurance preconditions, break-glass, and the authorization audit log. - **P3 — Step-up & audit.** MFA/assurance preconditions, break-glass, and the authorization audit log.
- _Audit log delivered (lite)_: `AuditAuthz` logs reveal attempts (allow/deny) and
org-admin denials, no PII (§8). Step-up is stubbed as the `X-Step-Up` header (§5d); the
`capabilityGuard` is wired onto the admin route (§6). MFA and break-glass still unbuilt.
## 10. Cross-references ## 10. Cross-references
@@ -10,7 +10,7 @@ professional logs in, sees their registration, and can apply for
re-registration — "herregistratie"). re-registration — "herregistratie").
> New to functional programming or The Elm Architecture? Start with the progressive > New to functional programming or The Elm Architecture? Start with the progressive
> learning guide [`fp-tea-atomic-design.md`](./fp-tea-atomic-design.md), which teaches > learning guide [`fp-tea-atomic-design.md`](../fp-tea-atomic-design.md), which teaches
> the concepts (with Elm ↔ this-app examples) and the recipes; this document is the > the concepts (with Elm ↔ this-app examples) and the recipes; this document is the
> reference deep-dive it points back to. > reference deep-dive it points back to.
@@ -17,7 +17,7 @@ feature." A senior can skim Parts 1–4 and jump to **Part 5** (FP × atomic des
defined in plain words on first use and again in the **glossary** (Part 8). defined in plain words on first use and again in the **glossary** (Part 8).
This guide is the _teaching_ layer. For the reference deep-dives it points to This guide is the _teaching_ layer. For the reference deep-dives it points to
[`ARCHITECTURE.md`](./ARCHITECTURE.md) and [`ARCHITECTURE.md`](./architecture/ARCHITECTURE.md) and
[ADR-0001](./architecture/0001-bff-lite-decision-dtos.md) rather than repeating them. [ADR-0001](./architecture/0001-bff-lite-decision-dtos.md) rather than repeating them.
Every code snippet below is real code from this repo, with its file path. Every code snippet below is real code from this repo, with its file path.
@@ -127,7 +127,7 @@ Two kinds of type do most of the work:
The decisive move is choosing types so that **illegal states can't be written down**. The decisive move is choosing types so that **illegal states can't be written down**.
Compare three booleans (2³ = 8 combinations, most nonsense) with one union of the 4 real Compare three booleans (2³ = 8 combinations, most nonsense) with one union of the 4 real
states — see [`ARCHITECTURE.md` §2a](./ARCHITECTURE.md#2a-remotedata--one-value-instead-of-three-booleans) states — see [`ARCHITECTURE.md` §2a](./architecture/ARCHITECTURE.md#2a-remotedata--one-value-instead-of-three-booleans)
for the full `RemoteData` treatment and diagram. The wizard's own Model is the same for the full `RemoteData` treatment and diagram. The wizard's own Model is the same
idea (`herregistratie.machine.ts`): idea (`herregistratie.machine.ts`):
@@ -216,7 +216,7 @@ This app implements TEA with Angular **signals**. There is no extra state librar
important shape difference from textbook Elm: **state is per-wizard, not one global important shape difference from textbook Elm: **state is per-wizard, not one global
Model** — each flow (`herregistratie`, `intake`, `registratie`) has its own little Model** — each flow (`herregistratie`, `intake`, `registratie`) has its own little
store. Cross-page state that _must_ be shared lives in one root singleton store. Cross-page state that _must_ be shared lives in one root singleton
(`BigProfileStore`, see [`ARCHITECTURE.md` §2e](./ARCHITECTURE.md#2e-optimistic-update--rollback-and-shared-state-across-pages)). (`BigProfileStore`, see [`ARCHITECTURE.md` §2e](./architecture/ARCHITECTURE.md#2e-optimistic-update--rollback-and-shared-state-across-pages)).
### 4a. The store — TEA's runtime in ~10 lines ### 4a. The store — TEA's runtime in ~10 lines
@@ -424,7 +424,7 @@ Molecules compose atoms; organisms compose molecules — exactly like composing
functions, where the composite is still pure. `address-fields` is pure because the functions, where the composite is still pure. `address-fields` is pure because the
`form-field` and `text-input` it's built from are pure. Each atomic level only uses the `form-field` and `text-input` it's built from are pure. Each atomic level only uses the
level(s) below it (see the hierarchy diagram in level(s) below it (see the hierarchy diagram in
[`ARCHITECTURE.md` §1](./ARCHITECTURE.md#1-the-big-picture-three-contexts-four-layers)). [`ARCHITECTURE.md` §1](./architecture/ARCHITECTURE.md#1-the-big-picture-three-contexts-four-layers)).
### 5c. Pages / containers are the TEA runtime (the shell) ### 5c. Pages / containers are the TEA runtime (the shell)
@@ -474,7 +474,7 @@ Each property maps to a tangible benefit you can point at in this repo:
- **Illegal states won't compile.** `Submitting` carries `Valid` data and has no `errors` - **Illegal states won't compile.** `Submitting` carries `Valid` data and has no `errors`
field, so "submit with errors showing" is unwritable. A whole bug class disappears field, so "submit with errors showing" is unwritable. A whole bug class disappears
before runtime — contrast the 8-state boolean soup in before runtime — contrast the 8-state boolean soup in
[`ARCHITECTURE.md` §2a](./ARCHITECTURE.md#2a-remotedata--one-value-instead-of-three-booleans). [`ARCHITECTURE.md` §2a](./architecture/ARCHITECTURE.md#2a-remotedata--one-value-instead-of-three-booleans).
- **Pure presentational components.** `address-fields` is tested by inputs → DOM and - **Pure presentational components.** `address-fields` is tested by inputs → DOM and
reused in two call-sites (the registratie wizard and the change-request form) with no reused in two call-sites (the registratie wizard and the change-request form) with no
@@ -500,7 +500,7 @@ Each recipe follows the existing pattern and naming, and ends with the same remi
### Recipe A — Add an atomic component (atom / molecule / organism) ### Recipe A — Add an atomic component (atom / molecule / organism)
**When:** you genuinely need a new building block (not a one-off; reuse must earn it — **When:** you genuinely need a new building block (not a one-off; reuse must earn it —
see [CLAUDE.md §2](../CLAUDE.md)). see [CLAUDE.md §2](../../CLAUDE.md)).
**Where:** `shared/ui/` if generic; a context's `ui/` if domain-specific. Pick the level **Where:** `shared/ui/` if generic; a context's `ui/` if domain-specific. Pick the level
by composition: composes nothing → **atom**; composes atoms → **molecule**; composes by composition: composes nothing → **atom**; composes atoms → **molecule**; composes
@@ -634,7 +634,7 @@ _This is the same loop, again — now nested inside the wizard._
--- ---
_See also:_ [`ARCHITECTURE.md`](./ARCHITECTURE.md) (reference deep-dive on RemoteData, _See also:_ [`ARCHITECTURE.md`](./architecture/ARCHITECTURE.md) (reference deep-dive on RemoteData,
the store, parse-don't-validate, and the .NET backend seam) and the store, parse-don't-validate, and the .NET backend seam) and
[ADR-0001](./architecture/0001-bff-lite-decision-dtos.md) (the BFF-lite + decision-DTO [ADR-0001](./architecture/0001-bff-lite-decision-dtos.md) (the BFF-lite + decision-DTO
decision). Live demo: `/concepts` in the running app. decision). Live demo: `/concepts` in the running app.
+4892 -3222
View File
File diff suppressed because one or more lines are too long
+1164 -190
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -55,6 +55,7 @@
"jsdom": "^29.0.0", "jsdom": "^29.0.0",
"nswag": "^14.7.1", "nswag": "^14.7.1",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"remark-gfm": "^4.0.1",
"storybook": "^10.4.6", "storybook": "^10.4.6",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.62.0", "typescript-eslint": "^8.62.0",
@@ -63,6 +64,7 @@
}, },
"comment-overrides": "Pin patched versions of vulnerable DEV/BUILD-only transitive deps (Storybook + build chain). The shipped app already audits clean; this clears the dev-tooling advisories without downgrading Angular 22.", "comment-overrides": "Pin patched versions of vulnerable DEV/BUILD-only transitive deps (Storybook + build chain). The shipped app already audits clean; this clears the dev-tooling advisories without downgrading Angular 22.",
"overrides": { "overrides": {
"@babel/core": "^7.29.7",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
"esbuild": "^0.28.1", "esbuild": "^0.28.1",
"http-proxy-middleware": "^3.0.7", "http-proxy-middleware": "^3.0.7",
+5 -2
View File
@@ -1,6 +1,6 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { ShellComponent } from '@shared/layout/shell/shell.component'; import { ShellComponent } from '@shared/layout/shell/shell.component';
import { authGuard } from '@auth/auth.guard'; import { authGuard, capabilityGuard } from '@auth/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -53,7 +53,10 @@ export const routes: Routes = [
}, },
{ {
path: 'brief/huisstijl', path: 'brief/huisstijl',
canActivate: [authGuard], // Admin-only org-template editor (WP-26): capabilityGuard denies-by-default
// unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces
// via the OrgAdmin gate — the guard just avoids loading a page that would 403.
canActivate: [capabilityGuard('orgtemplate:edit')],
loadComponent: () => loadComponent: () =>
import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage), import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage),
}, },
+63 -4
View File
@@ -1,10 +1,11 @@
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 { Brief, BriefDecisions, 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 { 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 { BriefStore } from './brief.store'; import { BriefStore } from './brief.store';
const decisions: BriefDecisions = { const decisions: BriefDecisions = {
@@ -12,6 +13,7 @@ const decisions: BriefDecisions = {
canApprove: true, canApprove: true,
canReject: true, canReject: true,
canSend: true, canSend: true,
canRevealBigNummer: true,
}; };
const brief: Brief = { const brief: Brief = {
@@ -37,7 +39,14 @@ const orgTemplate: OrgTemplate = {
version: 1, version: 1,
}; };
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate }; const caseContext: CaseContext = {
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
bigNummer: '19012345601',
beroep: 'arts',
aanvraagReferentie: 'HER-2026-000842',
};
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
function setup(adapter: Partial<BriefAdapter>): BriefStore { function setup(adapter: Partial<BriefAdapter>): BriefStore {
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] }); TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
@@ -45,7 +54,7 @@ function setup(adapter: Partial<BriefAdapter>): BriefStore {
} }
describe('BriefStore action state (Idle | Busy | Failed)', () => { describe('BriefStore action state (Idle | Busy | Failed)', () => {
it('is Busy synchronously once a transition starts, then Idle on success', async () => { it('is Busy synchronously once a transition starts', async () => {
const approved: BriefView = { const approved: BriefView = {
...view, ...view,
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } }, brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
@@ -61,7 +70,23 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
const pending = store.approve(); const pending = store.approve();
expect(store.busy()).toBe(true); // set synchronously, before any await resolves expect(store.busy()).toBe(true); // set synchronously, before any await resolves
await pending; await pending; // settle before the test ends
});
it('settles to Idle on a successful transition', async () => {
const approved: BriefView = {
...view,
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
};
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: approved }),
});
await store.load();
await store.approve();
expect(store.busy()).toBe(false); expect(store.busy()).toBe(false);
expect(store.lastError()).toBeNull(); expect(store.lastError()).toBeNull();
}); });
@@ -242,3 +267,37 @@ describe('BriefStore.previewLetter', () => {
expect(store.lastError()).toBe('De voorvertoning kon niet worden geopend.'); expect(store.lastError()).toBe('De voorvertoning kon niet worden geopend.');
}); });
}); });
describe('BriefStore.revealBigNummer (PRD-0002 §5c)', () => {
afterEach(() => vi.restoreAllMocks());
// Loaded with a MASKED BIG-nummer, as the server ships it by default.
const maskedView: BriefView = { ...view, caseContext: { ...caseContext, bigNummer: '********601' } };
it('swaps the masked value for the revealed one on success', async () => {
const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) });
await store.load();
expect(store.caseContext()?.bigNummer).toBe('********601');
vi.spyOn(TestBed.inject(RevealBigNummerAdapter), 'reveal').mockResolvedValue({
ok: true,
value: '19012345601',
});
await store.revealBigNummer();
expect(store.caseContext()?.bigNummer).toBe('19012345601');
expect(store.lastError()).toBeNull();
});
it('keeps the value masked and surfaces the error on failure', async () => {
const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) });
await store.load();
vi.spyOn(TestBed.inject(RevealBigNummerAdapter), 'reveal').mockResolvedValue({
ok: false,
error: 'geweigerd',
});
await store.revealBigNummer();
expect(store.caseContext()?.bigNummer).toBe('********601'); // unchanged
expect(store.lastError()).toBe('geweigerd');
});
});
+25
View File
@@ -4,6 +4,7 @@ import { RemoteData } from '@shared/application/remote-data';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { import {
Brief, Brief,
CaseContext,
allDiagnostics, allDiagnostics,
canSubmit, canSubmit,
hasBlockingErrors, hasBlockingErrors,
@@ -14,6 +15,7 @@ import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-di
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import { 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 { uploadContentUrl } from '@shared/upload/upload.adapter'; import { uploadContentUrl } from '@shared/upload/upload.adapter';
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union /** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
@@ -39,6 +41,7 @@ type LoadedBriefState = Extract<BriefState, { tag: 'loaded' }>;
export class BriefStore { export class BriefStore {
private adapter = inject(BriefAdapter); private adapter = inject(BriefAdapter);
private previewAdapter = inject(LetterPreviewAdapter); private previewAdapter = inject(LetterPreviewAdapter);
private revealAdapter = inject(RevealBigNummerAdapter);
private store = createStore<BriefState, BriefMsg>(initial, reduce); private store = createStore<BriefState, BriefMsg>(initial, reduce);
readonly model = this.store.model; readonly model = this.store.model;
@@ -87,6 +90,10 @@ export class BriefStore {
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);
/** The case (zorgverlener + aanvraag) this letter concerns — server-joined context for
the behandel scherm header, not letter state. Set from every server view. */
readonly caseContext = signal<CaseContext | null>(null);
/** The org logo's content URL for the letterhead, or null when the template has none. */ /** The org logo's content URL for the letterhead, or null when the template has none. */
readonly logoUrl = computed<string | null>(() => { readonly logoUrl = computed<string | null>(() => {
const id = this.orgTemplate()?.logoDocumentId; const id = this.orgTemplate()?.logoDocumentId;
@@ -117,6 +124,8 @@ export class BriefStore {
readonly canApprove = computed(() => this.decisions()?.canApprove ?? false); readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);
readonly canReject = computed(() => this.decisions()?.canReject ?? false); readonly canReject = computed(() => this.decisions()?.canReject ?? false);
readonly canSend = computed(() => this.decisions()?.canSend ?? false); readonly canSend = computed(() => this.decisions()?.canSend ?? false);
/** Field-level PII reveal (PRD-0002 §5c), deny-by-default like the action gates. */
readonly canRevealBigNummer = computed(() => this.decisions()?.canRevealBigNummer ?? false);
private decisions = computed(() => { private decisions = computed(() => {
const s = this.model(); const s = this.model();
@@ -134,6 +143,7 @@ export class BriefStore {
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.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext);
this.clearHistory(); this.clearHistory();
this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else { } else {
@@ -212,6 +222,7 @@ export class BriefStore {
if (r.ok) { if (r.ok) {
this.actionState.set({ tag: 'Idle' }); this.actionState.set({ tag: 'Idle' });
this.orgTemplate.set(r.value.orgTemplate); this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext);
this.clearHistory(); this.clearHistory();
this.rejectionSnapshot.set(null); this.rejectionSnapshot.set(null);
this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
@@ -239,6 +250,19 @@ export class BriefStore {
window.open(URL.createObjectURL(r.value), '_blank'); window.open(URL.createObjectURL(r.value), '_blank');
} }
/** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability
+ step-up and audits the attempt; on success we swap the masked value in the
already-loaded caseContext (a field update, not a reload). The step-up gesture
itself is the UI's concern — this command just runs the audited server call. */
async revealBigNummer() {
const r = await this.revealAdapter.reveal();
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
return;
}
this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));
}
// 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>>) {
@@ -257,6 +281,7 @@ export class BriefStore {
private applyServerStatus(view: BriefView) { private applyServerStatus(view: BriefView) {
// `send` pins the org-template version server-side — mirror whatever came back. // `send` pins the org-template version server-side — mirror whatever came back.
this.orgTemplate.set(view.orgTemplate); this.orgTemplate.set(view.orgTemplate);
this.caseContext.set(view.caseContext);
const { brief, decisions } = view; const { brief, decisions } = view;
const s = brief.status; const s = brief.status;
switch (s.tag) { switch (s.tag) {
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { Besluit, LetterBlock, LibraryPassage } from './brief';
import { inferSelection, passagesForBesluit, redenenFor } from './besluit';
const block = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
const p = (over: Partial<LibraryPassage>): LibraryPassage => ({
passageId: over.passageId ?? 'x',
scope: 'global',
sectionKey: 'kern',
label: over.label ?? 'x',
content: block('x'),
version: 1,
...over,
});
const lib: LibraryPassage[] = [
p({ passageId: 'intro', besluit: undefined }), // shared, any besluit
p({ passageId: 'pos', besluit: 'positief' }),
p({ passageId: 'neg', besluit: 'negatief' }),
p({ passageId: 'neg-scholing', besluit: 'negatief', reason: 'onvoldoende_scholing', label: 'Onvoldoende scholing' }),
p({ passageId: 'neg-gegevens', besluit: 'negatief', reason: 'onjuiste_gegevens', label: 'Onjuiste gegevens' }),
p({ passageId: 'slot-x', sectionKey: 'slot', besluit: undefined }), // not kern → never offered
];
describe('passagesForBesluit', () => {
it('positief = shared intro + the positief passage, no negatief/reason passages', () => {
const ids = passagesForBesluit(lib, 'positief', []).map((x) => x.passageId);
expect(ids).toEqual(['intro', 'pos']);
});
it('negatief without redenen = intro + negatief base, but no reason-specific passages', () => {
const ids = passagesForBesluit(lib, 'negatief', []).map((x) => x.passageId);
expect(ids).toEqual(['intro', 'neg']);
});
it('negatief with a reden ticked includes that reason-specific passage only', () => {
const ids = passagesForBesluit(lib, 'negatief', ['onvoldoende_scholing']).map((x) => x.passageId);
expect(ids).toEqual(['intro', 'neg', 'neg-scholing']);
});
it('preserves library order (= reading order)', () => {
const ids = passagesForBesluit(lib, 'negatief', ['onjuiste_gegevens', 'onvoldoende_scholing']).map((x) => x.passageId);
expect(ids).toEqual(['intro', 'neg', 'neg-scholing', 'neg-gegevens']);
});
it('never offers non-kern passages', () => {
expect(passagesForBesluit(lib, 'positief', []).some((x) => x.sectionKey !== 'kern')).toBe(false);
});
});
describe('redenenFor', () => {
it('derives reason checkboxes (code + label) from the negatief reason passages', () => {
expect(redenenFor(lib, 'negatief')).toEqual([
{ code: 'onvoldoende_scholing', label: 'Onvoldoende scholing' },
{ code: 'onjuiste_gegevens', label: 'Onjuiste gegevens' },
]);
});
it('positief has no reason-specific redenen', () => {
expect(redenenFor(lib, 'positief')).toEqual([]);
});
});
describe('inferSelection', () => {
// Build the kern blocks a besluit would produce, then read the selection back off them.
const kern = (besluit: Besluit, reasons: string[]): LetterBlock[] =>
passagesForBesluit(lib, besluit, reasons).map((p, i) => ({
type: 'passage',
blockId: `local-${i + 1}`,
sourcePassageId: p.passageId,
sourceVersion: p.version,
content: p.content,
edited: false,
}));
it('round-trips a positief selection', () => {
expect(inferSelection(kern('positief', []), lib)).toEqual({ besluit: 'positief', reasons: [] });
});
it('round-trips a negatief selection with redenen (in order)', () => {
const blocks = kern('negatief', ['onjuiste_gegevens', 'onvoldoende_scholing']);
expect(inferSelection(blocks, lib)).toEqual({
besluit: 'negatief',
reasons: ['onvoldoende_scholing', 'onjuiste_gegevens'], // library order
});
});
it('an empty kern (nothing chosen) infers no besluit', () => {
expect(inferSelection([], lib)).toEqual({ besluit: null, reasons: [] });
});
it('ignores free-text blocks and unknown passage ids', () => {
const blocks: LetterBlock[] = [
{ type: 'freeText', blockId: 'local-9', content: block('vrij') },
...kern('positief', []),
];
expect(inferSelection(blocks, lib)).toEqual({ besluit: 'positief', reasons: [] });
});
});
+72
View File
@@ -0,0 +1,72 @@
import { Besluit, LetterBlock, LibraryPassage } from './brief';
/**
* Guided drafting: given the behandelaar's besluit + chosen redenen, which library
* passages belong in the kern. This is the "don't make them a detective" logic —
* pure, so it's unit-tested directly and the UI just renders the result.
*
* A passage is offered when:
* - it has no besluit tag (a shared intro/toelichting, relevant to any besluit), OR
* - its besluit matches AND either it isn't reason-specific, or its reason is ticked.
*
* Kept in library order (server order = reading order), so an inserted set already
* flows as a letter.
*/
export function passagesForBesluit(
passages: readonly LibraryPassage[],
besluit: Besluit,
reasons: readonly string[],
): LibraryPassage[] {
return passages.filter((p) => {
if (p.sectionKey !== 'kern') return false;
if (p.besluit === undefined) return true; // shared, any besluit
if (p.besluit !== besluit) return false;
if (p.reason === undefined) return true; // besluit-level, not reason-specific
return reasons.includes(p.reason);
});
}
/** A selectable reden for a besluit, derived from the reason-specific passages — no
separate catalog. `code` drives `passagesForBesluit`; `label` is the checkbox text.
ponytail: assumes one passage per reason (true for the seed); dedupes on code if not. */
export interface Reden {
readonly code: string;
readonly label: string;
}
export function redenenFor(passages: readonly LibraryPassage[], besluit: Besluit): Reden[] {
const seen = new Set<string>();
const out: Reden[] = [];
for (const p of passages) {
if (p.sectionKey !== 'kern' || p.besluit !== besluit || p.reason === undefined) continue;
if (seen.has(p.reason)) continue;
seen.add(p.reason);
out.push({ code: p.reason, label: p.label });
}
return out;
}
/**
* The inverse of `passagesForBesluit`: read the current besluit + redenen back off the
* kern's passage blocks (each carries its `sourcePassageId`), so the panel can re-seed
* itself on reload/undo without persisting the selection separately. Kern passage blocks
* are besluit-derived by construction (the only way passages enter the kern), so this
* round-trips: `inferSelection(kern(passagesForBesluit(lib, b, r)), lib) === { b, r }`.
* Free-text blocks carry no provenance and are ignored.
*/
export function inferSelection(
kernBlocks: readonly LetterBlock[],
passages: readonly LibraryPassage[],
): { besluit: Besluit | null; reasons: string[] } {
const byId = new Map(passages.map((p) => [p.passageId, p]));
let besluit: Besluit | null = null;
const reasons: string[] = [];
for (const b of kernBlocks) {
if (b.type !== 'passage') continue;
const source = byId.get(b.sourcePassageId);
if (!source) continue;
if (source.besluit !== undefined) besluit = source.besluit;
if (source.reason !== undefined && !reasons.includes(source.reason)) reasons.push(source.reason);
}
return { besluit, reasons };
}
+99 -61
View File
@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from './brief'; import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './brief';
import { RichTextBlock } from '@shared/kernel/rich-text'; import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderDef } from './placeholders'; import { PlaceholderDef } from './placeholders';
import { BriefState, BriefMsg, reduce } from './brief.machine'; import { BriefState, reduce } from './brief.machine';
const placeholders: PlaceholderDef[] = [ const placeholders: PlaceholderDef[] = [
{ key: 'naam', label: 'Naam', autoResolvable: true }, { key: 'naam', label: 'Naam', autoResolvable: true },
@@ -13,15 +13,32 @@ const text = (t: string): RichTextBlock => ({
paragraphs: [{ nodes: [{ type: 'text', text: t }] }], paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
}); });
const libPassage = (id: string, sectionKey: string): LibraryPassage => ({ const libPassage = (
id: string,
sectionKey: string,
extra: Partial<LibraryPassage> = {},
): LibraryPassage => ({
passageId: id, passageId: id,
scope: 'global', scope: 'global',
sectionKey, sectionKey,
label: `Passage ${id}`, label: `Passage ${id}`,
content: text(`inhoud ${id}`), content: text(`inhoud ${id}`),
version: 3, version: 3,
...extra,
}); });
// A besluit-tagged kern library: `intro` is shared (any besluit), `pos`/`neg` are
// besluit-level, `neg-r` is reason-specific. This drives every `BesluitSelected` here.
const lib: LibraryPassage[] = [
libPassage('intro', 'kern'),
libPassage('pos', 'kern', { besluit: 'positief' }),
libPassage('neg', 'kern', { besluit: 'negatief' }),
libPassage('neg-r', 'kern', { besluit: 'negatief', reason: 'r1', label: 'Reden 1' }),
];
const besluit = (b: Besluit | null, reasons: string[] = []) =>
({ tag: 'BesluitSelected', besluit: b, reasons }) as const;
function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief { function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
return { return {
briefId: 'b1', briefId: 'b1',
@@ -29,7 +46,7 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
templateId: 't1', templateId: 't1',
placeholders, placeholders,
sections: sections ?? [ sections: sections ?? [
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: false, blocks: [] }, { sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] },
{ sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] }, { sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
], ],
status, status,
@@ -44,6 +61,7 @@ const decisions: BriefDecisions = {
canApprove: true, canApprove: true,
canReject: true, canReject: true,
canSend: true, canSend: true,
canRevealBigNummer: true,
}; };
const loaded = ( const loaded = (
@@ -52,15 +70,20 @@ const loaded = (
): BriefState => ({ ): BriefState => ({
tag: 'loaded', tag: 'loaded',
brief: briefWith(status, sections), brief: briefWith(status, sections),
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')], 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) =>
sectionBlocks(s, key)
.filter((b) => b.type === 'passage')
.map((b) => (b.type === 'passage' ? b.sourcePassageId : ''));
describe('brief.machine reduce', () => { describe('brief.machine reduce', () => {
it('BriefLoaded / BriefLoadFailed / Seed set state directly', () => { it('BriefLoaded moves loading to loaded', () => {
expect( expect(
reduce(initialLoading(), { reduce(initialLoading(), {
tag: 'BriefLoaded', tag: 'BriefLoaded',
@@ -69,92 +92,98 @@ describe('brief.machine reduce', () => {
decisions, decisions,
}).tag, }).tag,
).toBe('loaded'); ).toBe('loaded');
});
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',
}); });
});
it('Seed sets the state directly', () => {
const seeded = loaded(); const seeded = loaded();
expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded); expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded);
}); });
it('PassagesInserted creates one frozen block per passage, in order, with local ids', () => { it('BesluitSelected composes the kern: the besluit passages, in reading order, as frozen local blocks', () => {
const s = reduce(loaded(), { const s = reduce(loaded(), besluit('positief'));
tag: 'PassagesInserted', const blocks = sectionBlocks(s, 'kern');
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
});
const blocks = sectionBlocks(s, 'aanhef');
expect(blocks.map((b) => b.blockId)).toEqual(['local-1', 'local-2']); expect(blocks.map((b) => b.blockId)).toEqual(['local-1', 'local-2']);
expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true); expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true);
expect(blocks[0].type === 'passage' && blocks[0].sourcePassageId).toBe('p1'); expect(passageIds(s, 'kern')).toEqual(['intro', 'pos']);
}); });
it('PassagesInserted deep-copies content — later library mutation does not leak in', () => { it('BesluitSelected swaps the passages when the selection changes, keeping free text', () => {
const passage = libPassage('p1', 'aanhef'); let s = reduce(loaded(), besluit('positief'));
const s = reduce(loaded(), { s = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' }); // drafter's own remark
tag: 'PassagesInserted', s = reduce(s, besluit('negatief', ['r1']));
sectionKey: 'aanhef', const blocks = sectionBlocks(s, 'kern');
passages: [passage], expect(blocks.map((b) => b.type)).toEqual(['passage', 'passage', 'passage', 'freeText']);
}); expect(passageIds(s, 'kern')).toEqual(['intro', 'neg', 'neg-r']);
// Mutate the source passage object after insertion. // Deselecting the besluit leaves only the free text.
s = reduce(s, besluit(null));
expect(sectionBlocks(s, 'kern').map((b) => b.type)).toEqual(['freeText']);
});
it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
const st: BriefState = {
tag: 'loaded',
brief: briefWith({ tag: 'draft' }),
availablePassages: [passage],
decisions,
};
const s = reduce(st, besluit('positief'));
// Mutate the source passage object after composition.
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED'; (passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
const block = sectionBlocks(s, 'aanhef')[0]; const block = sectionBlocks(s, 'kern')[0];
expect(block.content.paragraphs[0].nodes[0]).toEqual({ type: 'text', text: 'inhoud p1' }); expect(block.content.paragraphs[0].nodes[0]).toEqual({ type: 'text', text: 'inhoud intro' });
}); });
it('FreeTextBlockAdded appends an empty free-text block', () => { it('FreeTextBlockAdded appends an empty free-text block', () => {
const s = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'slot' }); const s = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
const blocks = sectionBlocks(s, 'slot'); const blocks = sectionBlocks(s, 'kern');
expect(blocks).toHaveLength(1); expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe('freeText'); expect(blocks[0].type).toBe('freeText');
}); });
it('BlockContentEdited replaces content and marks a passage block edited', () => { it('BlockContentEdited replaces content and marks a passage block edited', () => {
let s = reduce(loaded(), { let s = reduce(loaded(), besluit('positief'));
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef')],
});
s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') }); s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') });
const block = sectionBlocks(s, 'aanhef')[0]; const block = sectionBlocks(s, 'kern')[0];
expect(block.type === 'passage' && block.edited).toBe(true); expect(block.type === 'passage' && block.edited).toBe(true);
expect(block.content).toEqual(text('aangepast')); expect(block.content).toEqual(text('aangepast'));
}); });
it('BlockRemoved and BlockMovedWithinSection reorder within a section', () => { it('BlockMovedWithinSection reorders blocks within a section', () => {
let s = reduce(loaded(), { let s = reduce(loaded(), besluit('positief')); // local-1 intro, local-2 pos
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
});
s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 }); s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 });
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-2', 'local-1']); expect(sectionBlocks(s, 'kern').map((b) => b.blockId)).toEqual(['local-2', 'local-1']);
s = reduce(s, { tag: 'BlockRemoved', blockId: 'local-2' });
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-1']);
}); });
it('edits to a locked section are no-ops (insert, free-text, content, remove, move)', () => { it('BlockRemoved drops a block from a section', () => {
let s = reduce(loaded(), besluit('positief')); // local-1 intro, local-2 pos
s = reduce(s, { tag: 'BlockRemoved', blockId: 'local-2' });
expect(sectionBlocks(s, 'kern').map((b) => b.blockId)).toEqual(['local-1']);
});
it('edits to a locked section are no-ops (besluit, free-text, content, remove, move)', () => {
const lockedSections: Brief['sections'] = [ const lockedSections: Brief['sections'] = [
{ {
sectionKey: 'aanhef', sectionKey: 'kern',
title: 'Aanhef', title: 'Kern',
required: true, required: true,
locked: true, locked: true,
blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }], blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }],
}, },
{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] }, { sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
]; ];
const s = loaded({ tag: 'draft' }, lockedSections); const s = loaded({ tag: 'draft' }, lockedSections);
// The brief value is left untouched (withEdit reallocates state, but the guard returns // The brief value is left untouched (withEdit reallocates state, but the guard returns
// the same brief), so assert on deep equality of the section contents. // the same brief), so assert on deep equality of the section contents.
expect( expect(reduce(s, besluit('positief'))).toEqual(s);
reduce(s, { expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' })).toEqual(s);
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef')],
}),
).toEqual(s);
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' })).toEqual(s);
expect( expect(
reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') }), reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') }),
).toEqual(s); ).toEqual(s);
@@ -163,8 +192,8 @@ describe('brief.machine reduce', () => {
s, s,
); );
// the unlocked section still accepts edits // the unlocked section still accepts edits
const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' }); const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
expect(sectionBlocks(edited, 'kern')).toHaveLength(1); expect(sectionBlocks(edited, 'slot')).toHaveLength(1);
}); });
it('edits are no-ops once submitted (status invariant)', () => { it('edits are no-ops once submitted (status invariant)', () => {
@@ -185,10 +214,10 @@ describe('brief.machine reduce', () => {
}); });
it('Submitted fires only from draft and only when required sections are filled', () => { it('Submitted fires only from draft and only when required sections are filled', () => {
// required 'aanhef' empty → no-op // required 'kern' empty → no-op
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't', decisions })).toEqual(loaded()); expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't', decisions })).toEqual(loaded());
// fill the required section, then submit // fill the required section via the besluit, then submit
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' }); 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',
@@ -197,7 +226,7 @@ describe('brief.machine reduce', () => {
}); });
}); });
it('approve/reject fire only from submitted; send only from approved', () => { it('approve fires only from submitted', () => {
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' }); const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
// approve from draft is a no-op // approve from draft is a no-op
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded()); expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
@@ -207,7 +236,10 @@ describe('brief.machine reduce', () => {
approvedBy: 'u2', approvedBy: 'u2',
approvedAt: 't2', approvedAt: 't2',
}); });
// reject carries comments });
it('reject fires from submitted, carrying comments', () => {
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
const rejected = reduce(submitted, { const rejected = reduce(submitted, {
tag: 'Rejected', tag: 'Rejected',
by: 'u2', by: 'u2',
@@ -221,7 +253,12 @@ describe('brief.machine reduce', () => {
rejectedAt: 't2', rejectedAt: 't2',
comments: 'nee', comments: 'nee',
}); });
// send only from approved });
it('send fires only from approved', () => {
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
// 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' });
@@ -234,6 +271,7 @@ describe('brief.machine reduce', () => {
canApprove: false, canApprove: false,
canReject: false, canReject: false,
canSend: false, canSend: false,
canRevealBigNummer: false,
}; };
const approved = reduce(submitted, { const approved = reduce(submitted, {
tag: 'Approved', tag: 'Approved',
+31 -12
View File
@@ -1,5 +1,6 @@
import { assertNever } from '@shared/kernel/fp'; import { assertNever } from '@shared/kernel/fp';
import { import {
Besluit,
Brief, Brief,
BriefDecisions, BriefDecisions,
BriefStatus, BriefStatus,
@@ -10,6 +11,7 @@ import {
canSubmit, canSubmit,
} from './brief'; } from './brief';
import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text'; import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text';
import { passagesForBesluit } from './besluit';
/** /**
* The letter composition state machine (Model + Msg + pure reduce), modeled on * The letter composition state machine (Model + Msg + pure reduce), modeled on
@@ -54,7 +56,7 @@ export type BriefMsg =
decisions: BriefDecisions; decisions: BriefDecisions;
} }
| { tag: 'BriefLoadFailed'; reason: string } | { tag: 'BriefLoadFailed'; reason: string }
| { tag: 'PassagesInserted'; sectionKey: string; passages: readonly LibraryPassage[] } // multi-select | { tag: 'BesluitSelected'; besluit: Besluit | null; reasons: readonly string[] } // recomposes the kern's passages
| { tag: 'FreeTextBlockAdded'; sectionKey: string } | { tag: 'FreeTextBlockAdded'; sectionKey: string }
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock } | { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
| { tag: 'BlockRemoved'; blockId: string } | { tag: 'BlockRemoved'; blockId: string }
@@ -114,15 +116,11 @@ function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
return { ...s, brief }; return { ...s, brief };
} }
function insertPassages( function buildPassageBlocks(brief: Brief, passages: readonly LibraryPassage[]): LetterBlock[] {
brief: Brief,
sectionKey: string,
passages: readonly LibraryPassage[],
): Brief {
let idx = nextLocalIndex(brief); let idx = nextLocalIndex(brief);
// The freeze happens HERE: each block gets a deep VALUE copy of the library content, // The freeze happens HERE: each block gets a deep VALUE copy of the library content,
// so later library edits can never mutate this letter (frozen snapshot). // so later library edits can never mutate this letter (frozen snapshot).
const newBlocks: LetterBlock[] = passages.map((p) => ({ return passages.map((p) => ({
type: 'passage', type: 'passage',
blockId: `local-${idx++}`, blockId: `local-${idx++}`,
sourcePassageId: p.passageId, sourcePassageId: p.passageId,
@@ -130,7 +128,26 @@ function insertPassages(
content: deepCopyBlock(p.content), content: deepCopyBlock(p.content),
edited: false, edited: false,
})); }));
return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, ...newBlocks] })); }
/** Recompose the kern for a besluit selection: the besluit-driven passages (in reading
order) followed by the drafter's free-text blocks. The kern's `passage` blocks are
besluit-derived by construction, so replacing them wholesale is the reactive swap; the
`freeText` blocks are the drafter's own remarks and survive.
ponytail: free text always trails the besluit passages after a recompute. */
function composeKern(
brief: Brief,
availablePassages: readonly LibraryPassage[],
besluit: Besluit | null,
reasons: readonly string[],
): Brief {
const besluitBlocks = besluit
? buildPassageBlocks(brief, passagesForBesluit(availablePassages, besluit, reasons))
: [];
return mapSection(brief, 'kern', (s) => ({
...s,
blocks: [...besluitBlocks, ...s.blocks.filter((b) => b.type === 'freeText')],
}));
} }
function addFreeText(brief: Brief, sectionKey: string): Brief { function addFreeText(brief: Brief, sectionKey: string): Brief {
@@ -182,11 +199,13 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
case 'Seed': case 'Seed':
return m.state; return m.state;
// Section-level guard (defense-in-depth): locked sections never accept edits, even if a // The kern is besluit-driven: (re)compose its passages from the selection, keeping the
// Msg reaches the reducer. The UI already hides controls for locked sections. // drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
case 'PassagesInserted': case 'BesluitSelected':
return withEdit(s, (b) => return withEdit(s, (b) =>
isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b, s.tag === 'loaded' && isSectionEditable(b, 'kern')
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
: b,
); );
case 'FreeTextBlockAdded': case 'FreeTextBlockAdded':
return withEdit(s, (b) => return withEdit(s, (b) =>
+23 -1
View File
@@ -13,6 +13,9 @@ import { Diagnostic, lintPlaceholders, PlaceholderDef } from './placeholders';
export type PassageScope = 'global' | 'beroep'; export type PassageScope = 'global' | 'beroep';
/** The decision the behandelaar is communicating. Drives which passages are offered. */
export type Besluit = 'positief' | 'negatief';
// Re-export placeholderKeysIn for one-import convenience at call sites. // Re-export placeholderKeysIn for one-import convenience at call sites.
export { placeholderKeysIn }; export { placeholderKeysIn };
@@ -25,7 +28,11 @@ export interface LibraryPassage {
readonly label: string; readonly label: string;
readonly content: RichTextBlock; readonly content: RichTextBlock;
readonly version: number; // library version, for provenance only readonly version: number; // library version, for provenance only
readonly isDefault?: boolean; // part of the "standaardbrief" (kern) starter set // Guided-drafting tags: the behandelaar picks a besluit + reden, and `passagesForBesluit`
// (besluit.ts) filters to the matching passages. undefined besluit = shown for any
// besluit; undefined reason = not reason-specific. See @brief/domain/besluit.
readonly besluit?: Besluit;
readonly reason?: string;
} }
/** A block inside a letter section: a frozen passage snapshot, or free text. */ /** A block inside a letter section: a frozen passage snapshot, or free text. */
@@ -50,6 +57,8 @@ export interface LetterSection {
readonly required: boolean; readonly required: boolean;
// Predefined template sections (aanhef, slot) arrive locked and prefilled — the drafter // Predefined template sections (aanhef, slot) arrive locked and prefilled — the drafter
// composes only the unlocked section(s). The reducer refuses edits to locked sections. // composes only the unlocked section(s). The reducer refuses edits to locked sections.
// These come from the case-type template (`Brief.templateId`), so e.g. the slot's closing
// can differ per case type; the drafter never edits it, and it renders only in the preview.
readonly locked: boolean; readonly locked: boolean;
readonly blocks: readonly LetterBlock[]; readonly blocks: readonly LetterBlock[];
} }
@@ -109,6 +118,16 @@ export function canSubmit(brief: Brief): boolean {
return brief.sections.every((s) => !s.required || s.blocks.length > 0); return brief.sections.every((s) => !s.required || s.blocks.length > 0);
} }
/** The case this letter concerns — the zorgverlener + aanvraag the behandelaar is
handling. Server-joined onto the brief view (brief/ stays a shared-only leaf, so it
can't read the registratie context directly). Header context only. */
export interface CaseContext {
readonly zorgverlenerNaam: string;
readonly bigNummer: string;
readonly beroep: string;
readonly aanvraagReferentie: string;
}
/** Server-computed decision flags for the acting principal + this brief's live /** Server-computed decision flags for the acting principal + this brief's live
status (PRD-0002 phase P1) — rendered as-is, never recomputed here. */ status (PRD-0002 phase P1) — rendered as-is, never recomputed here. */
export interface BriefDecisions { export interface BriefDecisions {
@@ -116,4 +135,7 @@ export interface BriefDecisions {
readonly canApprove: boolean; readonly canApprove: boolean;
readonly canReject: boolean; readonly canReject: boolean;
readonly canSend: boolean; readonly canSend: boolean;
/** Field-level PII (PRD-0002 §5c): may the acting principal unmask the case
BIG-nummer, which the server ships masked? Status-independent. */
readonly canRevealBigNummer: boolean;
} }
@@ -51,8 +51,18 @@ const view: BriefViewDto = {
content: { paragraphs: [{ nodes: [] }] }, content: { paragraphs: [{ nodes: [] }] },
version: 1, version: 1,
}, },
{
passageId: 'p-neg-scholing',
scope: 'global',
sectionKey: 'kern',
label: 'Onvoldoende scholing',
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'x' }] }] },
version: 1,
besluit: 'negatief',
reason: 'onvoldoende_scholing',
},
], ],
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false }, decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false, canRevealBigNummer: false },
orgTemplate: { orgTemplate: {
subOrgId: 'cibg-registers', subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers', orgName: 'CIBG — Registers',
@@ -65,6 +75,12 @@ const view: BriefViewDto = {
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 }, margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1, version: 1,
}, },
caseContext: {
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
bigNummer: '19012345601',
beroep: 'arts',
aanvraagReferentie: 'HER-2026-000842',
},
}; };
describe('brief.adapter parse boundary', () => { describe('brief.adapter parse boundary', () => {
@@ -91,7 +107,28 @@ describe('brief.adapter parse boundary', () => {
canApprove: true, canApprove: true,
canReject: true, canReject: true,
canSend: false, canSend: false,
canRevealBigNummer: false,
}); });
// Guided-drafting tags survive the boundary; the untagged passage has neither.
expect(r.value.availablePassages[0].besluit).toBeUndefined();
expect(r.value.availablePassages[1]).toMatchObject({
besluit: 'negatief',
reason: 'onvoldoende_scholing',
});
expect(r.value.caseContext).toEqual({
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
bigNummer: '19012345601',
beroep: 'arts',
aanvraagReferentie: 'HER-2026-000842',
});
});
it('rejects a view whose case context is missing or malformed', () => {
expect(parseBriefView({ ...view, caseContext: undefined }).ok).toBe(false);
expect(
parseBriefView({ ...view, caseContext: { ...view.caseContext!, bigNummer: undefined as never } })
.ok,
).toBe(false);
}); });
it('parses the org template and drops a null logoDocumentId', () => { it('parses the org template and drops a null logoDocumentId', () => {
@@ -119,6 +156,13 @@ describe('brief.adapter parse boundary', () => {
expect( expect(
parseBriefView({ ...view, decisions: { ...view.decisions, canSend: 'yes' as never } }).ok, parseBriefView({ ...view, decisions: { ...view.decisions, canSend: 'yes' as never } }).ok,
).toBe(false); ).toBe(false);
// The PII-reveal flag (PRD-0002 §5c) is required at the boundary too.
expect(
parseBriefView({
...view,
decisions: { ...view.decisions, canRevealBigNummer: undefined as never },
}).ok,
).toBe(false);
}); });
it('narrows node variants and rejects unknown ones', () => { it('narrows node variants and rejects unknown ones', () => {
+28 -2
View File
@@ -7,6 +7,7 @@ import {
BriefDto, BriefDto,
BriefStatusDto, BriefStatusDto,
BriefViewDto, BriefViewDto,
CaseContextDto,
LetterBlockDto, LetterBlockDto,
LetterSectionDto, LetterSectionDto,
LibraryPassageDto, LibraryPassageDto,
@@ -19,6 +20,7 @@ import {
Brief, Brief,
BriefDecisions, BriefDecisions,
BriefStatus, BriefStatus,
CaseContext,
LetterBlock, LetterBlock,
LetterSection, LetterSection,
LibraryPassage, LibraryPassage,
@@ -40,6 +42,7 @@ export interface BriefView {
readonly availablePassages: LibraryPassage[]; readonly availablePassages: LibraryPassage[];
readonly decisions: BriefDecisions; readonly decisions: BriefDecisions;
readonly orgTemplate: OrgTemplate; readonly orgTemplate: OrgTemplate;
readonly caseContext: CaseContext;
} }
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`; export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
@@ -249,7 +252,25 @@ function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
content: content.value, content: content.value,
version: dto.version, version: dto.version,
...(dto.beroep != null ? { beroep: dto.beroep } : {}), ...(dto.beroep != null ? { beroep: dto.beroep } : {}),
...(dto.isDefault ? { isDefault: true } : {}), ...(dto.besluit === 'positief' || dto.besluit === 'negatief' ? { besluit: dto.besluit } : {}),
...(dto.reason != null ? { reason: dto.reason } : {}),
});
}
function parseCaseContext(dto: CaseContextDto | undefined): Result<string, CaseContext> {
if (
typeof dto?.zorgverlenerNaam !== 'string' ||
typeof dto.bigNummer !== 'string' ||
typeof dto.beroep !== 'string' ||
typeof dto.aanvraagReferentie !== 'string'
) {
return err('brief-view: missing/invalid case context');
}
return ok({
zorgverlenerNaam: dto.zorgverlenerNaam,
bigNummer: dto.bigNummer,
beroep: dto.beroep,
aanvraagReferentie: dto.aanvraagReferentie,
}); });
} }
@@ -293,7 +314,8 @@ function parseDecisions(dto: BriefDecisionsDto | undefined): Result<string, Brie
typeof dto?.canEdit !== 'boolean' || typeof dto?.canEdit !== 'boolean' ||
typeof dto.canApprove !== 'boolean' || typeof dto.canApprove !== 'boolean' ||
typeof dto.canReject !== 'boolean' || typeof dto.canReject !== 'boolean' ||
typeof dto.canSend !== 'boolean' typeof dto.canSend !== 'boolean' ||
typeof dto.canRevealBigNummer !== 'boolean'
) { ) {
return err('brief-view: missing/invalid decisions'); return err('brief-view: missing/invalid decisions');
} }
@@ -302,6 +324,7 @@ function parseDecisions(dto: BriefDecisionsDto | undefined): Result<string, Brie
canApprove: dto.canApprove, canApprove: dto.canApprove,
canReject: dto.canReject, canReject: dto.canReject,
canSend: dto.canSend, canSend: dto.canSend,
canRevealBigNummer: dto.canRevealBigNummer,
}); });
} }
@@ -351,6 +374,8 @@ export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
if (!decisions.ok) return decisions; if (!decisions.ok) return decisions;
const orgTemplate = parseOrgTemplate(dto.orgTemplate); const orgTemplate = parseOrgTemplate(dto.orgTemplate);
if (!orgTemplate.ok) return orgTemplate; if (!orgTemplate.ok) return orgTemplate;
const caseContext = parseCaseContext(dto.caseContext);
if (!caseContext.ok) return caseContext;
const availablePassages: LibraryPassage[] = []; const availablePassages: LibraryPassage[] = [];
for (const p of dto.availablePassages ?? []) { for (const p of dto.availablePassages ?? []) {
const parsed = parsePassage(p); const parsed = parsePassage(p);
@@ -362,6 +387,7 @@ export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
availablePassages, availablePassages,
decisions: decisions.value, decisions: decisions.value,
orgTemplate: orgTemplate.value, orgTemplate: orgTemplate.value,
caseContext: caseContext.value,
}); });
} }
@@ -0,0 +1,47 @@
import { Injectable } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '../../../environments/environment';
const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
/**
* Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;
* this unmasks it, gated server-side by the reveal capability AND a step-up. The
* step-up is stubbed as the `X-Step-Up` header — the caller sends it only after the
* user's confirm gesture, so a plain call (or a role without the capability) 403s.
*
* Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;
* `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the
* same seam as `/brief/preview` and uploads — which also means `X-Role` is set here.
*/
@Injectable({ providedIn: 'root' })
export class RevealBigNummerAdapter {
async reveal(): Promise<Result<string, string>> {
let res: Response;
try {
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, {
method: 'POST',
headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' },
});
} catch {
return err(REVEAL_FAILED);
}
if (!res.ok) return err(await errorMessage(res));
const body: unknown = await res.json().catch(() => null);
// Trust boundary: validate the shape before handing back a plain string.
if (typeof body === 'object' && body !== null && typeof (body as { bigNummer?: unknown }).bigNummer === 'string') {
return ok((body as { bigNummer: string }).bigNummer);
}
return err(REVEAL_FAILED);
}
}
async function errorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), REVEAL_FAILED);
} catch {
return REVEAL_FAILED;
}
}
@@ -0,0 +1,247 @@
import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
import { inferSelection } from '@brief/domain/besluit';
import { Diagnostic } from '@brief/domain/placeholders';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
import { LetterEditorComponent } from '@brief/ui/letter-editor/letter-editor.component';
import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.component';
/** Organism: the behandelaar's drafting step. Frames "Brief opstellen" as one step in
the case workflow — a case-context header + stepper (Beoordelen → Brief opstellen →
Indienen, neighbours stubbed) — with the besluit-driven guidance, the lean letter
editor, and an on-demand full-letter preview in a modal. Only ever renders for an
editable brief (draft/rejected); the approver's read-only flow stays in
letter-composer. Presentational: emits edit/submit/preview intents. */
@Component({
selector: 'app-behandel-scherm',
imports: [
ButtonComponent,
HeadingComponent,
StepperComponent,
LetterCanvasComponent,
DiagnosticsPanelComponent,
RejectionCommentsComponent,
LetterEditorComponent,
BesluitPanelComponent,
],
styles: [
`
:host {
display: block;
}
.case-head {
margin-block-end: var(--rhc-space-max-lg);
padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);
border-inline-start: 4px solid var(--rhc-color-primary, var(--rhc-color-border-strong));
background: var(--rhc-color-background-subtle, transparent);
}
.case-meta {
display: flex;
flex-wrap: wrap;
gap: var(--rhc-space-max-md);
color: var(--rhc-color-foreground-subtle);
}
.step-body {
display: grid;
gap: var(--rhc-space-max-xl);
margin-block-start: var(--rhc-space-max-lg);
}
.bar {
display: flex;
flex-wrap: wrap;
gap: var(--rhc-space-max-md);
align-items: center;
margin-block-start: var(--rhc-space-max-xl);
}
dialog {
border: none;
border-radius: var(--rhc-radius-md, 4px);
padding: 0;
max-width: min(900px, 95vw);
width: 100%;
}
dialog::backdrop {
background: rgb(0 0 0 / 45%); /* token-ok: modal scrim, not a palette colour */
}
.modal-body {
padding: var(--rhc-space-max-lg);
max-height: 80vh;
overflow: auto;
}
.modal-bar {
display: flex;
justify-content: space-between;
gap: var(--rhc-space-max-md);
padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);
border-block-start: 1px solid var(--rhc-color-border);
}
`,
],
template: `
<div class="case-head">
<app-heading [level]="2">{{ caseHeading() }}</app-heading>
<div class="case-meta">
<span>{{ caseContext().aanvraagReferentie }}</span>
<span>{{ caseContext().zorgverlenerNaam }}</span>
<span>
{{ bigLabel() }} {{ caseContext().bigNummer }}
@if (canRevealBigNummer() && isMasked()) {
<app-button variant="subtle" (click)="onReveal()">{{ revealLabel() }}</app-button>
}
</span>
<span>{{ caseContext().beroep }}</span>
</div>
</div>
<app-stepper
[steps]="steps()"
[current]="1"
[processName]="processName()"
[stepTitle]="stepTitle()"
/>
<div class="step-body">
@if (status() === 'rejected') {
<app-rejection-comments mode="show" [comments]="rejectComments()" />
}
<app-besluit-panel
[passages]="availablePassages()"
[besluit]="selection().besluit"
[initialRedenen]="selection().reasons"
(selectionChange)="onSelection($event)"
/>
<app-letter-editor [brief]="brief()" [placeholders]="menu()" (edit)="edit.emit($event)" />
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
<div class="bar">
<app-button variant="subtle" (click)="openPreview()">{{ previewLabel() }}</app-button>
<app-button
variant="primary"
[disabled]="!canSubmit() || busy()"
(click)="submit.emit()"
>{{ submitLabel() }}</app-button
>
@if (!canSubmit()) {
<span class="app-text-subtle">{{ submitHint() }}</span>
}
</div>
</div>
<dialog #previewDialog>
<div class="modal-body">
<app-letter-canvas
[brief]="brief()"
[orgTemplate]="orgTemplate()"
[logoUrl]="logoUrl()"
[editableRegions]="'none'"
[diagnostics]="diagnostics()"
/>
</div>
<div class="modal-bar">
<app-button variant="secondary" (click)="preview.emit()">{{ openDocumentLabel() }}</app-button>
<app-button variant="primary" (click)="closePreview()">{{ closeLabel() }}</app-button>
</div>
</dialog>
`,
})
export class BehandelSchermComponent {
brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>();
logoUrl = input<string | null>(null);
availablePassages = input<readonly LibraryPassage[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
caseContext = input.required<CaseContext>();
canSubmit = input(false);
busy = input(false);
/** Server decision (PRD-0002 §5c): may this actor unmask the case BIG-nummer? */
canRevealBigNummer = input(false);
edit = output<BriefMsg>();
submit = output<void>();
preview = output<void>();
locate = output<Diagnostic>();
revealBigNummer = output<void>();
/** The BIG-nummer arrives masked (contains `*`); once revealed the swapped value has
no `*`, so the reveal action hides itself — no separate "revealed" flag needed. */
protected isMasked = computed(() => this.caseContext().bigNummer.includes('*'));
/** Step-up (PRD-0002 §5d) stubbed as a native confirm — the extra verification gesture
before an audited PII reveal. ponytail: real systems prompt MFA / recent re-auth. */
protected onReveal() {
if (confirm(this.stepUpPrompt())) this.revealBigNummer.emit();
}
private previewDialog = viewChild<ElementRef<HTMLDialogElement>>('previewDialog');
protected status = computed(() => this.brief().status.tag);
protected rejectComments = computed(() => {
const s = this.brief().status;
return s.tag === 'rejected' ? s.comments : '';
});
/** The besluit + redenen the letter currently reflects, read back off the kern's
passages — this seeds the panel so it survives reload/undo (no separate storage). */
protected selection = computed(() => {
const kern = this.brief().sections.find((s) => s.sectionKey === 'kern');
return inferSelection(kern?.blocks ?? [], this.availablePassages());
});
// Same insert menu as the composer: only valid, fillable, non-deprecated fields.
protected menu = computed<PlaceholderOption[]>(() =>
this.brief()
.placeholders.filter((p) => p.fillable !== false && !p.deprecated)
.map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),
);
/** Besluit/redenen changed → recompose the kern as one edit (= one undo step). */
protected onSelection(sel: { besluit: Besluit | null; reasons: string[] }) {
this.edit.emit({ tag: 'BesluitSelected', besluit: sel.besluit, reasons: sel.reasons });
}
protected openPreview() {
this.previewDialog()?.nativeElement.showModal();
}
protected closePreview() {
this.previewDialog()?.nativeElement.close();
}
protected submitLabel = computed(() =>
this.status() === 'rejected'
? $localize`:@@brief.resubmit:Opnieuw indienen`
: $localize`:@@brief.submit:Indienen ter beoordeling`,
);
protected steps = input<string[]>([
$localize`:@@brief.step.beoordelen:Beoordelen`,
$localize`:@@brief.step.opstellen:Brief opstellen`,
$localize`:@@brief.step.indienen:Indienen`,
]);
protected processName = input($localize`:@@brief.process:Herregistratie behandelen`);
protected stepTitle = input($localize`:@@brief.step.opstellen:Brief opstellen`);
protected caseHeading = input($localize`:@@brief.case.heading:Aanvraag herregistratie`);
protected bigLabel = input($localize`:@@brief.case.big:BIG-nummer`);
protected revealLabel = input($localize`:@@brief.case.reveal:Toon BIG-nummer`);
protected stepUpPrompt = input(
$localize`:@@brief.case.revealConfirm:Extra verificatie vereist. Het tonen van het BIG-nummer wordt vastgelegd. Doorgaan?`,
);
protected previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
protected openDocumentLabel = input(
$localize`:@@brief.preview.openDocument:Openen als document (PDF)`,
);
protected closeLabel = input($localize`:@@common.close:Sluiten`);
protected submitHint = input(
$localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,
);
}
@@ -0,0 +1,104 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { Brief, BriefStatus, CaseContext, LibraryPassage, allDiagnostics } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { BehandelSchermComponent } from './behandel-scherm.component';
const text = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
const orgTemplate: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
footerContact: 'www.bigregister.nl',
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1,
};
const caseContext: CaseContext = {
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
bigNummer: '19012345601',
beroep: 'arts',
aanvraagReferentie: 'HER-2026-000842',
};
const passages: LibraryPassage[] = [
{ passageId: 'p-kern-positief', scope: 'global', sectionKey: 'kern', label: 'Toewijzing', version: 1, besluit: 'positief', content: text('Uw aanvraag is toegewezen.') },
{ passageId: 'p-kern-negatief', scope: 'global', sectionKey: 'kern', label: 'Afwijzing', version: 1, besluit: 'negatief', content: text('Uw aanvraag is afgewezen.') },
{ passageId: 'p-kern-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') },
];
function brief(status: BriefStatus, kernBlocks: Brief['sections'][number]['blocks'] = []): Brief {
return {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
drafterId: 'demo-drafter',
status,
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
],
sections: [
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: true, blocks: [{ type: 'freeText', blockId: 'aanhef-1', content: text('Geachte heer/mevrouw,') }] },
{ sectionKey: 'kern', title: 'Kern van het besluit', required: true, locked: false, blocks: kernBlocks },
{ sectionKey: 'slot', title: 'Slot', required: false, locked: true, blocks: [{ type: 'freeText', blockId: 'slot-1', content: text('Met vriendelijke groet,') }] },
],
};
}
const meta: Meta<BehandelSchermComponent> = {
title: 'Domein/Brief/Behandel Scherm',
component: BehandelSchermComponent,
args: { orgTemplate, caseContext, availablePassages: passages, busy: false },
};
export default meta;
type Story = StoryObj<BehandelSchermComponent>;
/** Fresh case: no besluit chosen yet, so the kern is empty and only the editable
body shows (aanhef/slot appear in the preview). */
export const EmptyKern: Story = {
args: { brief: brief({ tag: 'draft' }), diagnostics: [], canSubmit: false },
};
// A besluit-sourced kern block (carries provenance), so the panel re-seeds itself from it.
const negatiefScholingKern: Brief['sections'][number]['blocks'] = [
{ type: 'passage', blockId: 'local-1', sourcePassageId: 'p-kern-negatief', sourceVersion: 1, edited: false, content: text('Uw aanvraag is afgewezen.') },
{ type: 'passage', blockId: 'local-2', sourcePassageId: 'p-kern-scholing', sourceVersion: 1, edited: false, content: text('Onvoldoende scholing.') },
];
/** Draft with a negatief besluit: the kern is filled from the selection and the besluit
panel reflects it (negatief + "onvoldoende scholing" ticked), read back off the kern. */
export const WithContent: Story = {
render: (args) => {
const b = brief({ tag: 'draft' }, negatiefScholingKern);
return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } };
},
};
/** Field-level PII (PRD-0002 §5c): the case BIG-nummer arrives MASKED, as the server
ships it. The behandelaar holds the reveal capability, so the "Toon BIG-nummer"
action shows — it runs a step-up confirm and an audited server call before unmasking. */
export const MaskedBigNummer: Story = {
args: {
brief: brief({ tag: 'draft' }),
diagnostics: [],
canSubmit: false,
caseContext: { ...caseContext, bigNummer: '********601' },
canRevealBigNummer: true,
},
};
/** Rejected: the drafter reopens; the rejection comments show above the editor. */
export const Rejected: Story = {
render: (args) => {
const b = brief(
{ tag: 'rejected', rejectedBy: 'demo-approver', rejectedAt: '2026-07-01', comments: 'Graag de reden concreter.' },
negatiefScholingKern,
);
return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } };
},
};
@@ -0,0 +1,109 @@
import { Component, computed, input, linkedSignal, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { Besluit, LibraryPassage } from '@brief/domain/brief';
import { redenenFor } from '@brief/domain/besluit';
/** Organism: the guided-drafting selector. The behandelaar picks the besluit
(positief/negatief) and — for a negatief besluit — the reden(en); the kern's
standaardteksten follow the selection LIVE (`selectionChange` → the store recomposes
the kern). This is the "no detective work" step: which passages belong is decided by
the besluit, not by the drafter hunting the library.
ponytail: view-state signals, not a form-machine — no validation/submission of its own;
it just reports the selection. `besluit`/`redenen` inputs re-seed it (via linkedSignal)
from the persisted letter on reload/undo, so it always reflects the letter's real state. */
@Component({
selector: 'app-besluit-panel',
imports: [FormsModule, CheckboxComponent, RadioGroupComponent, HeadingComponent],
styles: [
`
:host {
display: block;
padding: var(--rhc-space-max-lg);
border: 1px solid var(--rhc-color-border);
border-radius: var(--rhc-radius-md, 4px);
background: var(--rhc-color-background-subtle, transparent);
}
.redenen {
display: grid;
gap: var(--rhc-space-max-sm);
margin-block-start: var(--rhc-space-max-md);
}
`,
],
template: `
<app-heading [level]="3">{{ heading() }}</app-heading>
<p class="app-text-subtle">{{ intro() }}</p>
<app-radio-group
name="besluit"
[options]="besluitOptions()"
[ngModel]="selected()"
(ngModelChange)="onBesluit($event)"
/>
@if (redenen().length > 0) {
<div class="redenen" role="group" [attr.aria-label]="redenenLabel()">
@for (r of redenen(); track r.code) {
<app-checkbox
[checkboxId]="'reden-' + r.code"
[label]="r.label"
[ngModel]="checked().has(r.code)"
(ngModelChange)="toggle(r.code, $event)"
/>
}
</div>
}
`,
})
export class BesluitPanelComponent {
/** The passage library — the redenen checkboxes are derived from its negatief tags. */
passages = input<readonly LibraryPassage[]>([]);
/** The letter's current selection, inferred from its kern passages — re-seeds the panel. */
besluit = input<Besluit | null>(null);
initialRedenen = input<readonly string[]>([]);
selectionChange = output<{ besluit: Besluit | null; reasons: string[] }>();
protected selected = linkedSignal<Besluit | ''>(() => this.besluit() ?? '');
protected checked = linkedSignal<ReadonlySet<string>>(() => new Set(this.initialRedenen()));
/** The reden checkboxes for the chosen besluit — derived from the reason-tagged passages. */
protected redenen = computed(() =>
this.selected() === '' ? [] : redenenFor(this.passages(), this.selected() as Besluit),
);
protected onBesluit(value: string) {
this.selected.set(value === 'positief' || value === 'negatief' ? value : '');
this.checked.set(new Set()); // redenen only apply to the chosen besluit
this.emit();
}
protected toggle(code: string, on: boolean) {
const next = new Set(this.checked());
if (on) next.add(code);
else next.delete(code);
this.checked.set(next);
this.emit();
}
private emit() {
this.selectionChange.emit({
besluit: this.selected() === '' ? null : (this.selected() as Besluit),
reasons: [...this.checked()],
});
}
protected besluitOptions = input<RadioOption[]>([
{ value: 'positief', label: $localize`:@@brief.besluit.positief:Positief besluit (toewijzen)` },
{ value: 'negatief', label: $localize`:@@brief.besluit.negatief:Negatief besluit (afwijzen)` },
]);
protected heading = input($localize`:@@brief.besluit.heading:Besluit`);
protected intro = input(
$localize`:@@brief.besluit.intro:Kies het besluit; de juiste standaardteksten verschijnen meteen in de brief.`,
);
protected redenenLabel = input($localize`:@@brief.besluit.redenen:Reden(en) voor afwijzing`);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LibraryPassage } from '@brief/domain/brief';
import { BesluitPanelComponent } from './besluit-panel.component';
const text = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
const passages: LibraryPassage[] = [
{ passageId: 'p-kern-positief', scope: 'global', sectionKey: 'kern', label: 'Toewijzing', version: 1, besluit: 'positief', content: text('Toegewezen.') },
{ passageId: 'p-kern-negatief', scope: 'global', sectionKey: 'kern', label: 'Afwijzing', version: 1, besluit: 'negatief', content: text('Afgewezen.') },
{ passageId: 'p-kern-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') },
{ passageId: 'p-kern-gegevens', scope: 'global', sectionKey: 'kern', label: 'Onjuiste gegevens', version: 1, besluit: 'negatief', reason: 'onjuiste_gegevens', content: text('Onjuiste gegevens.') },
];
const meta: Meta<BesluitPanelComponent> = {
title: 'Domein/Brief/Besluit Panel',
component: BesluitPanelComponent,
args: { passages },
};
export default meta;
type Story = StoryObj<BesluitPanelComponent>;
/** Pick a besluit; a negatief besluit reveals the reason checkboxes. Each change emits
`selectionChange` and the kern's standaardteksten follow live — no generate button. */
export const Default: Story = {};
/** Re-seeded from a persisted letter: a negatief besluit with a reden already ticked. */
export const NegatiefMetReden: Story = {
args: { besluit: 'negatief', initialRedenen: ['onvoldoende_scholing'] },
};
+45 -22
View File
@@ -5,13 +5,21 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/async/async.component';
import { BriefStore } from '@brief/application/brief.store'; import { BriefStore } from '@brief/application/brief.store';
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component'; import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component';
/** Page: thin container. Injects the root store, kicks off the load, and passes its /** Page: thin container. Injects the root store, kicks off the load, and passes its
derived read-model to the composer. Business/UI logic lives below in pure pieces; derived read-model to the composer. Business/UI logic lives below in pure pieces;
this just wires signals to the organism and events back to store commands. */ this just wires signals to the organism and events back to store commands. */
@Component({ @Component({
selector: 'app-brief-page', selector: 'app-brief-page',
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent], imports: [
PageShellComponent,
AlertComponent,
ButtonComponent,
...ASYNC,
LetterComposerComponent,
BehandelSchermComponent,
],
host: { '(document:keydown)': 'onKey($event)' }, host: { '(document:keydown)': 'onKey($event)' },
styles: [ styles: [
` `
@@ -76,27 +84,42 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
resetLabel resetLabel
}}</app-button> }}</app-button>
</div> </div>
<app-letter-composer @if (store.canEdit() && store.caseContext(); as caseContext) {
[brief]="s.brief" <!-- Drafter (behandelaar): guided drafting step in the case workflow. -->
[orgTemplate]="orgTemplate" <app-behandel-scherm
[logoUrl]="store.logoUrl()" [brief]="s.brief"
[availablePassages]="s.availablePassages" [orgTemplate]="orgTemplate"
[diagnostics]="store.diagnostics()" [logoUrl]="store.logoUrl()"
[blockDiffs]="store.blockDiffs()" [availablePassages]="s.availablePassages"
[removedCount]="store.removedSinceReject()" [diagnostics]="store.diagnostics()"
[canEdit]="store.canEdit()" [caseContext]="caseContext"
[canApprove]="store.canApprove()" [canSubmit]="store.canSubmit()"
[canReject]="store.canReject()" [busy]="store.busy()"
[canSend]="store.canSend()" [canRevealBigNummer]="store.canRevealBigNummer()"
[canSubmit]="store.canSubmit()" (edit)="store.edit($event)"
[busy]="store.busy()" (submit)="store.submit()"
(edit)="store.edit($event)" (preview)="store.previewLetter()"
(submit)="store.submit()" (revealBigNummer)="store.revealBigNummer()"
(approve)="store.approve()" />
(reject)="store.reject($event)" } @else {
(send)="store.send()" <!-- Approver / read-only: review + approve/reject/send. -->
(preview)="store.previewLetter()" <app-letter-composer
/> [brief]="s.brief"
[orgTemplate]="orgTemplate"
[logoUrl]="store.logoUrl()"
[diagnostics]="store.diagnostics()"
[blockDiffs]="store.blockDiffs()"
[removedCount]="store.removedSinceReject()"
[canApprove]="store.canApprove()"
[canReject]="store.canReject()"
[canSend]="store.canSend()"
[busy]="store.busy()"
(approve)="store.approve()"
(reject)="store.reject($event)"
(send)="store.send()"
(preview)="store.previewLetter()"
/>
}
} }
} }
</ng-template> </ng-template>
@@ -14,16 +14,13 @@ import {
import { NgTemplateOutlet } from '@angular/common'; import { NgTemplateOutlet } from '@angular/common';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component'; import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { formatDatumNl } from '@shared/kernel/datum'; import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text'; import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock, LibraryPassage } from '@brief/domain/brief'; import { Brief, LetterBlock } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine'; import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine';
import { BlockDiffKind } from '@brief/domain/brief-diff'; import { BlockDiffKind } from '@brief/domain/brief-diff';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */ /** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
type PreviewSegment = { type PreviewSegment = {
@@ -60,7 +57,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
the same file the backend preview renderer inlines (WP-25). */ the same file the backend preview renderer inlines (WP-25). */
@Component({ @Component({
selector: 'app-letter-canvas', selector: 'app-letter-canvas',
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent, LetterSectionComponent], imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
styles: [ styles: [
` `
:host { :host {
@@ -113,17 +110,6 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
.surface .letter { .surface .letter {
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */ box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */
} }
/* Org-identity regions: visibly not the drafter's to edit. */
.from-template {
background: var(--rhc-color-grijs-100);
outline: 2mm solid var(--rhc-color-grijs-100);
}
.from-template-caption {
font-size: 7.5pt;
/* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */
color: var(--rhc-color-grijs-700);
margin: 0 0 2mm;
}
/* Admin edit-in-place (editableRegions='template'): the org-identity fields /* Admin edit-in-place (editableRegions='template'): the org-identity fields
become controls styled to sit in the letter, with a visible editable affordance. */ become controls styled to sit in the letter, with a visible editable affordance. */
.tmpl-input, .tmpl-input,
@@ -208,10 +194,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoomLevel()"> <div class="letter" #page [style]="marginStyle()" [style.zoom]="zoomLevel()">
<!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements <!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
(robijn footer background) — the letter surface must stay letter.css-only. --> (robijn footer background) — the letter surface must stay letter.css-only. -->
<div class="letter__letterhead" [class.from-template]="tintTemplate()"> <div class="letter__letterhead">
@if (tintTemplate()) {
<p class="from-template-caption">{{ fromTemplateCaption() }}</p>
}
@if (logoUrl()) { @if (logoUrl()) {
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" /> <img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
} }
@@ -247,31 +230,18 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
</div> </div>
<div class="letter__body"> <div class="letter__body">
@if (editableRegions() === 'content') { @for (section of brief().sections; track section.sectionKey) {
@for (section of brief().sections; track section.sectionKey) { <section>
<section> <h3>{{ section.title }}</h3>
<app-letter-section @for (block of section.blocks; track block.blockId) {
[section]="section" @let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
[availablePassages]="availablePassages()" <div class="diff-block" [class.diff-changed]="!!diffKind">
[placeholders]="placeholders()" @if (diffKind) {
[editable]="!section.locked" <span class="diff-badge" [class.added]="diffKind === 'added'">{{
(edit)="edit.emit($event)" diffLabel(diffKind)
/> }}</span>
</section> }
} @for (seg of segmentsOf(block); track $index) {
} @else {
@for (section of brief().sections; track section.sectionKey) {
<section>
<h3>{{ section.title }}</h3>
@for (block of section.blocks; track block.blockId) {
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
<div class="diff-block" [class.diff-changed]="!!diffKind">
@if (diffKind) {
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
diffLabel(diffKind)
}}</span>
}
@for (seg of segmentsOf(block); track $index) {
@if (seg.list === 'bullet') { @if (seg.list === 'bullet') {
<ul> <ul>
@for (para of seg.items; track $index) { @for (para of seg.items; track $index) {
@@ -302,15 +272,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
/> />
</p> </p>
} }
} }
</div> </div>
} }
</section> </section>
}
} }
</div> </div>
<div class="letter__signature" [class.from-template]="tintTemplate()"> <div class="letter__signature">
@if (editing()) { @if (editing()) {
<input <input
class="tmpl-input" class="tmpl-input"
@@ -337,7 +306,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
} }
</div> </div>
<div class="letter__footer" [class.from-template]="tintTemplate()"> <div class="letter__footer">
@if (editing()) { @if (editing()) {
<textarea <textarea
class="tmpl-textarea footer-contact" class="tmpl-textarea footer-contact"
@@ -370,11 +339,9 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
export class LetterCanvasComponent { export class LetterCanvasComponent {
brief = input.required<Brief>(); brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>(); orgTemplate = input.required<OrgTemplate>();
/** Who edits what on the surface: drafter ('content'), read-only ('none'), /** Who edits what on the surface: read-only ('none', the drafter preview + approver
admin editor ('template', consumer arrives in WP-26). */ view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */
editableRegions = input<'content' | 'template' | 'none'>('none'); editableRegions = input<'template' | 'none'>('none');
availablePassages = input<readonly LibraryPassage[]>([]);
placeholders = input<readonly PlaceholderOption[]>([]);
diagnostics = input<readonly Diagnostic[]>([]); diagnostics = input<readonly Diagnostic[]>([]);
/** Initial zoom; the in-canvas controls take over from here (WP-27). */ /** Initial zoom; the in-canvas controls take over from here (WP-27). */
zoom = input(1); zoom = input(1);
@@ -385,15 +352,11 @@ export class LetterCanvasComponent {
showDiff = input(false); showDiff = input(false);
/** The org logo's content URL (letterhead), or null when none is set. */ /** The org logo's content URL (letterhead), or null when none is set. */
logoUrl = input<string | null>(null); logoUrl = input<string | null>(null);
edit = output<BriefMsg>();
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */ /** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
templateEdit = output<{ field: OrgTemplateTextField; value: string }>(); templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`); showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`); hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
fromTemplateCaption = input(
$localize`:@@brief.canvas.fromTemplate:Komt uit de huisstijl van de organisatie — niet bewerkbaar in de brief.`,
);
pageBreakCaption = input( pageBreakCaption = input(
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`, $localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
); );
@@ -430,9 +393,6 @@ export class LetterCanvasComponent {
protected diffLabel = (kind: BlockDiffKind) => protected diffLabel = (kind: BlockDiffKind) =>
kind === 'added' ? this.addedLabel() : this.changedLabel(); kind === 'added' ? this.addedLabel() : this.changedLabel();
/** The letterhead/signature/footer are tinted "not yours" only while composing —
in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */
protected tintTemplate = computed(() => this.editableRegions() === 'content');
/** Admin edit-in-place: the org-identity regions render as controls. */ /** Admin edit-in-place: the org-identity regions render as controls. */
protected editing = computed(() => this.editableRegions() === 'template'); protected editing = computed(() => this.editableRegions() === 'template');
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { Brief, LibraryPassage, allDiagnostics } from '@brief/domain/brief'; import { Brief, allDiagnostics } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { LetterCanvasComponent } from './letter-canvas.component'; import { LetterCanvasComponent } from './letter-canvas.component';
@@ -16,17 +16,6 @@ const orgTemplate: OrgTemplate = {
version: 1, version: 1,
}; };
const passages: LibraryPassage[] = [
{
passageId: 'p1',
scope: 'global',
sectionKey: 'kern',
label: 'Standaard toelichting',
version: 1,
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Standaardtekst.' }] }] },
},
];
const brief: Brief = { const brief: Brief = {
briefId: 'b1', briefId: 'b1',
beroep: 'arts', beroep: 'arts',
@@ -124,19 +113,14 @@ const meta: Meta<LetterCanvasComponent> = {
args: { args: {
brief, brief,
orgTemplate, orgTemplate,
availablePassages: passages,
placeholders: brief.placeholders,
diagnostics: allDiagnostics(brief), diagnostics: allDiagnostics(brief),
}, },
}; };
export default meta; export default meta;
type Story = StoryObj<LetterCanvasComponent>; type Story = StoryObj<LetterCanvasComponent>;
/** Drafter: content blocks editable in place; org-identity regions tinted read-only. */ /** Read-only rendered letter: the drafter's preview modal and the approver view, with the
export const ContentMode: Story = { args: { editableRegions: 'content' } }; sample-values toggle and diagnostic placeholder chips (absorbs the old Letter Preview). */
/** Approver/locked: the identical surface fully read-only, with the sample-values
toggle and diagnostic placeholder chips (absorbs the old Letter Preview). */
export const ReadOnly: Story = { args: { editableRegions: 'none' } }; export const ReadOnly: Story = { args: { editableRegions: 'none' } };
export const ReadOnlyZonderBevindingen: Story = { export const ReadOnlyZonderBevindingen: Story = {
@@ -3,11 +3,9 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component'; import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import { Brief } from '@brief/domain/brief';
import { Brief, LibraryPassage } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine';
import { BlockDiffKind } from '@brief/domain/brief-diff'; import { BlockDiffKind } from '@brief/domain/brief-diff';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component'; import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component'; import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
@@ -90,13 +88,10 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
[brief]="brief()" [brief]="brief()"
[orgTemplate]="orgTemplate()" [orgTemplate]="orgTemplate()"
[logoUrl]="logoUrl()" [logoUrl]="logoUrl()"
[editableRegions]="canEdit() ? 'content' : 'none'" [editableRegions]="'none'"
[availablePassages]="availablePassages()"
[placeholders]="menu()"
[diagnostics]="diagnostics()" [diagnostics]="diagnostics()"
[blockDiffs]="blockDiffs()" [blockDiffs]="blockDiffs()"
[showDiff]="showDiff()" [showDiff]="showDiff()"
(edit)="edit.emit($event)"
/> />
<div class="panel"> <div class="panel">
@@ -105,29 +100,6 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<div class="bar"> <div class="bar">
@switch (status()) { @switch (status()) {
@case ('draft') {
@if (canEdit()) {
<app-button
variant="primary"
[disabled]="!canSubmit() || busy()"
(click)="submit.emit()"
>{{ submitLabel() }}</app-button
>
@if (!canSubmit()) {
<span class="app-text-subtle">{{ submitHint() }}</span>
}
}
}
@case ('rejected') {
@if (canEdit()) {
<app-button
variant="primary"
[disabled]="!canSubmit() || busy()"
(click)="submit.emit()"
>{{ resubmitLabel() }}</app-button
>
}
}
@case ('submitted') { @case ('submitted') {
@if (canApprove() || canReject()) { @if (canApprove() || canReject()) {
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{ <app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{
@@ -156,13 +128,10 @@ export class LetterComposerComponent {
brief = input.required<Brief>(); brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>(); orgTemplate = input.required<OrgTemplate>();
logoUrl = input<string | null>(null); logoUrl = input<string | null>(null);
availablePassages = input<readonly LibraryPassage[]>([]);
diagnostics = input<readonly Diagnostic[]>([]); diagnostics = input<readonly Diagnostic[]>([]);
canEdit = input(false);
canApprove = input(false); canApprove = input(false);
canReject = input(false); canReject = input(false);
canSend = input(false); canSend = input(false);
canSubmit = input(false);
busy = input(false); busy = input(false);
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The /** Rejection diff (WP-27): the changed/added/removed blocks and their count. The
"Toon wijzigingen" toggle only appears when there's something to show. */ "Toon wijzigingen" toggle only appears when there's something to show. */
@@ -171,8 +140,6 @@ export class LetterComposerComponent {
protected hasRejectionDiff = computed(() => this.blockDiffs().size > 0); protected hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
protected showDiff = signal(false); protected showDiff = signal(false);
edit = output<BriefMsg>();
submit = output<void>();
approve = output<void>(); approve = output<void>();
reject = output<string>(); reject = output<string>();
send = output<void>(); send = output<void>();
@@ -187,11 +154,6 @@ export class LetterComposerComponent {
() => () =>
$localize`:@@brief.diff.removed:${this.removedCount()}:count: blok(ken) verwijderd sinds afwijzing.`, $localize`:@@brief.diff.removed:${this.removedCount()}:count: blok(ken) verwijderd sinds afwijzing.`,
); );
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
submitHint = input(
$localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,
);
approveLabel = input($localize`:@@brief.approve:Goedkeuren`); approveLabel = input($localize`:@@brief.approve:Goedkeuren`);
sendLabel = input($localize`:@@brief.send:Versturen`); sendLabel = input($localize`:@@brief.send:Versturen`);
awaitingText = input( awaitingText = input(
@@ -205,14 +167,6 @@ export class LetterComposerComponent {
return s.tag === 'rejected' ? s.comments : ''; return s.tag === 'rejected' ? s.comments : '';
}); });
// The insert menu offers only valid, fillable, non-deprecated fields — inserting an
// unknown/retired key is structurally impossible.
protected menu = computed<PlaceholderOption[]>(() =>
this.brief()
.placeholders.filter((p) => p.fillable !== false && !p.deprecated)
.map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),
);
protected statusLabel = computed(() => { protected statusLabel = computed(() => {
switch (this.status()) { switch (this.status()) {
case 'draft': case 'draft':
@@ -121,15 +121,13 @@ const render = (b: Brief, decisions: BriefDecisions) => ({
props: { props: {
brief: b, brief: b,
orgTemplate, orgTemplate,
availablePassages: passages,
diagnostics: allDiagnostics(b), diagnostics: allDiagnostics(b),
...decisions, ...decisions,
canSubmit: true,
busy: false, busy: false,
}, },
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate" [availablePassages]="availablePassages" template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate"
[diagnostics]="diagnostics" [canEdit]="canEdit" [canApprove]="canApprove" [canReject]="canReject" [diagnostics]="diagnostics" [canApprove]="canApprove" [canReject]="canReject"
[canSend]="canSend" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`, [canSend]="canSend" [busy]="busy"></app-letter-composer>`,
}); });
const meta: Meta<LetterComposerComponent> = { const meta: Meta<LetterComposerComponent> = {
@@ -139,15 +137,6 @@ const meta: Meta<LetterComposerComponent> = {
export default meta; export default meta;
type Story = StoryObj<LetterComposerComponent>; type Story = StoryObj<LetterComposerComponent>;
export const DraftDrafter: Story = {
render: () =>
render(brief({ tag: 'draft' }), {
canEdit: true,
canApprove: false,
canReject: false,
canSend: false,
}),
};
export const SubmittedApprover: Story = { export const SubmittedApprover: Story = {
render: () => render: () =>
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), { render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
@@ -155,19 +144,18 @@ export const SubmittedApprover: Story = {
canApprove: true, canApprove: true,
canReject: true, canReject: true,
canSend: false, canSend: false,
canRevealBigNummer: false,
}), }),
}; };
export const Rejected: Story = { export const ApprovedSender: Story = {
render: () => render: () =>
render( render(brief({ tag: 'approved', approvedBy: 'demo-approver', approvedAt: '2026-07-01' }), {
brief({ canEdit: false,
tag: 'rejected', canApprove: false,
rejectedBy: 'demo-approver', canReject: false,
rejectedAt: '2026-07-01', canSend: true,
comments: 'Graag de aanhef formeler.', canRevealBigNummer: false,
}), }),
{ canEdit: true, canApprove: false, canReject: false, canSend: false },
),
}; };
export const Sent: Story = { export const Sent: Story = {
render: () => render: () =>
@@ -176,5 +164,6 @@ export const Sent: Story = {
canApprove: false, canApprove: false,
canReject: false, canReject: false,
canSend: false, canSend: false,
canRevealBigNummer: false,
}), }),
}; };
@@ -0,0 +1,40 @@
import { Component, computed, input, output } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { Brief } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
/** Organism: the lean authoring surface — just the editable letter sections and their
add/edit controls, no letterhead/signature/footer/zoom. The full rendered letter
(including the locked aanhef/slot) lives in the preview modal (see behandel-scherm),
so the drafter stays focused on composing the body they actually own. */
@Component({
selector: 'app-letter-editor',
imports: [LetterSectionComponent],
styles: [
`
:host {
display: grid;
gap: var(--rhc-space-max-xl);
}
`,
],
template: `
@for (section of editableSections(); track section.sectionKey) {
<app-letter-section
[section]="section"
[placeholders]="placeholders()"
[editable]="true"
(edit)="edit.emit($event)"
/>
}
`,
})
export class LetterEditorComponent {
brief = input.required<Brief>();
placeholders = input<readonly PlaceholderOption[]>([]);
edit = output<BriefMsg>();
/** Only unlocked sections are authored here; locked aanhef/slot appear in the preview. */
protected editableSections = computed(() => this.brief().sections.filter((s) => !s.locked));
}
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { Brief } from '@brief/domain/brief';
import { LetterEditorComponent } from './letter-editor.component';
const brief: Brief = {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
drafterId: 'demo-drafter',
status: { tag: 'draft' },
placeholders: [{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false }],
sections: [
{
sectionKey: 'aanhef',
title: 'Aanhef',
required: true,
locked: true,
blocks: [
{
type: 'freeText',
blockId: 'aanhef-1',
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
},
],
},
{
sectionKey: 'kern',
title: 'Kern van het besluit',
required: true,
locked: false,
blocks: [
{
type: 'freeText',
blockId: 'kern-1',
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Wij hebben besloten...' }] }] },
},
],
},
],
};
const meta: Meta<LetterEditorComponent> = {
title: 'Domein/Brief/Letter Editor',
component: LetterEditorComponent,
args: { brief, placeholders: [] },
};
export default meta;
type Story = StoryObj<LetterEditorComponent>;
/** The lean authoring surface: only the editable sections (the kern); the locked
aanhef/slot are hidden here and appear only in the preview. */
export const Default: Story = {};
/** Empty kern: shows the empty-state hint plus the free-text action, no starter content. */
export const EmptyKern: Story = {
args: {
brief: {
...brief,
sections: brief.sections.map((s) => (s.sectionKey === 'kern' ? { ...s, blocks: [] } : s)),
},
},
};
@@ -1,19 +1,19 @@
import { Component, computed, input, output, signal } from '@angular/core'; import { Component, input, output } from '@angular/core';
import { RichTextBlock } from '@shared/kernel/rich-text'; import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LetterSection, LibraryPassage } from '@brief/domain/brief'; import { LetterSection } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine'; import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component'; import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.component';
/** Organism: one template section — its ordered blocks plus (when editable) the /** Organism: one template section — its ordered blocks plus (when editable) the
add-passages and add-free-text actions. Maps child events to `BriefMsg`s; sections add-free-text action. Standaardteksten enter the kern via the besluit panel, not a
themselves can never be added/removed/reordered (no message exists for it). */ per-section picker, so this only offers free text. Maps child events to `BriefMsg`s;
sections themselves can never be added/removed/reordered (no message exists for it). */
@Component({ @Component({
selector: 'app-letter-section', selector: 'app-letter-section',
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent, PassagePickerComponent], imports: [ButtonComponent, HeadingComponent, LetterBlockComponent],
styles: [ styles: [
` `
:host { :host {
@@ -63,58 +63,24 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
@if (editable()) { @if (editable()) {
<div class="actions"> <div class="actions">
@if (showStandardLetter()) {
<app-button variant="primary" (click)="insertStandardLetter()">{{
standardLetterLabel()
}}</app-button>
}
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{
addPassageLabel()
}}</app-button>
<app-button <app-button
variant="subtle" variant="subtle"
(click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })" (click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })"
>{{ addFreeLabel() }}</app-button >{{ addFreeLabel() }}</app-button
> >
</div> </div>
@if (pickerOpen()) {
<app-passage-picker [passages]="sectionPassages()" (insert)="onInsert($event)" />
}
} }
`, `,
}) })
export class LetterSectionComponent { export class LetterSectionComponent {
section = input.required<LetterSection>(); section = input.required<LetterSection>();
availablePassages = input<readonly LibraryPassage[]>([]);
placeholders = input<readonly PlaceholderOption[]>([]); placeholders = input<readonly PlaceholderOption[]>([]);
editable = input(false); editable = input(false);
edit = output<BriefMsg>(); edit = output<BriefMsg>();
requiredLabel = input($localize`:@@brief.section.required:verplicht`); requiredLabel = input($localize`:@@brief.section.required:verplicht`);
emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`); emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`);
addPassageLabel = input($localize`:@@brief.section.addPassage:Standaardtekst toevoegen`);
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`); addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
standardLetterLabel = input($localize`:@@brief.section.standardLetter:Standaardbrief invoegen`);
protected pickerOpen = signal(false);
protected sectionPassages = computed(() =>
this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey),
);
/** The "standaardbrief" starter set for this section (server-flagged defaults). */
protected defaultPassages = computed(() => this.sectionPassages().filter((p) => p.isDefault));
/** One-click starter, offered only while the section is still empty and defaults exist. */
protected showStandardLetter = computed(
() => this.section().blocks.length === 0 && this.defaultPassages().length > 0,
);
protected insertStandardLetter() {
// One Msg → one undo step (see brief.store `edit`).
this.edit.emit({
tag: 'PassagesInserted',
sectionKey: this.section().sectionKey,
passages: this.defaultPassages(),
});
}
protected onContent(blockId: string, content: RichTextBlock) { protected onContent(blockId: string, content: RichTextBlock) {
this.edit.emit({ tag: 'BlockContentEdited', blockId, content }); this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
@@ -124,9 +90,4 @@ export class LetterSectionComponent {
const i = this.section().blocks.findIndex((b) => b.blockId === blockId); const i = this.section().blocks.findIndex((b) => b.blockId === blockId);
this.edit.emit({ tag: 'BlockMovedWithinSection', blockId, toIndex: i + direction }); this.edit.emit({ tag: 'BlockMovedWithinSection', blockId, toIndex: i + direction });
} }
protected onInsert(passages: LibraryPassage[]) {
this.edit.emit({ tag: 'PassagesInserted', sectionKey: this.section().sectionKey, passages });
this.pickerOpen.set(false);
}
} }
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { LetterSection, LibraryPassage } from '@brief/domain/brief'; import { LetterSection } from '@brief/domain/brief';
import { LetterSectionComponent } from './letter-section.component'; import { LetterSectionComponent } from './letter-section.component';
const section: LetterSection = { const section: LetterSection = {
@@ -28,30 +28,17 @@ const section: LetterSection = {
const emptySection: LetterSection = { ...section, blocks: [] }; const emptySection: LetterSection = { ...section, blocks: [] };
const passages: LibraryPassage[] = [
{
passageId: 'p2',
scope: 'beroep',
beroep: 'arts',
sectionKey: 'kern',
label: 'Toelichting arts',
version: 1,
isDefault: true, // part of the standaardbrief starter set (WP-27)
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] },
},
];
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }]; const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
const meta: Meta<LetterSectionComponent> = { const meta: Meta<LetterSectionComponent> = {
title: 'Domein/Brief/Letter Section', title: 'Domein/Brief/Letter Section',
component: LetterSectionComponent, component: LetterSectionComponent,
args: { section, availablePassages: passages, placeholders, edit: () => {} }, args: { section, placeholders, edit: () => {} },
}; };
export default meta; export default meta;
type Story = StoryObj<LetterSectionComponent>; type Story = StoryObj<LetterSectionComponent>;
export const ReadOnly: Story = { args: { editable: false } }; export const ReadOnly: Story = { args: { editable: false } };
export const Editable: Story = { args: { editable: true } }; export const Editable: Story = { args: { editable: true } };
/** Empty section: the one-click "Standaardbrief invoegen" starter appears (WP-27). */ /** Empty section: shows the empty-state hint plus the free-text action. */
export const EditableEmpty: Story = { args: { section: emptySection, editable: true } }; export const EditableEmpty: Story = { args: { section: emptySection, editable: true } };
@@ -136,9 +136,13 @@ describe('submit', () => {
expect((withScholing as any).data.punten).toBe(200); expect((withScholing as any).data.punten).toBe(200);
}); });
it('resolve maps Submitting to Submitted / Failed', () => { it('resolve maps Submitting to Submitted on a successful submit', () => {
const submitting = submit(answering(complete)); const submitting = submit(answering(complete));
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted'); expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
});
it('resolve maps Submitting to Failed on a failed submit', () => {
const submitting = submit(answering(complete));
expect(resolve(submitting, err('boom')).tag).toBe('Failed'); expect(resolve(submitting, err('boom')).tag).toBe('Failed');
}); });
}); });
@@ -21,7 +21,7 @@ type Err = Error | undefined;
* The dashboard data now comes from ONE screen-shaped ("BFF-lite") call that * The dashboard data now comes from ONE screen-shaped ("BFF-lite") call that
* returns registration + person + server-computed `decisions`. One request → one * returns registration + person + server-computed `decisions`. One request → one
* consistent snapshot, instead of stitching three independently loading/erroring * consistent snapshot, instead of stitching three independently loading/erroring
* resources together client-side. See docs/architecture/0001-bff-lite-decision-dtos.md. * resources together client-side. See docs/reference/architecture/0001-bff-lite-decision-dtos.md.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class BigProfileStore { export class BigProfileStore {
@@ -3,7 +3,7 @@
* *
* In production this is GENERATED from the OpenAPI/TypeSpec spec and served by our * In production this is GENERATED from the OpenAPI/TypeSpec spec and served by our
* own backend, which talks to the BRP behind an adapter. The frontend never sees * own backend, which talks to the BRP behind an adapter. The frontend never sees
* the BRP's own wire format. See docs/architecture/0001-bff-lite-decision-dtos.md. * the BRP's own wire format. See docs/reference/architecture/0001-bff-lite-decision-dtos.md.
* *
* "Geen adres bekend" is a first-class outcome (`gevonden: false`), not an error — * "Geen adres bekend" is a first-class outcome (`gevonden: false`), not an error —
* the wizard falls back to manual entry (PRD §7). Slice 1 ships only the happy * the wizard falls back to manual entry (PRD §7). Slice 1 ships only the happy
@@ -10,7 +10,7 @@
* In production these types are GENERATED from the OpenAPI/TypeSpec spec (one * In production these types are GENERATED from the OpenAPI/TypeSpec spec (one
* source of truth for both sides), and the `decisions` block is computed BY THE * source of truth for both sides), and the `decisions` block is computed BY THE
* BACKEND — never recomputed on the client. The frontend renders decisions; it * BACKEND — never recomputed on the client. The frontend renders decisions; it
* does not own the rules. See docs/architecture/0001-bff-lite-decision-dtos.md. * does not own the rules. See docs/reference/architecture/0001-bff-lite-decision-dtos.md.
* *
* One screen-shaped call replaces the previous three (BIG-register + BRP + …), * One screen-shaped call replaces the previous three (BIG-register + BRP + …),
* so the page always sees one consistent snapshot instead of three independently * so the page always sees one consistent snapshot instead of three independently
@@ -34,15 +34,27 @@ describe('change-request reduce', () => {
expect((s as Extract<ChangeRequestState, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA'); expect((s as Extract<ChangeRequestState, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA');
}); });
it('confirms and fails only from Submitting; Retry re-submits a failure', () => { it('SubmitConfirmed maps Submitting to Submitted with the referentie', () => {
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), { const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), {
tag: 'Submit', tag: 'Submit',
}); });
const ok = reduce(submitting, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-1' }); const ok = reduce(submitting, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-1' });
expect(ok).toMatchObject({ tag: 'Submitted', referentie: 'BIG-2026-1' }); expect(ok).toMatchObject({ tag: 'Submitted', referentie: 'BIG-2026-1' });
});
it('SubmitFailed maps Submitting to Failed with the error', () => {
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), {
tag: 'Submit',
});
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' }); const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' }); expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
});
it('Retry re-submits a failure', () => {
const submitting = reduce(editingWith({ straat: 'A 1', postcode: '2514 EA' }), {
tag: 'Submit',
});
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting'); expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
}); });
@@ -187,8 +187,11 @@ describe('manual diploma fallback', () => {
}); });
describe('submit', () => { describe('submit', () => {
it('reaches Indienen ONLY with a complete, valid draft', () => { it('stays in Invullen when the draft is incomplete (no diploma)', () => {
expect(submit(invullen(validAdres)).tag).toBe('Invullen'); // no diploma expect(submit(invullen(validAdres)).tag).toBe('Invullen');
});
it('reaches Indienen with a complete, valid draft, carrying its data', () => {
const good = submit(invullen(validDraft)); const good = submit(invullen(validDraft));
expect(good.tag).toBe('Indienen'); expect(good.tag).toBe('Indienen');
expect((good as any).data.beroep).toBe('Arts'); expect((good as any).data.beroep).toBe('Arts');
@@ -196,11 +199,14 @@ describe('submit', () => {
expect((good as any).data.adresHerkomst).toBe('brp'); expect((good as any).data.adresHerkomst).toBe('brp');
}); });
it('resolve maps Indienen to Ingediend (with referentie) / Mislukt', () => { it('resolve maps Indienen to Ingediend with the referentie', () => {
const indienen = submit(invullen(validDraft)); const ingediend = resolve(submit(invullen(validDraft)), ok('BIG-2026-001'));
expect(resolve(indienen, ok('BIG-2026-001')).tag).toBe('Ingediend'); expect(ingediend.tag).toBe('Ingediend');
expect((resolve(indienen, ok('BIG-2026-001')) as any).referentie).toBe('BIG-2026-001'); expect((ingediend as any).referentie).toBe('BIG-2026-001');
expect(resolve(indienen, err('boom')).tag).toBe('Mislukt'); });
it('resolve maps Indienen to Mislukt on a failed submit', () => {
expect(resolve(submit(invullen(validDraft)), err('boom')).tag).toBe('Mislukt');
}); });
}); });
@@ -225,13 +231,20 @@ describe('reduce (message-driven happy path)', () => {
expect(s.tag).toBe('Ingediend'); expect(s.tag).toBe('Ingediend');
}); });
it('SubmitFailed then Retry returns to Indienen with the same data', () => { it('SubmitFailed moves Indienen to Mislukt', () => {
let s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { const s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
tag: 'SubmitFailed', tag: 'SubmitFailed',
error: 'boom', error: 'boom',
}); });
expect(s.tag).toBe('Mislukt'); expect(s.tag).toBe('Mislukt');
s = reduce(s, { tag: 'Retry' }); });
it('Retry returns Mislukt to Indienen with the same data', () => {
const mislukt = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
tag: 'SubmitFailed',
error: 'boom',
});
const s = reduce(mislukt, { tag: 'Retry' });
expect(s.tag).toBe('Indienen'); expect(s.tag).toBe('Indienen');
expect((s as any).data.beroep).toBe('Arts'); expect((s as any).data.beroep).toBe('Arts');
}); });
@@ -25,7 +25,7 @@ const meta: Meta<AanvraagBlockComponent> = {
parameters: { parameters: {
// Structural: app-aanvraag-block's host sits between the keuzelijst <ul> and its <li> // Structural: app-aanvraag-block's host sits between the keuzelijst <ul> and its <li>
// — axe's list/listitem rule needs them adjacent regardless of `display:contents`. // — axe's list/listitem rule needs them adjacent regardless of `display:contents`.
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/backlog/WP-11-markup-fidelity.md. // WP-11 (CIBG markup fidelity) reworks this markup; see docs/project/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true }, a11y: { disable: true },
}, },
}; };
@@ -19,14 +19,11 @@ export type AdresErrors = Partial<Record<keyof AdresValue, string>>;
@Component({ @Component({
selector: 'app-address-fields', selector: 'app-address-fields',
imports: [FormsModule, FormFieldComponent, TextInputComponent], imports: [FormsModule, FormFieldComponent, TextInputComponent],
// No scoped `fieldset` reset here: the fieldset must keep CIBG's
// `.form-horizontal fieldset` grey-box padding/margin. A local `fieldset{padding:0}`
// would tie on specificity and (injected later) win, flattening the padding.
styles: [ styles: [
` `
fieldset {
border: 0;
margin: 0;
padding: 0;
min-inline-size: 0;
}
legend { legend {
padding: 0; padding: 0;
font-weight: var(--rhc-text-font-weight-semi-bold); font-weight: var(--rhc-text-font-weight-semi-bold);
@@ -7,6 +7,7 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component'; import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component'; import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { import {
@@ -67,6 +68,7 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
AlertComponent, AlertComponent,
SkeletonComponent, SkeletonComponent,
DataRowComponent, DataRowComponent,
DataBlockComponent,
ReviewSectionComponent, ReviewSectionComponent,
ConfirmationComponent, ConfirmationComponent,
WizardShellComponent, WizardShellComponent,
@@ -216,14 +218,14 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
</app-form-field> </app-form-field>
</fieldset> </fieldset>
} @else if (draft().beroep) { } @else if (draft().beroep) {
<dl class="mb-0 app-section"> <app-data-block class="app-section">
<div <div
app-data-row app-data-row
i18n-key="@@regWizard.beroepAfgeleid" i18n-key="@@regWizard.beroepAfgeleid"
key="Beroep (afgeleid uit diploma)" key="Beroep (afgeleid uit diploma)"
[value]="draft().beroep ?? ''" [value]="draft().beroep ?? ''"
></div> ></div>
</dl> </app-data-block>
} }
@if (actieveVragen(data).length) { @if (actieveVragen(data).length) {
@@ -1579,6 +1579,7 @@ export interface BriefDecisionsDto {
canApprove?: boolean; canApprove?: boolean;
canReject?: boolean; canReject?: boolean;
canSend?: boolean; canSend?: boolean;
canRevealBigNummer?: boolean;
} }
export interface BriefDto { export interface BriefDto {
@@ -1608,6 +1609,7 @@ export interface BriefViewDto {
availablePassages?: LibraryPassageDto[] | undefined; availablePassages?: LibraryPassageDto[] | undefined;
decisions?: BriefDecisionsDto; decisions?: BriefDecisionsDto;
orgTemplate?: OrgTemplateDto; orgTemplate?: OrgTemplateDto;
caseContext?: CaseContextDto;
} }
export interface BrpAddressDto { export interface BrpAddressDto {
@@ -1615,6 +1617,13 @@ export interface BrpAddressDto {
adres?: AdresDto; adres?: AdresDto;
} }
export interface CaseContextDto {
zorgverlenerNaam?: string | undefined;
bigNummer?: string | undefined;
beroep?: string | undefined;
aanvraagReferentie?: string | undefined;
}
export interface ChangeRequestRequest { export interface ChangeRequestRequest {
straat?: string | undefined; straat?: string | undefined;
postcode?: string | undefined; postcode?: string | undefined;
@@ -1713,6 +1722,8 @@ export interface LibraryPassageDto {
version?: number; version?: number;
beroep?: string | undefined; beroep?: string | undefined;
isDefault?: boolean; isDefault?: boolean;
besluit?: string | undefined;
reason?: string | undefined;
} }
export interface ManualDiplomaPolicyDto { export interface ManualDiplomaPolicyDto {
@@ -91,7 +91,7 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
</div> </div>
</div> </div>
<!-- Wizard pages wrap their field groups in <fieldset>s; CIBG's <!-- Wizard pages wrap their field groups in <fieldset>s; CIBG's
".form-horizontal fieldset" gives each a grey #f1f5f9 surface with a 1.25em ".form-horizontal fieldset" gives each a grey #f1f5f9 surface with a 1.25em token-ok: hex named in prose, not a style value
gap. The shell stays group-agnostic and does NOT add its own fieldset (an gap. The shell stays group-agnostic and does NOT add its own fieldset (an
outer grey fieldset would hide the white gaps between the page groups). --> outer grey fieldset would hide the white gaps between the page groups). -->
<ng-content /> <ng-content />
@@ -48,6 +48,9 @@ import { RouterLink } from '@angular/router';
<ng-content select="[applicationActions]" /> <ng-content select="[applicationActions]" />
<ng-template #body> <ng-template #body>
<div class="content"> <div class="content">
<!-- Raw <h3>, not <app-heading>: the vendored ".applications li a h3" chain styles
the bare h3 (link-blue); an app-heading host wrapper would sit between and can
break that selector. Documented in atomic-design.mdx (convergence decisions). -->
<h3 class="h3">{{ heading() }}</h3> <h3 class="h3">{{ heading() }}</h3>
@if (subtitle()) { @if (subtitle()) {
<div class="subtitle">{{ subtitle() }}</div> <div class="subtitle">{{ subtitle() }}</div>
-45
View File
@@ -1,45 +0,0 @@
import { Component, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
// CIBG-GAP EXTENSION: n/a — no vendored generic-card class; `.app-card` is a
// hand-rolled surface (NOT Bootstrap's `.card`), see cibg-gaps.mdx. Prefer the
// vendored Datablock (WP-12, `app-data-block`) for application/user data.
/** Molecule: a content card. Standardises the repeated card surface (white,
subtle border, rounded, padded) so pages compose cards instead of hand-rolling
a hand-rolled card surface. Optional heading; the rest is projected.
Local class is .app-card (NOT Bootstrap's .card, whose padding-on-body layout differs). */
@Component({
selector: 'app-card',
imports: [HeadingComponent],
styles: [
`
:host {
display: block;
block-size: 100%;
}
.app-card {
background: var(--rhc-color-wit);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-subtle);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-xl);
block-size: 100%;
box-sizing: border-box;
}
.app-card > * + * {
margin-block-start: var(--rhc-space-max-md);
}
`,
],
template: `
<section class="app-card">
@if (heading()) {
<app-heading [level]="level()">{{ heading() }}</app-heading>
}
<ng-content />
</section>
`,
})
export class CardComponent {
heading = input<string>();
level = input<1 | 2 | 3 | 4 | 5>(3);
}
-24
View File
@@ -1,24 +0,0 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { CardComponent } from './card.component';
const meta: Meta<CardComponent> = {
title: 'Design System/Molecules/Card',
component: CardComponent,
render: (args) => ({
props: args,
template: `
<app-card [heading]="heading" [level]="level">
<p class="rhc-paragraph">Een kaart groepeert samenhangende inhoud op een schone, omkaderde vlak.</p>
</app-card>`,
}),
args: { heading: 'Persoonsgegevens (BRP)', level: 3 },
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
};
export default meta;
type Story = StoryObj<CardComponent>;
export const Default: Story = {};
export const ZonderKop: Story = { args: { heading: undefined } };
@@ -1,6 +1,11 @@
import { Component, forwardRef, input } from '@angular/core'; import { Component, computed, forwardRef, input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
// Per-instance fallback ids, so the label's `for` always targets THIS checkbox. The
// CIBG styled checkbox hides the native input and routes clicks through the label, so a
// shared/undefined id silently makes every label toggle the first input — hence a default.
let nextCheckboxId = 0;
/** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Thin /** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Thin
wrapper over the CIBG Huisstijl `.form-check.styled` checkbox CSS; native wrapper over the CIBG Huisstijl `.form-check.styled` checkbox CSS; native
input for full keyboard + screen-reader support. */ input for full keyboard + screen-reader support. */
@@ -11,13 +16,13 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
<input <input
class="form-check-input" class="form-check-input"
type="checkbox" type="checkbox"
[id]="checkboxId()" [id]="resolvedId()"
[checked]="value" [checked]="value"
[disabled]="disabled" [disabled]="disabled"
(change)="onToggle($event)" (change)="onToggle($event)"
(blur)="onTouched()" (blur)="onTouched()"
/> />
<label class="form-check-label" [for]="checkboxId()">{{ label() }}</label> <label class="form-check-label" [for]="resolvedId()">{{ label() }}</label>
</div> </div>
`, `,
providers: [ providers: [
@@ -28,6 +33,10 @@ export class CheckboxComponent implements ControlValueAccessor {
checkboxId = input<string>(); checkboxId = input<string>();
label = input(''); label = input('');
/** The caller's id, or a unique fallback — never undefined, so labels never collide. */
private autoId = `app-checkbox-${nextCheckboxId++}`;
protected resolvedId = computed(() => this.checkboxId() ?? this.autoId);
value = false; value = false;
disabled = false; disabled = false;
onChange: (v: boolean) => void = () => {}; onChange: (v: boolean) => void = () => {};
@@ -15,7 +15,7 @@ const meta: Meta<ChoiceLinkComponent> = {
parameters: { parameters: {
// Structural: app-choice-link's host sits between the <ul> and its <li> — axe's // Structural: app-choice-link's host sits between the <ul> and its <li> — axe's
// list/listitem rule requires them adjacent regardless of `display:contents`. // list/listitem rule requires them adjacent regardless of `display:contents`.
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/backlog/WP-11-markup-fidelity.md. // WP-11 (CIBG markup fidelity) reworks this markup; see docs/project/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true }, a11y: { disable: true },
}, },
}; };

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