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

@@ -929,6 +929,284 @@ export class ApiClient {
}
return Promise.resolve<SubmitApplicationResponse>(null as any);
}
/**
* @return OK
*/
briefGET(): Promise<BriefViewDto> {
let url_ = this.baseUrl + "/api/v1/brief";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processBriefGET(_response);
});
}
protected processBriefGET(response: Response): Promise<BriefViewDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<BriefViewDto>(null as any);
}
/**
* @return OK
*/
briefPUT(body: SaveBriefRequest): Promise<BriefDto> {
let url_ = this.baseUrl + "/api/v1/brief";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
let options_: RequestInit = {
body: content_,
method: "PUT",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processBriefPUT(_response);
});
}
protected processBriefPUT(response: Response): Promise<BriefDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
return result200;
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status === 409) {
return response.text().then((_responseText) => {
let result409: any = null;
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Conflict", status, _responseText, _headers, result409);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<BriefDto>(null as any);
}
/**
* @return OK
*/
briefSubmit(): Promise<BriefDto> {
let url_ = this.baseUrl + "/api/v1/brief/submit";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "POST",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processBriefSubmit(_response);
});
}
protected processBriefSubmit(response: Response): Promise<BriefDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
return result200;
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status === 409) {
return response.text().then((_responseText) => {
let result409: any = null;
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Conflict", status, _responseText, _headers, result409);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<BriefDto>(null as any);
}
/**
* @return OK
*/
approve(): Promise<BriefDto> {
let url_ = this.baseUrl + "/api/v1/brief/approve";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "POST",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processApprove(_response);
});
}
protected processApprove(response: Response): Promise<BriefDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
return result200;
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status === 409) {
return response.text().then((_responseText) => {
let result409: any = null;
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Conflict", status, _responseText, _headers, result409);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<BriefDto>(null as any);
}
/**
* @return OK
*/
reject(body: RejectBriefRequest): Promise<BriefDto> {
let url_ = this.baseUrl + "/api/v1/brief/reject";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
let options_: RequestInit = {
body: content_,
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processReject(_response);
});
}
protected processReject(response: Response): Promise<BriefDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
return result200;
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status === 409) {
return response.text().then((_responseText) => {
let result409: any = null;
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Conflict", status, _responseText, _headers, result409);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<BriefDto>(null as any);
}
/**
* @return OK
*/
send(): Promise<BriefDto> {
let url_ = this.baseUrl + "/api/v1/brief/send";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "POST",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processSend(_response);
});
}
protected processSend(response: Response): Promise<BriefDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
return result200;
});
} else if (status === 409) {
return response.text().then((_responseText) => {
let result409: any = null;
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Conflict", status, _responseText, _headers, result409);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<BriefDto>(null as any);
}
}
export interface AantekeningDto {
@@ -973,6 +1251,33 @@ export interface ApplicationSummaryDto {
submittedAt?: string | undefined;
}
export interface BriefDto {
briefId?: string | undefined;
beroep?: string | undefined;
templateId?: string | undefined;
placeholders?: PlaceholderDefDto[] | undefined;
sections?: LetterSectionDto[] | undefined;
status?: BriefStatusDto;
drafterId?: string | undefined;
}
export interface BriefStatusDto {
tag?: string | undefined;
submittedBy?: string | undefined;
submittedAt?: string | undefined;
approvedBy?: string | undefined;
approvedAt?: string | undefined;
rejectedBy?: string | undefined;
rejectedAt?: string | undefined;
comments?: string | undefined;
sentAt?: string | undefined;
}
export interface BriefViewDto {
brief?: BriefDto;
availablePassages?: LibraryPassageDto[] | undefined;
}
export interface BrpAddressDto {
gevonden?: boolean;
adres?: AdresDto;
@@ -1050,17 +1355,55 @@ export interface IntakeRequest {
uren?: number;
}
export interface LetterBlockDto {
type?: string | undefined;
blockId?: string | undefined;
content?: RichTextBlockDto;
sourcePassageId?: string | undefined;
sourceVersion?: number | undefined;
edited?: boolean | undefined;
}
export interface LetterSectionDto {
sectionKey?: string | undefined;
title?: string | undefined;
required?: boolean;
blocks?: LetterBlockDto[] | undefined;
}
export interface LibraryPassageDto {
passageId?: string | undefined;
scope?: string | undefined;
sectionKey?: string | undefined;
label?: string | undefined;
content?: RichTextBlockDto;
version?: number;
beroep?: string | undefined;
}
export interface ManualDiplomaPolicyDto {
beroepen?: string[] | undefined;
policyQuestions?: PolicyQuestionDto[] | undefined;
}
export interface ParagraphDto {
nodes?: RichTextNodeDto[] | undefined;
}
export interface PersonDto {
naam?: string | undefined;
geboortedatum?: string | undefined;
adres?: AdresDto;
}
export interface PlaceholderDefDto {
key?: string | undefined;
label?: string | undefined;
autoResolvable?: boolean;
fillable?: boolean | undefined;
deprecated?: boolean | undefined;
}
export interface PolicyQuestionDto {
id?: string | undefined;
vraag?: string | undefined;
@@ -1103,6 +1446,25 @@ export interface RegistrationStatusDto {
doorgehaaldOp?: string | undefined;
}
export interface RejectBriefRequest {
comments?: string | undefined;
}
export interface RichTextBlockDto {
paragraphs?: ParagraphDto[] | undefined;
}
export interface RichTextNodeDto {
type?: string | undefined;
text?: string | undefined;
marks?: string[] | undefined;
key?: string | undefined;
}
export interface SaveBriefRequest {
sections?: LetterSectionDto[] | undefined;
}
export interface SubmitApplicationRequest {
diplomaHerkomst?: string | undefined;
uren?: number | undefined;