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>
325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
import { Injectable, computed, inject, signal } from '@angular/core';
|
|
import { Result } from '@shared/kernel/fp';
|
|
import { createStore } from '@shared/application/store';
|
|
import { ActionState, SaveState } from '@shared/application/action-state';
|
|
import { createHistory } from '@shared/application/history';
|
|
import { createDebouncedSave } from '@shared/application/debounced-save';
|
|
import { machineRemoteData } from '@shared/application/machine-remote-data';
|
|
import {
|
|
Brief,
|
|
CaseContext,
|
|
allDiagnostics,
|
|
canSubmit,
|
|
hasBlockingErrors,
|
|
unresolvedPlaceholders,
|
|
} from '@brief/domain/brief';
|
|
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 { 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';
|
|
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
|
|
|
/**
|
|
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
|
|
* read-model, and the commands (effects) that call the adapter and dispatch the
|
|
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
|
|
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
|
|
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
|
|
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
|
|
*/
|
|
@Injectable({ providedIn: 'root' })
|
|
export class BriefStore implements PendingSave {
|
|
private adapter = inject(BriefAdapter);
|
|
private previewAdapter = inject(LetterPreviewAdapter);
|
|
private revealAdapter = inject(RevealBigNummerAdapter);
|
|
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
|
|
|
readonly model = this.store.model;
|
|
|
|
private actionState = signal<ActionState>({ tag: 'Idle' });
|
|
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
|
readonly lastError = computed(() => {
|
|
const s = this.actionState();
|
|
return s.tag === 'Failed' ? s.error : null;
|
|
});
|
|
|
|
/** Surfaced autosave state for the indicator + aria-live region. */
|
|
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
|
|
|
/** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of
|
|
`Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded
|
|
(they flow through `edit()`); status transitions never enter history, or undo would
|
|
replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine
|
|
changes. */
|
|
private history = createHistory<Brief>(50);
|
|
readonly canUndo = this.history.canUndo;
|
|
readonly canRedo = this.history.canRedo;
|
|
|
|
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The
|
|
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
|
|
full page reload loses it — a real system would persist the rejected revision. */
|
|
private rejectionSnapshot = signal<Brief | null>(null);
|
|
/** Changed/added/removed blocks since rejection — a pure fold over two snapshots. */
|
|
readonly blockDiffs = computed<ReadonlyMap<string, BlockDiffKind>>(() => {
|
|
const before = this.rejectionSnapshot();
|
|
const after = this.brief();
|
|
return before && after ? changedBlocks(diffBlocks(before, after)) : new Map();
|
|
});
|
|
/** Count of blocks removed since rejection — badged as a summary, since a removed
|
|
block no longer renders inline. */
|
|
readonly removedSinceReject = computed(
|
|
() => [...this.blockDiffs().values()].filter((k) => k === 'removed').length,
|
|
);
|
|
readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
|
|
|
|
/** The org template the letter renders with (WP-24). Server-owned appearance data,
|
|
not letter state — held beside the machine, never inside it (`brief.machine.ts`
|
|
stays untouched by design). Set from every server view that carries it. */
|
|
readonly orgTemplate = signal<OrgTemplate | null>(null);
|
|
|
|
/** The case (zorgverlener + aanvraag) this letter concerns — server-joined context for
|
|
the behandel scherm header, not letter state. Set from every server view. */
|
|
readonly caseContext = signal<CaseContext | null>(null);
|
|
|
|
/** The org logo's content URL for the letterhead, or null when the template has none. */
|
|
readonly logoUrl = computed<string | null>(() => {
|
|
const id = this.orgTemplate()?.logoDocumentId;
|
|
return id ? uploadContentUrl(id) : null;
|
|
});
|
|
|
|
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
|
|
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
|
|
purely a projection of its loading/failed tags onto the shared async seam. */
|
|
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
|
|
|
private brief = computed<Brief | null>(() => {
|
|
const s = this.model();
|
|
return s.tag === 'loaded' ? s.brief : null;
|
|
});
|
|
|
|
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
|
|
readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);
|
|
readonly canReject = computed(() => this.decisions()?.canReject ?? false);
|
|
readonly canSend = computed(() => this.decisions()?.canSend ?? false);
|
|
/** Field-level PII reveal (PRD-0002 §5c), deny-by-default like the action gates. */
|
|
readonly canRevealBigNummer = computed(() => this.decisions()?.canRevealBigNummer ?? false);
|
|
|
|
private decisions = computed(() => {
|
|
const s = this.model();
|
|
return s.tag === 'loaded' ? s.decisions : null;
|
|
});
|
|
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
|
|
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
|
|
/** Submit is allowed only when required sections are filled AND no blocking errors. */
|
|
readonly canSubmit = computed(() => {
|
|
const b = this.brief();
|
|
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.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 });
|
|
}
|
|
}
|
|
|
|
/** An edit: apply it optimistically in the pure reducer, then debounce-save. Records
|
|
an undo step only when the reducer actually changed the brief (a no-op edit — e.g.
|
|
a locked section — returns the same value and leaves no dead history step). */
|
|
edit(msg: BriefMsg) {
|
|
const before = this.brief();
|
|
this.store.dispatch(msg);
|
|
const after = this.brief();
|
|
// Record only a real change: a no-op edit (e.g. a locked section) returns the same
|
|
// value and leaves no dead history step.
|
|
if (before && after && after !== before) this.history.record(before);
|
|
this.debouncedSave.schedule();
|
|
}
|
|
|
|
/** Undo/redo: restore a snapshot via the existing `Seed` Msg, then autosave. */
|
|
undo() {
|
|
this.restore((current) => this.history.undo(current));
|
|
}
|
|
redo() {
|
|
this.restore((current) => this.history.redo(current));
|
|
}
|
|
private restore(step: (current: Brief) => Brief | undefined) {
|
|
const s = this.model();
|
|
if (s.tag !== 'loaded') return;
|
|
const target = step(s.brief);
|
|
if (target === undefined) return;
|
|
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
|
|
this.debouncedSave.schedule();
|
|
}
|
|
|
|
constructor() {
|
|
// Register so the CanDeactivate guard / beforeunload handler can flush a pending
|
|
// debounced edit before navigation or unload (see pending-saves.ts).
|
|
registerPendingSave(this);
|
|
}
|
|
|
|
// 600ms debounced autosave (the server is the store of record). Timer mechanics live in
|
|
// the shared helper; `flushSave` below is the store-specific write + save-state (WP-31).
|
|
private debouncedSave = createDebouncedSave({
|
|
canSave: () => this.canEdit(),
|
|
flush: () => this.flushSave(),
|
|
});
|
|
/** PendingSave: delegate to the debounce helper so the guard/unload can flush. */
|
|
hasPendingSave = () => this.debouncedSave.hasPendingSave();
|
|
flushPending = () => this.debouncedSave.flushPending();
|
|
private async flushSave() {
|
|
const b = this.brief();
|
|
if (!b) return;
|
|
this.saveState.set({ tag: 'Saving' });
|
|
const r = await this.adapter.save(b.sections);
|
|
if (r.ok) {
|
|
this.saveState.set({ tag: 'Saved' });
|
|
} else {
|
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
|
this.saveState.set({ tag: 'Error' });
|
|
}
|
|
}
|
|
|
|
/** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */
|
|
retrySave() {
|
|
void this.flushSave();
|
|
}
|
|
|
|
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
|
async resetDemo() {
|
|
this.actionState.set({ tag: 'Busy' });
|
|
this.debouncedSave.cancel();
|
|
const r = await this.adapter.reset();
|
|
this.saveState.set({ tag: 'Idle' });
|
|
if (r.ok) {
|
|
this.actionState.set({ tag: 'Idle' });
|
|
this.orgTemplate.set(r.value.orgTemplate);
|
|
this.caseContext.set(r.value.caseContext);
|
|
this.history.clear();
|
|
this.rejectionSnapshot.set(null);
|
|
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
|
} else {
|
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
|
}
|
|
}
|
|
|
|
submit = () => this.transition(() => this.adapter.submit());
|
|
approve = () => this.transition(() => this.adapter.approve());
|
|
reject = (comments: string) => this.transition(() => this.adapter.reject(comments));
|
|
send = () => this.transition(() => this.adapter.send());
|
|
|
|
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
|
|
letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and
|
|
the tab outlives this call; not worth a teardown hook for a POC. */
|
|
async previewLetter() {
|
|
this.actionState.set({ tag: 'Busy' });
|
|
const r = await this.previewAdapter.preview();
|
|
if (!r.ok) {
|
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
|
return;
|
|
}
|
|
this.actionState.set({ tag: 'Idle' });
|
|
window.open(URL.createObjectURL(r.value), '_blank');
|
|
}
|
|
|
|
/** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability
|
|
+ step-up and audits the attempt; on success we swap the masked value in the
|
|
already-loaded caseContext (a field update, not a reload). The step-up gesture
|
|
itself is the UI's concern (`behandel-scherm.component.ts`'s `onReveal()` confirm)
|
|
— this command is only reachable once that gesture has happened, so it is the one
|
|
that tells the adapter to send `X-Step-Up` (BIO-006a: the adapter itself no longer
|
|
hardcodes the header). */
|
|
async revealBigNummer() {
|
|
const r = await this.revealAdapter.reveal(true);
|
|
if (!r.ok) {
|
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
|
return;
|
|
}
|
|
this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));
|
|
}
|
|
|
|
// A transition: flush any pending save, call the server (authoritative), then mirror
|
|
// the returned status through the pure reducer's guarded transition.
|
|
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
|
this.actionState.set({ tag: 'Busy' });
|
|
this.debouncedSave.cancel();
|
|
await this.flushSave();
|
|
const r = await action();
|
|
if (!r.ok) {
|
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
|
return;
|
|
}
|
|
this.actionState.set({ tag: 'Idle' });
|
|
this.applyServerStatus(r.value);
|
|
}
|
|
|
|
private applyServerStatus(view: BriefView) {
|
|
// `send` pins the org-template version server-side — mirror whatever came back.
|
|
this.orgTemplate.set(view.orgTemplate);
|
|
this.caseContext.set(view.caseContext);
|
|
const { brief, decisions } = view;
|
|
const s = brief.status;
|
|
switch (s.tag) {
|
|
case 'submitted':
|
|
this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt, decisions });
|
|
break;
|
|
case 'approved':
|
|
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
|
|
break;
|
|
case 'rejected':
|
|
// Capture the letter as-rejected for the resubmission diff (WP-27). This is the
|
|
// "before" snapshot the approver later compares against.
|
|
this.rejectionSnapshot.set(brief);
|
|
this.store.dispatch({
|
|
tag: 'Rejected',
|
|
by: s.rejectedBy,
|
|
at: s.rejectedAt,
|
|
comments: s.comments,
|
|
decisions,
|
|
});
|
|
break;
|
|
case 'sent':
|
|
this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });
|
|
break;
|
|
case 'draft':
|
|
// reopened by a save on a rejected letter — reducer already handled it locally.
|
|
break;
|
|
}
|
|
}
|
|
}
|