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
+99
View File
@@ -0,0 +1,99 @@
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';
// 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
}
/** 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;
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);
}