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

@@ -0,0 +1,66 @@
-- 0065_predeploy_schema.sql
-- Pre-deploy schema additions per PRE-DEPLOY-PLAN § 1.4:
-- 1. berths.archived_at — soft-delete column + partial index, filter for the
-- public berth feed so retired berths stop showing up on the marketing site.
-- 2. clients.source_inquiry_id — preserves the linkage from a website inquiry
-- to the client that came out of the "Convert to client" triage step
-- (P-4.5). Drives the conversion-funnel-by-source chart.
-- 3. email_bounces — bounce-monitoring storage; the IMAP poller writes here
-- and document_sends / notifications / email_threads consumers can join
-- this in to surface "your message bounced" indicators.
-- ─── berths.archived_at ─────────────────────────────────────────────────────
ALTER TABLE berths
ADD COLUMN IF NOT EXISTS archived_at timestamptz;
ALTER TABLE berths
ADD COLUMN IF NOT EXISTS archived_by text;
ALTER TABLE berths
ADD COLUMN IF NOT EXISTS archive_reason text;
CREATE INDEX IF NOT EXISTS idx_berths_active
ON berths (port_id)
WHERE archived_at IS NULL;
-- ─── clients.source_inquiry_id ──────────────────────────────────────────────
ALTER TABLE clients
ADD COLUMN IF NOT EXISTS source_inquiry_id text REFERENCES website_submissions(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_clients_source_inquiry
ON clients (source_inquiry_id)
WHERE source_inquiry_id IS NOT NULL;
-- ─── email_bounces ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS email_bounces (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
-- The mailbox we polled to find this bounce (e.g. 'noreply@portnimara.com').
mailbox_address text NOT NULL,
-- The address that bounced (the original recipient).
bounced_address text NOT NULL,
-- Which outbound surface the bounced message came from. NULL when we
-- can't match the DSN's In-Reply-To / Message-Id back to a known row.
original_send_type text,
-- Surrogate id of the original send (document_sends.id /
-- notifications.id / email_threads message id).
original_send_id text,
-- RFC 3464 DSN action/status. NULL when the upstream provider used a
-- non-RFC bounce format.
dsn_action text,
dsn_status text,
dsn_diagnostic text,
received_at timestamptz NOT NULL DEFAULT now(),
raw_message text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_email_bounces_port_received
ON email_bounces (port_id, received_at DESC);
CREATE INDEX IF NOT EXISTS idx_email_bounces_bounced_address
ON email_bounces (bounced_address);
CREATE INDEX IF NOT EXISTS idx_email_bounces_original_send
ON email_bounces (original_send_type, original_send_id)
WHERE original_send_id IS NOT NULL;