feat(supplemental-info): pre-EOI public form flow

Lets a sales rep send a client a one-shot link to fill out the
information we need before drafting the EOI (intent, dimensions,
signatory, timeline). Token-keyed: single-use, soft-expiring, scoped
to one interest + client. Public POST endpoint accepts the form
submission; CRM endpoint mints tokens for rep-initiated requests;
portal page renders the form for the recipient.

Schema: supplemental_form_tokens table (migration 0061) with port_id
+ interest_id + client_id refs, unique token, consumed_at marker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 03:36:56 +02:00
parent e11529ffcc
commit 0fe3e984d1
7 changed files with 858 additions and 0 deletions

View File

@@ -0,0 +1,307 @@
'use client';
import { use, useEffect, useState } from 'react';
import { CheckCircle2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
import type { CountryCode } from '@/lib/i18n/countries';
interface PrefillData {
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;
}
interface PageProps {
params: Promise<{ token: string }>;
}
export default function SupplementalInfoPage({ params }: PageProps) {
const { token } = use(params);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<PrefillData | null>(null);
// Form fields
const [fullName, setFullName] = useState('');
const [address, setAddress] = useState('');
const [country, setCountry] = useState<CountryCode | null>(null);
const [email, setEmail] = useState('');
const [phone, setPhone] = useState<PhoneInputValue | null>(null);
const [yachtName, setYachtName] = useState('');
const [yachtLength, setYachtLength] = useState('');
const [yachtWidth, setYachtWidth] = useState('');
const [yachtDraft, setYachtDraft] = useState('');
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch(`/api/public/supplemental-info/${token}`);
if (!res.ok) {
setError(
res.status === 404
? 'This link is no longer valid. It may have expired or already been used.'
: 'Could not load this form. Please try again later.',
);
return;
}
const payload = (await res.json()) as { data: PrefillData };
if (cancelled) return;
setData(payload.data);
setFullName(payload.data.client.fullName ?? '');
setAddress(payload.data.client.streetAddress ?? '');
setCountry((payload.data.client.country as CountryCode | null) ?? null);
setEmail(payload.data.client.primaryEmail ?? '');
if (payload.data.client.primaryPhone) {
setPhone({
e164: payload.data.client.primaryPhone,
country: (payload.data.client.primaryPhoneCountry as CountryCode | null) ?? 'US',
});
}
if (payload.data.yacht) {
setYachtName(payload.data.yacht.name ?? '');
setYachtLength(payload.data.yacht.lengthFt ?? '');
setYachtWidth(payload.data.yacht.widthFt ?? '');
setYachtDraft(payload.data.yacht.draftFt ?? '');
}
} catch {
setError('Could not load this form. Please check your connection and try again.');
} finally {
if (!cancelled) setLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, [token]);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!fullName.trim()) {
toast.error('Please enter your full name.');
return;
}
setSubmitting(true);
try {
const res = await fetch(`/api/public/supplemental-info/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fullName: fullName.trim(),
address: address.trim() || null,
country: country ?? null,
email: email.trim() || null,
phoneE164: phone?.e164 ?? null,
phoneCountry: phone?.country ?? null,
yachtName: yachtName.trim() || null,
yachtLengthFt: parseFloat(yachtLength) || null,
yachtWidthFt: parseFloat(yachtWidth) || null,
yachtDraftFt: parseFloat(yachtDraft) || null,
}),
});
if (!res.ok) {
const payload = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
toast.error(payload.error?.message ?? 'Failed to submit. Please try again.');
return;
}
setSubmitted(true);
} catch {
toast.error('Failed to submit. Please check your connection.');
} finally {
setSubmitting(false);
}
}
if (loading) {
return (
<BrandedAuthShell>
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</BrandedAuthShell>
);
}
if (error) {
return (
<BrandedAuthShell>
<div className="text-center space-y-2 py-6">
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</BrandedAuthShell>
);
}
if (data?.token.consumed) {
return (
<BrandedAuthShell>
<div className="text-center space-y-3 py-6">
<CheckCircle2 className="h-10 w-10 text-emerald-600 mx-auto" />
<h1 className="text-lg font-semibold">Thanks we already have your details</h1>
<p className="text-sm text-muted-foreground">
This form was already submitted. Your sales contact will be in touch shortly.
</p>
</div>
</BrandedAuthShell>
);
}
if (submitted) {
return (
<BrandedAuthShell>
<div className="text-center space-y-3 py-6">
<CheckCircle2 className="h-10 w-10 text-emerald-600 mx-auto" />
<h1 className="text-lg font-semibold">Thanks got it</h1>
<p className="text-sm text-muted-foreground">
Your details have been sent to the team. Watch your inbox for your EOI document shortly.
</p>
</div>
</BrandedAuthShell>
);
}
return (
<BrandedAuthShell>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-1 text-center">
<h1 className="text-xl font-semibold">A few details before we draft your EOI</h1>
<p className="text-sm text-muted-foreground">
We&apos;ve pre-filled what we have on file. Please review, correct anything that&apos;s
wrong, and add what&apos;s missing.
</p>
</div>
<fieldset className="space-y-4">
<legend className="text-sm font-semibold">Your details</legend>
<div className="space-y-1.5">
<Label htmlFor="fullName">Full name</Label>
<Input
id="fullName"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="phone">Phone</Label>
<PhoneInput id="phone" value={phone} onChange={setPhone} placeholder="Phone number" />
<p className="text-[11px] text-muted-foreground">
Use a different number than the one you signed up with if you&apos;d prefer to be
reached there instead.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="address">Address</Label>
<Textarea
id="address"
rows={2}
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="Street, city, postal code"
/>
</div>
<div className="space-y-1.5">
<Label>Country</Label>
<CountryCombobox value={country} onChange={(c) => setCountry(c ?? null)} clearable />
</div>
</fieldset>
<fieldset className="space-y-4">
<legend className="text-sm font-semibold">Your yacht (optional)</legend>
<div className="space-y-1.5">
<Label htmlFor="yachtName">Yacht name</Label>
<Input
id="yachtName"
value={yachtName}
onChange={(e) => setYachtName(e.target.value)}
placeholder="Name on the hull"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label htmlFor="length">Length (ft)</Label>
<Input
id="length"
type="number"
step="0.1"
value={yachtLength}
onChange={(e) => setYachtLength(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="width">Width (ft)</Label>
<Input
id="width"
type="number"
step="0.1"
value={yachtWidth}
onChange={(e) => setYachtWidth(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="draft">Draft (ft)</Label>
<Input
id="draft"
type="number"
step="0.1"
value={yachtDraft}
onChange={(e) => setYachtDraft(e.target.value)}
placeholder="0"
/>
</div>
</div>
</fieldset>
<Button type="submit" className="w-full" disabled={submitting}>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
<p className="text-center text-[11px] text-muted-foreground">
This link is private to you and expires after one use.
</p>
</form>
</BrandedAuthShell>
);
}

View File

@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { loadByToken, applySubmission } from '@/lib/services/supplemental-forms.service';
import { errorResponse } from '@/lib/errors';
/**
* Public — no auth. Loads the prefill data for the form. The token in
* the URL is the only credential; rejects expired / unknown tokens with
* 404 (deliberately conflated to avoid leaking which tokens exist).
*/
export async function GET(
_req: NextRequest,
ctx: { params: Promise<{ token: string }> },
): Promise<NextResponse> {
try {
const { token } = await ctx.params;
const data = await loadByToken(token);
if (!data) {
return NextResponse.json({ error: 'Link not found or expired' }, { status: 404 });
}
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}
const submissionSchema = z.object({
fullName: z.string().min(1).max(200),
address: z.string().max(500).nullable().optional(),
country: z.string().length(2).nullable().optional(),
email: z.string().email().nullable().optional(),
phoneE164: z
.string()
.regex(/^\+[1-9]\d{1,14}$/)
.nullable()
.optional(),
phoneCountry: z.string().length(2).nullable().optional(),
yachtName: z.string().max(200).nullable().optional(),
yachtLengthFt: z.number().positive().nullable().optional(),
yachtWidthFt: z.number().positive().nullable().optional(),
yachtDraftFt: z.number().positive().nullable().optional(),
});
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ token: string }> },
): Promise<NextResponse> {
try {
const { token } = await ctx.params;
const body = submissionSchema.parse(await req.json());
await applySubmission(token, {
fullName: body.fullName,
address: body.address ?? null,
country: body.country ?? null,
email: body.email ?? null,
phoneE164: body.phoneE164 ?? null,
phoneCountry: body.phoneCountry ?? null,
yachtName: body.yachtName ?? null,
yachtLengthFt: body.yachtLengthFt ?? null,
yachtWidthFt: body.yachtWidthFt ?? null,
yachtDraftFt: body.yachtDraftFt ?? null,
});
return NextResponse.json({ data: { success: true } });
} catch (error) {
return errorResponse(error);
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { issueToken } from '@/lib/services/supplemental-forms.service';
import { sendEmail } from '@/lib/email';
import { env } from '@/lib/env';
/**
* POST /api/v1/interests/[id]/supplemental-info-request
*
* Auth: requires `interests.edit` so any rep working the deal can fire it.
* Generates a one-shot token + emails the client the public form URL.
*/
export const POST = withAuth(
withPermission('interests', 'edit', async (_req: NextRequest, ctx, params) => {
try {
const interestId = params.id as string;
const result = await issueToken({
interestId,
portId: ctx.portId,
issuedBy: ctx.userId,
});
const link = `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`;
if (result.clientEmail) {
const html = `
<p>Hello ${escapeHtml(result.clientName)},</p>
<p>Before we draft your Expression of Interest, we need to confirm a few details.
The form below is pre-filled with what we have on file — please review, correct
anything that's wrong, and add what's missing.</p>
<p style="text-align:center;margin:24px 0">
<a href="${link}"
style="background:#1e3a8a;color:#fff;text-decoration:none;padding:12px 24px;border-radius:6px;display:inline-block">
Open the form
</a>
</p>
<p style="color:#64748b;font-size:12px">
This link expires on ${result.expiresAt.toUTCString()} and can only be used once.
If you didn't expect this email, please let us know.
</p>
`;
await sendEmail(
result.clientEmail,
'Please complete a few details before we draft your EOI',
html,
undefined,
undefined,
ctx.portId,
);
}
return NextResponse.json({
data: {
link,
expiresAt: result.expiresAt.toISOString(),
emailSent: !!result.clientEmail,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

View File

@@ -0,0 +1,24 @@
-- 0061_supplemental_form_tokens.sql
-- ----------------------------------------------------------------------------
-- Pre-EOI supplemental info form tokens. One row per public form link the
-- CRM emails to a client. Token-keyed lookups + soft expiry + one-shot
-- consumption.
CREATE TABLE IF NOT EXISTS supplemental_form_tokens (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
port_id text NOT NULL REFERENCES ports(id),
interest_id text NOT NULL REFERENCES interests(id) ON DELETE CASCADE,
client_id text NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
token text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
issued_by text
);
CREATE INDEX IF NOT EXISTS idx_supplemental_tokens_interest
ON supplemental_form_tokens (interest_id);
CREATE INDEX IF NOT EXISTS idx_supplemental_tokens_client
ON supplemental_form_tokens (client_id);
CREATE INDEX IF NOT EXISTS idx_supplemental_tokens_port
ON supplemental_form_tokens (port_id);

View File

@@ -65,5 +65,8 @@ export * from './migration';
// Website submissions (dual-write capture from the marketing site)
export * from './website-submissions';
// Pre-EOI supplemental form tokens
export * from './supplemental-forms';
// Relations (must come last - references all tables)
export * from './relations';

View File

@@ -0,0 +1,55 @@
/**
* Pre-EOI supplemental info form tokens.
*
* The CRM rep clicks "Request more information" on an interest, which
* generates one of these rows + emails the client a public link
* containing the token. The client fills out a form prefilled with
* whatever's already on file (name, address, contacts, yacht info)
* and submits — the submission updates the client + interest rows.
*
* One-shot: `consumedAt` flips on submit, the token can't be reused.
* Tokens expire after 30 days even if unused.
*/
import { pgTable, text, timestamp, index } from 'drizzle-orm/pg-core';
import { ports } from './ports';
import { interests } from './interests';
import { clients } from './clients';
export const supplementalFormTokens = pgTable(
'supplemental_form_tokens',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
interestId: text('interest_id')
.notNull()
.references(() => interests.id, { onDelete: 'cascade' }),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'cascade' }),
/** Opaque URL-safe random string the client receives via email. Indexed
* for O(1) lookup; high-entropy so brute force is infeasible. */
token: text('token').notNull().unique(),
/** When the rep generated the token (= when the email was queued). */
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
/** Hard cutoff (default: createdAt + 30 days). */
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
/** Flipped on first successful submission; subsequent attempts 410. */
consumedAt: timestamp('consumed_at', { withTimezone: true }),
/** User id of the rep who issued the token (audit + ownership). */
issuedBy: text('issued_by'),
},
(table) => [
index('idx_supplemental_tokens_interest').on(table.interestId),
index('idx_supplemental_tokens_client').on(table.clientId),
index('idx_supplemental_tokens_port').on(table.portId),
],
);
export type SupplementalFormToken = typeof supplementalFormTokens.$inferSelect;
export type NewSupplementalFormToken = typeof supplementalFormTokens.$inferInsert;

View File

@@ -0,0 +1,327 @@
/**
* 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 = 30;
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<PrefillData | null> {
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<void> {
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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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));
});
}