feat(schema): berths.archived_at + clients.source_inquiry_id + email_bounces

Step 3 schema additions per PRE-DEPLOY-PLAN § 1.4.

berths.archived_at (+ archived_by, archive_reason) — soft-delete column
so retired moorings can be hidden from the public feed and admin lists
without losing historical interest joins. Partial index `idx_berths_active`
on (port_id) WHERE archived_at IS NULL keeps the active-only list path
fast. Already wired:
- /api/public/berths and /api/public/berths/[mooringNumber] now filter
  out archived rows.
- berths.service.listBerths defaults to active-only with an
  ?includeArchived=true escape hatch for the archive bin.

clients.source_inquiry_id — text column with ON DELETE SET NULL FK to
website_submissions(id). Preserves the linkage from a website inquiry
to the client that came out of the "Convert to client" triage flow
(P-4.5). Drives the conversion-funnel-by-source chart (Step 6). The
Drizzle column ships without `.references()` to avoid the cross-file
circular import; the FK lives in the migration SQL.

email_bounces table — bounce-monitoring storage. The DSN poller worker
(forthcoming, depends on this table existing) writes one row per parsed
bounce; consumers join via (original_send_type, original_send_id).
Three secondary indexes cover the expected access patterns (port +
recent bounces; lookup by bounced address; lookup by original send).

Schema additions plus the migration SQL are ready for `pnpm db:push`
(or the migration runner once its journal is backfilled — separate
concern, journal currently stops at 0042 despite migrations through
0065 existing on disk).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 15:33:20 +02:00
parent fd2c7d6b12
commit e933e32dbd
9 changed files with 153 additions and 10 deletions

View File

@@ -1,3 +1,4 @@
import { sql } from 'drizzle-orm';
import {
pgTable,
text,
@@ -77,6 +78,13 @@ export const berths = pgTable(
statusLastChangedBy: text('status_last_changed_by'), // user ID
statusLastChangedReason: text('status_last_changed_reason'),
statusLastModified: timestamp('status_last_modified', { withTimezone: true }),
// Soft-delete: when set, the berth is hidden from the public feed
// (`/api/public/berths`) and admin lists by default. Sticks around in
// historical interest joins so reporting against pre-archive deals
// still works. Hard-delete is reserved for genuine data corruption.
archivedAt: timestamp('archived_at', { withTimezone: true }),
archivedBy: text('archived_by'),
archiveReason: text('archive_reason'),
// Optional override flag carried over from NocoDB ("auto" or null in legacy data).
// Reserved for future "manual override" semantics; not surfaced in the UI today.
statusOverrideMode: text('status_override_mode'),
@@ -97,6 +105,9 @@ export const berths = pgTable(
index('idx_berths_status').on(table.portId, table.status),
index('idx_berths_area').on(table.portId, table.area),
uniqueIndex('idx_berths_mooring').on(table.portId, table.mooringNumber),
index('idx_berths_active')
.on(table.portId)
.where(sql`${table.archivedAt} IS NULL`),
],
);

View File

@@ -30,6 +30,12 @@ export const clients = pgTable(
timezone: text('timezone'),
source: text('source'), // website, manual, referral, broker
sourceDetails: text('source_details'),
/** When this client came out of a "Convert inquiry to client" triage
* step, points back at the originating `website_submissions` row.
* Drives the conversion-funnel-by-source chart. Migration 0065
* installs the FK with ON DELETE SET NULL — Drizzle doesn't reflect
* it here to avoid the cross-file circular import. */
sourceInquiryId: text('source_inquiry_id'),
archivedAt: timestamp('archived_at', { withTimezone: true }),
/** Better-auth user id of the operator who archived this client. */
archivedBy: text('archived_by'),

View File

@@ -0,0 +1,46 @@
import { pgTable, text, timestamp, index } from 'drizzle-orm/pg-core';
import { ports } from './ports';
/**
* Bounce-monitoring storage. The IMAP poller writes one row per parsed
* DSN (Delivery Status Notification) it finds in a monitored sender's
* inbox. Consumers (admin/bounces page, notifications worker, UI badges
* on document_sends rows) join in via `originalSendType` /
* `originalSendId`.
*/
export const emailBounces = pgTable(
'email_bounces',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id, { onDelete: 'cascade' }),
/** The mailbox we polled to find this bounce. */
mailboxAddress: text('mailbox_address').notNull(),
/** The address that bounced (the original recipient). */
bouncedAddress: text('bounced_address').notNull(),
/** One of `document_send` / `notification` / `email_thread` / `null`. */
originalSendType: text('original_send_type'),
/** The id of the original send row, when resolvable. */
originalSendId: text('original_send_id'),
/** RFC 3464 DSN fields. Null when the upstream provider uses a
* non-RFC bounce format (some providers send HTML-only bounces). */
dsnAction: text('dsn_action'),
dsnStatus: text('dsn_status'),
dsnDiagnostic: text('dsn_diagnostic'),
receivedAt: timestamp('received_at', { withTimezone: true }).notNull().defaultNow(),
/** Full raw message for forensics. Trimmed to ~32KB before insert. */
rawMessage: text('raw_message'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_email_bounces_port_received').on(table.portId, table.receivedAt),
index('idx_email_bounces_bounced_address').on(table.bouncedAddress),
index('idx_email_bounces_original_send').on(table.originalSendType, table.originalSendId),
],
);
export type EmailBounce = typeof emailBounces.$inferSelect;
export type NewEmailBounce = typeof emailBounces.$inferInsert;

View File

@@ -34,6 +34,9 @@ export * from './financial';
// Email
export * from './email';
// Email bounces (DSN poller storage)
export * from './email-bounces';
// Portal (client-portal auth)
export * from './portal';