feat(brief): letter composition + two-person approval (teaching slice)
CI / backend (push) Failing after 22s
CI / frontend (push) Successful in 1m26s
CI / api-client-drift (push) Successful in 1m45s

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:
eho
2026-07-01 21:32:22 +02:00
co-authored by Claude Opus 4.8
parent 0aada9037e
commit 053160c5c9
49 changed files with 13963 additions and 573 deletions
+168
View File
@@ -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() } };
}