From e933e32dbdedf72ca6c4c7e98f3fe5f532e2a14b Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 15:33:20 +0200 Subject: [PATCH] feat(schema): berths.archived_at + clients.source_inquiry_id + email_bounces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../public/berths/[mooringNumber]/route.ts | 8 ++- src/app/api/public/berths/route.ts | 13 ++-- .../db/migrations/0065_predeploy_schema.sql | 66 +++++++++++++++++++ src/lib/db/schema/berths.ts | 11 ++++ src/lib/db/schema/clients.ts | 6 ++ src/lib/db/schema/email-bounces.ts | 46 +++++++++++++ src/lib/db/schema/index.ts | 3 + src/lib/services/berths.service.ts | 7 +- tests/unit/services/public-berths.test.ts | 3 + 9 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 src/lib/db/migrations/0065_predeploy_schema.sql create mode 100644 src/lib/db/schema/email-bounces.ts diff --git a/src/app/api/public/berths/[mooringNumber]/route.ts b/src/app/api/public/berths/[mooringNumber]/route.ts index 6dc5672d..81a409b3 100644 --- a/src/app/api/public/berths/[mooringNumber]/route.ts +++ b/src/app/api/public/berths/[mooringNumber]/route.ts @@ -69,7 +69,13 @@ export async function GET( const [berth] = await db .select() .from(berths) - .where(and(eq(berths.portId, port.id), eq(berths.mooringNumber, mooringNumber))) + .where( + and( + eq(berths.portId, port.id), + eq(berths.mooringNumber, mooringNumber), + isNull(berths.archivedAt), + ), + ) .limit(1); if (!berth) { diff --git a/src/app/api/public/berths/route.ts b/src/app/api/public/berths/route.ts index 53e9b2cd..b0ecb1ff 100644 --- a/src/app/api/public/berths/route.ts +++ b/src/app/api/public/berths/route.ts @@ -72,13 +72,12 @@ export async function GET(request: Request): Promise { ); } - // 1. Active berths for the port (archived would be an explicit field - // once we add one - today we don't have an archived_at on berths, - // so we surface every row except those marked status='sold' on - // request? No: §4.5 says "filters out berths archived in CRM". - // The current schema has no archived flag for berths, so this is - // a no-op today; future archive flag plugs in here. - const berthRows = await db.select().from(berths).where(eq(berths.portId, port.id)); + // 1. Active berths for the port — retired moorings are hidden via + // the archived_at soft-delete column (migration 0065). + const berthRows = await db + .select() + .from(berths) + .where(and(eq(berths.portId, port.id), isNull(berths.archivedAt))); if (berthRows.length === 0) { return jsonResponse({ list: [], pageInfo: emptyPageInfo() }); diff --git a/src/lib/db/migrations/0065_predeploy_schema.sql b/src/lib/db/migrations/0065_predeploy_schema.sql new file mode 100644 index 00000000..5f7510f4 --- /dev/null +++ b/src/lib/db/migrations/0065_predeploy_schema.sql @@ -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; diff --git a/src/lib/db/schema/berths.ts b/src/lib/db/schema/berths.ts index 6810e57e..c05145ac 100644 --- a/src/lib/db/schema/berths.ts +++ b/src/lib/db/schema/berths.ts @@ -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`), ], ); diff --git a/src/lib/db/schema/clients.ts b/src/lib/db/schema/clients.ts index 7f0f37ad..8e29c2ba 100644 --- a/src/lib/db/schema/clients.ts +++ b/src/lib/db/schema/clients.ts @@ -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'), diff --git a/src/lib/db/schema/email-bounces.ts b/src/lib/db/schema/email-bounces.ts new file mode 100644 index 00000000..2e73e35b --- /dev/null +++ b/src/lib/db/schema/email-bounces.ts @@ -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; diff --git a/src/lib/db/schema/index.ts b/src/lib/db/schema/index.ts index 3fb3e5c5..75a4d8cc 100644 --- a/src/lib/db/schema/index.ts +++ b/src/lib/db/schema/index.ts @@ -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'; diff --git a/src/lib/services/berths.service.ts b/src/lib/services/berths.service.ts index 9a6d8ab6..e2d3d2e9 100644 --- a/src/lib/services/berths.service.ts +++ b/src/lib/services/berths.service.ts @@ -110,8 +110,11 @@ export async function listBerths(portId: string, query: ListBerthsQuery) { pageSize: query.limit, searchColumns: [berths.mooringNumber, berths.area], searchTerm: query.search, - // No archivedAt column on berths - includeArchived: true, + // berths.archivedAt + ?includeArchived flag landed in migration 0065. + // Default the admin list to active-only; an `?includeArchived=true` + // query string surfaces the archive bin for ops. + archivedAtColumn: berths.archivedAt, + includeArchived: Boolean(query.includeArchived), }); // Attach tags for list items diff --git a/tests/unit/services/public-berths.test.ts b/tests/unit/services/public-berths.test.ts index ce2d7243..b2dae69f 100644 --- a/tests/unit/services/public-berths.test.ts +++ b/tests/unit/services/public-berths.test.ts @@ -50,6 +50,9 @@ function makeBerth(overrides: Partial = {}): Berth { statusOverrideMode: null, lastImportedAt: null, currentPdfVersionId: null, + archivedAt: null, + archivedBy: null, + archiveReason: null, lengthUnit: 'ft', widthUnit: 'ft', draftUnit: 'ft',