BriefStore.load() now treats a 404 from GET /brief as "no brief exists yet" and calls the existing reset() command once, instead of showing the generic load-failed error. BriefAdapter.load() gains a BriefLoadFailure error channel (notFound | error) so the store can tell a 404 apart from every other failure; every other adapter method stays on runSubmit, unchanged. The once-only bound is a field on the store, not a comment: a second 404 (from a later load() call) always falls through to the ordinary error path, and the recovery path never calls load() again, so no loop can form. This is the expand half of CQ-007's split (04-cqrs-light.md). Today's backend never 404s GET /brief, so the new branch is dead code until RB-23 (the backend contract half) ships in a later merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
467 lines
16 KiB
TypeScript
467 lines
16 KiB
TypeScript
import { Injectable, inject } from '@angular/core';
|
|
import { Result, ok, err } from '@shared/kernel/fp';
|
|
import { runSubmit } from '@shared/application/submit';
|
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
|
import {
|
|
ApiClient,
|
|
BriefDecisionsDto,
|
|
BriefDto,
|
|
BriefStatusDto,
|
|
BriefViewDto,
|
|
CaseContextDto,
|
|
LetterBlockDto,
|
|
LetterSectionDto,
|
|
LibraryPassageDto,
|
|
OrgTemplateDto,
|
|
PlaceholderDefDto,
|
|
RichTextBlockDto,
|
|
RichTextNodeDto,
|
|
} from '@shared/infrastructure/api-client';
|
|
import {
|
|
Brief,
|
|
BriefDecisions,
|
|
BriefStatus,
|
|
CaseContext,
|
|
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. Every mutation folds through `runSubmit`
|
|
* (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the
|
|
* returned brief. `load` (the only read) does its own try/catch instead of the
|
|
* shared `runResult` fold, because it needs one extra bit `runResult` throws away:
|
|
* whether the failure was an HTTP 404 (see `BriefLoadFailure` — RB-22, CQ-007's
|
|
* expand half). Today's backend never 404s `GET /brief` (RB-23 adds that), so the
|
|
* `notFound` branch is unreached until RB-23 ships; this adapter is ready in advance.
|
|
*/
|
|
|
|
export interface BriefView {
|
|
readonly brief: Brief;
|
|
readonly availablePassages: LibraryPassage[];
|
|
readonly decisions: BriefDecisions;
|
|
readonly orgTemplate: OrgTemplate;
|
|
readonly caseContext: CaseContext;
|
|
}
|
|
|
|
/**
|
|
* Why `load()` did not return a brief. `notFound` is a bare HTTP 404 — kept
|
|
* distinct from every other failure so `BriefStore.load()` can tolerate it (call
|
|
* `reset()` instead of showing an error banner) without conflating it with a real
|
|
* failure. See the class docstring above.
|
|
*/
|
|
export type BriefLoadFailure =
|
|
{ readonly tag: 'notFound' } | { readonly tag: 'error'; readonly reason: string };
|
|
|
|
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.`;
|
|
|
|
/** True when the thrown value carries an HTTP 404 status — matches both the
|
|
generic `SwaggerException` (today's shape, since `GET /brief` declares no 404
|
|
response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once
|
|
RB-23 gives the endpoint a documented 404 response). */
|
|
function isHttpNotFound(e: unknown): boolean {
|
|
return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404;
|
|
}
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class BriefAdapter {
|
|
private client = inject(ApiClient);
|
|
|
|
async load(): Promise<Result<BriefLoadFailure, BriefView>> {
|
|
try {
|
|
const dto = await this.client.briefGET();
|
|
const parsed = parseBriefView(dto);
|
|
return parsed.ok ? ok(parsed.value) : err({ tag: 'error', reason: parsed.error });
|
|
} catch (e) {
|
|
if (isHttpNotFound(e)) return err({ tag: 'notFound' });
|
|
return err({ tag: 'error', reason: problemDetail(e, BRIEF_LOAD_FAILED) });
|
|
}
|
|
}
|
|
|
|
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 } : {}),
|
|
...(dto.besluit === 'positief' || dto.besluit === 'negatief' ? { besluit: dto.besluit } : {}),
|
|
...(dto.reason != null ? { reason: dto.reason } : {}),
|
|
});
|
|
}
|
|
|
|
function parseCaseContext(dto: CaseContextDto | undefined): Result<string, CaseContext> {
|
|
if (
|
|
typeof dto?.zorgverlenerNaam !== 'string' ||
|
|
typeof dto.bigNummer !== 'string' ||
|
|
typeof dto.beroep !== 'string' ||
|
|
typeof dto.aanvraagReferentie !== 'string'
|
|
) {
|
|
return err('brief-view: missing/invalid case context');
|
|
}
|
|
return ok({
|
|
zorgverlenerNaam: dto.zorgverlenerNaam,
|
|
bigNummer: dto.bigNummer,
|
|
beroep: dto.beroep,
|
|
aanvraagReferentie: dto.aanvraagReferentie,
|
|
});
|
|
}
|
|
|
|
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' ||
|
|
typeof dto.canRevealBigNummer !== 'boolean'
|
|
) {
|
|
return err('brief-view: missing/invalid decisions');
|
|
}
|
|
return ok({
|
|
canEdit: dto.canEdit,
|
|
canApprove: dto.canApprove,
|
|
canReject: dto.canReject,
|
|
canSend: dto.canSend,
|
|
canRevealBigNummer: dto.canRevealBigNummer,
|
|
});
|
|
}
|
|
|
|
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 caseContext = parseCaseContext(dto.caseContext);
|
|
if (!caseContext.ok) return caseContext;
|
|
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,
|
|
caseContext: caseContext.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),
|
|
};
|
|
}
|