Files
atomic-design-poc/apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts
T
ehoandClaude Opus 5 80de261299 refactor(shared): split runResult out of runSubmit (RB-17)
runSubmit did two things at once: fold a call into a Result, and mint
an Idempotency-Key for it. Five call sites are reads and had no
business minting one — brief.adapter.ts:load, org-template.adapter.ts
:list/:load, and stamdata.adapter.ts:list/:load. stamdata.adapter.ts's
own docstring already said "Both endpoints are reads … There is no
write method" while both called runSubmit; that mismatch is the
sharpest evidence, and the reason the baseline's original "~13
mutations" count (derived from the helper's name, not the code) was
wrong by five in one direction.

Split submit.ts in place: runResult is the try/catch + problemDetail
fold with no mint; runSubmit is runResult wrapping
withIdempotencyKey. Zero behaviour change for the 8 real mutations
(brief save/submit/approve/reject/send/reset, org-template
save/publish/rollback) — same fold, same mint, same timing. The five
reads now run the fold with no pendingIdempotencyKey touched.

submit.spec.ts asserts the split behaviourally via
currentIdempotencyKey() (two reads inside the same call agree only
when a key was minted and reused) rather than mocking a relative
import, matching this repo's existing vitest convention. Verified red
without the fix by temporarily reintroducing the mint into runResult.

ApplicationsStore.cancel/AdminCasesStore.delete (RB-20) and
FeatureFlagStore.set are out of scope and untouched — the latter
already calls runSubmit correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:31:13 +02:00

168 lines
6.1 KiB
TypeScript

import { Injectable, inject, isDevMode } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { runResult, 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`.
* `X-Role` there is a dev-only identity stand-in (`role.ts`) and is sent only under
* `isDevMode()`, mirroring `roleInterceptor`'s own dev-only registration — a production
* build never sends it from this hand-written call either (BIO-012).
*/
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
export 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 runResult(() => 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 runResult(() => 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: isDevMode() ? { 'X-Role': currentRole() } : {} },
);
} catch {
return err(PROEFBRIEF_FAILED);
}
if (!res.ok) return err(await proefbriefErrorMessage(res));
return ok(await res.blob());
}
}
/** Trust boundary (TE-002): maps a non-OK proefbrief response to a message. Exported
so a spec can call it directly instead of stubbing `globalThis.fetch`. */
export async function proefbriefErrorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), PROEFBRIEF_FAILED);
} catch {
return PROEFBRIEF_FAILED;
}
}
// --- 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,
};
}