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,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));
});
}