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)
|
// Website submissions (dual-write capture from the marketing site)
|
||||||
export * from './website-submissions';
|
export * from './website-submissions';
|
||||||
|
|
||||||
|
// Pre-EOI supplemental form tokens
|
||||||
|
export * from './supplemental-forms';
|
||||||
|
|
||||||
// Relations (must come last - references all tables)
|
// Relations (must come last - references all tables)
|
||||||
export * from './relations';
|
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