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(), /** Triage workflow state. Default 'open'; transitions to * 'converted' (operator created a client/interest from this row), * 'dismissed' (operator marked as not actionable), or 'assigned' * (operator opened it but hasn't resolved yet). The inbox default * query filters to open + assigned. */ triageState: text('triage_state').notNull().default('open'), triagedAt: timestamp('triaged_at', { withTimezone: true }), /** better-auth user id of the operator who last changed triage_state. */ triagedBy: text('triaged_by'), }, (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), index('idx_ws_triage_state').on(table.portId, table.triageState, table.receivedAt), ], ); export type WebsiteSubmission = typeof websiteSubmissions.$inferSelect; export type NewWebsiteSubmission = typeof websiteSubmissions.$inferInsert;