The action lifecycle (Idle | Busy | Failed) lived in an imperative store-level signal, set from ten call sites outside the reducer. The reducer could not enforce which action transitions are legal. Add `action` to `BriefState.Loaded`, driven by three new messages (ActionStarted, ActionFinished, ActionFailed) and handled in `reduce`. Replace every `actionState.set(...)` call in `brief.store.ts` with the matching `dispatch`. `BriefLoaded` resets `action` to Idle, so a fresh load clears a stale action error instead of letting it outlive the reload. `busy` and `lastError` stay as `computed`s on the store with a byte-identical public signature — they are the render seam for four components and two page templates, and the union belongs in the machine, not the components. `revealBigNummer` still sets only `Failed`, never `Busy` — an existing asymmetry, not changed here. `SaveState`, `org-template.store.ts`, and `pendingPublish` are out of scope (RD-13, RD-14). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
335 lines
15 KiB
TypeScript
335 lines
15 KiB
TypeScript
import { Injectable, computed, inject, signal } from '@angular/core';
|
|
import { Result } from '@shared/kernel/fp';
|
|
import { createStore } from '@shared/application/store';
|
|
import { SaveState } from '@shared/application/action-state';
|
|
import { createHistory } from '@shared/application/history';
|
|
import { createDebouncedSave } from '@shared/application/debounced-save';
|
|
import { fromLoadLifecycle } from '@shared/application/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/infrastructure/upload.adapter';
|
|
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
|
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
|
|
|
/**
|
|
* 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 blobPresenter = inject(BLOB_PRESENTER);
|
|
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
|
|
|
readonly model = this.store.model;
|
|
|
|
/** The one-shot action lifecycle now lives on the machine's `Loaded.action` (RD-12);
|
|
these stay as plain `computed`s so the render seam (four `busy = input(...)`
|
|
components, two page templates) keeps a byte-identical boolean/string API. */
|
|
readonly busy = computed(() => {
|
|
const s = this.model();
|
|
return s.tag === 'Loaded' && s.action.tag === 'Busy';
|
|
});
|
|
readonly lastError = computed(() => {
|
|
const s = this.model();
|
|
return s.tag === 'Loaded' && s.action.tag === 'Failed' ? s.action.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(() => fromLoadLifecycle(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 {
|
|
// The autosave failure legitimately surfaces in two places: the small save
|
|
// indicator below (kept as-is) and the action error line (RD-12).
|
|
this.store.dispatch({ tag: 'ActionFailed', 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.store.dispatch({ tag: 'ActionStarted' });
|
|
this.debouncedSave.cancel();
|
|
const r = await this.adapter.reset();
|
|
this.saveState.set({ tag: 'Idle' });
|
|
if (r.ok) {
|
|
this.store.dispatch({ tag: 'ActionFinished' });
|
|
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.store.dispatch({ tag: 'ActionFailed', 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 via `BLOB_PRESENTER.open` — see its doc comment for why the
|
|
object URL is never revoked. */
|
|
async previewLetter() {
|
|
this.store.dispatch({ tag: 'ActionStarted' });
|
|
const r = await this.previewAdapter.preview();
|
|
if (!r.ok) {
|
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
|
return;
|
|
}
|
|
this.store.dispatch({ tag: 'ActionFinished' });
|
|
this.blobPresenter.open(r.value);
|
|
}
|
|
|
|
/** 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) {
|
|
// Never sets Busy — an existing asymmetry (RD-12), not fixed here.
|
|
this.store.dispatch({ tag: 'ActionFailed', 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.store.dispatch({ tag: 'ActionStarted' });
|
|
this.debouncedSave.cancel();
|
|
await this.flushSave();
|
|
const r = await action();
|
|
if (!r.ok) {
|
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
|
return;
|
|
}
|
|
this.store.dispatch({ tag: 'ActionFinished' });
|
|
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;
|
|
}
|
|
}
|
|
}
|