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
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { parseBigNummer } from './big-nummer';
describe('parseBigNummer', () => {
it('accepts exactly 11 digits, trimming whitespace', () => {
const r = parseBigNummer(' 12345678901 ');
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe('12345678901');
});
it('rejects wrong length or non-digits', () => {
expect(parseBigNummer('').ok).toBe(false);
expect(parseBigNummer('1234567890').ok).toBe(false); // 10 digits
expect(parseBigNummer('123456789012').ok).toBe(false); // 12 digits
expect(parseBigNummer('1234567890a').ok).toBe(false);
});
});
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { parsePostcode } from './postcode';
describe('parsePostcode', () => {
it('normalises to "1234 AB" (uppercase, single space, trimmed)', () => {
for (const raw of ['1234ab', '1234 AB', ' 1234ab ', '1234AB']) {
const r = parsePostcode(raw);
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe('1234 AB');
}
});
it('rejects malformed postcodes', () => {
expect(parsePostcode('').ok).toBe(false);
expect(parsePostcode('0234AB').ok).toBe(false); // leading zero
expect(parsePostcode('123AB').ok).toBe(false); // 3 digits
expect(parsePostcode('1234A').ok).toBe(false); // 1 letter
expect(parsePostcode('1234ABC').ok).toBe(false); // 3 letters
});
});
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { parseUren } from './uren';
describe('parseUren', () => {
it('accepts non-negative whole numbers, including 0', () => {
for (const [raw, n] of [['0', 0], [' 40 ', 40], ['1000', 1000]] as const) {
const r = parseUren(raw);
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe(n);
}
});
it('rejects empty, negative, and non-integer input', () => {
expect(parseUren('').ok).toBe(false);
expect(parseUren('-1').ok).toBe(false);
expect(parseUren('1.5').ok).toBe(false);
expect(parseUren('abc').ok).toBe(false);
});
});
+1
View File
@@ -165,6 +165,7 @@ export class DashboardPage {
{ label: $localize`:@@dashboard.nav.herregistratie:Herregistratie`, to: '/herregistratie' },
{ label: $localize`:@@dashboard.nav.inschrijven:Inschrijven`, to: '/registreren' },
{ label: $localize`:@@dashboard.nav.concepten:Functionele patronen`, to: '/concepts' },
{ label: $localize`:@@dashboard.nav.brief:Brief opstellen`, to: '/brief' },
];
/** Primary transactional actions, as a card grid. */