feat(brief): tolerate a 404 on GET /brief with a one-shot reset (RB-22)

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>
This commit is contained in:
eho
2026-08-27 18:43:27 +02:00
co-authored by Claude Opus 5
parent 7fbac8fca5
commit 7a29f5facc
6 changed files with 309 additions and 59 deletions
@@ -3,7 +3,12 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { Result } from '@shared/kernel/fp';
import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import {
BRIEF_LOAD_FAILED,
BriefAdapter,
BriefLoadFailure,
BriefView,
} from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter, PREVIEW_FAILED } from '@brief/infrastructure/letter-preview.adapter';
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
import { BriefStore } from './brief.store';
@@ -60,7 +65,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
};
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: approved }),
@@ -79,7 +85,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
};
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: approved }),
@@ -93,7 +100,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: false, error: 'niet toegestaan' }),
@@ -108,7 +116,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
it('a subsequent successful transition clears a prior Failed state', async () => {
let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> => Promise.resolve(approveResult),
});
@@ -156,8 +165,10 @@ function loadedBrief(store: BriefStore): Brief {
}
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: v });
// Untyped return (inferred as the narrow `{ ok: true; value }` literal) so this one
// helper satisfies both `load` (error channel `BriefLoadFailure`) and `save` (error
// channel `string`) — it only ever produces the `ok: true` branch.
const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
await store.load();
return store;
@@ -255,8 +266,7 @@ describe('BriefStore rejection diff', () => {
...filledBrief,
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
};
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: v });
const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
const store = setup({
load: () => ok({ ...filledView, brief: submitted }),
save: () => ok(filledView),
@@ -283,7 +293,8 @@ describe('BriefStore.previewLetter', () => {
it('opens the composed letter in a new tab on success', async () => {
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
});
await store.load();
const blob = new Blob(['<html></html>'], { type: 'text/html' });
@@ -301,7 +312,8 @@ describe('BriefStore.previewLetter', () => {
it('surfaces the error without opening a tab on failure', async () => {
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
});
await store.load();
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
@@ -377,3 +389,42 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
expect(save).not.toHaveBeenCalled();
});
});
// --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the
// existing reset() command, exactly once. Today's backend never 404s (RB-23 adds
// that); this fake adapter is what exercises the branch until then. ---
describe('BriefStore.load — 404 tolerance (RB-22)', () => {
const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } };
const resetOk: Result<string, BriefView> = { ok: true, value: view };
it('a 404 drives exactly one reset(), which populates the store', async () => {
// Given GET /brief 404s (no brief exists yet) and reset() succeeds.
const load = vi.fn(() => Promise.resolve(notFound));
const reset = vi.fn(() => Promise.resolve(resetOk));
const store = setup({ load, reset });
// When the store loads...
await store.load();
// Then reset() ran exactly once, and the store ends up loaded from its result.
expect(reset).toHaveBeenCalledTimes(1);
expect(store.model().tag).toBe('loaded');
});
it('a second 404 does not drive a second reset()', async () => {
// Given every load() attempt 404s (e.g. the brief still fails to appear).
const load = vi.fn(() => Promise.resolve(notFound));
const reset = vi.fn(() => Promise.resolve(resetOk));
const store = setup({ load, reset });
// When the store loads twice...
await store.load();
await store.load();
// Then reset() ran exactly once — the once-only bound holds across calls, not
// just within one — and the second 404 surfaces as an ordinary load failure.
expect(reset).toHaveBeenCalledTimes(1);
expect(store.model()).toEqual({ tag: 'failed', reason: BRIEF_LOAD_FAILED });
});
});
@@ -16,7 +16,7 @@ import {
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { BRIEF_LOAD_FAILED, BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
import { uploadContentUrl } from '@shared/upload/upload.adapter';
@@ -119,13 +119,40 @@ export class BriefStore implements PendingSave {
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
});
/** True once a 404-triggered recovery has been attempted (RB-22, CQ-007's expand
half — see `recoverFromMissingBrief`). This is the structural once-only bound:
a repeated 404 falls straight to the `error` branch below and can never reach
`adapter.reset()` a second time, regardless of how many times `load()` runs. */
private hasRecoveredFromMissingBrief = false;
async load() {
const r = await this.adapter.load();
if (r.ok) {
this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext);
this.history.clear();
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
this.applyLoadedView(r.value);
} else if (r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief) {
this.hasRecoveredFromMissingBrief = true;
await this.recoverFromMissingBrief();
} else {
const reason = r.error.tag === 'notFound' ? BRIEF_LOAD_FAILED : r.error.reason;
this.store.dispatch({ tag: 'BriefLoadFailed', reason });
}
}
private applyLoadedView(view: BriefView) {
this.orgTemplate.set(view.orgTemplate);
this.caseContext.set(view.caseContext);
this.history.clear();
this.store.dispatch({ tag: 'BriefLoaded', ...view });
}
/** `GET /brief` 404'd — no brief exists yet for this owner. Recover by calling the
existing `reset()` command directly (the same POST `resetDemo()` uses) and
applying whatever it returns; this NEVER calls `load()` again, so a second 404
(e.g. `reset()` itself failing) cannot loop back into this method. */
private async recoverFromMissingBrief() {
const r = await this.adapter.reset();
if (r.ok) {
this.applyLoadedView(r.value);
} else {
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
}
@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { runResult, runSubmit } from '@shared/application/submit';
import { runSubmit } from '@shared/application/submit';
import { problemDetail } from '@shared/infrastructure/api-error';
import {
ApiClient,
BriefDecisionsDto,
@@ -33,9 +34,13 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
* 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. `load` (the only read) folds through `runResult`;
* every mutation folds through `runSubmit` (ProblemDetails → error string, plus the
* Idempotency-Key mint), then parses the returned brief.
* 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 {
@@ -46,16 +51,39 @@ export interface BriefView {
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<string, BriefView>> {
const r = await runResult(() => this.client.briefGET(), BRIEF_LOAD_FAILED);
return r.ok ? parseBriefView(r.value) : r;
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>> {