From 9f5786890ed5801ccfaa69230d0c727b88007d0b Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 18 May 2026 15:09:22 +0200 Subject: [PATCH] feat(post-audit): Phase 3/6/7 schema foundations + bounce parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 — EOI override foundation (migration 0073): - client_contacts/addresses/yachts get source + source_document_id with FK SET NULL on doc deletion. CHECK constraints enforce the allow-list of source values (manual/imported/eoi-custom-input or manual/imported/eoi-generated for yachts). - documents.override_client_* + override_yacht_* columns mirror the AcroForm field set per docs/eoi-documenso-field-mapping.md. When NULL the canonical record value flows; when set, this document uses the override without touching the underlying record. - Drizzle schema mirrors all new columns; numeric import added to documents schema for the yacht-dimensions override columns. Phase 6 — IMAP bounce foundation (migration 0074): - document_sends.bounce_status / bounce_reason / bounce_detected_at with bounce_status CHECK constraint (hard/soft/ooo). - Partial index for the "show bounced sends" UI filter. - New src/lib/email/bounce-parser.ts library — handles RFC 3464 DSN + Outlook NDR shapes + OOO auto-replies. Returns null recipient + 'unknown' class when shape isn't recognizable. Cron worker deferred to Phase 6b. Phase 7 — PDF editor field-map types: - New src/lib/templates/field-map.ts defines FieldMap shape with percent-coord positioning so placements survive page-size changes. - Zod schemas for API boundary validation. - validateFieldMapAgainstPageCount helper for the "new PDF upload" warning. - No schema migration needed — existing document_templates. overlay_positions JSONB column accepts the new shape; the editor migrates legacy absolute-coord entries on first save. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migrations/0073_phase3_eoi_overrides.sql | 114 +++++++++++ .../0074_phase6_bounce_tracking.sql | 33 ++++ src/lib/db/schema/brochures.ts | 7 + src/lib/db/schema/clients.ts | 10 + src/lib/db/schema/documents.ts | 19 ++ src/lib/db/schema/yachts.ts | 6 + src/lib/email/bounce-parser.ts | 183 ++++++++++++++++++ src/lib/templates/field-map.ts | 53 +++++ 8 files changed, 425 insertions(+) create mode 100644 src/lib/db/migrations/0073_phase3_eoi_overrides.sql create mode 100644 src/lib/db/migrations/0074_phase6_bounce_tracking.sql create mode 100644 src/lib/email/bounce-parser.ts create mode 100644 src/lib/templates/field-map.ts diff --git a/src/lib/db/migrations/0073_phase3_eoi_overrides.sql b/src/lib/db/migrations/0073_phase3_eoi_overrides.sql new file mode 100644 index 00000000..dffc5911 --- /dev/null +++ b/src/lib/db/migrations/0073_phase3_eoi_overrides.sql @@ -0,0 +1,114 @@ +-- Phase 3 — EOI field override foundation (POST-AUDIT-SPEC §1 + MASTER-PLAN §C). +-- +-- This migration is foundation-only. The EOI generate-and-sign endpoint +-- + the dialog UI extensions land in subsequent sub-sessions (3a-3d). +-- +-- Adds: +-- 1. client_contacts.{source, source_document_id} +-- 2. client_addresses.{source, source_document_id} +-- 3. yachts.{source, source_document_id} +-- 4. documents.override_* columns mirroring the AcroForm field map +-- +-- audit_actions is a free-text column (text, not enum) — new action verbs +-- 'eoi_field_override', 'promote_to_primary', 'eoi_spawn_yacht' don't +-- need DDL; they just appear in inserted rows. + +-- ─── client_contacts ────────────────────────────────────────────────── +ALTER TABLE client_contacts + ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'manual', + ADD COLUMN IF NOT EXISTS source_document_id text; + +-- Add FK only if it doesn't exist yet. ON DELETE SET NULL — keep the +-- contact row around even if the originating document is deleted. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'client_contacts_source_document_id_fkey') THEN + ALTER TABLE client_contacts + ADD CONSTRAINT client_contacts_source_document_id_fkey + FOREIGN KEY (source_document_id) REFERENCES documents(id) ON DELETE SET NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_client_contacts_source') THEN + ALTER TABLE client_contacts + ADD CONSTRAINT chk_client_contacts_source + CHECK (source IN ('manual', 'imported', 'eoi-custom-input')); + END IF; +END $$; + +-- ─── client_addresses ────────────────────────────────────────────────── +ALTER TABLE client_addresses + ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'manual', + ADD COLUMN IF NOT EXISTS source_document_id text; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'client_addresses_source_document_id_fkey') THEN + ALTER TABLE client_addresses + ADD CONSTRAINT client_addresses_source_document_id_fkey + FOREIGN KEY (source_document_id) REFERENCES documents(id) ON DELETE SET NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_client_addresses_source') THEN + ALTER TABLE client_addresses + ADD CONSTRAINT chk_client_addresses_source + CHECK (source IN ('manual', 'imported', 'eoi-custom-input')); + END IF; +END $$; + +-- ─── yachts ──────────────────────────────────────────────────────────── +ALTER TABLE yachts + ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'manual', + ADD COLUMN IF NOT EXISTS source_document_id text; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'yachts_source_document_id_fkey') THEN + ALTER TABLE yachts + ADD CONSTRAINT yachts_source_document_id_fkey + FOREIGN KEY (source_document_id) REFERENCES documents(id) ON DELETE SET NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_yachts_source') THEN + ALTER TABLE yachts + ADD CONSTRAINT chk_yachts_source + CHECK (source IN ('manual', 'imported', 'eoi-generated')); + END IF; +END $$; + +-- ─── documents — per-document overrides ──────────────────────────────── +-- These columns mirror the AcroForm field set per +-- docs/eoi-documenso-field-mapping.md. When NULL, the canonical +-- client/yacht values flow through unchanged. When set, the EOI uses +-- the override value without touching the underlying record. +ALTER TABLE documents + ADD COLUMN IF NOT EXISTS override_client_email text, + ADD COLUMN IF NOT EXISTS override_client_phone text, + ADD COLUMN IF NOT EXISTS override_client_address_line_1 text, + ADD COLUMN IF NOT EXISTS override_client_address_line_2 text, + ADD COLUMN IF NOT EXISTS override_client_city text, + ADD COLUMN IF NOT EXISTS override_client_state text, + ADD COLUMN IF NOT EXISTS override_client_postal_code text, + ADD COLUMN IF NOT EXISTS override_client_country text, + ADD COLUMN IF NOT EXISTS override_yacht_name text, + ADD COLUMN IF NOT EXISTS override_yacht_length_ft numeric(10, 2), + ADD COLUMN IF NOT EXISTS override_yacht_width_ft numeric(10, 2), + ADD COLUMN IF NOT EXISTS override_yacht_draft_ft numeric(10, 2); + +-- ─── Comments ────────────────────────────────────────────────────────── +COMMENT ON COLUMN client_contacts.source IS + 'Phase 3: origin of this contact row. manual = rep typed it; imported = bulk import; eoi-custom-input = inline-typed during EOI generation.'; +COMMENT ON COLUMN client_contacts.source_document_id IS + 'Phase 3: when source=eoi-custom-input, points at the EOI that created it.'; +COMMENT ON COLUMN yachts.source IS + 'Phase 3: origin. eoi-generated = spawned from the EOI dialog''s "+ New yacht" inline form.'; +COMMENT ON COLUMN documents.override_client_email IS + 'Phase 3: per-document override of the client''s email field on this EOI. NULL = use canonical client_contacts row.'; diff --git a/src/lib/db/migrations/0074_phase6_bounce_tracking.sql b/src/lib/db/migrations/0074_phase6_bounce_tracking.sql new file mode 100644 index 00000000..fc3a2865 --- /dev/null +++ b/src/lib/db/migrations/0074_phase6_bounce_tracking.sql @@ -0,0 +1,33 @@ +-- Phase 6 — IMAP bounce-to-interest linking foundation +-- (POST-AUDIT-SPEC §14.9 / MASTER-PLAN §F). +-- +-- Schema-only. Parser library + cron worker land in 6b/6c. +-- +-- Adds bounce_status / bounce_reason / bounce_detected_at columns to +-- document_sends + a partial index so the UI's "show bounced rows" filter +-- is cheap. + +ALTER TABLE document_sends + ADD COLUMN IF NOT EXISTS bounce_status text, + ADD COLUMN IF NOT EXISTS bounce_reason text, + ADD COLUMN IF NOT EXISTS bounce_detected_at timestamptz; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_document_sends_bounce_status') THEN + ALTER TABLE document_sends + ADD CONSTRAINT chk_document_sends_bounce_status + CHECK (bounce_status IS NULL OR bounce_status IN ('hard', 'soft', 'ooo')); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_document_sends_bounce + ON document_sends (port_id, bounce_detected_at) + WHERE bounce_status IS NOT NULL; + +COMMENT ON COLUMN document_sends.bounce_status IS + 'Phase 6: hard|soft|ooo classification when an NDR for this send was matched by the IMAP poller. NULL = no bounce detected (default).'; +COMMENT ON COLUMN document_sends.bounce_reason IS + 'Phase 6: human-readable reason from the parsed NDR (e.g. "user not found"). NULL when not bounced.'; +COMMENT ON COLUMN document_sends.bounce_detected_at IS + 'Phase 6: timestamp the cron worker detected this bounce. Drives the in-CRM notification firing once.'; diff --git a/src/lib/db/schema/brochures.ts b/src/lib/db/schema/brochures.ts index 0a95b42f..c73b94df 100644 --- a/src/lib/db/schema/brochures.ts +++ b/src/lib/db/schema/brochures.ts @@ -143,6 +143,13 @@ export const documentSends = pgTable( failedAt: timestamp('failed_at', { withTimezone: true }), /** Human-readable failure reason; only meaningful when failedAt is non-null. */ errorReason: text('error_reason'), + // Phase 6 — async bounce tracking. Populated by the IMAP NDR + // poller (`src/jobs/processors/imap-bounce-poller.ts`) when a + // delivery failure message arrives in the configured mailbox and + // matches this send via recipient_email + sent_at window. + bounceStatus: text('bounce_status'), // 'hard' | 'soft' | 'ooo' + bounceReason: text('bounce_reason'), + bounceDetectedAt: timestamp('bounce_detected_at', { withTimezone: true }), }, (t) => [ index('idx_ds_client').on(t.clientId, t.sentAt), diff --git a/src/lib/db/schema/clients.ts b/src/lib/db/schema/clients.ts index eb25dede..f43d4f85 100644 --- a/src/lib/db/schema/clients.ts +++ b/src/lib/db/schema/clients.ts @@ -89,6 +89,13 @@ export const clientContacts = pgTable( label: text('label'), // primary, secondary, work, personal, broker, assistant isPrimary: boolean('is_primary').notNull().default(false), notes: text('notes'), + // Phase 3 — origin tracking. + // source: 'manual' | 'imported' | 'eoi-custom-input' + // source_document_id: when source='eoi-custom-input', points at the + // EOI document this row was spawned from. Surfaces an [EOI] badge + // on the client detail panel + a link back to the generating doc. + source: text('source').notNull().default('manual'), + sourceDocumentId: text('source_document_id'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, @@ -249,6 +256,9 @@ export const clientAddresses = pgTable( /** ISO-3166-1 alpha-2 country code. */ countryIso: text('country_iso'), isPrimary: boolean('is_primary').notNull().default(true), + // Phase 3 — origin tracking, same pattern as client_contacts. + source: text('source').notNull().default('manual'), + sourceDocumentId: text('source_document_id'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, diff --git a/src/lib/db/schema/documents.ts b/src/lib/db/schema/documents.ts index 20bbe953..6e3d1d26 100644 --- a/src/lib/db/schema/documents.ts +++ b/src/lib/db/schema/documents.ts @@ -4,6 +4,7 @@ import { text, boolean, integer, + numeric, timestamp, jsonb, index, @@ -112,6 +113,24 @@ export const documents = pgTable( invitationMessage: text('invitation_message'), remindersDisabled: boolean('reminders_disabled').notNull().default(false), reminderCadenceOverride: integer('reminder_cadence_override'), + // Phase 3 — per-document field overrides. When NULL, the canonical + // client/yacht record value flows through; when set, this document + // uses the override without touching the underlying record. Mirrors + // the AcroForm field set per docs/eoi-documenso-field-mapping.md. + // These are NOT promoted to client_contacts/addresses/yachts unless + // the dialog's "Save as new" toggle is ticked at generate time. + overrideClientEmail: text('override_client_email'), + overrideClientPhone: text('override_client_phone'), + overrideClientAddressLine1: text('override_client_address_line_1'), + overrideClientAddressLine2: text('override_client_address_line_2'), + overrideClientCity: text('override_client_city'), + overrideClientState: text('override_client_state'), + overrideClientPostalCode: text('override_client_postal_code'), + overrideClientCountry: text('override_client_country'), + overrideYachtName: text('override_yacht_name'), + overrideYachtLengthFt: numeric('override_yacht_length_ft'), + overrideYachtWidthFt: numeric('override_yacht_width_ft'), + overrideYachtDraftFt: numeric('override_yacht_draft_ft'), createdBy: text('created_by').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), diff --git a/src/lib/db/schema/yachts.ts b/src/lib/db/schema/yachts.ts index 5f219f97..039c6854 100644 --- a/src/lib/db/schema/yachts.ts +++ b/src/lib/db/schema/yachts.ts @@ -46,6 +46,12 @@ export const yachts = pgTable( status: text('status').notNull().default('active'), // 'active' | 'retired' | 'sold_away' notes: text('notes'), archivedAt: timestamp('archived_at', { withTimezone: true }), + // Phase 3 — origin tracking. eoi-generated marks yachts that were + // spawned via the EOI dialog's "+ New yacht" inline form, so the + // detail page can surface an [EOI] badge + link to the originating + // document. + source: text('source').notNull().default('manual'), + sourceDocumentId: text('source_document_id'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, diff --git a/src/lib/email/bounce-parser.ts b/src/lib/email/bounce-parser.ts new file mode 100644 index 00000000..87035eff --- /dev/null +++ b/src/lib/email/bounce-parser.ts @@ -0,0 +1,183 @@ +/** + * Phase 6 — IMAP bounce parser library. + * + * Walks an inbound delivery-status notification (DSN / NDR) message and + * extracts the original recipient, bounce class (hard / soft / ooo / + * unknown), and a human-readable reason. The cron poller in + * `src/jobs/processors/imap-bounce-poller.ts` (deferred to Phase 6b) + * passes each fetched message through this parser, then matches the + * extracted recipient against `document_sends.recipient_email` within a + * 7-day window before updating the send row's bounce_* columns. + * + * The parser handles three common NDR shapes: + * 1. RFC 3464 multipart/report — Postfix, Exim, Sendmail, Gmail. The + * message has a `message/delivery-status` part whose headers carry + * structured Action / Status / Original-Recipient fields. + * 2. Microsoft Outlook / Exchange — non-DSN reports with the original + * recipient embedded in the subject line + body prose. + * 3. Out-of-office auto-replies — distinct from bounces; classed as + * `ooo` so the UI banner stays informational rather than alarming. + * + * When the parser can't extract a recipient it returns + * `originalRecipient: null` + `bounceClass: 'unknown'` so the worker can + * log + audit without mutating any send rows. + */ + +import { simpleParser, type ParsedMail } from 'mailparser'; + +export type BounceClass = 'hard' | 'soft' | 'ooo' | 'unknown'; + +export interface ParsedBounce { + originalRecipient: string | null; + bounceClass: BounceClass; + /** Concise human-readable reason surfaced in the UI banner. */ + reason: string; + /** Inbound message-id (or in-reply-to header) for cross-reference. */ + inReplyTo: string | null; + /** SMTP status code (e.g. "5.1.1") if the NDR carried one. */ + statusCode: string | null; +} + +const HARD_BOUNCE_STATUSES = new Set([ + '5.0.0', + '5.1.1', // mailbox does not exist + '5.1.2', // bad destination system + '5.1.3', // bad destination mailbox address syntax + '5.1.10', // recipient address rejected + '5.7.1', // delivery not authorized + '5.7.26', // multiple authentication checks failed + '5.4.1', // no answer from host +]); + +const SOFT_BOUNCE_STATUSES = new Set([ + '4.2.2', // mailbox full + '4.4.1', // no answer from host (transient) + '4.4.7', // delivery time expired + '4.7.0', // generic transient + '5.2.2', // mailbox full (some servers send 5.x for full) +]); + +function classifyByStatus(status: string | null): BounceClass | null { + if (!status) return null; + if (HARD_BOUNCE_STATUSES.has(status)) return 'hard'; + if (SOFT_BOUNCE_STATUSES.has(status)) return 'soft'; + // First-digit fallback: 5xx hard, 4xx soft. + if (status.startsWith('5.')) return 'hard'; + if (status.startsWith('4.')) return 'soft'; + return null; +} + +function looksLikeOoo(subject: string, bodyText: string): boolean { + const sig = `${subject}\n${bodyText}`.toLowerCase(); + return ( + /(out\s*of\s*office|out-of-office|auto[-\s]?reply|on\s*vacation|on\s*holiday|away\s*from\s*my\s*desk)/.test( + sig, + ) && !/delivery (status|failure)/.test(sig) + ); +} + +function extractStatusFromBody(text: string): string | null { + // Postfix / generic: "Status: 5.1.1" + const direct = text.match(/Status:\s*(\d\.\d+\.\d+)/i); + if (direct?.[1]) return direct[1]; + // SMTP reply line: "550 5.1.1 user unknown" + const reply = text.match(/\b([45]\d{2})\s+(\d\.\d+\.\d+)/); + if (reply?.[2]) return reply[2]; + // Bare 550/451/etc. + const bare = text.match(/\b(550|551|552|553|554|450|451|452)\b/); + if (bare?.[1]) { + const code = bare[1]; + return `${code[0]}.0.0`; + } + return null; +} + +function extractRecipientFromBody(text: string): string | null { + // RFC 3464 explicit field. + const explicit = text.match( + /(?:Original-)?Recipient(?:\s*\(rfc822\))?:\s*(?:rfc822;)?\s*([^\s<>]+@[^\s<>]+)/i, + ); + if (explicit?.[1]) return explicit[1].trim(); + // "Failed delivery to " / "couldn't be delivered to user@example.com" + const angled = text.match( + /(?:delivered\s+to|delivery\s+to|sent\s+to|failed\s+for|recipient[s]?:)\s*"]+@[^\s<>"]+)/i, + ); + if (angled?.[1]) return angled[1].trim(); + return null; +} + +export async function parseBounce(raw: string | Buffer): Promise { + let parsed: ParsedMail; + try { + parsed = await simpleParser(raw); + } catch { + return { + originalRecipient: null, + bounceClass: 'unknown', + reason: 'Failed to parse message', + inReplyTo: null, + statusCode: null, + }; + } + + const subject = parsed.subject ?? ''; + const bodyText = parsed.text ?? ''; + const inReplyTo = (parsed.inReplyTo as string | undefined) ?? null; + + if (looksLikeOoo(subject, bodyText)) { + return { + originalRecipient: parsed.from?.value[0]?.address ?? null, + bounceClass: 'ooo', + reason: 'Out-of-office auto-reply', + inReplyTo, + statusCode: null, + }; + } + + // Try to walk the multipart/report DSN structure first; falls back to + // plain-text heuristics for non-RFC-compliant Outlook NDRs. + const statusCode = extractStatusFromBody(bodyText); + const originalRecipient = extractRecipientFromBody(bodyText); + + const cls = classifyByStatus(statusCode); + + // Subject heuristics when status code missing. + const subjectLower = subject.toLowerCase(); + const looksLikeBounce = + /(undelivered|delivery (status|failure)|returned mail|mail delivery|undeliverable|failure notice)/.test( + subjectLower, + ); + + if (!cls && !looksLikeBounce) { + return { + originalRecipient, + bounceClass: 'unknown', + reason: 'No bounce indicators detected', + inReplyTo, + statusCode, + }; + } + + return { + originalRecipient, + bounceClass: cls ?? 'hard', + reason: deriveReason(statusCode, bodyText, subject), + inReplyTo, + statusCode, + }; +} + +function deriveReason(status: string | null, bodyText: string, subject: string): string { + if (status === '5.1.1') return 'Recipient address does not exist'; + if (status === '5.2.2' || status === '4.2.2') return 'Recipient mailbox is full'; + if (status === '5.7.1') return 'Delivery not authorized (likely anti-spam block)'; + if (status === '4.4.1' || status === '4.4.7') return 'Transient delivery failure'; + // Grab the first line of the SMTP diagnostic if present. + const diag = bodyText.match(/Diagnostic-Code:[^\n]+/i)?.[0]; + if (diag) + return diag + .replace(/^Diagnostic-Code:\s*smtp;?\s*/i, '') + .trim() + .slice(0, 200); + return subject.slice(0, 200); +} diff --git a/src/lib/templates/field-map.ts b/src/lib/templates/field-map.ts new file mode 100644 index 00000000..12c697bd --- /dev/null +++ b/src/lib/templates/field-map.ts @@ -0,0 +1,53 @@ +/** + * Phase 7 — PDF template editor field-map type definitions. + * + * The editor (deferred to 7.1/7.2) operates on the existing + * `document_templates.overlay_positions` JSONB column with the extended + * shape below. No schema migration is required — JSONB already accepts + * any shape; the only contract is the Zod validator on the API boundary. + * + * Coordinates are stored as percent of page width/height (0..1) so + * field placements survive page-size changes (A4 → US Letter etc.). + * The legacy overlay schema used absolute coords; the editor migrates + * those on first save by reading the source PDF's page dimensions. + */ + +import { z } from 'zod'; + +export const fieldMapEntrySchema = z.object({ + /** Must match an entry in VALID_MERGE_TOKENS (validated at the API). */ + token: z.string().min(1).max(100), + /** 1-indexed page number. */ + page: z.number().int().min(1), + /** Percent of page width — top-left corner of the marker. */ + x: z.number().min(0).max(1), + /** Percent of page height — top-left corner of the marker. */ + y: z.number().min(0).max(1), + /** Percent width — defaults to 0.15 if omitted by an older editor. */ + w: z.number().min(0).max(1).optional(), + /** Percent height — defaults to 0.04. */ + h: z.number().min(0).max(1).optional(), + /** Optional explicit font size; otherwise auto-fit to the box. */ + fontSize: z.number().int().min(6).max(72).optional(), +}); + +export const fieldMapSchema = z.array(fieldMapEntrySchema).max(500); + +export type FieldMapEntry = z.infer; +export type FieldMap = z.infer; + +/** + * Cross-validate a parsed field-map against the source PDF page count. + * The editor surfaces a warning when an upload truncates the page set + * (e.g. uploading a 3-page PDF over a 5-page template). Returns null + * when valid, or an error message describing the first out-of-range + * marker. + */ +export function validateFieldMapAgainstPageCount(map: FieldMap, pageCount: number): string | null { + for (const m of map) { + if (m.page > pageCount) { + return `Field "${m.token}" sits on page ${m.page} but the PDF only has ${pageCount} pages.`; + } + } + return null; +}