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, 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[]; } 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> { const r = await runSubmit(() => this.client.briefGET(), BRIEF_LOAD_FAILED); return r.ok ? parseBriefView(r.value) : r; } async save(sections: readonly LetterSection[]): Promise> { const r = await runSubmit( () => this.client.briefPUT({ sections: sections.map(sectionToDto) }), BRIEF_ACTION_FAILED, ); return r.ok ? parseBrief(r.value) : r; } async submit(): Promise> { const r = await runSubmit(() => this.client.briefSubmit(), BRIEF_ACTION_FAILED); return r.ok ? parseBrief(r.value) : r; } async approve(): Promise> { const r = await runSubmit(() => this.client.approve(), BRIEF_ACTION_FAILED); return r.ok ? parseBrief(r.value) : r; } async reject(comments: string): Promise> { const r = await runSubmit(() => this.client.reject({ comments }), BRIEF_ACTION_FAILED); return r.ok ? parseBrief(r.value) : r; } async send(): Promise> { const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED); return r.ok ? parseBrief(r.value) : r; } /** Demo "start over" — recreate a fresh brief server-side and return the new view. */ async reset(): Promise> { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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), ...(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), }; }