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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user