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(initial, reduce); readonly model = this.store.model; private actionState = signal({ 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({ 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(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(null); /** Changed/added/removed blocks since rejection — a pure fold over two snapshots. */ readonly blockDiffs = computed>(() => { 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(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(null); /** The org logo's content URL for the letterhead, or null when the template has none. */ readonly logoUrl = computed(() => { const id = this.orgTemplate()?.logoDocumentId; return id ? uploadContentUrl(id) : null; }); /** The load lifecycle as `RemoteData`, for `` — 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(() => { 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>) { 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; } } }