feat(WP-67): merge behandelportal into this repo as a monorepo

Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-02 21:01:57 +02:00
co-authored by Claude Sonnet 5
parent d3f3b13345
commit e7156c5132
403 changed files with 7103 additions and 60917 deletions
@@ -0,0 +1,136 @@
import { describe, it, expect } from 'vitest';
import { Besluit, LetterBlock, LibraryPassage } from './brief';
import { besluitGuidance, 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: [] });
});
});
describe('besluitGuidance', () => {
it('positief: counts inserted passages, no reden needed (positief has no redenen)', () => {
expect(besluitGuidance(lib, 'positief', [])).toEqual({ insertedCount: 2, needsReason: false });
});
it('negatief without a reden: flags that a reden must be chosen', () => {
expect(besluitGuidance(lib, 'negatief', [])).toEqual({ insertedCount: 2, needsReason: true });
});
it('negatief with a reden: no longer flags, and the reason passage is counted', () => {
expect(besluitGuidance(lib, 'negatief', ['onvoldoende_scholing'])).toEqual({
insertedCount: 3,
needsReason: false,
});
});
});
+93
View File
@@ -0,0 +1,93 @@
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;
}
/** Visible assistance for the behandelaar on top of the silent auto-insert: how many kern
standaardteksten the current besluit+redenen produced, and whether a reden still needs
choosing (the besluit has reason-specific motivering passages but none is ticked). Pure
DATA — the component maps it to localized copy. */
export interface BesluitGuidance {
readonly insertedCount: number;
readonly needsReason: boolean;
}
export function besluitGuidance(
passages: readonly LibraryPassage[],
besluit: Besluit,
reasons: readonly string[],
): BesluitGuidance {
return {
insertedCount: passagesForBesluit(passages, besluit, reasons).length,
needsReason: redenenFor(passages, besluit).length > 0 && reasons.length === 0,
};
}
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 };
}
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { Brief, LetterBlock } from './brief';
import { diffBlocks, changedBlocks } from './brief-diff';
function block(id: string, text: string): LetterBlock {
return {
type: 'freeText',
blockId: id,
content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] },
};
}
function brief(blocks: LetterBlock[]): Brief {
return {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
placeholders: [],
sections: [{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks }],
status: { tag: 'draft' },
drafterId: 'u1',
};
}
describe('diffBlocks', () => {
it('marks added, removed, changed and unchanged by blockId', () => {
const before = brief([block('local-1', 'a'), block('local-2', 'b'), block('local-3', 'c')]);
const after = brief([block('local-1', 'a'), block('local-2', 'B!'), block('local-4', 'd')]);
const diffs = diffBlocks(before, after);
const byId = new Map(diffs.map((d) => [d.blockId, d.kind]));
expect(byId.get('local-1')).toBe('unchanged');
expect(byId.get('local-2')).toBe('changed');
expect(byId.get('local-3')).toBe('removed'); // gone from after
expect(byId.get('local-4')).toBe('added'); // new in after
});
it('changedBlocks drops unchanged and keeps added/removed/changed', () => {
const before = brief([block('local-1', 'a'), block('local-2', 'b')]);
const after = brief([block('local-1', 'a'), block('local-2', 'B'), block('local-3', 'c')]);
const map = changedBlocks(diffBlocks(before, after));
expect(map.has('local-1')).toBe(false);
expect(map.get('local-2')).toBe('changed');
expect(map.get('local-3')).toBe('added');
});
});
@@ -0,0 +1,50 @@
import { Brief, LetterBlock, allBlocks } from './brief';
/**
* The rejection diff as a PURE function over two immutable `Brief` values — the whole
* teaching payload of WP-27: because state is one value, "what changed since the letter
* was rejected" is just a fold over two snapshots, no change-tracking bookkeeping.
*
* Blocks are matched by `blockId` (stable `local-N`/seed ids):
* - in `after` but not `before` → `added`
* - in `before` but not `after` → `removed`
* - in both, different content → `changed`
* - in both, same content → `unchanged`
*/
export type BlockDiffKind = 'added' | 'removed' | 'changed' | 'unchanged';
export interface BlockDiff {
readonly blockId: string;
readonly kind: BlockDiffKind;
}
/** Content equality by value. Blocks are JSON-shaped immutable trees, so a canonical
stringify is an honest deep-equal here (no functions, no cycles). */
function contentEqual(a: LetterBlock, b: LetterBlock): boolean {
return JSON.stringify(a.content) === JSON.stringify(b.content);
}
export function diffBlocks(before: Brief, after: Brief): BlockDiff[] {
const beforeById = new Map(allBlocks(before).map((b) => [b.blockId, b]));
const afterById = new Map(allBlocks(after).map((b) => [b.blockId, b]));
const out: BlockDiff[] = [];
for (const a of afterById.values()) {
const b = beforeById.get(a.blockId);
out.push({
blockId: a.blockId,
kind: !b ? 'added' : contentEqual(a, b) ? 'unchanged' : 'changed',
});
}
for (const b of beforeById.values()) {
if (!afterById.has(b.blockId)) out.push({ blockId: b.blockId, kind: 'removed' });
}
return out;
}
/** Lookup of only the blocks that changed since rejection (drops `unchanged`), for
badging the canvas. Keyed by `blockId`; removed ids are present too (the caller
surfaces them as a count — a removed block no longer renders inline). */
export function changedBlocks(diffs: readonly BlockDiff[]): ReadonlyMap<string, BlockDiffKind> {
return new Map(diffs.filter((d) => d.kind !== 'unchanged').map((d) => [d.blockId, d.kind]));
}
@@ -0,0 +1,288 @@
import { describe, it, expect } from 'vitest';
import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './brief';
import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderDef } from './placeholders';
import { BriefState, reduce } from './brief.machine';
const placeholders: PlaceholderDef[] = [
{ key: 'naam', label: 'Naam', autoResolvable: true },
{ key: 'reden', label: 'Reden', autoResolvable: false },
];
const text = (t: string): RichTextBlock => ({
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
});
const libPassage = (
id: string,
sectionKey: string,
extra: Partial<LibraryPassage> = {},
): LibraryPassage => ({
passageId: id,
scope: 'global',
sectionKey,
label: `Passage ${id}`,
content: text(`inhoud ${id}`),
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 {
return {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
placeholders,
sections: sections ?? [
{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] },
{ sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
],
status,
drafterId: 'u1',
};
}
// A machine test cares about status transitions, not who may act — a fixed,
// unrestrictive fixture keeps every existing assertion focused on that.
const decisions: BriefDecisions = {
canEdit: true,
canApprove: true,
canReject: true,
canSend: true,
canRevealBigNummer: true,
};
const loaded = (
status: BriefStatus = { tag: 'draft' },
sections?: Brief['sections'],
): BriefState => ({
tag: 'loaded',
brief: briefWith(status, sections),
availablePassages: lib,
decisions,
});
const sectionBlocks = (s: BriefState, key: string) =>
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
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', () => {
it('BriefLoaded moves loading to loaded', () => {
expect(
reduce(initialLoading(), {
tag: 'BriefLoaded',
brief: briefWith({ tag: 'draft' }),
availablePassages: [],
decisions,
}).tag,
).toBe('loaded');
});
it('BriefLoadFailed moves loading to failed with the reason', () => {
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
tag: 'failed',
reason: 'x',
});
});
it('Seed sets the state directly', () => {
const seeded = loaded();
expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded);
});
it('BesluitSelected composes the kern: the besluit passages, in reading order, as frozen local blocks', () => {
const s = reduce(loaded(), besluit('positief'));
const blocks = sectionBlocks(s, 'kern');
expect(blocks.map((b) => b.blockId)).toEqual(['local-1', 'local-2']);
expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true);
expect(passageIds(s, 'kern')).toEqual(['intro', 'pos']);
});
it('BesluitSelected swaps the passages when the selection changes, keeping free text', () => {
let s = reduce(loaded(), besluit('positief'));
s = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' }); // drafter's own remark
s = reduce(s, besluit('negatief', ['r1']));
const blocks = sectionBlocks(s, 'kern');
expect(blocks.map((b) => b.type)).toEqual(['passage', 'passage', 'passage', 'freeText']);
expect(passageIds(s, 'kern')).toEqual(['intro', 'neg', 'neg-r']);
// 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';
const block = sectionBlocks(s, 'kern')[0];
expect(block.content.paragraphs[0].nodes[0]).toEqual({ type: 'text', text: 'inhoud intro' });
});
it('FreeTextBlockAdded appends an empty free-text block', () => {
const s = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
const blocks = sectionBlocks(s, 'kern');
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe('freeText');
});
it('BlockContentEdited replaces content and marks a passage block edited', () => {
let s = reduce(loaded(), besluit('positief'));
s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') });
const block = sectionBlocks(s, 'kern')[0];
expect(block.type === 'passage' && block.edited).toBe(true);
expect(block.content).toEqual(text('aangepast'));
});
it('BlockMovedWithinSection reorders blocks within a section', () => {
let s = reduce(loaded(), besluit('positief')); // local-1 intro, local-2 pos
s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 });
expect(sectionBlocks(s, 'kern').map((b) => b.blockId)).toEqual(['local-2', 'local-1']);
});
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'] = [
{
sectionKey: 'kern',
title: 'Kern',
required: true,
locked: true,
blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }],
},
{ sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
];
const s = loaded({ tag: 'draft' }, lockedSections);
// 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.
expect(reduce(s, besluit('positief'))).toEqual(s);
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' })).toEqual(s);
expect(
reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') }),
).toEqual(s);
expect(reduce(s, { tag: 'BlockRemoved', blockId: 'local-1' })).toEqual(s);
expect(reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 0 })).toEqual(
s,
);
// the unlocked section still accepts edits
const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
expect(sectionBlocks(edited, 'slot')).toHaveLength(1);
});
it('edits are no-ops once submitted (status invariant)', () => {
const s = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' })).toBe(s);
});
it('editing a rejected letter reopens it to draft', () => {
const s = loaded({
tag: 'rejected',
rejectedBy: 'u2',
rejectedAt: 't',
comments: 'graag aanpassen',
});
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft');
expect(sectionBlocks(next, 'slot')).toHaveLength(1);
});
it('Submitted fires only from draft and only when required sections are filled', () => {
// required 'kern' empty → no-op
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't', decisions })).toEqual(loaded());
// fill the required section via the besluit, then submit
const filled = reduce(loaded(), besluit('positief'));
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
tag: 'submitted',
submittedBy: 'u1',
submittedAt: 't',
});
});
it('approve fires only from submitted', () => {
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
// approve from draft is a no-op
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
tag: 'approved',
approvedBy: 'u2',
approvedAt: 't2',
});
});
it('reject fires from submitted, carrying comments', () => {
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
const rejected = reduce(submitted, {
tag: 'Rejected',
by: 'u2',
at: 't2',
comments: 'nee',
decisions,
});
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
tag: 'rejected',
rejectedBy: 'u2',
rejectedAt: 't2',
comments: 'nee',
});
});
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);
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
});
it('a status transition replaces decisions with the fresh server value', () => {
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
const staleApprover: BriefDecisions = {
canEdit: false,
canApprove: false,
canReject: false,
canSend: false,
canRevealBigNummer: false,
};
const approved = reduce(submitted, {
tag: 'Approved',
by: 'u2',
at: 't2',
decisions: staleApprover,
});
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover);
});
});
function initialLoading(): BriefState {
return { tag: 'loading' };
}
@@ -0,0 +1,280 @@
import { assertNever } from '@shared/kernel/fp';
import {
Besluit,
Brief,
BriefDecisions,
BriefStatus,
LetterBlock,
LetterSection,
LibraryPassage,
allBlocks,
canSubmit,
} from './brief';
import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text';
import { passagesForBesluit } from './besluit';
/**
* The letter composition state machine (Model + Msg + pure reduce), modeled on
* `herregistratie/domain/intake.machine.ts`.
*
* Two invariants are enforced *here*, not in the UI:
* - Status transitions are total and guarded — an out-of-order transition Msg is a
* no-op (`draft→submitted→approved/rejected→draft`, `approved→sent`).
* - Edits are only possible in `draft`/`rejected`; editing a `rejected` letter flips
* it back to `draft`. Sections can never be added, removed, or reordered — there
* is no Msg for it, so it is unrepresentable.
*
* Authorization is NOT a reducer concern: `decisions` (canEdit/canApprove/canReject/
* canSend) arrives from the server on every load and every status transition (PRD-0002
* phase P1) and is carried through unchanged by the reducer — never recomputed here.
* The reducer guards the status invariant; the server is the sole authority on who may
* act on it.
*
* Note: there is no `PlaceholderInserted` Msg. The editor inserts a placeholder NODE
* at the caret and emits the whole new block via `BlockContentEdited`; its insert menu
* only offers keys from `brief.placeholders`, so inserting an unknown key is
* structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`).
*/
export type BriefState =
| { tag: 'loading' }
| {
tag: 'loaded';
brief: Brief;
availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions;
}
| { tag: 'failed'; reason: string };
export const initial: BriefState = { tag: 'loading' };
export type BriefMsg =
| {
tag: 'BriefLoaded';
brief: Brief;
availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions;
}
| { tag: 'BriefLoadFailed'; reason: string }
| { tag: 'BesluitSelected'; besluit: Besluit | null; reasons: readonly string[] } // recomposes the kern's passages
| { tag: 'FreeTextBlockAdded'; sectionKey: string }
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
| { tag: 'BlockRemoved'; blockId: string }
| { tag: 'BlockMovedWithinSection'; blockId: string; toIndex: number }
| { tag: 'Submitted'; by: string; at: string; decisions: BriefDecisions } // draft → submitted
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
| { tag: 'Seed'; state: BriefState };
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
function isEditable(status: BriefStatus): boolean {
return status.tag === 'draft' || status.tag === 'rejected';
}
/** Next `local-N` block id — DERIVED from existing ids (max + 1), not a stored counter. */
function nextLocalIndex(brief: Brief): number {
let max = 0;
for (const b of allBlocks(brief)) {
const m = /^local-(\d+)$/.exec(b.blockId);
if (m) max = Math.max(max, Number(m[1]));
}
return max + 1;
}
function mapSection(
brief: Brief,
sectionKey: string,
f: (s: LetterSection) => LetterSection,
): Brief {
return {
...brief,
sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)),
};
}
/** The section a block currently lives in, or undefined if the block is gone. */
function sectionKeyOfBlock(brief: Brief, blockId: string): string | undefined {
return brief.sections.find((s) => s.blocks.some((b) => b.blockId === blockId))?.sectionKey;
}
/** A section accepts edits only when it is not a locked (predefined) template section. */
function isSectionEditable(brief: Brief, sectionKey: string | undefined): boolean {
const section = brief.sections.find((s) => s.sectionKey === sectionKey);
return !!section && !section.locked;
}
function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBlock[]): Brief {
return { ...brief, sections: brief.sections.map((s) => ({ ...s, blocks: f(s.blocks) })) };
}
/** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
if (s.tag !== 'loaded' || !isEditable(s.brief.status)) return s;
let brief = f(s.brief);
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
return { ...s, brief };
}
function buildPassageBlocks(brief: Brief, passages: readonly LibraryPassage[]): LetterBlock[] {
let idx = nextLocalIndex(brief);
// 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).
return passages.map((p) => ({
type: 'passage',
blockId: `local-${idx++}`,
sourcePassageId: p.passageId,
sourceVersion: p.version,
content: deepCopyBlock(p.content),
edited: false,
}));
}
/** 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 {
const block: LetterBlock = {
type: 'freeText',
blockId: `local-${nextLocalIndex(brief)}`,
content: emptyBlock(),
};
return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, block] }));
}
function editBlockContent(brief: Brief, blockId: string, content: RichTextBlock): Brief {
return mapBlocks(brief, (blocks) =>
blocks.map((b) =>
b.blockId !== blockId
? b
: b.type === 'passage'
? { ...b, content, edited: true } // editing a snapshot marks it, keeps provenance
: { ...b, content },
),
);
}
function moveWithinSection(
blocks: readonly LetterBlock[],
blockId: string,
toIndex: number,
): LetterBlock[] {
const from = blocks.findIndex((b) => b.blockId === blockId);
if (from === -1) return [...blocks];
const clamped = Math.max(0, Math.min(toIndex, blocks.length - 1));
const next = [...blocks];
const [moved] = next.splice(from, 1);
next.splice(clamped, 0, moved);
return next;
}
export function reduce(s: BriefState, m: BriefMsg): BriefState {
switch (m.tag) {
case 'BriefLoaded':
return {
tag: 'loaded',
brief: m.brief,
availablePassages: m.availablePassages,
decisions: m.decisions,
};
case 'BriefLoadFailed':
return { tag: 'failed', reason: m.reason };
case 'Seed':
return m.state;
// The kern is besluit-driven: (re)compose its passages from the selection, keeping the
// drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
case 'BesluitSelected':
return withEdit(s, (b) =>
s.tag === 'loaded' && isSectionEditable(b, 'kern')
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
: b,
);
case 'FreeTextBlockAdded':
return withEdit(s, (b) =>
isSectionEditable(b, m.sectionKey) ? addFreeText(b, m.sectionKey) : b,
);
case 'BlockContentEdited':
return withEdit(s, (b) =>
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
? editBlockContent(b, m.blockId, m.content)
: b,
);
case 'BlockRemoved':
return withEdit(s, (b) =>
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
? mapBlocks(b, (blocks) => blocks.filter((x) => x.blockId !== m.blockId))
: b,
);
case 'BlockMovedWithinSection':
return withEdit(s, (b) =>
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
? mapBlocks(b, (blocks) =>
blocks.some((x) => x.blockId === m.blockId)
? moveWithinSection(blocks, m.blockId, m.toIndex)
: [...blocks],
)
: b,
);
case 'Submitted':
// Guard the transition AND the completeness invariant.
return transition(
s,
'draft',
() => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }),
m.decisions,
canSubmit,
);
case 'Approved':
return transition(
s,
'submitted',
() => ({ tag: 'approved', approvedBy: m.by, approvedAt: m.at }),
m.decisions,
);
case 'Rejected':
return transition(
s,
'submitted',
() => ({ tag: 'rejected', rejectedBy: m.by, rejectedAt: m.at, comments: m.comments }),
m.decisions,
);
case 'Sent':
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
default:
return assertNever(m);
}
}
/** A guarded status transition: only fires from `from`, and only if `guard` passes.
`decisions` replaces the prior server-computed flags — always fresh from the
same response that carried the new status. */
function transition(
s: BriefState,
from: BriefStatus['tag'],
next: () => BriefStatus,
decisions: BriefDecisions,
guard: (b: Brief) => boolean = () => true,
): BriefState {
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
return { ...s, brief: { ...s.brief, status: next() }, decisions };
}
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import {
Brief,
LetterBlock,
allDiagnostics,
canSubmit,
hasBlockingErrors,
unresolvedPlaceholders,
} from './brief';
import { PlaceholderDef } from './placeholders';
import { RichTextBlock } from '@shared/kernel/rich-text';
const placeholders: PlaceholderDef[] = [
{ key: 'naam', label: 'Naam', autoResolvable: true },
{ key: 'reden', label: 'Reden', autoResolvable: false },
];
const content = (...keys: string[]): RichTextBlock => ({
paragraphs: [{ nodes: keys.map((key) => ({ type: 'placeholder', key })) }],
});
const passage = (blockId: string, ...keys: string[]): LetterBlock => ({
type: 'freeText',
blockId,
content: content(...keys),
});
function brief(sections: Brief['sections']): Brief {
return {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
placeholders,
sections,
status: { tag: 'draft' },
drafterId: 'u1',
};
}
describe('brief selectors', () => {
it('unresolvedPlaceholders returns deduped manual keys only (auto excluded)', () => {
const b = brief([
{
sectionKey: 's1',
title: 'S1',
required: true,
locked: false,
blocks: [passage('local-1', 'naam', 'reden')],
},
{
sectionKey: 's2',
title: 'S2',
required: false,
locked: false,
blocks: [passage('local-2', 'reden')],
},
]);
expect(unresolvedPlaceholders(b)).toEqual(['reden']); // 'naam' is auto; 'reden' deduped
});
it('allDiagnostics flattens across sections and blocks', () => {
const b = brief([
{
sectionKey: 's1',
title: 'S1',
required: true,
locked: false,
blocks: [passage('local-1', 'reden', 'onbekend')],
},
]);
const codes = allDiagnostics(b).map((d) => d.code);
expect(codes).toContain('unresolved-at-send'); // reden
expect(codes).toContain('unknown-placeholder'); // onbekend
expect(hasBlockingErrors(allDiagnostics(b))).toBe(true); // unknown is an error
});
it('canSubmit is false when a required section is empty, true otherwise', () => {
expect(
canSubmit(
brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [] }]),
),
).toBe(false);
expect(
canSubmit(
brief([{ sectionKey: 's1', title: 'S1', required: false, locked: false, blocks: [] }]),
),
).toBe(true);
expect(
canSubmit(
brief([
{
sectionKey: 's1',
title: 'S1',
required: true,
locked: false,
blocks: [passage('local-1')],
},
]),
),
).toBe(true);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { RichTextBlock, placeholderKeysIn } from '@shared/kernel/rich-text';
import { Diagnostic, lintPlaceholders, PlaceholderDef } from './placeholders';
/**
* The `Brief` (letter) entity and its derived selectors.
*
* A letter has a FIXED section structure (from a template, server-instantiated); the
* drafter fills a skeleton, never reorders sections. Each block is either a frozen
* snapshot of a library passage (provenance kept) or free text. Everything the UI
* needs beyond the stored shape — diagnostics, unresolved placeholders, whether it
* can be submitted — is DERIVED here, never stored.
*/
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.
export { placeholderKeysIn };
/** A passage in the library (the source). Snapshotted into a letter on insert. */
export interface LibraryPassage {
readonly passageId: string;
readonly scope: PassageScope;
readonly beroep?: string; // set when scope === 'beroep'
readonly sectionKey: string;
readonly label: string;
readonly content: RichTextBlock;
readonly version: number; // library version, for provenance only
// 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. */
export type LetterBlock =
| {
readonly type: 'passage';
readonly blockId: string;
readonly sourcePassageId: string; // provenance
readonly sourceVersion: number; // library version at snapshot time (audit only)
readonly content: RichTextBlock; // FROZEN, possibly edited — source of truth for this block
readonly edited: boolean; // changed from the snapshot?
}
| {
readonly type: 'freeText';
readonly blockId: string;
readonly content: RichTextBlock;
};
export interface LetterSection {
readonly sectionKey: string;
readonly title: string;
readonly required: boolean;
// Predefined template sections (aanhef, slot) arrive locked and prefilled — the drafter
// 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 blocks: readonly LetterBlock[];
}
/** The approval state machine as a sum type — transitions are total and guarded in
`brief.machine.ts`; illegal transitions are unrepresentable. */
export type BriefStatus =
| { readonly tag: 'draft' }
| { readonly tag: 'submitted'; readonly submittedBy: string; readonly submittedAt: string }
| { readonly tag: 'approved'; readonly approvedBy: string; readonly approvedAt: string }
| {
readonly tag: 'rejected';
readonly rejectedBy: string;
readonly rejectedAt: string;
readonly comments: string;
}
| { readonly tag: 'sent'; readonly sentAt: string };
export interface Brief {
readonly briefId: string;
readonly beroep: string; // drives which beroep-scoped passages apply
readonly templateId: string;
readonly placeholders: readonly PlaceholderDef[]; // valid fields for this letter
readonly sections: readonly LetterSection[]; // instantiated from the template, in order
readonly status: BriefStatus;
readonly drafterId: string;
}
// --- Derived selectors (pure; recomputed, never stored) ---
export function allBlocks(brief: Brief): LetterBlock[] {
return brief.sections.flatMap((s) => s.blocks);
}
/** Every diagnostic in the letter, in section→block→node order. This is what the
diagnostics panel renders and what the send gate checks. */
export function allDiagnostics(brief: Brief): Diagnostic[] {
return allBlocks(brief).flatMap((b) =>
lintPlaceholders(b.content, brief.placeholders, b.blockId),
);
}
export function hasBlockingErrors(diagnostics: readonly Diagnostic[]): boolean {
return diagnostics.some((d) => d.severity === 'error');
}
/** Manual (non-auto-resolvable) placeholder keys still present, deduped. These are the
`unresolved-at-send` warnings, surfaced as a completeness list. */
export function unresolvedPlaceholders(brief: Brief): string[] {
const auto = new Set(brief.placeholders.filter((p) => p.autoResolvable).map((p) => p.key));
const used = allBlocks(brief).flatMap((b) => placeholderKeysIn(b.content));
return [...new Set(used.filter((k) => !auto.has(k)))];
}
/** A letter can be submitted only when every REQUIRED section has at least one block. */
export function canSubmit(brief: Brief): boolean {
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
status (PRD-0002 phase P1) — rendered as-is, never recomputed here. */
export interface BriefDecisions {
readonly canEdit: boolean;
readonly canApprove: boolean;
readonly canReject: boolean;
readonly canSend: boolean;
/** 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;
}
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest';
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
import { OrgTemplateState, reduce } from './org-template.machine';
import { DocumentCategory } from '@shared/upload/upload.machine';
const template: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG',
returnAddress: 'Postbus 1\n2500 AA Den Haag',
footerContact: 'info@cibg.nl',
footerLegal: 'CIBG is onderdeel van VWS',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 },
version: 3,
};
const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView => ({
draft: template,
publishedVersion: 3,
history: [],
unsentBriefs: 2,
...over,
});
const loaded = (): OrgTemplateState =>
reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
const logoCategory: DocumentCategory = {
categoryId: 'org-logo',
label: 'Logo',
description: '',
required: false,
acceptedTypes: ['image/png'],
maxSizeMb: 2,
multiple: false,
allowPostDelivery: false,
};
describe('org-template.machine', () => {
it('DraftLoaded moves to loaded with the draft, clean', () => {
const s = loaded();
expect(s.tag).toBe('loaded');
if (s.tag !== 'loaded') return;
expect(s.draft.orgName).toBe('CIBG');
expect(s.subOrgId).toBe('cibg-registers');
expect(s.unsentBriefs).toBe(2);
expect(s.dirty).toBe(false);
});
it('LoadFailed carries the reason', () => {
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' });
expect(s).toEqual({ tag: 'failed', reason: 'boom' });
});
it('FieldEdited edits the draft and marks dirty', () => {
const s = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' });
expect(s.tag === 'loaded' && s.draft.orgName).toBe('CIBG Nieuw');
expect(s.tag === 'loaded' && s.dirty).toBe(true);
});
it('MarginEdited edits one edge and marks dirty', () => {
const s = reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 });
expect(s.tag === 'loaded' && s.draft.margins.topMm).toBe(40);
expect(s.tag === 'loaded' && s.draft.margins.leftMm).toBe(20);
expect(s.tag === 'loaded' && s.dirty).toBe(true);
});
it('DraftSaved clears dirty when the saved draft is the current one', () => {
const edited = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
const savedDraft = edited.tag === 'loaded' ? edited.draft : template;
const s = reduce(edited, { tag: 'DraftSaved', savedDraft });
expect(s.tag === 'loaded' && s.dirty).toBe(false);
expect(s.tag === 'loaded' && s.draft.orgName).toBe('X');
});
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
const editing = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
const savedDraft = editing.tag === 'loaded' ? editing.draft : template;
// a further edit changes the draft reference before the save resolves
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
const s = reduce(raced, { tag: 'DraftSaved', savedDraft });
expect(s.tag === 'loaded' && s.dirty).toBe(true);
});
it('edits are no-ops in non-loaded states', () => {
expect(
reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
).toEqual({
tag: 'loading',
});
});
it('a completed logo upload sets logoDocumentId + dirty', () => {
const withCat = reduce(loaded(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
});
const selected = reduce(withCat, {
tag: 'Upload',
msg: {
type: 'FileSelected',
categoryId: 'org-logo',
localId: 'a',
fileName: 'l.png',
fileSizeMb: 0.1,
},
});
const done = reduce(selected, {
tag: 'Upload',
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
});
expect(done.tag === 'loaded' && done.draft.logoDocumentId).toBe('doc-1');
expect(done.tag === 'loaded' && done.dirty).toBe(true);
});
it('removing the logo clears logoDocumentId + dirty', () => {
const withLogo = reduce(loaded(), {
tag: 'Upload',
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
});
const removed = reduce(withLogo, {
tag: 'Upload',
msg: { type: 'UploadRemoved', localId: 'a' },
});
expect(removed.tag === 'loaded' && removed.draft.logoDocumentId).toBeUndefined();
expect(removed.tag === 'loaded' && removed.dirty).toBe(true);
});
it('DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads', () => {
const withCat = reduce(loaded(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
});
const switched = reduce(withCat, {
tag: 'DraftLoaded',
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
});
expect(switched.tag === 'loaded' && switched.upload.categories).toHaveLength(1);
expect(switched.tag === 'loaded' && switched.upload.uploads).toHaveLength(0);
expect(switched.tag === 'loaded' && switched.subOrgId).toBe('cibg-vakbekwaamheid');
});
});
@@ -0,0 +1,102 @@
import { assertNever } from '@shared/kernel/fp';
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine';
/**
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —
* the same idiom as the wizards. The DRAFT org template is form state (edited in
* place on the canvas); publish/rollback are effects that come back as `DraftLoaded`.
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
* the composable upload sub-machine folded in, exactly like the wizards fold
* `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`.
*/
/** The org-identity text fields editable directly on the letter canvas. */
export type OrgTemplateTextField =
| 'orgName'
| 'returnAddress'
| 'footerContact'
| 'footerLegal'
| 'signatureName'
| 'signatureRole'
| 'signatureClosing';
export type OrgTemplateState =
| { tag: 'loading' }
| { tag: 'failed'; reason: string }
| {
tag: 'loaded';
subOrgId: string;
draft: OrgTemplate;
publishedVersion: number;
history: readonly OrgTemplateVersion[];
unsentBriefs: number;
dirty: boolean;
/** Logo upload sub-state (single file, `org-logo` category). */
upload: UploadState;
};
export const initial: OrgTemplateState = { tag: 'loading' };
export type OrgTemplateMsg =
| { tag: 'Loading' }
| { tag: 'DraftLoaded'; view: OrgTemplateAdminView }
| { tag: 'LoadFailed'; reason: string }
| { tag: 'FieldEdited'; field: OrgTemplateTextField; value: string }
| { tag: 'MarginEdited'; edge: keyof Margins; value: number }
/** Carries the draft that was saved: clears `dirty` only if no edit landed during
the round-trip (reference-equal), so a concurrent edit keeps its pending save. */
| { tag: 'DraftSaved'; savedDraft: OrgTemplate }
| { tag: 'Upload'; msg: UploadMsg };
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState {
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
}
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
switch (m.tag) {
case 'Loading':
return { tag: 'loading' };
case 'LoadFailed':
return { tag: 'failed', reason: m.reason };
case 'DraftLoaded':
return {
tag: 'loaded',
subOrgId: m.view.draft.subOrgId,
draft: m.view.draft,
publishedVersion: m.view.publishedVersion,
history: m.view.history,
unsentBriefs: m.view.unsentBriefs,
dirty: false,
// Keep the loaded logo category across sub-org switches (it's the same
// `org-logo` category, loaded once); drop only any in-flight/finished uploads.
upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
};
case 'FieldEdited':
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
case 'MarginEdited':
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
case 'DraftSaved':
return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
case 'Upload': {
if (s.tag !== 'loaded') return s;
const upload = reduceUpload(s.upload, m.msg);
// A completed/removed logo upload also updates the draft's logoDocumentId.
if (m.msg.type === 'UploadComplete')
return {
...s,
upload,
draft: { ...s.draft, logoDocumentId: m.msg.documentId },
dirty: true,
};
if (m.msg.type === 'UploadRemoved') {
const { logoDocumentId: _dropped, ...rest } = s.draft;
return { ...s, upload, draft: rest, dirty: true };
}
return { ...s, upload };
}
default:
return assertNever(m);
}
}
@@ -0,0 +1,67 @@
/**
* The organization template (Brief v2 PRD §3, WP-23/24): the SECOND template axis —
* appearance/identity per sub-organization (letterhead, footer, signature, margins).
* Orthogonal to the case-type template (sections + placeholders); the two only meet
* at render time, on the letter canvas. Server-owned: the FE renders it verbatim,
* never edits it here (the admin editor is WP-26).
*/
export interface Margins {
readonly topMm: number;
readonly rightMm: number;
readonly bottomMm: number;
readonly leftMm: number;
}
export interface OrgTemplate {
readonly subOrgId: string;
readonly orgName: string;
/** Multiline; rendered above the envelope window. */
readonly returnAddress: string;
readonly logoDocumentId?: string;
/** Multiline contact block in the footer. */
readonly footerContact: string;
readonly footerLegal: string;
readonly signatureName: string;
readonly signatureRole: string;
readonly signatureClosing: string;
readonly margins: Margins;
/** 0 = draft; n>0 = the published snapshot this letter renders with. */
readonly version: number;
}
// --- admin editor (WP-26) ---
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */
export interface OrgTemplateVersion {
readonly version: number;
readonly publishedAt: string;
readonly template: OrgTemplate;
}
/** The admin editor's view of one sub-org: the editable draft plus publish metadata. */
export interface OrgTemplateAdminView {
readonly draft: OrgTemplate;
readonly publishedVersion: number;
readonly history: readonly OrgTemplateVersion[];
/** How many not-yet-sent letters a publish would re-render (the impact count). */
readonly unsentBriefs: number;
}
/** One row in the sub-org switcher. */
export interface SubOrgSummary {
readonly subOrgId: string;
readonly orgName: string;
readonly publishedVersion: number;
}
/** Publish outcome: the new version and how many unsent letters it touched. */
export interface PublishResult {
readonly version: number;
readonly affectedUnsentBriefs: number;
}
/** Margin bounds (server-owned, `OrgTemplateRules`): the FE mirrors them for instant
feedback via `<input min max>`; the server re-validates and stays the authority. */
export const MARGIN_MIN_MM = 10;
export const MARGIN_MAX_MM = 50;
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';
import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderDef, lintPlaceholders, severityOf } from './placeholders';
const valid: PlaceholderDef[] = [
{ key: 'naam', label: 'Naam', autoResolvable: true }, // clean when used
{ key: 'reden', label: 'Reden', autoResolvable: false }, // manual → unresolved-at-send
{ key: 'oud_veld', label: 'Oud veld', autoResolvable: true, deprecated: true },
{ key: 'niet_invulbaar', label: 'Niet invulbaar', autoResolvable: true, fillable: false },
];
const withPlaceholder = (key: string): RichTextBlock => ({
paragraphs: [{ nodes: [{ type: 'placeholder', key }] }],
});
const withText = (text: string): RichTextBlock => ({
paragraphs: [{ nodes: [{ type: 'text', text }] }],
});
describe('lintPlaceholders', () => {
it('clean content (auto-resolvable, fillable, current key) yields no diagnostics', () => {
expect(lintPlaceholders(withPlaceholder('naam'), valid, 'b1')).toEqual([]);
expect(lintPlaceholders(withText('gewone tekst'), valid, 'b1')).toEqual([]);
});
it('flags an unknown key as an error', () => {
const [d] = lintPlaceholders(withPlaceholder('onbekend'), valid, 'b1');
expect(d.code).toBe('unknown-placeholder');
expect(d.severity).toBe('error');
expect(d.placeholderKey).toBe('onbekend');
expect(d.location).toEqual({ blockId: 'b1', paragraphIndex: 0, nodeIndex: 0 });
});
it('flags a not-fillable key as an error', () => {
const [d] = lintPlaceholders(withPlaceholder('niet_invulbaar'), valid, 'b1');
expect(d.code).toBe('not-fillable');
expect(d.severity).toBe('error');
});
it('flags a deprecated key as a warning', () => {
const [d] = lintPlaceholders(withPlaceholder('oud_veld'), valid, 'b1');
expect(d.code).toBe('deprecated');
expect(d.severity).toBe('warning');
});
it('flags a manual placeholder as unresolved-at-send (warning)', () => {
const [d] = lintPlaceholders(withPlaceholder('reden'), valid, 'b1');
expect(d.code).toBe('unresolved-at-send');
expect(d.severity).toBe('warning');
});
it('flags raw braces in text as malformed (paste safety net)', () => {
const [d] = lintPlaceholders(withText('Beste {{naam'), valid, 'b1');
expect(d.code).toBe('malformed');
expect(d.severity).toBe('error');
expect(d.placeholderKey).toBeUndefined();
});
it('returns diagnostics in document order across paragraphs/nodes', () => {
const content: RichTextBlock = {
paragraphs: [
{ nodes: [{ type: 'placeholder', key: 'onbekend' }] },
{
nodes: [
{ type: 'text', text: 'ok' },
{ type: 'placeholder', key: 'reden' },
],
},
],
};
const codes = lintPlaceholders(content, valid, 'b1').map(
(d) => `${d.code}@${d.location.paragraphIndex}.${d.location.nodeIndex}`,
);
expect(codes).toEqual(['unknown-placeholder@0.0', 'unresolved-at-send@1.1']);
});
it('severityOf maps each code to its policy', () => {
expect(severityOf('malformed')).toBe('error');
expect(severityOf('unknown-placeholder')).toBe('error');
expect(severityOf('not-fillable')).toBe('error');
expect(severityOf('deprecated')).toBe('warning');
expect(severityOf('unresolved-at-send')).toBe('warning');
});
});
@@ -0,0 +1,121 @@
import { RichTextBlock } from '@shared/kernel/rich-text';
/**
* Placeholder fields and the PURE linter over them.
*
* `lintPlaceholders` is a total, effect-free function of (content, valid set). It
* runs identically on the client (for live UX) and could run on the server (for
* authority) — same rules, same config, so the two agree by construction. The FE
* never STORES its output: diagnostics are a `computed()` over content (see
* `brief.ts` selectors), the same "derive, don't store" discipline as wizard step
* validity.
*/
/** A placeholder field the template knows about. `fillable`/`deprecated` default to
the healthy case; the seed flips them to exercise the not-fillable/deprecated rules. */
export interface PlaceholderDef {
readonly key: string; // e.g. 'naam_zorgverlener'
readonly label: string; // human label for the insert menu, e.g. 'Naam zorgverlener'
readonly autoResolvable: boolean; // server can fill from case data (name, date, …)
readonly fillable?: boolean; // default true; false → not resolvable for this beroep/case type
readonly deprecated?: boolean; // default false; true → retired but still referenceable in old snapshots
}
export type DiagnosticSeverity = 'error' | 'warning';
export type DiagnosticCode =
| 'malformed' // raw braces in a text node (a paste that should have been a chip)
| 'unknown-placeholder' // well-formed key not in the valid set
| 'not-fillable' // key exists but isn't resolvable for this case type / beroep
| 'deprecated' // key was valid once but the template no longer offers it
| 'unresolved-at-send'; // manual (non-auto-resolvable) placeholder still to be filled
export interface DiagnosticLocation {
readonly blockId: string;
readonly paragraphIndex: number;
readonly nodeIndex: number;
}
export interface Diagnostic {
readonly severity: DiagnosticSeverity;
readonly code: DiagnosticCode;
readonly placeholderKey?: string;
readonly location: DiagnosticLocation;
readonly message: string; // human-readable, Dutch
}
/** `error` blocks save (author time) and send (send time); `warning` is surfaced but allowed. */
export function severityOf(code: DiagnosticCode): DiagnosticSeverity {
switch (code) {
case 'malformed':
case 'unknown-placeholder':
case 'not-fillable':
return 'error';
case 'deprecated':
case 'unresolved-at-send':
return 'warning';
}
}
// Raw `{{` or `}}` in text — the only way a malformed placeholder can exist, since
// menu insertion always produces a proper placeholder NODE. Paste safety net.
const RAW_BRACES = /\{\{|\}\}/;
function messageFor(code: DiagnosticCode, key?: string): string {
switch (code) {
case 'malformed':
return $localize`:@@brief.lint.malformed:Deze tekst bevat losse accolades ({{ of }}). Voeg een veld toe via het menu in plaats van het te typen.`;
case 'unknown-placeholder':
return $localize`:@@brief.lint.unknown:Onbekend veld “${key}:key:”. Dit veld hoort niet bij dit sjabloon.`;
case 'not-fillable':
return $localize`:@@brief.lint.notFillable:Veld “${key}:key:” kan niet worden ingevuld voor dit beroep.`;
case 'deprecated':
return $localize`:@@brief.lint.deprecated:Veld “${key}:key:” is verouderd en wordt niet meer aangeboden.`;
case 'unresolved-at-send':
return $localize`:@@brief.lint.unresolved:Veld “${key}:key:” wordt handmatig ingevuld en is nog leeg.`;
}
}
function diag(code: DiagnosticCode, location: DiagnosticLocation, key?: string): Diagnostic {
return {
severity: severityOf(code),
code,
placeholderKey: key,
location,
message: messageFor(code, key),
};
}
/**
* Lint one block against the template's placeholder set. Pure: given the same
* content + valid set + blockId it always returns the same diagnostics, in
* document order. One diagnostic per node at most (most-severe wins).
*/
export function lintPlaceholders(
content: RichTextBlock,
valid: readonly PlaceholderDef[],
blockId: string,
): Diagnostic[] {
const byKey = new Map(valid.map((p) => [p.key, p]));
const out: Diagnostic[] = [];
content.paragraphs.forEach((p, paragraphIndex) => {
p.nodes.forEach((n, nodeIndex) => {
const location: DiagnosticLocation = { blockId, paragraphIndex, nodeIndex };
if (n.type === 'text') {
if (RAW_BRACES.test(n.text)) out.push(diag('malformed', location));
return;
}
if (n.type !== 'placeholder') return; // lineBreak — nothing to check
const def = byKey.get(n.key);
if (!def) out.push(diag('unknown-placeholder', location, n.key));
else if (def.fillable === false) out.push(diag('not-fillable', location, n.key));
else if (def.deprecated) out.push(diag('deprecated', location, n.key));
else if (!def.autoResolvable) out.push(diag('unresolved-at-send', location, n.key));
// auto-resolvable & fillable & not deprecated → clean (filled by the server at send)
});
});
return out;
}