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; }