/** * Pre-EOI supplemental info form service. * * Three operations: * 1. `issueToken` — rep clicks "Request more info" → token row + email queued. * 2. `loadByToken` — public form fetches prefill data; rejects expired/consumed tokens. * 3. `applySubmission` — public form POST → diff against current data, apply * updates, consume token. All inside one transaction. */ import { and, eq, isNull } from 'drizzle-orm'; import crypto from 'node:crypto'; import { db } from '@/lib/db'; import { supplementalFormTokens, interests, clients, clientAddresses, yachts, clientContacts, } from '@/lib/db/schema'; import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; const TOKEN_TTL_DAYS = 14; const TOKEN_BYTES = 32; // 256-bit → ~43 base64url chars; brute-force infeasible. function generateToken(): string { return crypto.randomBytes(TOKEN_BYTES).toString('base64url'); } export interface IssueTokenInput { interestId: string; portId: string; issuedBy: string; } export async function issueToken(input: IssueTokenInput): Promise<{ token: string; expiresAt: Date; clientEmail: string | null; clientName: string; }> { // Resolve the interest's client + at least one email contact so the // calling code can queue the email immediately without a second hop. const interest = await db.query.interests.findFirst({ where: and(eq(interests.id, input.interestId), eq(interests.portId, input.portId)), }); if (!interest) throw new NotFoundError('interest'); const client = await db.query.clients.findFirst({ where: eq(clients.id, interest.clientId) }); if (!client) throw new NotFoundError('client'); const emailContact = await db.query.clientContacts.findFirst({ where: and(eq(clientContacts.clientId, client.id), eq(clientContacts.channel, 'email')), }); const token = generateToken(); const expiresAt = new Date(Date.now() + TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000); await db.insert(supplementalFormTokens).values({ portId: input.portId, interestId: interest.id, clientId: client.id, token, expiresAt, issuedBy: input.issuedBy, }); return { token, expiresAt, clientEmail: emailContact?.value ?? null, clientName: client.fullName ?? client.id, }; } export interface PrefillData { /** Token metadata so the form can disable itself when consumed. */ token: { expiresAt: string; consumed: boolean }; client: { fullName: string; streetAddress: string | null; city: string | null; postalCode: string | null; country: string | null; primaryEmail: string | null; primaryPhone: string | null; primaryPhoneCountry: string | null; }; yacht: { name: string | null; lengthFt: string | null; widthFt: string | null; draftFt: string | null; } | null; } /** * Hydrate the public form. Returns null when the token doesn't exist * (avoid leaking whether it's expired vs. fake); returns a payload with * `consumed: true` when it's already been used so the form can render * a friendly "already submitted" state. */ export async function loadByToken(token: string): Promise { const row = await db.query.supplementalFormTokens.findFirst({ where: eq(supplementalFormTokens.token, token), }); if (!row) return null; if (row.expiresAt.getTime() < Date.now()) return null; const client = await db.query.clients.findFirst({ where: eq(clients.id, row.clientId) }); if (!client) return null; const interest = await db.query.interests.findFirst({ where: eq(interests.id, row.interestId) }); const yacht = interest?.yachtId ? await db.query.yachts.findFirst({ where: eq(yachts.id, interest.yachtId) }) : null; // Prefer the primary contact when one is flagged; otherwise the first // email/phone record. We need email + phone country code for the form's // i18n-aware PhoneInput. const contacts = await db.query.clientContacts.findMany({ where: eq(clientContacts.clientId, client.id), }); const emailContact = contacts.find((c) => c.channel === 'email' && c.isPrimary) ?? contacts.find((c) => c.channel === 'email') ?? null; const phoneContact = contacts.find((c) => (c.channel === 'phone' || c.channel === 'whatsapp') && c.isPrimary) ?? contacts.find((c) => c.channel === 'phone' || c.channel === 'whatsapp') ?? null; const primaryAddress = await db.query.clientAddresses.findFirst({ where: and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)), }); return { token: { expiresAt: row.expiresAt.toISOString(), consumed: !!row.consumedAt, }, client: { fullName: client.fullName, streetAddress: primaryAddress?.streetAddress ?? null, city: primaryAddress?.city ?? null, postalCode: primaryAddress?.postalCode ?? null, country: primaryAddress?.countryIso ?? null, primaryEmail: emailContact?.value ?? null, primaryPhone: phoneContact?.valueE164 ?? phoneContact?.value ?? null, primaryPhoneCountry: phoneContact?.valueCountry ?? null, }, yacht: yacht ? { name: yacht.name ?? null, lengthFt: yacht.lengthFt ?? null, widthFt: yacht.widthFt ?? null, draftFt: yacht.draftFt ?? null, } : interest?.desiredLengthFt || interest?.desiredWidthFt || interest?.desiredDraftFt ? { name: null, lengthFt: interest.desiredLengthFt ?? null, widthFt: interest.desiredWidthFt ?? null, draftFt: interest.desiredDraftFt ?? null, } : null, }; } export interface SubmissionInput { fullName: string; address: string | null; country: string | null; email: string | null; phoneE164: string | null; phoneCountry: string | null; yachtName: string | null; yachtLengthFt: number | null; yachtWidthFt: number | null; yachtDraftFt: number | null; } /** * Apply a public-form submission. Diffs against current values and only * writes the changed fields. Consumes the token in the same transaction * so a retry can't double-apply. */ export async function applySubmission(token: string, input: SubmissionInput): Promise { if (!input.fullName?.trim()) { throw new ValidationError('Name is required'); } await db.transaction(async (tx) => { const row = await tx.query.supplementalFormTokens.findFirst({ where: and( eq(supplementalFormTokens.token, token), isNull(supplementalFormTokens.consumedAt), ), }); if (!row) { throw new ConflictError('This link has already been used or is no longer valid.'); } if (row.expiresAt.getTime() < Date.now()) { throw new ConflictError('This link has expired.'); } const client = await tx.query.clients.findFirst({ where: eq(clients.id, row.clientId) }); if (!client) throw new NotFoundError('client'); // Client patch: name lives on clients; address fields live on the // dedicated client_addresses row. fullName is required so always sent. if (input.fullName.trim() !== client.fullName) { await tx .update(clients) .set({ fullName: input.fullName.trim() }) .where(eq(clients.id, client.id)); } if (input.address || input.country) { const existingAddr = await tx.query.clientAddresses.findFirst({ where: and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)), }); if (!existingAddr) { await tx.insert(clientAddresses).values({ clientId: client.id, portId: row.portId, label: 'Primary', streetAddress: input.address ?? null, countryIso: input.country ?? null, isPrimary: true, }); } else { const addrPatch: Record = {}; if (input.address && input.address !== existingAddr.streetAddress) addrPatch.streetAddress = input.address; if (input.country && input.country !== existingAddr.countryIso) addrPatch.countryIso = input.country; if (Object.keys(addrPatch).length > 0) { await tx .update(clientAddresses) .set(addrPatch) .where(eq(clientAddresses.id, existingAddr.id)); } } } // Email / phone go to client_contacts. Upsert if changed. if (input.email && input.email.trim()) { const existing = await tx.query.clientContacts.findFirst({ where: and(eq(clientContacts.clientId, client.id), eq(clientContacts.channel, 'email')), }); if (!existing) { await tx.insert(clientContacts).values({ clientId: client.id, channel: 'email', value: input.email.trim().toLowerCase(), isPrimary: true, }); } else if (existing.value !== input.email.trim().toLowerCase()) { await tx .update(clientContacts) .set({ value: input.email.trim().toLowerCase() }) .where(eq(clientContacts.id, existing.id)); } } if (input.phoneE164 && input.phoneE164.trim()) { const existing = await tx.query.clientContacts.findFirst({ where: and(eq(clientContacts.clientId, client.id), eq(clientContacts.channel, 'phone')), }); if (!existing) { await tx.insert(clientContacts).values({ clientId: client.id, channel: 'phone', value: input.phoneE164, valueE164: input.phoneE164, valueCountry: input.phoneCountry, isPrimary: true, }); } else if (existing.valueE164 !== input.phoneE164) { await tx .update(clientContacts) .set({ value: input.phoneE164, valueE164: input.phoneE164, valueCountry: input.phoneCountry ?? existing.valueCountry, }) .where(eq(clientContacts.id, existing.id)); } } // Yacht block: best-effort. If interest.yachtId is set, update that; // otherwise we don't auto-create a yacht (rep should do it explicitly). const interest = await tx.query.interests.findFirst({ where: eq(interests.id, row.interestId), }); if (interest?.yachtId && (input.yachtName || input.yachtLengthFt)) { const yachtPatch: Record = {}; if (input.yachtName) yachtPatch.name = input.yachtName; if (input.yachtLengthFt !== null) yachtPatch.lengthFt = String(input.yachtLengthFt); if (input.yachtWidthFt !== null) yachtPatch.widthFt = String(input.yachtWidthFt); if (input.yachtDraftFt !== null) yachtPatch.draftFt = String(input.yachtDraftFt); if (Object.keys(yachtPatch).length > 0) { await tx.update(yachts).set(yachtPatch).where(eq(yachts.id, interest.yachtId)); } } // Mirror yacht dimensions onto the interest's desired-dimensions // fields so the recommender picks up the corrected values. if (interest && (input.yachtLengthFt || input.yachtWidthFt || input.yachtDraftFt)) { const interestPatch: Record = {}; if (input.yachtLengthFt !== null) interestPatch.desiredLengthFt = String(input.yachtLengthFt); if (input.yachtWidthFt !== null) interestPatch.desiredWidthFt = String(input.yachtWidthFt); if (input.yachtDraftFt !== null) interestPatch.desiredDraftFt = String(input.yachtDraftFt); if (Object.keys(interestPatch).length > 0) { await tx.update(interests).set(interestPatch).where(eq(interests.id, interest.id)); } } await tx .update(supplementalFormTokens) .set({ consumedAt: new Date() }) .where(eq(supplementalFormTokens.id, row.id)); }); }