/** * Pure, framework-free logic for the review-first record drawer (ReviewDrawer). * * A "review" surface turns a status-carrying entity type into a fast triage loop: * read the record, cast a verdict, approve/reject it (a status move), jot a note, * and step to the next one — all without leaving the drawer. Everything that can be * decided WITHOUT React lives here so it unit-tests in isolation and the component * stays thin: * - resolve the review "field map" from a declared schema (which attr is the * title / summary / status / verdict / note / rank / sources), with smart * defaults that match the OGMC content schema but are fully overridable; * - derive the Approve / Reject status targets from the status enum's choices; * - the bare-letter keyboard-shortcut guard (never fire while the user is typing). * * Nothing here is tenant-specific — the defaults are conventions, not requirements. */ import type { AttributeDef, EntityTypeDef } from './types'; import { readData, toCamelKey } from './data'; // ---- keyboard guard (lifted so the drawer + any review surface share it) ---- /** The bit of an event target we need in order to ask "is the user typing?". */ export interface TypingTarget { tagName?: string; isContentEditable?: boolean; } /** The bit of a KeyboardEvent a shortcut decision depends on. */ export interface ShortcutEventLike { key: string; metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean; target?: unknown; } const TYPING_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']); /** True when the event target takes typed input (form control or contenteditable). */ export function isTypingTarget(target: unknown): boolean { if (!target || typeof target !== 'object') return false; const el = target as TypingTarget; if (el.isContentEditable === true) return true; return typeof el.tagName === 'string' && TYPING_TAGS.has(el.tagName.toUpperCase()); } /** * True when a bare-letter review shortcut must NOT fire. Any of cmd/ctrl/alt means * the user is reaching for a browser/OS command; a form control / contenteditable * means they're typing (a note, an edit) — firing "x" would silently reject. */ export function shouldIgnoreShortcut(event: ShortcutEventLike): boolean { if (event.metaKey || event.ctrlKey || event.altKey) return true; return isTypingTarget(event.target); } // ---- verdict vocabulary ---- export type VerdictTone = 'good' | 'bad' | 'neutral'; export interface VerdictOption { value: string; label: string; tone: VerdictTone; } /** The default team-verdict vocabulary (👍 good / 👎 bad / ✎ needs-edit). */ export const DEFAULT_VERDICTS: VerdictOption[] = [ { value: 'good', label: 'Good', tone: 'good' }, { value: 'bad', label: 'Bad', tone: 'bad' }, { value: 'edit', label: 'Edit', tone: 'neutral' }, ]; // ---- status transitions (Approve / Reject) ---- const REJECT_RE = /reject|declin|archiv|spam|discard|dismiss|kill/i; const TERMINAL_RE = /written|publish|post|done|complete|sent|live|shipped|closed/i; /** The declared enum choices of an attribute (coerced to strings), or []. */ export function choicesOf(attr: AttributeDef | null | undefined): string[] { const c = attr?.config?.choices; return Array.isArray(c) ? c.map((x) => String(x)) : []; } /** * The attribute whose enum choices drive the status pipeline: prefer one named * exactly `preferred` (default "status"), else the first enum attr with choices. * null when the type declares none (the caller falls back / hides the actions). */ export function pickStatusAttr( type: EntityTypeDef | undefined | null, preferred = 'status', ): AttributeDef | null { const enums = (type?.attributes ?? []).filter( (a) => a.dataType === 'enum' && choicesOf(a).length > 0, ); if (enums.length === 0) return null; return enums.find((a) => a.name === preferred) ?? enums[0]; } export interface ReviewTransitions { /** Status value the Approve action moves a record to (null → no Approve button). */ approve: string | null; /** Status value the Reject action moves a record to (null → no Reject button). */ reject: string | null; } /** * Derive the Approve / Reject status targets from a status enum's choices. * * Convention (matches `suggested → ready → rejected → written`): * - REJECT = the first choice that reads like a rejection (rejected/declined/…); * - APPROVE = the first "forward" choice: not the entry (first) stage, not the * reject stage, and not a terminal/published stage — i.e. the "accepted, ready * to act" stage. Falls back to the entry stage's immediate successor. * Explicit overrides always win. Returns nulls when nothing sensible resolves, so * a two-state or non-workflow enum simply shows fewer buttons rather than guessing. */ export function deriveReviewTransitions( choices: string[], override?: { approve?: string | null; reject?: string | null }, ): ReviewTransitions { const reject = override?.reject !== undefined ? override.reject : choices.find((c) => REJECT_RE.test(c)) ?? null; let approve: string | null; if (override?.approve !== undefined) { approve = override.approve; } else { const entry = choices[0]; approve = choices.find( (c, i) => i > 0 && c !== reject && !TERMINAL_RE.test(c) && c !== entry, ) ?? // nothing "forward & non-terminal" → the successor of the entry stage. choices.find((c, i) => i > 0 && c !== reject) ?? null; } return { approve, reject }; } // ---- resolved review field map ---- /** Consumer-facing overrides for the review field map (all optional). */ export interface ReviewConfig { /** Enum attr that drives the status pipeline (default: "status", else first enum). */ statusAttr?: string; /** Explicit Approve target status (default: derived from the choices). */ approveStatus?: string | null; /** Explicit Reject target status (default: derived from the choices). */ rejectStatus?: string | null; /** Attr the verdict buttons write (default: "team_verdict"). */ verdictAttr?: string; /** Verdict vocabulary (default: good / bad / edit). */ verdicts?: VerdictOption[]; /** Attr the quick-note writes (default: "team_notes"). */ noteAttr?: string; /** Attr used as the readable heading (default: "title", else record.name). */ titleAttr?: string; /** Attr used as the one-line summary under the title (default: "angle"). */ summaryAttr?: string; /** Numeric attr shown as a rank chip (default: "ai_rank"). */ rankAttr?: string; /** Short attrs shown as meta chips (default: ["content_type", "market"]). */ metaAttrs?: string[]; /** Attrs holding source urls/lines (default: source_1..3 + "sources"). */ sourceAttrs?: string[]; /** Drop the middle "Needs work" decision — a binary accept/reject flow (default false). */ omitNeedsWork?: boolean; } export interface ResolvedReview { statusAttr: AttributeDef | null; statusName: string; statusChoices: string[]; transitions: ReviewTransitions; verdictAttr: string; verdicts: VerdictOption[]; noteAttr: string; titleAttr: string; summaryAttr: string; rankAttr: string; metaAttrs: string[]; sourceAttrs: string[]; omitNeedsWork: boolean; } const DEFAULT_SOURCE_ATTRS = ['source_1', 'source_2', 'source_3', 'sources']; const DEFAULT_META_ATTRS = ['content_type', 'market']; /** Resolve the full review field map from a type's schema + optional overrides. */ export function resolveReviewConfig( type: EntityTypeDef | undefined | null, config: ReviewConfig = {}, ): ResolvedReview { const statusAttr = pickStatusAttr(type, config.statusAttr ?? 'status'); const statusChoices = choicesOf(statusAttr); const declared = new Set((type?.attributes ?? []).map((a) => a.name)); return { statusAttr, statusName: statusAttr?.name ?? config.statusAttr ?? 'status', statusChoices, transitions: deriveReviewTransitions(statusChoices, { approve: config.approveStatus, reject: config.rejectStatus, }), verdictAttr: config.verdictAttr ?? 'team_verdict', verdicts: config.verdicts ?? DEFAULT_VERDICTS, noteAttr: config.noteAttr ?? 'team_notes', titleAttr: config.titleAttr ?? 'title', summaryAttr: config.summaryAttr ?? 'angle', rankAttr: config.rankAttr ?? 'ai_rank', metaAttrs: (config.metaAttrs ?? DEFAULT_META_ATTRS).filter((a) => declared.has(a)), // Keep source attrs the caller asked for; default to the ones this type declares. sourceAttrs: (config.sourceAttrs ?? DEFAULT_SOURCE_ATTRS).filter((a) => declared.has(a)), omitNeedsWork: config.omitNeedsWork ?? false, }; } // ---- the single review decision (Approve / Needs work / Reject) ---- // // status and team_verdict are two fields with different consumers (status = the // pipeline gate the writer keys off; team_verdict = the signal the re-rank agent // turns INTO status). Their outcomes overlap, so the reviewer makes ONE decision // and we write BOTH fields coherently — never two competing button rows. export interface ReviewDecision { /** Stable id ('approve' | 'needs_work' | 'reject'). */ key: string; label: string; /** Status to write (null → leave status untouched). */ status: string | null; /** Verdict to write (null → leave verdict untouched). */ verdict: string | null; tone: VerdictTone; /** Auto-advance to the next record after this decision. */ advance: boolean; /** Bare-letter shortcut. */ shortcut: string; /** Open the note box on this decision (a rejection/hold wants a reason). */ promptNote: boolean; } /** * The (up to) three review decisions derived from the resolved config: * - Approve → the approve status + the "good" verdict, auto-advances * - Needs work → back to the entry stage + the "edit"/neutral verdict, holds * (so it's revisited) and prompts a note * - Reject → the reject status + the "bad" verdict, auto-advances * A decision only appears when its status target resolves (or, for Needs work, * when there's an entry stage or an edit verdict) — so a two-state or non-workflow * enum simply shows fewer buttons instead of guessing. */ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] { const verdictOf = (tone: VerdictTone): string | null => r.verdicts.find((v) => v.tone === tone)?.value ?? null; const entry = r.statusChoices[0] ?? null; const out: ReviewDecision[] = []; if (r.transitions.approve) { out.push({ key: 'approve', label: 'Approve', status: r.transitions.approve, verdict: verdictOf('good'), tone: 'good', advance: true, shortcut: 'a', promptNote: false, }); } const editVerdict = verdictOf('neutral'); if (!r.omitNeedsWork && entry && (editVerdict || entry !== r.transitions.approve)) { out.push({ key: 'needs_work', label: 'Needs work', status: entry, verdict: editVerdict, tone: 'neutral', advance: false, shortcut: 'w', promptNote: true, }); } if (r.transitions.reject) { out.push({ key: 'reject', label: 'Reject', status: r.transitions.reject, verdict: verdictOf('bad'), tone: 'bad', advance: true, shortcut: 'x', promptNote: false, }); } return out; } /** * Merge a decision's status + verdict into a record's (camelCased) data blob and * return the FULL next blob — the backend PATCH replaces `data`, so callers must * send the whole thing. Pure, so both the drawer and the inline row actions apply * a decision identically (and it's unit-tested). Null status/verdict are skipped. */ export function applyDecisionToData( data: Record | undefined, decision: ReviewDecision, cfg: ResolvedReview, ): Record { const next: Record = { ...(data ?? {}) }; const set = (attr: string, value: string | null) => { if (value == null || value === '') return; const camel = toCamelKey(attr); next[camel] = value; if (camel !== attr) delete next[attr]; }; set(cfg.statusName, decision.status); set(cfg.verdictAttr, decision.verdict); return next; } // ---- small read helpers used by the component (kept here so they're tested) ---- /** String read of an attr off a record's data blob ('' when missing). */ export function fieldStr(data: Record | undefined, name: string): string { const v = readData(data, name); return v == null ? '' : String(v); } /** Normalise a url to its bare host (lowercased, `www.` stripped). '' if unparseable. */ export function reviewHostOf(url: string): string { try { return new URL(/^https?:\/\//.test(url) ? url : `https://${url}`).hostname .toLowerCase() .replace(/^www\./, ''); } catch { return ''; } } /** * The index to move to after acting on `current` within `ids`. Returns the next * item, or the previous when acting on the last, or -1 when the list empties — so * an Approve/Reject can auto-advance the reviewer to fresh work without a dead end. */ export function nextReviewIndex(ids: string[], currentId: string): number { const i = ids.indexOf(currentId); if (i === -1) return ids.length > 0 ? 0 : -1; if (i + 1 < ids.length) return i + 1; if (i - 1 >= 0) return i - 1; return -1; }