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:
2026-07-01 21:32:22 +02:00
parent 0aada9037e
commit 053160c5c9
49 changed files with 13963 additions and 573 deletions

View File

@@ -0,0 +1,115 @@
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;
}