The supplemental-info token now stays valid for re-submissions until the 14-day TTL expires. Previously the link was single-use: `applySubmission` required `consumedAt IS NULL`, which locked clients out of correcting a typo or finishing a partial submission. - Service: drops the `isNull(consumedAt)` filter; TTL is the sole validity check. `consumedAt` is still stamped on each submit so the rep / loader can see "last submitted at" context. - Public form: the "already submitted" lockout screen is removed. Instead, when the token has been used before, the form renders with the prefill (already reflecting the latest data) plus a soft amber banner noting that changes overwrite the previous submission. - Drive-by em-dash fix on the post-submit thank-you copy (matches the Wave-1 lint guard). tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
/**
|
|
* Pre-EOI supplemental info form service.
|
|
*
|
|
* Three operations:
|
|
* 1. `issueToken` — rep clicks "Request more info" → token row + email queued.
|
|
* 2. `loadByToken` — public form fetches prefill data; rejects expired/consumed tokens.
|
|
* 3. `applySubmission` — public form POST → diff against current data, apply
|
|
* updates, consume token. All inside one transaction.
|
|
*/
|
|
|
|
import { and, eq } from 'drizzle-orm';
|
|
import crypto from 'node:crypto';
|
|
|
|
import { db } from '@/lib/db';
|
|
import {
|
|
supplementalFormTokens,
|
|
interests,
|
|
clients,
|
|
clientAddresses,
|
|
yachts,
|
|
clientContacts,
|
|
} from '@/lib/db/schema';
|
|
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
|
|
|
const TOKEN_TTL_DAYS = 14;
|
|
const TOKEN_BYTES = 32; // 256-bit → ~43 base64url chars; brute-force infeasible.
|
|
|
|
function generateToken(): string {
|
|
return crypto.randomBytes(TOKEN_BYTES).toString('base64url');
|
|
}
|
|
|
|
export interface IssueTokenInput {
|
|
interestId: string;
|
|
portId: string;
|
|
issuedBy: string;
|
|
}
|
|
|
|
export async function issueToken(input: IssueTokenInput): Promise<{
|
|
token: string;
|
|
expiresAt: Date;
|
|
clientEmail: string | null;
|
|
clientName: string;
|
|
}> {
|
|
// Resolve the interest's client + at least one email contact so the
|
|
// calling code can queue the email immediately without a second hop.
|
|
const interest = await db.query.interests.findFirst({
|
|
where: and(eq(interests.id, input.interestId), eq(interests.portId, input.portId)),
|
|
});
|
|
if (!interest) throw new NotFoundError('interest');
|
|
|
|
const client = await db.query.clients.findFirst({ where: eq(clients.id, interest.clientId) });
|
|
if (!client) throw new NotFoundError('client');
|
|
|
|
const emailContact = await db.query.clientContacts.findFirst({
|
|
where: and(eq(clientContacts.clientId, client.id), eq(clientContacts.channel, 'email')),
|
|
});
|
|
|
|
const token = generateToken();
|
|
const expiresAt = new Date(Date.now() + TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
|
|
|
|
await db.insert(supplementalFormTokens).values({
|
|
portId: input.portId,
|
|
interestId: interest.id,
|
|
clientId: client.id,
|
|
token,
|
|
expiresAt,
|
|
issuedBy: input.issuedBy,
|
|
});
|
|
|
|
return {
|
|
token,
|
|
expiresAt,
|
|
clientEmail: emailContact?.value ?? null,
|
|
clientName: client.fullName ?? client.id,
|
|
};
|
|
}
|
|
|
|
export interface PrefillData {
|
|
/** Token metadata so the form can disable itself when consumed. */
|
|
token: { expiresAt: string; consumed: boolean };
|
|
client: {
|
|
fullName: string;
|
|
streetAddress: string | null;
|
|
city: string | null;
|
|
postalCode: string | null;
|
|
country: string | null;
|
|
primaryEmail: string | null;
|
|
primaryPhone: string | null;
|
|
primaryPhoneCountry: string | null;
|
|
};
|
|
yacht: {
|
|
name: string | null;
|
|
lengthFt: string | null;
|
|
widthFt: string | null;
|
|
draftFt: string | null;
|
|
} | null;
|
|
}
|
|
|
|
/**
|
|
* Hydrate the public form. Returns null when the token doesn't exist
|
|
* (avoid leaking whether it's expired vs. fake); returns a payload with
|
|
* `consumed: true` when it's already been used so the form can render
|
|
* a friendly "already submitted" state.
|
|
*/
|
|
export async function loadByToken(token: string): Promise<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) => {
|
|
// Reusable-until-expiry: the link stays valid for repeat
|
|
// submissions until it expires. `consumedAt` is still stamped on
|
|
// first submit so the rep / loader can show "last submitted at
|
|
// <time>" context, but it no longer gates the submission. The TTL
|
|
// gate below is the sole validity check.
|
|
const row = await tx.query.supplementalFormTokens.findFirst({
|
|
where: eq(supplementalFormTokens.token, token),
|
|
});
|
|
if (!row) {
|
|
throw new ConflictError('This link 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));
|
|
});
|
|
}
|