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:
@@ -69,7 +69,13 @@ export async function GET(
|
|||||||
const [berth] = await db
|
const [berth] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(berths)
|
.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);
|
.limit(1);
|
||||||
|
|
||||||
if (!berth) {
|
if (!berth) {
|
||||||
|
|||||||
@@ -72,13 +72,12 @@ export async function GET(request: Request): Promise<Response> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Active berths for the port (archived would be an explicit field
|
// 1. Active berths for the port — retired moorings are hidden via
|
||||||
// once we add one - today we don't have an archived_at on berths,
|
// the archived_at soft-delete column (migration 0065).
|
||||||
// so we surface every row except those marked status='sold' on
|
const berthRows = await db
|
||||||
// request? No: §4.5 says "filters out berths archived in CRM".
|
.select()
|
||||||
// The current schema has no archived flag for berths, so this is
|
.from(berths)
|
||||||
// a no-op today; future archive flag plugs in here.
|
.where(and(eq(berths.portId, port.id), isNull(berths.archivedAt)));
|
||||||
const berthRows = await db.select().from(berths).where(eq(berths.portId, port.id));
|
|
||||||
|
|
||||||
if (berthRows.length === 0) {
|
if (berthRows.length === 0) {
|
||||||
return jsonResponse({ list: [], pageInfo: emptyPageInfo() });
|
return jsonResponse({ list: [], pageInfo: emptyPageInfo() });
|
||||||
|
|||||||
66
src/lib/db/migrations/0065_predeploy_schema.sql
Normal file
66
src/lib/db/migrations/0065_predeploy_schema.sql
Normal 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;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
text,
|
text,
|
||||||
@@ -77,6 +78,13 @@ export const berths = pgTable(
|
|||||||
statusLastChangedBy: text('status_last_changed_by'), // user ID
|
statusLastChangedBy: text('status_last_changed_by'), // user ID
|
||||||
statusLastChangedReason: text('status_last_changed_reason'),
|
statusLastChangedReason: text('status_last_changed_reason'),
|
||||||
statusLastModified: timestamp('status_last_modified', { withTimezone: true }),
|
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).
|
// 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.
|
// Reserved for future "manual override" semantics; not surfaced in the UI today.
|
||||||
statusOverrideMode: text('status_override_mode'),
|
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_status').on(table.portId, table.status),
|
||||||
index('idx_berths_area').on(table.portId, table.area),
|
index('idx_berths_area').on(table.portId, table.area),
|
||||||
uniqueIndex('idx_berths_mooring').on(table.portId, table.mooringNumber),
|
uniqueIndex('idx_berths_mooring').on(table.portId, table.mooringNumber),
|
||||||
|
index('idx_berths_active')
|
||||||
|
.on(table.portId)
|
||||||
|
.where(sql`${table.archivedAt} IS NULL`),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export const clients = pgTable(
|
|||||||
timezone: text('timezone'),
|
timezone: text('timezone'),
|
||||||
source: text('source'), // website, manual, referral, broker
|
source: text('source'), // website, manual, referral, broker
|
||||||
sourceDetails: text('source_details'),
|
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 }),
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||||
/** Better-auth user id of the operator who archived this client. */
|
/** Better-auth user id of the operator who archived this client. */
|
||||||
archivedBy: text('archived_by'),
|
archivedBy: text('archived_by'),
|
||||||
|
|||||||
46
src/lib/db/schema/email-bounces.ts
Normal file
46
src/lib/db/schema/email-bounces.ts
Normal 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;
|
||||||
@@ -34,6 +34,9 @@ export * from './financial';
|
|||||||
// Email
|
// Email
|
||||||
export * from './email';
|
export * from './email';
|
||||||
|
|
||||||
|
// Email bounces (DSN poller storage)
|
||||||
|
export * from './email-bounces';
|
||||||
|
|
||||||
// Portal (client-portal auth)
|
// Portal (client-portal auth)
|
||||||
export * from './portal';
|
export * from './portal';
|
||||||
|
|
||||||
|
|||||||
@@ -110,8 +110,11 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
|||||||
pageSize: query.limit,
|
pageSize: query.limit,
|
||||||
searchColumns: [berths.mooringNumber, berths.area],
|
searchColumns: [berths.mooringNumber, berths.area],
|
||||||
searchTerm: query.search,
|
searchTerm: query.search,
|
||||||
// No archivedAt column on berths
|
// berths.archivedAt + ?includeArchived flag landed in migration 0065.
|
||||||
includeArchived: true,
|
// 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
|
// Attach tags for list items
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ function makeBerth(overrides: Partial<Berth> = {}): Berth {
|
|||||||
statusOverrideMode: null,
|
statusOverrideMode: null,
|
||||||
lastImportedAt: null,
|
lastImportedAt: null,
|
||||||
currentPdfVersionId: null,
|
currentPdfVersionId: null,
|
||||||
|
archivedAt: null,
|
||||||
|
archivedBy: null,
|
||||||
|
archiveReason: null,
|
||||||
lengthUnit: 'ft',
|
lengthUnit: 'ft',
|
||||||
widthUnit: 'ft',
|
widthUnit: 'ft',
|
||||||
draftUnit: 'ft',
|
draftUnit: 'ft',
|
||||||
|
|||||||
Reference in New Issue
Block a user