feat(website-intake): dual-write endpoint + migration chain repair
Adds website_submissions table + shared-secret POST endpoint so the marketing site can dual-write inquiries alongside its NocoDB write. Race-safe via INSERT ... ON CONFLICT, idempotent on submission_id, refuses every request when WEBSITE_INTAKE_SECRET is unset. Also repairs pre-existing 0020/0021/0022 prevId collision (renumbered + journal re-sorted) so db:generate works again. 11 unit tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,7 @@ export * from './portal';
|
||||
// CRM admin invites (better-auth realm)
|
||||
export * from './crm-invites';
|
||||
|
||||
// Residential (parallel domain — separate clients & interests for the
|
||||
// Residential (parallel domain - separate clients & interests for the
|
||||
// external residential team)
|
||||
export * from './residential';
|
||||
|
||||
@@ -56,8 +56,11 @@ export * from './ai-usage';
|
||||
// GDPR export tracking (Phase 3d)
|
||||
export * from './gdpr';
|
||||
|
||||
// Migration ledger (one-shot scripts — NocoDB import etc.)
|
||||
// Migration ledger (one-shot scripts - NocoDB import etc.)
|
||||
export * from './migration';
|
||||
|
||||
// Relations (must come last — references all tables)
|
||||
// Website submissions (dual-write capture from the marketing site)
|
||||
export * from './website-submissions';
|
||||
|
||||
// Relations (must come last - references all tables)
|
||||
export * from './relations';
|
||||
|
||||
67
src/lib/db/schema/website-submissions.ts
Normal file
67
src/lib/db/schema/website-submissions.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { pgTable, text, jsonb, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { ports } from './ports';
|
||||
|
||||
/**
|
||||
* Raw capture of every website inquiry submission, dual-written from the
|
||||
* marketing site alongside its existing NocoDB write. Acts as a passive
|
||||
* collector while the website still uses NocoDB as its primary system of
|
||||
* record — the new CRM observes incoming traffic without altering it,
|
||||
* letting us validate the data flow before any cutover.
|
||||
*
|
||||
* v1 deliberately stores the raw payload as JSON without promoting to
|
||||
* `clients` / `interests` rows. Once we trust the pipeline, a separate
|
||||
* "promote" job can transform these submissions into proper entities
|
||||
* with full dedup / merge logic.
|
||||
*
|
||||
* Idempotency: each submission carries a `submission_id` UUID minted by
|
||||
* the website's server. Re-delivery (network retry, double-click) hits
|
||||
* the unique index and is treated as a no-op.
|
||||
*/
|
||||
export const websiteSubmissions = pgTable(
|
||||
'website_submissions',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
/** Multi-tenant: every submission belongs to a port. Resolved from
|
||||
* `port_slug` in the request payload (defaults to port-nimara if
|
||||
* unspecified, since it's currently the only port with a public
|
||||
* marketing site). */
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
/** UUID v4 minted by the website's server when the form is processed.
|
||||
* The unique index on this column enforces idempotency: retries from
|
||||
* the website resolve to the existing row instead of creating a
|
||||
* duplicate. */
|
||||
submissionId: text('submission_id').notNull(),
|
||||
/** Discriminator for the form type. Mirrors the website's existing
|
||||
* branches (`berth_inquiry` from /api/register?interest=berths,
|
||||
* `residence_inquiry` from /api/register?interest=residences,
|
||||
* `contact_form` from /api/contact). Add new kinds as the website
|
||||
* grows new form types. */
|
||||
kind: text('kind').notNull(),
|
||||
/** Verbatim form payload, including any reCAPTCHA / IP / user-agent
|
||||
* metadata the website chose to forward. Stored as JSONB so the
|
||||
* capture stays schema-flexible while we figure out which fields
|
||||
* matter for the eventual promote step. */
|
||||
payload: jsonb('payload').notNull(),
|
||||
/** Cross-reference back to the legacy NocoDB row id created by the
|
||||
* same form submission. Useful for reconciling: pick any submission
|
||||
* here, look up the matching NocoDB row, confirm both halves agree. */
|
||||
legacyNocodbId: text('legacy_nocodb_id'),
|
||||
/** Capture-time metadata for debugging. */
|
||||
sourceIp: text('source_ip'),
|
||||
userAgent: text('user_agent'),
|
||||
receivedAt: timestamp('received_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('idx_ws_submission_id').on(table.submissionId),
|
||||
index('idx_ws_port_received').on(table.portId, table.receivedAt),
|
||||
index('idx_ws_kind').on(table.kind),
|
||||
],
|
||||
);
|
||||
|
||||
export type WebsiteSubmission = typeof websiteSubmissions.$inferSelect;
|
||||
export type NewWebsiteSubmission = typeof websiteSubmissions.$inferInsert;
|
||||
Reference in New Issue
Block a user