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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user