feat(post-audit): Phase 3/6/7 schema foundations + bounce parser

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 15:09:22 +02:00
parent fb4a09e2ec
commit 9f5786890e
8 changed files with 425 additions and 0 deletions

View File

@@ -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.';

View File

@@ -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.';

View File

@@ -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),

View File

@@ -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(),
},

View File

@@ -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(),

View File

@@ -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(),
},