feat(brief): letter composition + two-person approval (teaching slice)
New `brief` context — a letter-composition feature with a drafter/approver approval workflow, built as a teaching vertical slice on the repo's existing FP + Elm + atomic-design patterns (see plan in ~/.claude/plans). Domain (pure): - Rich text as a serialisable value tree (placeholders are first-class nodes), moved to @shared/kernel/rich-text.ts so the shared editor can use it. - lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored. - brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot = deep value copy; derived diagnostics/editability. Full specs. Backend (.NET stub): - BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints, role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards, audit logging. Regenerated typed client via gen:api. +6 backend tests. Seam: - brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the parse boundary (+ spec). UI (atomic): - shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep contenteditable, DOM<->RichTextBlock round-trip tested). - brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments, letter-section, letter-composer, letter-preview, brief.page + /brief route. - Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link. Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared). Also included (same session): - Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap. - src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI). - .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully broken — $localize unresolved — dev + build). Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green, Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import { assertNever } from '@shared/kernel/fp';
|
||||
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, allBlocks, canSubmit } from './brief';
|
||||
import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Role (drafter vs approver) is NOT a reducer concern: the UI derives `editable` from
|
||||
* role+status and simply doesn't dispatch edits when the actor may not edit. The
|
||||
* reducer guards the status invariant; the UI guards the role invariant.
|
||||
*
|
||||
* 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[] }
|
||||
| { tag: 'failed'; reason: string };
|
||||
|
||||
export const initial: BriefState = { tag: 'loading' };
|
||||
|
||||
export type BriefMsg =
|
||||
| { tag: 'BriefLoaded'; brief: Brief; availablePassages: readonly LibraryPassage[] }
|
||||
| { tag: 'BriefLoadFailed'; reason: string }
|
||||
| { tag: 'PassagesInserted'; sectionKey: string; passages: readonly LibraryPassage[] } // multi-select
|
||||
| { 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 } // draft → submitted
|
||||
| { tag: 'Approved'; by: string; at: string } // submitted → approved
|
||||
| { tag: 'Rejected'; by: string; at: string; comments: string } // submitted → rejected
|
||||
| { tag: 'Sent'; at: string } // 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)) };
|
||||
}
|
||||
|
||||
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 insertPassages(brief: Brief, sectionKey: string, passages: readonly LibraryPassage[]): Brief {
|
||||
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).
|
||||
const newBlocks: LetterBlock[] = passages.map((p) => ({
|
||||
type: 'passage',
|
||||
blockId: `local-${idx++}`,
|
||||
sourcePassageId: p.passageId,
|
||||
sourceVersion: p.version,
|
||||
content: deepCopyBlock(p.content),
|
||||
edited: false,
|
||||
}));
|
||||
return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, ...newBlocks] }));
|
||||
}
|
||||
|
||||
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 };
|
||||
case 'BriefLoadFailed':
|
||||
return { tag: 'failed', reason: m.reason };
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
|
||||
case 'PassagesInserted':
|
||||
return withEdit(s, (b) => insertPassages(b, m.sectionKey, m.passages));
|
||||
case 'FreeTextBlockAdded':
|
||||
return withEdit(s, (b) => addFreeText(b, m.sectionKey));
|
||||
case 'BlockContentEdited':
|
||||
return withEdit(s, (b) => editBlockContent(b, m.blockId, m.content));
|
||||
case 'BlockRemoved':
|
||||
return withEdit(s, (b) => mapBlocks(b, (blocks) => blocks.filter((x) => x.blockId !== m.blockId)));
|
||||
case 'BlockMovedWithinSection':
|
||||
return withEdit(s, (b) =>
|
||||
mapBlocks(b, (blocks) =>
|
||||
blocks.some((x) => x.blockId === m.blockId) ? moveWithinSection(blocks, m.blockId, m.toIndex) : [...blocks],
|
||||
),
|
||||
);
|
||||
|
||||
case 'Submitted':
|
||||
// Guard the transition AND the completeness invariant.
|
||||
return transition(s, 'draft', () => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }), canSubmit);
|
||||
case 'Approved':
|
||||
return transition(s, 'submitted', () => ({ tag: 'approved', approvedBy: m.by, approvedAt: m.at }));
|
||||
case 'Rejected':
|
||||
return transition(s, 'submitted', () => ({ tag: 'rejected', rejectedBy: m.by, rejectedAt: m.at, comments: m.comments }));
|
||||
case 'Sent':
|
||||
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }));
|
||||
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
|
||||
/** A guarded status transition: only fires from `from`, and only if `guard` passes. */
|
||||
function transition(
|
||||
s: BriefState,
|
||||
from: BriefStatus['tag'],
|
||||
next: () => BriefStatus,
|
||||
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() } };
|
||||
}
|
||||
Reference in New Issue
Block a user