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:
@@ -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;
|
||||
Reference in New Issue
Block a user