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:
307
src/app/(portal)/public/supplemental-info/[token]/page.tsx
Normal file
307
src/app/(portal)/public/supplemental-info/[token]/page.tsx
Normal 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've pre-filled what we have on file. Please review, correct anything that's
|
||||
wrong, and add what'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'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>
|
||||
);
|
||||
}
|
||||
68
src/app/api/public/supplemental-info/[token]/route.ts
Normal file
68
src/app/api/public/supplemental-info/[token]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
24
src/lib/db/migrations/0061_supplemental_form_tokens.sql
Normal file
24
src/lib/db/migrations/0061_supplemental_form_tokens.sql
Normal 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);
|
||||
@@ -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';
|
||||
|
||||
55
src/lib/db/schema/supplemental-forms.ts
Normal file
55
src/lib/db/schema/supplemental-forms.ts
Normal 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;
|
||||
327
src/lib/services/supplemental-forms.service.ts
Normal file
327
src/lib/services/supplemental-forms.service.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user