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

@@ -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;