/** * 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 } 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) => { // Reusable-until-expiry: the link stays valid for repeat // submissions until it expires. `consumedAt` is still stamped on // first submit so the rep / loader can show "last submitted at //