Some checks failed
CI / frontend (push) Failing after 59s
CI / storybook-a11y (push) Successful in 4m22s
CI / backend (push) Successful in 1m18s
CI / codeql (csharp) (push) Failing after 1m47s
CI / codeql (javascript-typescript) (push) Failing after 1m20s
CI / api-client-drift (push) Successful in 1m42s
CI / e2e (push) Failing after 3h8m54s
One letter surface for every role: LetterCanvasComponent renders the org template's letterhead/signature/footer around the case-type sections, with editableRegions content|template|none. public/letter.css is the FE⇄BE rendering contract (WP-25 inlines it verbatim). letter-preview deleted — its read-only rendering absorbed into 'none' mode. brief.machine.ts byte-identical; orgTemplate parses at the adapter boundary and lives beside the machine in BriefStore. Also fixes passage-picker multi-select (checkboxes all shared id="undefined", so labels only toggled the first box) and keeps the ±page-break marks from drawing through canvas content. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
411 lines
13 KiB
TypeScript
411 lines
13 KiB
TypeScript
import { Injectable, inject } from '@angular/core';
|
|
import { Result, ok, err } from '@shared/kernel/fp';
|
|
import { runSubmit } from '@shared/application/submit';
|
|
import {
|
|
ApiClient,
|
|
BriefDecisionsDto,
|
|
BriefDto,
|
|
BriefStatusDto,
|
|
BriefViewDto,
|
|
LetterBlockDto,
|
|
LetterSectionDto,
|
|
LibraryPassageDto,
|
|
OrgTemplateDto,
|
|
PlaceholderDefDto,
|
|
RichTextBlockDto,
|
|
RichTextNodeDto,
|
|
} from '@shared/infrastructure/api-client';
|
|
import {
|
|
Brief,
|
|
BriefDecisions,
|
|
BriefStatus,
|
|
LetterBlock,
|
|
LetterSection,
|
|
LibraryPassage,
|
|
} from '@brief/domain/brief';
|
|
import { OrgTemplate } from '@brief/domain/org-template';
|
|
import { PlaceholderDef } from '@brief/domain/placeholders';
|
|
import { Mark, Paragraph, 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[];
|
|
readonly decisions: BriefDecisions;
|
|
readonly orgTemplate: OrgTemplate;
|
|
}
|
|
|
|
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, BriefView>> {
|
|
const r = await runSubmit(
|
|
() => this.client.briefPUT({ sections: sections.map(sectionToDto) }),
|
|
BRIEF_ACTION_FAILED,
|
|
);
|
|
return r.ok ? parseBriefView(r.value) : r;
|
|
}
|
|
|
|
async submit(): Promise<Result<string, BriefView>> {
|
|
const r = await runSubmit(() => this.client.briefSubmit(), BRIEF_ACTION_FAILED);
|
|
return r.ok ? parseBriefView(r.value) : r;
|
|
}
|
|
|
|
async approve(): Promise<Result<string, BriefView>> {
|
|
const r = await runSubmit(() => this.client.approve(), BRIEF_ACTION_FAILED);
|
|
return r.ok ? parseBriefView(r.value) : r;
|
|
}
|
|
|
|
async reject(comments: string): Promise<Result<string, BriefView>> {
|
|
const r = await runSubmit(() => this.client.reject({ comments }), BRIEF_ACTION_FAILED);
|
|
return r.ok ? parseBriefView(r.value) : r;
|
|
}
|
|
|
|
async send(): Promise<Result<string, BriefView>> {
|
|
const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED);
|
|
return r.ok ? parseBriefView(r.value) : r;
|
|
}
|
|
|
|
/** Demo "start over" — recreate a fresh brief server-side and return the new view. */
|
|
async reset(): Promise<Result<string, BriefView>> {
|
|
const r = await runSubmit(() => this.client.briefReset(), BRIEF_ACTION_FAILED);
|
|
return r.ok ? parseBriefView(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: Paragraph[] = [];
|
|
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);
|
|
}
|
|
const list = p.list === 'bullet' || p.list === 'number' ? p.list : undefined;
|
|
paragraphs.push(list ? { nodes, list } : { 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,
|
|
locked: dto.locked ?? false,
|
|
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') return err('passage: bad shape');
|
|
if (dto.scope !== 'global' && dto.scope !== 'beroep')
|
|
return err(`passage: unknown scope ${dto.scope}`);
|
|
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,
|
|
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,
|
|
});
|
|
}
|
|
|
|
function parseDecisions(dto: BriefDecisionsDto | undefined): Result<string, BriefDecisions> {
|
|
if (
|
|
typeof dto?.canEdit !== 'boolean' ||
|
|
typeof dto.canApprove !== 'boolean' ||
|
|
typeof dto.canReject !== 'boolean' ||
|
|
typeof dto.canSend !== 'boolean'
|
|
) {
|
|
return err('brief-view: missing/invalid decisions');
|
|
}
|
|
return ok({
|
|
canEdit: dto.canEdit,
|
|
canApprove: dto.canApprove,
|
|
canReject: dto.canReject,
|
|
canSend: dto.canSend,
|
|
});
|
|
}
|
|
|
|
export function parseOrgTemplate(dto: OrgTemplateDto | undefined): Result<string, OrgTemplate> {
|
|
if (
|
|
typeof dto?.subOrgId !== 'string' ||
|
|
typeof dto.orgName !== 'string' ||
|
|
typeof dto.returnAddress !== 'string' ||
|
|
typeof dto.footerContact !== 'string' ||
|
|
typeof dto.footerLegal !== 'string' ||
|
|
typeof dto.signatureName !== 'string' ||
|
|
typeof dto.signatureRole !== 'string' ||
|
|
typeof dto.signatureClosing !== 'string' ||
|
|
typeof dto.version !== 'number'
|
|
) {
|
|
return err('org-template: bad shape');
|
|
}
|
|
const m = dto.margins;
|
|
if (
|
|
typeof m?.topMm !== 'number' ||
|
|
typeof m.rightMm !== 'number' ||
|
|
typeof m.bottomMm !== 'number' ||
|
|
typeof m.leftMm !== 'number'
|
|
) {
|
|
return err('org-template: bad margins');
|
|
}
|
|
return ok({
|
|
subOrgId: dto.subOrgId,
|
|
orgName: dto.orgName,
|
|
returnAddress: dto.returnAddress,
|
|
...(dto.logoDocumentId != null ? { logoDocumentId: dto.logoDocumentId } : {}),
|
|
footerContact: dto.footerContact,
|
|
footerLegal: dto.footerLegal,
|
|
signatureName: dto.signatureName,
|
|
signatureRole: dto.signatureRole,
|
|
signatureClosing: dto.signatureClosing,
|
|
margins: { topMm: m.topMm, rightMm: m.rightMm, bottomMm: m.bottomMm, leftMm: m.leftMm },
|
|
version: dto.version,
|
|
});
|
|
}
|
|
|
|
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 decisions = parseDecisions(dto.decisions);
|
|
if (!decisions.ok) return decisions;
|
|
const orgTemplate = parseOrgTemplate(dto.orgTemplate);
|
|
if (!orgTemplate.ok) return orgTemplate;
|
|
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,
|
|
decisions: decisions.value,
|
|
orgTemplate: orgTemplate.value,
|
|
});
|
|
}
|
|
|
|
// --- 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),
|
|
...(p.list ? { list: p.list } : {}),
|
|
})),
|
|
};
|
|
}
|
|
|
|
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,
|
|
locked: s.locked,
|
|
blocks: s.blocks.map(blockToDto),
|
|
};
|
|
}
|