feat(WP-67): merge behandelportal into this repo as a monorepo

Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-02 21:01:57 +02:00
co-authored by Claude Sonnet 5
parent d3f3b13345
commit e7156c5132
403 changed files with 7103 additions and 60917 deletions
@@ -0,0 +1,256 @@
import { describe, it, expect } from 'vitest';
import { BriefViewDto } from '@shared/infrastructure/api-client';
import {
parseBrief,
parseBriefView,
parseNode,
parseOrgTemplate,
parseStatus,
} from './brief.adapter';
const view: BriefViewDto = {
brief: {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
drafterId: 'demo-drafter',
status: { tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' },
placeholders: [
{ key: 'naam', label: 'Naam', autoResolvable: true },
{ key: 'code', label: 'Code', autoResolvable: true, fillable: false },
],
sections: [
{
sectionKey: 'aanhef',
title: 'Aanhef',
required: true,
blocks: [
{
type: 'passage',
blockId: 'local-1',
content: { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] },
sourcePassageId: 'p1',
sourceVersion: 2,
edited: true,
},
{
type: 'freeText',
blockId: 'local-2',
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'hoi', marks: ['bold'] }] }] },
},
],
},
],
},
availablePassages: [
{
passageId: 'p1',
scope: 'global',
sectionKey: 'aanhef',
label: 'Aanhef',
content: { paragraphs: [{ nodes: [] }] },
version: 1,
},
{
passageId: 'p-neg-scholing',
scope: 'global',
sectionKey: 'kern',
label: 'Onvoldoende scholing',
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'x' }] }] },
version: 1,
besluit: 'negatief',
reason: 'onvoldoende_scholing',
},
],
decisions: {
canEdit: false,
canApprove: true,
canReject: true,
canSend: false,
canRevealBigNummer: false,
},
orgTemplate: {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
footerLegal: 'KvK 00000000',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1,
},
caseContext: {
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
bigNummer: '19012345601',
beroep: 'arts',
aanvraagReferentie: 'HER-2026-000842',
},
};
describe('brief.adapter parse boundary', () => {
it('parses a well-formed view into the domain unions', () => {
const r = parseBriefView(view);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.brief.status).toEqual({
tag: 'submitted',
submittedBy: 'demo-drafter',
submittedAt: '2026-07-01',
});
const [passage, free] = r.value.brief.sections[0].blocks;
expect(passage.type === 'passage' && passage.edited).toBe(true);
expect(free.type).toBe('freeText');
expect(r.value.brief.placeholders[1]).toEqual({
key: 'code',
label: 'Code',
autoResolvable: true,
fillable: false,
});
expect(r.value.decisions).toEqual({
canEdit: false,
canApprove: true,
canReject: true,
canSend: false,
canRevealBigNummer: false,
});
// Guided-drafting tags survive the boundary; the untagged passage has neither.
expect(r.value.availablePassages[0].besluit).toBeUndefined();
expect(r.value.availablePassages[1]).toMatchObject({
besluit: 'negatief',
reason: 'onvoldoende_scholing',
});
expect(r.value.caseContext).toEqual({
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
bigNummer: '19012345601',
beroep: 'arts',
aanvraagReferentie: 'HER-2026-000842',
});
});
it('rejects a view whose case context is missing or malformed', () => {
expect(parseBriefView({ ...view, caseContext: undefined }).ok).toBe(false);
expect(
parseBriefView({
...view,
caseContext: { ...view.caseContext!, bigNummer: undefined as never },
}).ok,
).toBe(false);
});
it('parses the org template and drops a null logoDocumentId', () => {
const r = parseOrgTemplate(view.orgTemplate);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.orgName).toBe('CIBG — Registers');
expect(r.value.margins).toEqual({ topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 });
expect('logoDocumentId' in r.value).toBe(false);
});
it('rejects a view whose org template is missing or malformed', () => {
expect(parseBriefView({ ...view, orgTemplate: undefined }).ok).toBe(false);
expect(parseOrgTemplate({ ...view.orgTemplate, signatureName: undefined }).ok).toBe(false);
expect(
parseOrgTemplate({
...view.orgTemplate,
margins: { topMm: 25, rightMm: 25, bottomMm: 25 },
}).ok,
).toBe(false);
});
it('rejects a view whose decisions are missing or malformed', () => {
expect(parseBriefView({ ...view, decisions: undefined as never }).ok).toBe(false);
expect(
parseBriefView({ ...view, decisions: { ...view.decisions, canSend: 'yes' as never } }).ok,
).toBe(false);
// The PII-reveal flag (PRD-0002 §5c) is required at the boundary too.
expect(
parseBriefView({
...view,
decisions: { ...view.decisions, canRevealBigNummer: undefined as never },
}).ok,
).toBe(false);
});
it('narrows node variants and rejects unknown ones', () => {
expect(parseNode({ type: 'text', text: 'x' })).toEqual({
ok: true,
value: { type: 'text', text: 'x' },
});
expect(parseNode({ type: 'placeholder', key: 'k' })).toEqual({
ok: true,
value: { type: 'placeholder', key: 'k' },
});
expect(parseNode({ type: 'lineBreak' })).toEqual({ ok: true, value: { type: 'lineBreak' } });
expect(parseNode({ type: 'text' }).ok).toBe(false); // missing text
expect(parseNode({ type: 'bogus' } as never).ok).toBe(false);
});
it('rejects a status DTO missing its required fields', () => {
expect(parseStatus({ tag: 'submitted' }).ok).toBe(false); // no submittedBy/At
expect(parseStatus({ tag: 'rejected', rejectedBy: 'x', rejectedAt: 't' }).ok).toBe(false); // no comments
expect(parseStatus({ tag: 'draft' }).ok).toBe(true);
});
it('reads section.locked (default false) and paragraph.list', () => {
const r = parseBrief({
...view.brief,
sections: [
{
sectionKey: 'aanhef',
title: 'Aanhef',
required: true,
locked: true,
blocks: [
{
type: 'freeText',
blockId: 'b1',
content: {
paragraphs: [
{ nodes: [{ type: 'text', text: 'een' }], list: 'bullet' },
{ nodes: [{ type: 'text', text: 'twee' }], list: 'number' },
{ nodes: [{ type: 'text', text: 'plat' }] },
],
},
},
],
},
{ sectionKey: 'kern', title: 'Kern', required: true, blocks: [] }, // no `locked` → false
],
});
expect(r.ok).toBe(true);
if (!r.ok) return;
const [aanhef, kern] = r.value.sections;
expect(aanhef.locked).toBe(true);
expect(kern.locked).toBe(false);
expect(aanhef.blocks[0].content.paragraphs.map((p) => p.list)).toEqual([
'bullet',
'number',
undefined,
]);
});
it('rejects a library passage with an unknown scope', () => {
const r = parseBriefView({
...view,
availablePassages: [{ ...view.availablePassages![0], scope: 'bogus' as never }],
});
expect(r.ok).toBe(false);
});
it('rejects a passage block missing provenance', () => {
const r = parseBrief({
...view.brief,
sections: [
{
sectionKey: 's',
title: 'S',
required: false,
blocks: [{ type: 'passage', blockId: 'b', content: { paragraphs: [] } }],
},
],
});
expect(r.ok).toBe(false);
});
});
@@ -0,0 +1,437 @@
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,
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. 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;
readonly caseContext: CaseContext;
}
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 } : {}),
...(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),
};
}
@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '@shared/environments/environment';
const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`;
/**
* `/brief/preview` returns `text/html`, not JSON, and is `.ExcludeFromDescription()`'d
* to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
* `roleInterceptor`, so `X-Role` is set here explicitly.
*/
@Injectable({ providedIn: 'root' })
export class LetterPreviewAdapter {
async preview(): Promise<Result<string, Blob>> {
let res: Response;
try {
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
headers: { 'X-Role': currentRole() },
});
} catch {
return err(PREVIEW_FAILED);
}
if (!res.ok) return err(await errorMessage(res));
return ok(await res.blob());
}
}
async function errorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), PREVIEW_FAILED);
} catch {
return PREVIEW_FAILED;
}
}
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
import { parseOrgTemplateAdminView } from './org-template.adapter';
const draft: OrgTemplateDto = {
subOrgId: 'cibg-registers',
orgName: 'CIBG',
returnAddress: 'Postbus 1',
footerContact: 'info@cibg.nl',
footerLegal: 'onderdeel van VWS',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 },
version: 3,
};
const view: OrgTemplateAdminViewDto = {
draft,
publishedVersion: 3,
unsentBriefs: 2,
history: [{ version: 2, publishedAt: '2026-06-01', template: draft }],
};
describe('parseOrgTemplateAdminView', () => {
it('parses a well-formed admin view', () => {
const r = parseOrgTemplateAdminView(view);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.draft.orgName).toBe('CIBG');
expect(r.value.publishedVersion).toBe(3);
expect(r.value.unsentBriefs).toBe(2);
expect(r.value.history).toHaveLength(1);
expect(r.value.history[0].version).toBe(2);
});
it('rejects a missing draft', () => {
const r = parseOrgTemplateAdminView({ ...view, draft: undefined });
expect(r.ok).toBe(false);
});
it('rejects a missing count field', () => {
const r = parseOrgTemplateAdminView({ ...view, unsentBriefs: undefined });
expect(r.ok).toBe(false);
});
it('rejects a malformed history entry', () => {
const r = parseOrgTemplateAdminView({
...view,
history: [
{ version: 2, publishedAt: '2026-06-01', template: { ...draft, orgName: undefined } },
],
});
expect(r.ok).toBe(false);
});
});
@@ -0,0 +1,160 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit';
import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '@shared/environments/environment';
import {
ApiClient,
OrgTemplateAdminViewDto,
OrgTemplateDto,
OrgTemplateVersionDto,
PublishOrgTemplateResponse,
SubOrgSummaryDto,
} from '@shared/infrastructure/api-client';
import {
OrgTemplate,
OrgTemplateAdminView,
OrgTemplateVersion,
PublishResult,
SubOrgSummary,
} from '@brief/domain/org-template';
import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';
/**
* The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/
* rollback go through the generated client (X-Role added by `roleInterceptor`);
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.
*/
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
@Injectable({ providedIn: 'root' })
export class OrgTemplateAdapter {
private client = inject(ApiClient);
async list(): Promise<Result<string, SubOrgSummary[]>> {
const r = await runSubmit(() => this.client.orgTemplates(), FAILED);
if (!r.ok) return r;
const out: SubOrgSummary[] = [];
for (const s of r.value ?? []) {
const parsed = parseSubOrg(s);
if (!parsed.ok) return parsed;
out.push(parsed.value);
}
return ok(out);
}
async load(subOrgId: string): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED);
return r.ok ? parseAdminView(r.value) : r;
}
async save(subOrgId: string, draft: OrgTemplate): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(
() => this.client.orgTemplatePUT(subOrgId, { draft: toDto(draft) }),
FAILED,
);
return r.ok ? parseAdminView(r.value) : r;
}
async publish(subOrgId: string): Promise<Result<string, PublishResult>> {
const r = await runSubmit(() => this.client.orgTemplatePublish(subOrgId), FAILED);
return r.ok ? parsePublish(r.value) : r;
}
async rollback(subOrgId: string, version: number): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED);
return r.ok ? parseAdminView(r.value) : r;
}
/** Proefbrief: the unpublished draft rendered over a fixture letter, opened as a Blob. */
async proefbrief(subOrgId: string): Promise<Result<string, Blob>> {
let res: Response;
try {
res = await fetch(
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
{ headers: { 'X-Role': currentRole() } },
);
} catch {
return err(PROEFBRIEF_FAILED);
}
if (!res.ok) {
try {
return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));
} catch {
return err(PROEFBRIEF_FAILED);
}
}
return ok(await res.blob());
}
}
// --- parse: wire → domain, validating at the boundary ---
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
if (typeof dto.subOrgId !== 'string' || typeof dto.orgName !== 'string')
return err('sub-org: bad shape');
return ok({
subOrgId: dto.subOrgId,
orgName: dto.orgName,
publishedVersion: dto.publishedVersion ?? 0,
});
}
function parseVersion(dto: OrgTemplateVersionDto): Result<string, OrgTemplateVersion> {
if (typeof dto.version !== 'number' || typeof dto.publishedAt !== 'string')
return err('version: bad shape');
const template = parseOrgTemplate(dto.template);
if (!template.ok) return template;
return ok({ version: dto.version, publishedAt: dto.publishedAt, template: template.value });
}
export function parseOrgTemplateAdminView(
dto: OrgTemplateAdminViewDto,
): Result<string, OrgTemplateAdminView> {
const draft = parseOrgTemplate(dto.draft);
if (!draft.ok) return draft;
if (typeof dto.publishedVersion !== 'number' || typeof dto.unsentBriefs !== 'number')
return err('admin-view: bad shape');
const history: OrgTemplateVersion[] = [];
for (const v of dto.history ?? []) {
const parsed = parseVersion(v);
if (!parsed.ok) return parsed;
history.push(parsed.value);
}
return ok({
draft: draft.value,
publishedVersion: dto.publishedVersion,
history,
unsentBriefs: dto.unsentBriefs,
});
}
const parseAdminView = parseOrgTemplateAdminView;
function parsePublish(dto: PublishOrgTemplateResponse): Result<string, PublishResult> {
if (typeof dto.version !== 'number' || typeof dto.affectedUnsentBriefs !== 'number')
return err('publish: bad shape');
return ok({ version: dto.version, affectedUnsentBriefs: dto.affectedUnsentBriefs });
}
// --- toDto: domain → wire (for save) ---
function toDto(t: OrgTemplate): OrgTemplateDto {
return {
subOrgId: t.subOrgId,
orgName: t.orgName,
returnAddress: t.returnAddress,
...(t.logoDocumentId != null ? { logoDocumentId: t.logoDocumentId } : {}),
footerContact: t.footerContact,
footerLegal: t.footerLegal,
signatureName: t.signatureName,
signatureRole: t.signatureRole,
signatureClosing: t.signatureClosing,
margins: { ...t.margins },
version: t.version,
};
}
@@ -0,0 +1,51 @@
import { Injectable } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '@shared/environments/environment';
const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
/**
* Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;
* this unmasks it, gated server-side by the reveal capability AND a step-up. The
* step-up is stubbed as the `X-Step-Up` header — the caller sends it only after the
* user's confirm gesture, so a plain call (or a role without the capability) 403s.
*
* Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;
* `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the
* same seam as `/brief/preview` and uploads — which also means `X-Role` is set here.
*/
@Injectable({ providedIn: 'root' })
export class RevealBigNummerAdapter {
async reveal(): Promise<Result<string, string>> {
let res: Response;
try {
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, {
method: 'POST',
headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' },
});
} catch {
return err(REVEAL_FAILED);
}
if (!res.ok) return err(await errorMessage(res));
const body: unknown = await res.json().catch(() => null);
// Trust boundary: validate the shape before handing back a plain string.
if (
typeof body === 'object' &&
body !== null &&
typeof (body as { bigNummer?: unknown }).bigNummer === 'string'
) {
return ok((body as { bigNummer: string }).bigNummer);
}
return err(REVEAL_FAILED);
}
}
async function errorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), REVEAL_FAILED);
} catch {
return REVEAL_FAILED;
}
}