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,66 @@
import { describe, it, expect } from 'vitest';
import { BriefViewDto } from '@shared/infrastructure/api-client';
import { parseBrief, parseBriefView, parseNode, parseStatus } from './brief.adapter';
const view: BriefViewDto = {
brief: {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
drafterId: 'demo-drafter',
status: { tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' },
placeholders: [
{ key: 'naam', label: 'Naam', autoResolvable: true },
{ key: 'code', label: 'Code', autoResolvable: true, fillable: false },
],
sections: [
{
sectionKey: 'aanhef',
title: 'Aanhef',
required: true,
blocks: [
{ type: 'passage', blockId: 'local-1', content: { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] }, sourcePassageId: 'p1', sourceVersion: 2, edited: true },
{ type: 'freeText', blockId: 'local-2', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'hoi', marks: ['bold'] }] }] } },
],
},
],
},
availablePassages: [
{ passageId: 'p1', scope: 'global', sectionKey: 'aanhef', label: 'Aanhef', content: { paragraphs: [{ nodes: [] }] }, version: 1 },
],
};
describe('brief.adapter parse boundary', () => {
it('parses a well-formed view into the domain unions', () => {
const r = parseBriefView(view);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.brief.status).toEqual({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' });
const [passage, free] = r.value.brief.sections[0].blocks;
expect(passage.type === 'passage' && passage.edited).toBe(true);
expect(free.type).toBe('freeText');
expect(r.value.brief.placeholders[1]).toEqual({ key: 'code', label: 'Code', autoResolvable: true, fillable: false });
});
it('narrows node variants and rejects unknown ones', () => {
expect(parseNode({ type: 'text', text: 'x' })).toEqual({ ok: true, value: { type: 'text', text: 'x' } });
expect(parseNode({ type: 'placeholder', key: 'k' })).toEqual({ ok: true, value: { type: 'placeholder', key: 'k' } });
expect(parseNode({ type: 'lineBreak' })).toEqual({ ok: true, value: { type: 'lineBreak' } });
expect(parseNode({ type: 'text' }).ok).toBe(false); // missing text
expect(parseNode({ type: 'bogus' } as never).ok).toBe(false);
});
it('rejects a status DTO missing its required fields', () => {
expect(parseStatus({ tag: 'submitted' }).ok).toBe(false); // no submittedBy/At
expect(parseStatus({ tag: 'rejected', rejectedBy: 'x', rejectedAt: 't' }).ok).toBe(false); // no comments
expect(parseStatus({ tag: 'draft' }).ok).toBe(true);
});
it('rejects a passage block missing provenance', () => {
const r = parseBrief({
...view.brief,
sections: [{ sectionKey: 's', title: 'S', required: false, blocks: [{ type: 'passage', blockId: 'b', content: { paragraphs: [] } }] }],
});
expect(r.ok).toBe(false);
});
});

View File

@@ -0,0 +1,259 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit';
import {
ApiClient,
BriefDto,
BriefStatusDto,
BriefViewDto,
LetterBlockDto,
LetterSectionDto,
LibraryPassageDto,
PlaceholderDefDto,
RichTextBlockDto,
RichTextNodeDto,
} from '@shared/infrastructure/api-client';
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, PassageScope } from '@brief/domain/brief';
import { PlaceholderDef } from '@brief/domain/placeholders';
import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
/**
* The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire
* uses FLAT unions (a `type`/`tag` string + nullable fields, the repo convention);
* the `parse*` boundary narrows them into the domain's proper discriminated unions
* and rejects malformed shapes. Mutations go through `runSubmit` (ProblemDetails →
* error string), then parse the returned brief.
*/
export interface BriefView {
readonly brief: Brief;
readonly availablePassages: LibraryPassage[];
}
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
@Injectable({ providedIn: 'root' })
export class BriefAdapter {
private client = inject(ApiClient);
async load(): Promise<Result<string, BriefView>> {
const r = await runSubmit(() => this.client.briefGET(), BRIEF_LOAD_FAILED);
return r.ok ? parseBriefView(r.value) : r;
}
async save(sections: readonly LetterSection[]): Promise<Result<string, Brief>> {
const r = await runSubmit(() => this.client.briefPUT({ sections: sections.map(sectionToDto) }), BRIEF_ACTION_FAILED);
return r.ok ? parseBrief(r.value) : r;
}
async submit(): Promise<Result<string, Brief>> {
const r = await runSubmit(() => this.client.briefSubmit(), BRIEF_ACTION_FAILED);
return r.ok ? parseBrief(r.value) : r;
}
async approve(): Promise<Result<string, Brief>> {
const r = await runSubmit(() => this.client.approve(), BRIEF_ACTION_FAILED);
return r.ok ? parseBrief(r.value) : r;
}
async reject(comments: string): Promise<Result<string, Brief>> {
const r = await runSubmit(() => this.client.reject({ comments }), BRIEF_ACTION_FAILED);
return r.ok ? parseBrief(r.value) : r;
}
async send(): Promise<Result<string, Brief>> {
const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED);
return r.ok ? parseBrief(r.value) : r;
}
}
// --- parse: wire (flat) → domain (discriminated unions), validating at the boundary ---
const MARKS: readonly string[] = ['bold', 'italic', 'underline'];
export function parseNode(dto: RichTextNodeDto): Result<string, RichTextNode> {
switch (dto.type) {
case 'text': {
if (typeof dto.text !== 'string') return err('node: text missing text');
const marks = dto.marks?.filter((m): m is Mark => MARKS.includes(m));
return ok(marks && marks.length ? { type: 'text', text: dto.text, marks } : { type: 'text', text: dto.text });
}
case 'placeholder':
return typeof dto.key === 'string' ? ok({ type: 'placeholder', key: dto.key }) : err('node: placeholder missing key');
case 'lineBreak':
return ok({ type: 'lineBreak' });
default:
return err(`node: unknown type ${dto.type}`);
}
}
export function parseBlockContent(dto: RichTextBlockDto | undefined): Result<string, RichTextBlock> {
if (!dto || !Array.isArray(dto.paragraphs)) return err('content: paragraphs not an array');
const paragraphs = [];
for (const p of dto.paragraphs) {
const nodes: RichTextNode[] = [];
for (const n of p.nodes ?? []) {
const parsed = parseNode(n);
if (!parsed.ok) return parsed;
nodes.push(parsed.value);
}
paragraphs.push({ nodes });
}
return ok({ paragraphs });
}
function parseBlock(dto: LetterBlockDto): Result<string, LetterBlock> {
if (typeof dto.blockId !== 'string') return err('block: missing blockId');
const content = parseBlockContent(dto.content);
if (!content.ok) return content;
switch (dto.type) {
case 'passage':
if (typeof dto.sourcePassageId !== 'string' || typeof dto.sourceVersion !== 'number') return err('block: bad passage provenance');
return ok({
type: 'passage',
blockId: dto.blockId,
sourcePassageId: dto.sourcePassageId,
sourceVersion: dto.sourceVersion,
content: content.value,
edited: dto.edited ?? false,
});
case 'freeText':
return ok({ type: 'freeText', blockId: dto.blockId, content: content.value });
default:
return err(`block: unknown type ${dto.type}`);
}
}
function parseSection(dto: LetterSectionDto): Result<string, LetterSection> {
if (typeof dto.sectionKey !== 'string' || typeof dto.title !== 'string' || typeof dto.required !== 'boolean') {
return err('section: bad shape');
}
const blocks: LetterBlock[] = [];
for (const b of dto.blocks ?? []) {
const parsed = parseBlock(b);
if (!parsed.ok) return parsed;
blocks.push(parsed.value);
}
return ok({ sectionKey: dto.sectionKey, title: dto.title, required: dto.required, blocks });
}
function parsePlaceholderDef(dto: PlaceholderDefDto): Result<string, PlaceholderDef> {
if (typeof dto.key !== 'string' || typeof dto.label !== 'string' || typeof dto.autoResolvable !== 'boolean') {
return err('placeholder: bad shape');
}
return ok({
key: dto.key,
label: dto.label,
autoResolvable: dto.autoResolvable,
...(dto.fillable != null ? { fillable: dto.fillable } : {}),
...(dto.deprecated != null ? { deprecated: dto.deprecated } : {}),
});
}
export function parseStatus(dto: BriefStatusDto | undefined): Result<string, BriefStatus> {
switch (dto?.tag) {
case 'draft':
return ok({ tag: 'draft' });
case 'submitted':
if (typeof dto.submittedBy !== 'string' || typeof dto.submittedAt !== 'string') return err('status: bad submitted');
return ok({ tag: 'submitted', submittedBy: dto.submittedBy, submittedAt: dto.submittedAt });
case 'approved':
if (typeof dto.approvedBy !== 'string' || typeof dto.approvedAt !== 'string') return err('status: bad approved');
return ok({ tag: 'approved', approvedBy: dto.approvedBy, approvedAt: dto.approvedAt });
case 'rejected':
if (typeof dto.rejectedBy !== 'string' || typeof dto.rejectedAt !== 'string' || typeof dto.comments !== 'string') return err('status: bad rejected');
return ok({ tag: 'rejected', rejectedBy: dto.rejectedBy, rejectedAt: dto.rejectedAt, comments: dto.comments });
case 'sent':
if (typeof dto.sentAt !== 'string') return err('status: bad sent');
return ok({ tag: 'sent', sentAt: dto.sentAt });
default:
return err(`status: unknown tag ${dto?.tag}`);
}
}
function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep')) return err('passage: bad shape');
if (typeof dto.sectionKey !== 'string' || typeof dto.label !== 'string' || typeof dto.version !== 'number') return err('passage: bad shape');
const content = parseBlockContent(dto.content);
if (!content.ok) return content;
return ok({
passageId: dto.passageId,
scope: dto.scope as PassageScope,
sectionKey: dto.sectionKey,
label: dto.label,
content: content.value,
version: dto.version,
...(dto.beroep != null ? { beroep: dto.beroep } : {}),
});
}
export function parseBrief(dto: BriefDto): Result<string, Brief> {
if (typeof dto.briefId !== 'string' || typeof dto.drafterId !== 'string' || typeof dto.beroep !== 'string' || typeof dto.templateId !== 'string') {
return err('brief: missing ids');
}
const status = parseStatus(dto.status);
if (!status.ok) return status;
const placeholders: PlaceholderDef[] = [];
for (const p of dto.placeholders ?? []) {
const parsed = parsePlaceholderDef(p);
if (!parsed.ok) return parsed;
placeholders.push(parsed.value);
}
const sections: LetterSection[] = [];
for (const s of dto.sections ?? []) {
const parsed = parseSection(s);
if (!parsed.ok) return parsed;
sections.push(parsed.value);
}
return ok({
briefId: dto.briefId,
beroep: dto.beroep,
templateId: dto.templateId,
placeholders,
sections,
status: status.value,
drafterId: dto.drafterId,
});
}
export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
if (!dto.brief) return err('brief-view: missing brief');
const brief = parseBrief(dto.brief);
if (!brief.ok) return brief;
const availablePassages: LibraryPassage[] = [];
for (const p of dto.availablePassages ?? []) {
const parsed = parsePassage(p);
if (!parsed.ok) return parsed;
availablePassages.push(parsed.value);
}
return ok({ brief: brief.value, availablePassages });
}
// --- toDto: domain → wire, for save (collapses the union to the flat shape) ---
function nodeToDto(n: RichTextNode): RichTextNodeDto {
switch (n.type) {
case 'text':
return { type: 'text', text: n.text, ...(n.marks ? { marks: [...n.marks] } : {}) };
case 'placeholder':
return { type: 'placeholder', key: n.key };
case 'lineBreak':
return { type: 'lineBreak' };
}
}
function contentToDto(content: RichTextBlock): RichTextBlockDto {
return { paragraphs: content.paragraphs.map((p) => ({ nodes: p.nodes.map(nodeToDto) })) };
}
function blockToDto(b: LetterBlock): LetterBlockDto {
return b.type === 'passage'
? { type: 'passage', blockId: b.blockId, content: contentToDto(b.content), sourcePassageId: b.sourcePassageId, sourceVersion: b.sourceVersion, edited: b.edited }
: { type: 'freeText', blockId: b.blockId, content: contentToDto(b.content) };
}
function sectionToDto(s: LetterSection): LetterSectionDto {
return { sectionKey: s.sectionKey, title: s.title, required: s.required, blocks: s.blocks.map(blockToDto) };
}