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:
66
src/app/brief/infrastructure/brief.adapter.spec.ts
Normal file
66
src/app/brief/infrastructure/brief.adapter.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
259
src/app/brief/infrastructure/brief.adapter.ts
Normal file
259
src/app/brief/infrastructure/brief.adapter.ts
Normal 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) };
|
||||
}
|
||||
Reference in New Issue
Block a user