Files
atomic-design-poc/src/app/brief/application/brief.store.ts
T
ehoandClaude Opus 4.8 ac3e9a9399 refactor(fp): WP-31 — shared store helpers (dedupe brief/org-template/stamdata)
Audit "apply high-value": extract four shared helpers into shared/application/ and
rewire the editor stores (behaviour unchanged, existing specs are the gate):
- action-state.ts: ActionState/SaveState (were duplicated in both brief stores).
- history.ts: createHistory<T> (extracted from BriefStore's WP-27 undo/redo; WP-32 reuses).
- debounced-save.ts: createDebouncedSave (the 600ms timer/PendingSave dance, was 2×+).
- machine-remote-data.ts: machineRemoteData (the loading/failed/loaded→RemoteData switch, 3×).
Each helper has a co-located spec. Deferred DDD findings (contracts/ inconsistency, a
parse* traverse combinator, the 6× Seed boilerplate) are reported in the WP file, not built.

npm run ci green; 323 tests (+13 helper specs); brief/org-template/stamdata specs unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:40:02 +02:00

295 lines
13 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 { 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());
});
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 });
} 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 — this command just runs the audited server call. */
async revealBigNummer() {
const r = await this.revealAdapter.reveal();
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;
}
}
}