CI / frontend (push) Failing after 1m15s
CI / storybook-a11y (push) Failing after 4m43s
CI / backend (push) Successful in 1m24s
CI / codeql (csharp) (push) Failing after 2m51s
CI / e2e (push) Failing after 3h4m8s
CI / codeql (javascript-typescript) (push) Failing after 1m30s
CI / api-client-drift (push) Successful in 1m53s
Brief letter-composition UX improvements: - undo/redo history in the brief store (snapshot stacks, Ctrl/Cmd+Z) + retry-save - "Standaardbrief invoegen" starter for empty sections; isDefault library passages (backend DTO/seed + adapter parse) - passage-picker client-side search (rich-text textOf helper) - rejection diff badges on the letter canvas + show/hide changes toggle (pure brief-diff domain fns + spec) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
4.7 KiB
TypeScript
120 lines
4.7 KiB
TypeScript
import { RichTextBlock, placeholderKeysIn } from '@shared/kernel/rich-text';
|
|
import { Diagnostic, lintPlaceholders, PlaceholderDef } from './placeholders';
|
|
|
|
/**
|
|
* The `Brief` (letter) entity and its derived selectors.
|
|
*
|
|
* A letter has a FIXED section structure (from a template, server-instantiated); the
|
|
* drafter fills a skeleton, never reorders sections. Each block is either a frozen
|
|
* snapshot of a library passage (provenance kept) or free text. Everything the UI
|
|
* needs beyond the stored shape — diagnostics, unresolved placeholders, whether it
|
|
* can be submitted — is DERIVED here, never stored.
|
|
*/
|
|
|
|
export type PassageScope = 'global' | 'beroep';
|
|
|
|
// Re-export placeholderKeysIn for one-import convenience at call sites.
|
|
export { placeholderKeysIn };
|
|
|
|
/** A passage in the library (the source). Snapshotted into a letter on insert. */
|
|
export interface LibraryPassage {
|
|
readonly passageId: string;
|
|
readonly scope: PassageScope;
|
|
readonly beroep?: string; // set when scope === 'beroep'
|
|
readonly sectionKey: string;
|
|
readonly label: string;
|
|
readonly content: RichTextBlock;
|
|
readonly version: number; // library version, for provenance only
|
|
readonly isDefault?: boolean; // part of the "standaardbrief" (kern) starter set
|
|
}
|
|
|
|
/** A block inside a letter section: a frozen passage snapshot, or free text. */
|
|
export type LetterBlock =
|
|
| {
|
|
readonly type: 'passage';
|
|
readonly blockId: string;
|
|
readonly sourcePassageId: string; // provenance
|
|
readonly sourceVersion: number; // library version at snapshot time (audit only)
|
|
readonly content: RichTextBlock; // FROZEN, possibly edited — source of truth for this block
|
|
readonly edited: boolean; // changed from the snapshot?
|
|
}
|
|
| {
|
|
readonly type: 'freeText';
|
|
readonly blockId: string;
|
|
readonly content: RichTextBlock;
|
|
};
|
|
|
|
export interface LetterSection {
|
|
readonly sectionKey: string;
|
|
readonly title: string;
|
|
readonly required: boolean;
|
|
// Predefined template sections (aanhef, slot) arrive locked and prefilled — the drafter
|
|
// composes only the unlocked section(s). The reducer refuses edits to locked sections.
|
|
readonly locked: boolean;
|
|
readonly blocks: readonly LetterBlock[];
|
|
}
|
|
|
|
/** The approval state machine as a sum type — transitions are total and guarded in
|
|
`brief.machine.ts`; illegal transitions are unrepresentable. */
|
|
export type BriefStatus =
|
|
| { readonly tag: 'draft' }
|
|
| { readonly tag: 'submitted'; readonly submittedBy: string; readonly submittedAt: string }
|
|
| { readonly tag: 'approved'; readonly approvedBy: string; readonly approvedAt: string }
|
|
| {
|
|
readonly tag: 'rejected';
|
|
readonly rejectedBy: string;
|
|
readonly rejectedAt: string;
|
|
readonly comments: string;
|
|
}
|
|
| { readonly tag: 'sent'; readonly sentAt: string };
|
|
|
|
export interface Brief {
|
|
readonly briefId: string;
|
|
readonly beroep: string; // drives which beroep-scoped passages apply
|
|
readonly templateId: string;
|
|
readonly placeholders: readonly PlaceholderDef[]; // valid fields for this letter
|
|
readonly sections: readonly LetterSection[]; // instantiated from the template, in order
|
|
readonly status: BriefStatus;
|
|
readonly drafterId: string;
|
|
}
|
|
|
|
// --- Derived selectors (pure; recomputed, never stored) ---
|
|
|
|
export function allBlocks(brief: Brief): LetterBlock[] {
|
|
return brief.sections.flatMap((s) => s.blocks);
|
|
}
|
|
|
|
/** Every diagnostic in the letter, in section→block→node order. This is what the
|
|
diagnostics panel renders and what the send gate checks. */
|
|
export function allDiagnostics(brief: Brief): Diagnostic[] {
|
|
return allBlocks(brief).flatMap((b) =>
|
|
lintPlaceholders(b.content, brief.placeholders, b.blockId),
|
|
);
|
|
}
|
|
|
|
export function hasBlockingErrors(diagnostics: readonly Diagnostic[]): boolean {
|
|
return diagnostics.some((d) => d.severity === 'error');
|
|
}
|
|
|
|
/** Manual (non-auto-resolvable) placeholder keys still present, deduped. These are the
|
|
`unresolved-at-send` warnings, surfaced as a completeness list. */
|
|
export function unresolvedPlaceholders(brief: Brief): string[] {
|
|
const auto = new Set(brief.placeholders.filter((p) => p.autoResolvable).map((p) => p.key));
|
|
const used = allBlocks(brief).flatMap((b) => placeholderKeysIn(b.content));
|
|
return [...new Set(used.filter((k) => !auto.has(k)))];
|
|
}
|
|
|
|
/** A letter can be submitted only when every REQUIRED section has at least one block. */
|
|
export function canSubmit(brief: Brief): boolean {
|
|
return brief.sections.every((s) => !s.required || s.blocks.length > 0);
|
|
}
|
|
|
|
/** Server-computed decision flags for the acting principal + this brief's live
|
|
status (PRD-0002 phase P1) — rendered as-is, never recomputed here. */
|
|
export interface BriefDecisions {
|
|
readonly canEdit: boolean;
|
|
readonly canApprove: boolean;
|
|
readonly canReject: boolean;
|
|
readonly canSend: boolean;
|
|
}
|