/** * Phase 3b — EOI field-override side-effects + persistence. * * The EOI dialog lets reps override pre-filled fields (email, phone, * yacht name) with one of three intents: * * 1. **Use only for this EOI** (`useOnlyForThisEoi=true`) * → write to `documents.override_*` columns only; never mutate * client_contacts or yachts. Future EOIs revert to the canonical * primary. * * 2. **Set as default for future docs** (`setAsDefault=true`) * → promote an existing `client_contacts` row to primary, or insert * + promote if the rep typed a fresh value. Demote the prior * primary inside the same transaction. `documents.override_*` * stays NULL because the canonical record now matches. * * 3. **Neither flag** (default — rep picked a secondary from the * combobox OR typed something fresh) * → if the value is fresh (no `contactId`), insert a non-primary * `client_contacts` row (`source='eoi-custom-input'`, * `source_document_id=`). Either way write * `documents.override_*` so the rendered doc records the * deviation from the canonical primary. * * Yacht name overrides have no contact-row analog. `useOnlyForThisEoi` * writes to `documents.override_yacht_name`; `setAsDefault` patches the * canonical `yachts.name` column. * * The applied override values are returned so the caller can layer them * onto the in-memory EOI context before rendering — without a separate * round-trip to re-read the freshly-mutated contact rows. */ import { and, eq, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { clients, clientContacts } from '@/lib/db/schema/clients'; import { documents } from '@/lib/db/schema/documents'; import { interests } from '@/lib/db/schema/interests'; import { yachts } from '@/lib/db/schema/yachts'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { ValidationError } from '@/lib/errors'; import { withTransaction } from '@/lib/db/utils'; export interface FieldOverrideInput { value: string; useOnlyForThisEoi: boolean; setAsDefault: boolean; contactId?: string | null; } export interface EoiOverridesInput { clientEmail?: FieldOverrideInput; clientPhone?: FieldOverrideInput; yachtName?: FieldOverrideInput; } export interface AppliedOverrides { /** Values to layer onto the in-memory EoiContext before rendering. */ resolved: { clientEmail?: string; clientPhone?: string; yachtName?: string; }; /** Columns to write to `documents.override_*` after the doc row exists. * Empty when every override either ran `setAsDefault` (canonical * updated) or no overrides were supplied. */ documentOverrideColumns: Partial<{ overrideClientEmail: string; overrideClientPhone: string; overrideYachtName: string; }>; } /** * Apply override side-effects (insert contacts, promote primaries, * patch yacht name) and return the values to be used at render time. * * Runs all mutations in a single transaction so a partial failure * (e.g. setAsDefault promotion succeeds for email but fails for * phone) doesn't leave the contact table in a split-brain state. * * Audit log entries: `eoi_field_override` per field touched. */ export async function applyEoiOverridesBeforeRender( portId: string, interestId: string, overrides: EoiOverridesInput | undefined, meta: AuditMeta, ): Promise { const empty: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} }; if (!overrides) return empty; // Resolve the interest's client (for contact mutations) and yacht (for // yacht-name mutations) up-front so the transaction body has everything // it needs without re-fetching. const interest = await db.query.interests.findFirst({ where: and(eq(interests.id, interestId), eq(interests.portId, portId)), }); if (!interest) throw new ValidationError('interest not found for overrides'); const client = await db.query.clients.findFirst({ where: and(eq(clients.id, interest.clientId), eq(clients.portId, portId)), }); if (!client) throw new ValidationError('client not found for overrides'); const yacht = interest.yachtId ? await db.query.yachts.findFirst({ where: and(eq(yachts.id, interest.yachtId), eq(yachts.portId, portId)), }) : null; // ─── Single transaction wrapping every side-effect ──────────────────────── return withTransaction(async (tx) => { const resolved: AppliedOverrides['resolved'] = {}; const documentOverrideColumns: AppliedOverrides['documentOverrideColumns'] = {}; // Helper for contact-channel overrides (email + phone share logic). const applyContactOverride = async ( override: FieldOverrideInput, channel: 'email' | 'phone', docColumn: 'overrideClientEmail' | 'overrideClientPhone', ): Promise => { const value = override.value.trim(); if (!value) throw new ValidationError(`${channel} override value cannot be empty`); if (override.useOnlyForThisEoi) { // No contact mutation. Override applies only to this document. documentOverrideColumns[docColumn] = value; return value; } if (override.setAsDefault) { // Promote: either an existing contactId or a fresh insert. Demote // the prior primary for the same channel first so the partial // unique index doesn't reject the promotion. await tx .update(clientContacts) .set({ isPrimary: false, updatedAt: new Date() }) .where( and( eq(clientContacts.clientId, client.id), eq(clientContacts.channel, channel), eq(clientContacts.isPrimary, true), ), ); if (override.contactId) { // Promote existing row. await tx .update(clientContacts) .set({ isPrimary: true, value, updatedAt: new Date() }) .where( and( eq(clientContacts.id, override.contactId), eq(clientContacts.clientId, client.id), ), ); } else { // Fresh insert + primary. await tx.insert(clientContacts).values({ clientId: client.id, channel, value, isPrimary: true, source: 'eoi-custom-input', }); } // Canonical now matches → documents.override_* stays NULL. return value; } // Neither flag set. If the rep picked an existing contact row // (contactId set) we don't mutate; if they typed a fresh value // we insert a non-primary contact so it shows up in future // dropdowns. Either way we record the deviation on the document. if (!override.contactId) { await tx.insert(clientContacts).values({ clientId: client.id, channel, value, isPrimary: false, source: 'eoi-custom-input', }); } documentOverrideColumns[docColumn] = value; return value; }; if (overrides.clientEmail) { resolved.clientEmail = await applyContactOverride( overrides.clientEmail, 'email', 'overrideClientEmail', ); } if (overrides.clientPhone) { resolved.clientPhone = await applyContactOverride( overrides.clientPhone, 'phone', 'overrideClientPhone', ); } if (overrides.yachtName) { const value = overrides.yachtName.value.trim(); if (!value) throw new ValidationError('yacht name override cannot be empty'); if (!yacht) { // Yacht-name override without a linked yacht only makes sense // for the per-document path — otherwise there's no canonical // record to update. if (overrides.yachtName.setAsDefault) { throw new ValidationError('cannot setAsDefault for yacht name when no yacht is linked'); } documentOverrideColumns.overrideYachtName = value; } else if (overrides.yachtName.useOnlyForThisEoi) { documentOverrideColumns.overrideYachtName = value; } else if (overrides.yachtName.setAsDefault) { await tx .update(yachts) .set({ name: value, updatedAt: new Date() }) .where(eq(yachts.id, yacht.id)); } else { // Default behaviour: per-document override. documentOverrideColumns.overrideYachtName = value; } resolved.yachtName = value; } // One audit row per touched field summarising the override intent. const auditFields: Array<{ field: string; override: FieldOverrideInput }> = []; if (overrides.clientEmail) auditFields.push({ field: 'clientEmail', override: overrides.clientEmail }); if (overrides.clientPhone) auditFields.push({ field: 'clientPhone', override: overrides.clientPhone }); if (overrides.yachtName) auditFields.push({ field: 'yachtName', override: overrides.yachtName }); for (const { field, override } of auditFields) { void createAuditLog({ userId: meta.userId, portId, action: 'eoi_field_override', entityType: 'interest', entityId: interestId, newValue: { field, // Truncate to avoid bloating audit rows with long free-text. value: override.value.slice(0, 200), useOnlyForThisEoi: override.useOnlyForThisEoi, setAsDefault: override.setAsDefault, fromContactId: override.contactId ?? null, }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); } return { resolved, documentOverrideColumns }; }); } /** * Persist `documents.override_*` columns after the document row has * been inserted. No-op when no columns are set. * * `source_document_id` on any client_contacts rows inserted by the * preceding `applyEoiOverridesBeforeRender` call is left NULL until * this point — the document id doesn't exist yet during the contact * insert. This function backfills it. */ export async function persistDocumentOverrides( documentId: string, applied: AppliedOverrides, meta: AuditMeta, ): Promise { const cols = applied.documentOverrideColumns; if (Object.keys(cols).length === 0) return; await db.update(documents).set(cols).where(eq(documents.id, documentId)); // Backfill source_document_id on any client_contacts rows this run // inserted. Done outside the override transaction because the // document id wasn't known yet at that point. await db .update(clientContacts) .set({ sourceDocumentId: documentId }) .where( and( eq(clientContacts.source, 'eoi-custom-input'), // Backfill only the recently-inserted rows that haven't been // attributed yet. Bounded by createdAt so re-runs don't sweep up // older orphans. sql`${clientContacts.createdAt} > NOW() - INTERVAL '1 minute'`, sql`${clientContacts.sourceDocumentId} IS NULL`, ), ); void createAuditLog({ userId: meta.userId, portId: meta.portId, action: 'update', entityType: 'document', entityId: documentId, metadata: { action: 'persist_eoi_overrides', columns: Object.keys(cols) }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); } /** * Layer applied override values onto an EOI context object so the * renderer (in-app pdf-lib OR Documenso payload) sees the override * values instead of the canonical record. Mutates the supplied object * (cheap; the caller built it). */ export function applyOverridesToContext< T extends { client: { primaryEmail: string | null; primaryPhone: string | null }; yacht: { name: string } | null; }, >(context: T, applied: AppliedOverrides): T { if (applied.resolved.clientEmail !== undefined) { context.client.primaryEmail = applied.resolved.clientEmail; } if (applied.resolved.clientPhone !== undefined) { context.client.primaryPhone = applied.resolved.clientPhone; } if (applied.resolved.yachtName !== undefined && context.yacht) { context.yacht.name = applied.resolved.yachtName; } return context; }