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`). */ /** The one-shot action lifecycle (submit/approve/reject/send/preview/reveal/reset), owned by the reducer instead of an imperative store-level signal (RD-12). */ export type BriefActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string }; export type BriefState = | { tag: 'Loading' } | { tag: 'Loaded'; brief: Brief; availablePassages: readonly LibraryPassage[]; decisions: BriefDecisions; action: BriefActionState; } | { 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 } | { tag: 'ActionStarted' } // a one-shot action (submit/approve/preview/…) began | { tag: 'ActionFinished' } // it completed successfully | { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show /** Edits are allowed only in these statuses; editing a rejected letter reopens it. */ function isEditable(status: BriefStatus): boolean { 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, // A fresh load clears a stale action error rather than letting it outlive // the reload (RD-12, decision 4). action: { tag: 'Idle' }, }; case 'BriefLoadFailed': return { tag: 'Failed', reason: m.reason }; 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); // The action lifecycle (RD-12): a no-op unless a brief is loaded, since there is // nothing to attach the action state to otherwise. case 'ActionStarted': return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s; case 'ActionFinished': return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s; case 'ActionFailed': return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s; default: return assertNever(m); } } /** 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 }; }