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>
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
import { assertNever } from '@shared/kernel/fp';
|
|
import {
|
|
Besluit,
|
|
Brief,
|
|
BriefDecisions,
|
|
BriefStatus,
|
|
LetterBlock,
|
|
LetterSection,
|
|
LibraryPassage,
|
|
allBlocks,
|
|
canSubmit,
|
|
} from './brief';
|
|
import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text';
|
|
import { passagesForBesluit } from './besluit';
|
|
|
|
/**
|
|
* The letter composition state machine (Model + Msg + pure reduce), modeled on
|
|
* `herregistratie/domain/intake.machine.ts`.
|
|
*
|
|
* Two invariants are enforced *here*, not in the UI:
|
|
* - Status transitions are total and guarded — an out-of-order transition Msg is a
|
|
* no-op (`draft→submitted→approved/rejected→draft`, `approved→sent`).
|
|
* - Edits are only possible in `draft`/`rejected`; editing a `rejected` letter flips
|
|
* it back to `draft`. Sections can never be added, removed, or reordered — there
|
|
* is no Msg for it, so it is unrepresentable.
|
|
*
|
|
* Authorization is NOT a reducer concern: `decisions` (canEdit/canApprove/canReject/
|
|
* canSend) arrives from the server on every load and every status transition (PRD-0002
|
|
* phase P1) and is carried through unchanged by the reducer — never recomputed here.
|
|
* The reducer guards the status invariant; the server is the sole authority on who may
|
|
* act on it.
|
|
*
|
|
* Note: there is no `PlaceholderInserted` Msg. The editor inserts a placeholder NODE
|
|
* at the caret and emits the whole new block via `BlockContentEdited`; its insert menu
|
|
* only offers keys from `brief.placeholders`, so inserting an unknown key is
|
|
* structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`).
|
|
*/
|
|
|
|
/** The one-shot action lifecycle (submit/approve/reject/send/preview/reveal/reset),
|
|
owned by the reducer instead of an imperative store-level signal (RD-12). */
|
|
export type BriefActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
|
|
|
|
export type BriefState =
|
|
| { tag: 'Loading' }
|
|
| {
|
|
tag: 'Loaded';
|
|
brief: Brief;
|
|
availablePassages: readonly LibraryPassage[];
|
|
decisions: BriefDecisions;
|
|
action: BriefActionState;
|
|
}
|
|
| { tag: 'Failed'; reason: string };
|
|
|
|
export const initial: BriefState = { tag: 'Loading' };
|
|
|
|
export type BriefMsg =
|
|
| {
|
|
tag: 'BriefLoaded';
|
|
brief: Brief;
|
|
availablePassages: readonly LibraryPassage[];
|
|
decisions: BriefDecisions;
|
|
}
|
|
| { tag: 'BriefLoadFailed'; reason: string }
|
|
| { tag: 'BesluitSelected'; besluit: Besluit | null; reasons: readonly string[] } // recomposes the kern's passages
|
|
| { tag: 'FreeTextBlockAdded'; sectionKey: string }
|
|
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
|
|
| { tag: 'BlockRemoved'; blockId: string }
|
|
| { tag: 'BlockMovedWithinSection'; blockId: string; toIndex: number }
|
|
| { tag: 'Submitted'; by: string; at: string; decisions: BriefDecisions } // draft → submitted
|
|
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
|
|
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
|
|
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
|
|
| { tag: 'Seed'; state: BriefState }
|
|
| { tag: 'ActionStarted' } // a one-shot action (submit/approve/preview/…) began
|
|
| { tag: 'ActionFinished' } // it completed successfully
|
|
| { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show
|
|
|
|
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
|
|
function isEditable(status: BriefStatus): boolean {
|
|
return status.tag === 'draft' || status.tag === 'rejected';
|
|
}
|
|
|
|
/** Next `local-N` block id — DERIVED from existing ids (max + 1), not a stored counter. */
|
|
function nextLocalIndex(brief: Brief): number {
|
|
let max = 0;
|
|
for (const b of allBlocks(brief)) {
|
|
const m = /^local-(\d+)$/.exec(b.blockId);
|
|
if (m) max = Math.max(max, Number(m[1]));
|
|
}
|
|
return max + 1;
|
|
}
|
|
|
|
function mapSection(
|
|
brief: Brief,
|
|
sectionKey: string,
|
|
f: (s: LetterSection) => LetterSection,
|
|
): Brief {
|
|
return {
|
|
...brief,
|
|
sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)),
|
|
};
|
|
}
|
|
|
|
/** The section a block currently lives in, or undefined if the block is gone. */
|
|
function sectionKeyOfBlock(brief: Brief, blockId: string): string | undefined {
|
|
return brief.sections.find((s) => s.blocks.some((b) => b.blockId === blockId))?.sectionKey;
|
|
}
|
|
|
|
/** A section accepts edits only when it is not a locked (predefined) template section. */
|
|
function isSectionEditable(brief: Brief, sectionKey: string | undefined): boolean {
|
|
const section = brief.sections.find((s) => s.sectionKey === sectionKey);
|
|
return !!section && !section.locked;
|
|
}
|
|
|
|
function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBlock[]): Brief {
|
|
return { ...brief, sections: brief.sections.map((s) => ({ ...s, blocks: f(s.blocks) })) };
|
|
}
|
|
|
|
/** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */
|
|
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
|
|
if (s.tag !== 'Loaded' || !isEditable(s.brief.status)) return s;
|
|
let brief = f(s.brief);
|
|
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
|
|
return { ...s, brief };
|
|
}
|
|
|
|
function buildPassageBlocks(brief: Brief, passages: readonly LibraryPassage[]): LetterBlock[] {
|
|
let idx = nextLocalIndex(brief);
|
|
// The freeze happens HERE: each block gets a deep VALUE copy of the library content,
|
|
// so later library edits can never mutate this letter (frozen snapshot).
|
|
return passages.map((p) => ({
|
|
type: 'passage',
|
|
blockId: `local-${idx++}`,
|
|
sourcePassageId: p.passageId,
|
|
sourceVersion: p.version,
|
|
content: deepCopyBlock(p.content),
|
|
edited: false,
|
|
}));
|
|
}
|
|
|
|
/** Recompose the kern for a besluit selection: the besluit-driven passages (in reading
|
|
order) followed by the drafter's free-text blocks. The kern's `passage` blocks are
|
|
besluit-derived by construction, so replacing them wholesale is the reactive swap; the
|
|
`freeText` blocks are the drafter's own remarks and survive.
|
|
ponytail: free text always trails the besluit passages after a recompute. */
|
|
function composeKern(
|
|
brief: Brief,
|
|
availablePassages: readonly LibraryPassage[],
|
|
besluit: Besluit | null,
|
|
reasons: readonly string[],
|
|
): Brief {
|
|
const besluitBlocks = besluit
|
|
? buildPassageBlocks(brief, passagesForBesluit(availablePassages, besluit, reasons))
|
|
: [];
|
|
return mapSection(brief, 'kern', (s) => ({
|
|
...s,
|
|
blocks: [...besluitBlocks, ...s.blocks.filter((b) => b.type === 'freeText')],
|
|
}));
|
|
}
|
|
|
|
function addFreeText(brief: Brief, sectionKey: string): Brief {
|
|
const block: LetterBlock = {
|
|
type: 'freeText',
|
|
blockId: `local-${nextLocalIndex(brief)}`,
|
|
content: emptyBlock(),
|
|
};
|
|
return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, block] }));
|
|
}
|
|
|
|
function editBlockContent(brief: Brief, blockId: string, content: RichTextBlock): Brief {
|
|
return mapBlocks(brief, (blocks) =>
|
|
blocks.map((b) =>
|
|
b.blockId !== blockId
|
|
? b
|
|
: b.type === 'passage'
|
|
? { ...b, content, edited: true } // editing a snapshot marks it, keeps provenance
|
|
: { ...b, content },
|
|
),
|
|
);
|
|
}
|
|
|
|
function moveWithinSection(
|
|
blocks: readonly LetterBlock[],
|
|
blockId: string,
|
|
toIndex: number,
|
|
): LetterBlock[] {
|
|
const from = blocks.findIndex((b) => b.blockId === blockId);
|
|
if (from === -1) return [...blocks];
|
|
const clamped = Math.max(0, Math.min(toIndex, blocks.length - 1));
|
|
const next = [...blocks];
|
|
const [moved] = next.splice(from, 1);
|
|
next.splice(clamped, 0, moved);
|
|
return next;
|
|
}
|
|
|
|
export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
|
switch (m.tag) {
|
|
case 'BriefLoaded':
|
|
return {
|
|
tag: 'Loaded',
|
|
brief: m.brief,
|
|
availablePassages: m.availablePassages,
|
|
decisions: m.decisions,
|
|
// A fresh load clears a stale action error rather than letting it outlive
|
|
// the reload (RD-12, decision 4).
|
|
action: { tag: 'Idle' },
|
|
};
|
|
case 'BriefLoadFailed':
|
|
return { tag: 'Failed', reason: m.reason };
|
|
case 'Seed':
|
|
return m.state;
|
|
|
|
// The kern is besluit-driven: (re)compose its passages from the selection, keeping the
|
|
// drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
|
|
case 'BesluitSelected':
|
|
return withEdit(s, (b) =>
|
|
s.tag === 'Loaded' && isSectionEditable(b, 'kern')
|
|
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
|
|
: b,
|
|
);
|
|
case 'FreeTextBlockAdded':
|
|
return withEdit(s, (b) =>
|
|
isSectionEditable(b, m.sectionKey) ? addFreeText(b, m.sectionKey) : b,
|
|
);
|
|
case 'BlockContentEdited':
|
|
return withEdit(s, (b) =>
|
|
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
|
|
? editBlockContent(b, m.blockId, m.content)
|
|
: b,
|
|
);
|
|
case 'BlockRemoved':
|
|
return withEdit(s, (b) =>
|
|
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
|
|
? mapBlocks(b, (blocks) => blocks.filter((x) => x.blockId !== m.blockId))
|
|
: b,
|
|
);
|
|
case 'BlockMovedWithinSection':
|
|
return withEdit(s, (b) =>
|
|
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
|
|
? mapBlocks(b, (blocks) =>
|
|
blocks.some((x) => x.blockId === m.blockId)
|
|
? moveWithinSection(blocks, m.blockId, m.toIndex)
|
|
: [...blocks],
|
|
)
|
|
: b,
|
|
);
|
|
|
|
case 'Submitted':
|
|
// Guard the transition AND the completeness invariant.
|
|
return transition(
|
|
s,
|
|
'draft',
|
|
() => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }),
|
|
m.decisions,
|
|
canSubmit,
|
|
);
|
|
case 'Approved':
|
|
return transition(
|
|
s,
|
|
'submitted',
|
|
() => ({ tag: 'approved', approvedBy: m.by, approvedAt: m.at }),
|
|
m.decisions,
|
|
);
|
|
case 'Rejected':
|
|
return transition(
|
|
s,
|
|
'submitted',
|
|
() => ({ tag: 'rejected', rejectedBy: m.by, rejectedAt: m.at, comments: m.comments }),
|
|
m.decisions,
|
|
);
|
|
case 'Sent':
|
|
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
|
|
|
|
// The action lifecycle (RD-12): a no-op unless a brief is loaded, since there is
|
|
// nothing to attach the action state to otherwise.
|
|
case 'ActionStarted':
|
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s;
|
|
case 'ActionFinished':
|
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
|
|
case 'ActionFailed':
|
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s;
|
|
|
|
default:
|
|
return assertNever(m);
|
|
}
|
|
}
|
|
|
|
/** A guarded status transition: only fires from `from`, and only if `guard` passes.
|
|
`decisions` replaces the prior server-computed flags — always fresh from the
|
|
same response that carried the new status. */
|
|
function transition(
|
|
s: BriefState,
|
|
from: BriefStatus['tag'],
|
|
next: () => BriefStatus,
|
|
decisions: BriefDecisions,
|
|
guard: (b: Brief) => boolean = () => true,
|
|
): BriefState {
|
|
if (s.tag !== 'Loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
|
|
return { ...s, brief: { ...s.brief, status: next() }, decisions };
|
|
}
|