The inquiry inbox was read-only — every inquiry stayed there forever
with no way to mark "I handled this" or "this is spam." Now:
- Migration 0045 adds triage_state ('open' | 'assigned' | 'converted'
| 'dismissed' default 'open') + triaged_at + triaged_by columns to
website_submissions, plus a (port_id, triage_state, received_at)
index for the inbox query.
- New PATCH /api/v1/admin/website-submissions/[id]/triage flips the
state with audit log entry.
- List endpoint takes a `state` filter (default 'inbox' = open +
assigned, hides converted + dismissed).
- UI: per-row Convert / Assign / Dismiss / Reopen actions; second
filter row for state; triage badge per card. "Convert" jumps to
/clients with prefill_name / prefill_email / prefill_phone /
prefill_source / prefill_inquiry_id query params + marks the row
converted (the client-create form will read those — same prefill
pattern other entry points use).
1175/1175 vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
78 lines
3.7 KiB
TypeScript
78 lines
3.7 KiB
TypeScript
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;
|