/** * 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, clientAddresses, 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 AddressOverrideInput { line1?: string; line2?: string; city?: string; subdivisionIso?: string; postalCode?: string; countryIso?: string; useOnlyForThisEoi: boolean; setAsDefault: boolean; /** Existing client_addresses.id when the rep picked one from a list; * null = fresh values typed in the dialog. */ addressId?: string | null; } export interface EoiOverridesInput { clientEmail?: FieldOverrideInput; clientPhone?: FieldOverrideInput; yachtName?: FieldOverrideInput; clientAddress?: AddressOverrideInput; } export interface AppliedOverrides { /** Values to layer onto the in-memory EoiContext before rendering. */ resolved: { clientEmail?: string; clientPhone?: string; yachtName?: string; clientAddress?: { line1: string; line2: string; city: string; subdivisionIso: string; postalCode: string; countryIso: 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; overrideClientAddressLine1: string; overrideClientAddressLine2: string; overrideClientCity: string; overrideClientState: string; overrideClientPostalCode: string; overrideClientCountry: 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; } if (overrides.clientAddress) { const a = overrides.clientAddress; const resolvedAddr = { line1: (a.line1 ?? '').trim(), line2: (a.line2 ?? '').trim(), city: (a.city ?? '').trim(), subdivisionIso: (a.subdivisionIso ?? '').trim(), postalCode: (a.postalCode ?? '').trim(), countryIso: (a.countryIso ?? '').trim().toUpperCase(), }; // Treat the address as one logical field - at least line1 + countryIso // must be present for an EOI to render legally. if (!resolvedAddr.line1 || !resolvedAddr.countryIso) { throw new ValidationError('address override requires line1 and countryIso'); } if (a.useOnlyForThisEoi) { documentOverrideColumns.overrideClientAddressLine1 = resolvedAddr.line1; if (resolvedAddr.line2) documentOverrideColumns.overrideClientAddressLine2 = resolvedAddr.line2; if (resolvedAddr.city) documentOverrideColumns.overrideClientCity = resolvedAddr.city; if (resolvedAddr.subdivisionIso) documentOverrideColumns.overrideClientState = resolvedAddr.subdivisionIso; if (resolvedAddr.postalCode) documentOverrideColumns.overrideClientPostalCode = resolvedAddr.postalCode; documentOverrideColumns.overrideClientCountry = resolvedAddr.countryIso; } else if (a.setAsDefault) { // Promote: demote the prior primary, then either update an existing // address row (when addressId was provided) or insert a fresh one. await tx .update(clientAddresses) .set({ isPrimary: false, updatedAt: new Date() }) .where(and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true))); if (a.addressId) { await tx .update(clientAddresses) .set({ // client_addresses has no addressLine2 column - concat line1+line2. streetAddress: resolvedAddr.line2 ? `${resolvedAddr.line1}\n${resolvedAddr.line2}` : resolvedAddr.line1, city: resolvedAddr.city || null, subdivisionIso: resolvedAddr.subdivisionIso || null, postalCode: resolvedAddr.postalCode || null, countryIso: resolvedAddr.countryIso, isPrimary: true, updatedAt: new Date(), }) .where( and(eq(clientAddresses.id, a.addressId), eq(clientAddresses.clientId, client.id)), ); } else { await tx.insert(clientAddresses).values({ clientId: client.id, portId: client.portId, // client_addresses has no addressLine2 column - concat line1+line2. streetAddress: resolvedAddr.line2 ? `${resolvedAddr.line1}\n${resolvedAddr.line2}` : resolvedAddr.line1, city: resolvedAddr.city || null, subdivisionIso: resolvedAddr.subdivisionIso || null, postalCode: resolvedAddr.postalCode || null, countryIso: resolvedAddr.countryIso, isPrimary: true, source: 'eoi-custom-input', }); } // Canonical now matches → documents.override_* stays NULL. } else { // Neither flag: persist per-doc + (if no addressId) insert a // non-primary address row for future reuse. if (!a.addressId) { await tx.insert(clientAddresses).values({ clientId: client.id, portId: client.portId, streetAddress: resolvedAddr.line2 ? `${resolvedAddr.line1}\n${resolvedAddr.line2}` : resolvedAddr.line1, city: resolvedAddr.city || null, subdivisionIso: resolvedAddr.subdivisionIso || null, postalCode: resolvedAddr.postalCode || null, countryIso: resolvedAddr.countryIso, isPrimary: false, source: 'eoi-custom-input', }); } documentOverrideColumns.overrideClientAddressLine1 = resolvedAddr.line1; if (resolvedAddr.line2) documentOverrideColumns.overrideClientAddressLine2 = resolvedAddr.line2; if (resolvedAddr.city) documentOverrideColumns.overrideClientCity = resolvedAddr.city; if (resolvedAddr.subdivisionIso) documentOverrideColumns.overrideClientState = resolvedAddr.subdivisionIso; if (resolvedAddr.postalCode) documentOverrideColumns.overrideClientPostalCode = resolvedAddr.postalCode; documentOverrideColumns.overrideClientCountry = resolvedAddr.countryIso; } resolved.clientAddress = resolvedAddr; } // One audit row per touched field summarising the override intent. const auditFields: Array<{ field: string; intent: Record }> = []; if (overrides.clientEmail) auditFields.push({ field: 'clientEmail', intent: { value: overrides.clientEmail.value.slice(0, 200), useOnlyForThisEoi: overrides.clientEmail.useOnlyForThisEoi, setAsDefault: overrides.clientEmail.setAsDefault, fromContactId: overrides.clientEmail.contactId ?? null, }, }); if (overrides.clientPhone) auditFields.push({ field: 'clientPhone', intent: { value: overrides.clientPhone.value.slice(0, 200), useOnlyForThisEoi: overrides.clientPhone.useOnlyForThisEoi, setAsDefault: overrides.clientPhone.setAsDefault, fromContactId: overrides.clientPhone.contactId ?? null, }, }); if (overrides.yachtName) auditFields.push({ field: 'yachtName', intent: { value: overrides.yachtName.value.slice(0, 200), useOnlyForThisEoi: overrides.yachtName.useOnlyForThisEoi, setAsDefault: overrides.yachtName.setAsDefault, }, }); if (overrides.clientAddress) auditFields.push({ field: 'clientAddress', intent: { useOnlyForThisEoi: overrides.clientAddress.useOnlyForThisEoi, setAsDefault: overrides.clientAddress.setAsDefault, fromAddressId: overrides.clientAddress.addressId ?? null, countryIso: overrides.clientAddress.countryIso, }, }); for (const { field, intent } of auditFields) { void createAuditLog({ userId: meta.userId, portId, action: 'eoi_field_override', entityType: 'interest', entityId: interestId, newValue: { field, ...intent }, 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; // Even when cols is empty (every override ran setAsDefault), we still // need to backfill source_document_id on freshly-inserted contact / // address / yacht rows whose insertion preceded the document row's // existence. Skip only when applied is the empty default. const hasResolved = Object.keys(applied.resolved).length > 0; if (Object.keys(cols).length > 0) { await db.update(documents).set(cols).where(eq(documents.id, documentId)); } else if (!hasResolved) { return; } // Backfill source_document_id on freshly-inserted contact + address + // yacht rows from this generation pass. Bounded by createdAt < 1 min // so re-runs don't sweep older orphans. 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'), sql`${clientContacts.createdAt} > NOW() - INTERVAL '1 minute'`, sql`${clientContacts.sourceDocumentId} IS NULL`, ), ); await db .update(clientAddresses) .set({ sourceDocumentId: documentId }) .where( and( eq(clientAddresses.source, 'eoi-custom-input'), sql`${clientAddresses.createdAt} > NOW() - INTERVAL '1 minute'`, sql`${clientAddresses.sourceDocumentId} IS NULL`, ), ); // Phase 3 follow-up - yacht spawn from EOI runs BEFORE generateAndSign // so the yacht row's source_document_id is NULL at insert time. Same // bounded backfill pattern as contacts. await db .update(yachts) .set({ sourceDocumentId: documentId }) .where( and( eq(yachts.source, 'eoi-generated'), sql`${yachts.createdAt} > NOW() - INTERVAL '1 minute'`, sql`${yachts.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; address: { street: string; city: string; subdivision: string; postalCode: string; country: string; countryIso: 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; } if (applied.resolved.clientAddress) { const a = applied.resolved.clientAddress; const combinedStreet = a.line2 ? `${a.line1}\n${a.line2}` : a.line1; // Strip the country-code prefix from the subdivision ISO so the EOI // renders the subdivision suffix exactly the way the canonical // address pipeline does (e.g. 'US-CA' → 'CA'). const subdivisionSuffix = a.subdivisionIso.includes('-') ? a.subdivisionIso.split('-').slice(1).join('-') : a.subdivisionIso; context.client.address = { street: combinedStreet, city: a.city, subdivision: subdivisionSuffix, postalCode: a.postalCode, // `country` (long name) is only used by the deprecated UI preview // line; the EOI's Address field renders countryIso, so we set them // consistently and leave the long-name lookup to the renderer. country: a.countryIso, countryIso: a.countryIso, }; } return context; }