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>
168 lines
6.1 KiB
TypeScript
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,
|
|
};
|
|
}
|