UAT findings landed across the last few Playwright + React Grab passes; single grouped commit so the index doesn't fragment into 30 one-liners. User & auth: - `user-settings`: name now updates the avatar + topbar menu after save (was reading stale session). - `me/password-reset`: 3 bugs (token validation, error response shape, redirect chain). - Admin user permission-overrides route honours the same envelope as the rest of the admin surface. Dashboard: - Removed obsolete `revenue-breakdown-chart` + `dashboard-widgets-card` (replaced by the customisable widget grid). - Strip `revenue_breakdown` from analytics route + use-analytics + service + integration test so nothing renders an empty card. - Activity log timeline overshoot fix (`interest-timeline` + `entity-activity-feed`). - Tightened tiles: active-deals, berth-heat-widget, pipeline-value, kpi-tile. - `dev-mode-banner`: derive dismissed state synchronously instead of via an effect (set-state-in-effect lint rule). Forms & lists (assorted polish): - client / company / yacht / interest / reminder forms — validation + empty-state copy + tab transitions. - companies/yachts list tweaks; berth recommender panel; qualification checklist; supplemental info request button. Infra & misc: - Queue workers (ai / email / notifications) — log shape + per-job timeout consistency. - Auth / brochures / users schema small adjustments; seeds reflect permissions matrix changes. - Scan shell + scanner manifest + AI admin page small fixes. - `next.config.transpilePackages` adds `echarts`/`zrender`/`echarts-for-react` (recommended config from echarts-for-react inside Next). Docs: - `docs/superpowers/audits/alpha-uat-master.md` — single rolling cross-cutting UAT findings doc (per CLAUDE.md convention). - `docs/BACKLOG.md`: dashboard stats cards (§I) + activity-log normalization (§J). - 2026-05-18 audit log updated with this batch. - `CLAUDE.md` — small manual UAT scaffold notes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
211 lines
9.0 KiB
TypeScript
211 lines
9.0 KiB
TypeScript
import {
|
||
pgTable,
|
||
text,
|
||
boolean,
|
||
integer,
|
||
timestamp,
|
||
index,
|
||
uniqueIndex,
|
||
} from 'drizzle-orm/pg-core';
|
||
import { sql } from 'drizzle-orm';
|
||
|
||
import { ports } from './ports';
|
||
import { clients } from './clients';
|
||
import { interests } from './interests';
|
||
import { berths } from './berths';
|
||
import { user } from './users';
|
||
|
||
/**
|
||
* Port-wide brochures (Phase 7 — see plan §3.3 / §4.8).
|
||
*
|
||
* Each port can have multiple brochures (e.g. "General", "Investor Pack")
|
||
* with one marked as `isDefault`. Archived brochures stay queryable for
|
||
* audit purposes but are hidden from the send-out picker.
|
||
*/
|
||
export const brochures = pgTable(
|
||
'brochures',
|
||
{
|
||
id: text('id')
|
||
.primaryKey()
|
||
.$defaultFn(() => crypto.randomUUID()),
|
||
portId: text('port_id')
|
||
.notNull()
|
||
.references(() => ports.id, { onDelete: 'cascade' }),
|
||
label: text('label').notNull(),
|
||
description: text('description'),
|
||
isDefault: boolean('is_default').notNull().default(false),
|
||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||
createdBy: text('created_by').notNull(),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
},
|
||
(table) => [
|
||
index('idx_brochures_port').on(table.portId),
|
||
// At most one default brochure per port (excluding archived rows).
|
||
// Service-layer "demote prior default then insert" is correct in the
|
||
// single-writer case, but two concurrent createBrochure(isDefault:true)
|
||
// calls without this index race past the read-then-write check and
|
||
// both win.
|
||
uniqueIndex('idx_brochures_one_default_per_port')
|
||
.on(table.portId)
|
||
.where(sql`${table.isDefault} = true AND ${table.archivedAt} IS NULL`),
|
||
],
|
||
);
|
||
|
||
/**
|
||
* Versioned brochure files. Identical lifecycle to `berth_pdf_versions`:
|
||
* each upload creates a new immutable row with a monotonic version number
|
||
* per brochure. `storageKey` follows the §4.7a renamed convention.
|
||
*/
|
||
export const brochureVersions = pgTable(
|
||
'brochure_versions',
|
||
{
|
||
id: text('id')
|
||
.primaryKey()
|
||
.$defaultFn(() => crypto.randomUUID()),
|
||
brochureId: text('brochure_id')
|
||
.notNull()
|
||
.references(() => brochures.id, { onDelete: 'cascade' }),
|
||
versionNumber: integer('version_number').notNull(),
|
||
/** Object key in the active storage backend (renamed from `s3_key` per §4.7a). */
|
||
storageKey: text('storage_key').notNull(),
|
||
fileName: text('file_name').notNull(),
|
||
fileSizeBytes: integer('file_size_bytes').notNull(),
|
||
contentSha256: text('content_sha256').notNull(),
|
||
uploadedBy: text('uploaded_by').notNull(),
|
||
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow(),
|
||
/** Cached signed-URL expiry per §11.1 — re-sign only when within 1h of expiry. */
|
||
downloadUrlExpiresAt: timestamp('download_url_expires_at', { withTimezone: true }),
|
||
},
|
||
(table) => [index('idx_brochure_versions_brochure').on(table.brochureId, table.uploadedAt)],
|
||
);
|
||
|
||
/**
|
||
* Send-out audit log for berth PDFs and brochures (Phase 7 — plan §3.3).
|
||
*
|
||
* One row per recipient per send. `documentKind` discriminates between
|
||
* `'berth_pdf'` and `'brochure'`; the corresponding `*_version_id` column
|
||
* pins the exact version sent.
|
||
*
|
||
* `berthPdfVersionId` is intentionally a plain text column (no FK) — the
|
||
* referenced table `berth_pdf_versions` is owned by Phase 6b. Loose-coupling
|
||
* keeps the two phases independent (per Phase 7 task brief).
|
||
*
|
||
* `failedAt` and `errorReason` capture send failures (SMTP auth, transport
|
||
* errors). Failed sends are still written so reps can see "I clicked send
|
||
* but it didn't go" in the timeline (per §14.7).
|
||
*/
|
||
export const documentSends = pgTable(
|
||
'document_sends',
|
||
{
|
||
id: text('id')
|
||
.primaryKey()
|
||
.$defaultFn(() => crypto.randomUUID()),
|
||
portId: text('port_id')
|
||
.notNull()
|
||
.references(() => ports.id),
|
||
/**
|
||
* Either client_id or interest_id is set (or both). All five FKs use
|
||
* `onDelete: 'set null'` so the audit row survives if the parent
|
||
* client/interest/berth/brochure is deleted — `recipient_email`,
|
||
* `document_kind`, `body_markdown`, and `from_address` are denormalized
|
||
* onto the row precisely so the audit trail outlasts the source.
|
||
*/
|
||
clientId: text('client_id').references(() => clients.id, { onDelete: 'set null' }),
|
||
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
|
||
recipientEmail: text('recipient_email').notNull(),
|
||
/** 'berth_pdf' | 'brochure' */
|
||
documentKind: text('document_kind').notNull(),
|
||
berthId: text('berth_id').references(() => berths.id, { onDelete: 'set null' }),
|
||
/** Forward FK ref — berth_pdf_versions defined in Phase 6b. Loose-coupled. */
|
||
berthPdfVersionId: text('berth_pdf_version_id'),
|
||
brochureId: text('brochure_id').references(() => brochures.id, { onDelete: 'set null' }),
|
||
brochureVersionId: text('brochure_version_id').references(() => brochureVersions.id, {
|
||
onDelete: 'set null',
|
||
}),
|
||
/** Exact body used (after merge-field expansion + sanitization). */
|
||
bodyMarkdown: text('body_markdown'),
|
||
/**
|
||
* better-auth user id of the sender. SET NULL on user delete so the
|
||
* audit row keeps `recipientEmail` + timestamp + body for compliance
|
||
* even when the originating user is removed from the system.
|
||
*/
|
||
sentByUserId: text('sent_by_user_id').references(() => user.id, { onDelete: 'set null' }),
|
||
fromAddress: text('from_address').notNull(),
|
||
sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(),
|
||
/** SMTP provider message-id for deliverability tracking. */
|
||
messageId: text('message_id'),
|
||
/** When the initial send had its attachment dropped because the SMTP server
|
||
* rejected the size (552 etc.) and the system retried with a download
|
||
* link, this captures the rejection reason for ops visibility. Null when
|
||
* the original send went through as-is. */
|
||
fallbackToLinkReason: text('fallback_to_link_reason'),
|
||
/** Set when the SMTP send transaction itself failed (auth/transport/etc). */
|
||
failedAt: timestamp('failed_at', { withTimezone: true }),
|
||
/** Human-readable failure reason; only meaningful when failedAt is non-null. */
|
||
errorReason: text('error_reason'),
|
||
// Phase 6 — async bounce tracking. Populated by the IMAP NDR
|
||
// poller (`src/jobs/processors/imap-bounce-poller.ts`) when a
|
||
// delivery failure message arrives in the configured mailbox and
|
||
// matches this send via recipient_email + sent_at window.
|
||
bounceStatus: text('bounce_status'), // 'hard' | 'soft' | 'ooo'
|
||
bounceReason: text('bounce_reason'),
|
||
bounceDetectedAt: timestamp('bounce_detected_at', { withTimezone: true }),
|
||
// Phase 4b — email open tracking. When `trackOpens` is true the send
|
||
// includes a 1×1 pixel pointing at /api/public/email-pixel/[sendId].
|
||
// `firstOpenedAt` + `openCount` are denormalised aggregates so the
|
||
// sends list can render an "opened" pill without a JOIN.
|
||
trackOpens: boolean('track_opens').notNull().default(false),
|
||
firstOpenedAt: timestamp('first_opened_at', { withTimezone: true }),
|
||
openCount: integer('open_count').notNull().default(0),
|
||
},
|
||
(t) => [
|
||
index('idx_ds_client').on(t.clientId, t.sentAt),
|
||
index('idx_ds_interest').on(t.interestId, t.sentAt),
|
||
index('idx_ds_berth').on(t.berthId, t.sentAt),
|
||
index('idx_ds_port').on(t.portId, t.sentAt),
|
||
// Reverse-lookups: "what sends used this brochure / version" and
|
||
// FK-RESTRICT scans on brochure delete.
|
||
index('idx_ds_brochure').on(t.brochureId),
|
||
index('idx_ds_brochure_version').on(t.brochureVersionId),
|
||
index('idx_ds_sent_by').on(t.sentByUserId),
|
||
],
|
||
);
|
||
|
||
/**
|
||
* Per-open log for emails with `trackOpens=true`. The 1×1 pixel
|
||
* endpoint inserts here on every fetch (Apple Mail privacy proxy will
|
||
* over-count; most other clients under-count when images are blocked —
|
||
* this is the universal email-tracking caveat). Cached aggregates on
|
||
* `document_sends` keep list rendering fast.
|
||
*/
|
||
export const documentSendOpens = pgTable(
|
||
'document_send_opens',
|
||
{
|
||
id: text('id')
|
||
.primaryKey()
|
||
.$defaultFn(() => crypto.randomUUID()),
|
||
portId: text('port_id')
|
||
.notNull()
|
||
.references(() => ports.id),
|
||
sendId: text('send_id')
|
||
.notNull()
|
||
.references(() => documentSends.id, { onDelete: 'cascade' }),
|
||
openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(),
|
||
userAgent: text('user_agent'),
|
||
referer: text('referer'),
|
||
},
|
||
(t) => [
|
||
index('idx_dso_send').on(t.sendId, t.openedAt),
|
||
index('idx_dso_port').on(t.portId, t.openedAt),
|
||
],
|
||
);
|
||
|
||
export type Brochure = typeof brochures.$inferSelect;
|
||
export type NewBrochure = typeof brochures.$inferInsert;
|
||
export type BrochureVersion = typeof brochureVersions.$inferSelect;
|
||
export type NewBrochureVersion = typeof brochureVersions.$inferInsert;
|
||
export type DocumentSend = typeof documentSends.$inferSelect;
|
||
export type NewDocumentSend = typeof documentSends.$inferInsert;
|
||
export type DocumentSendOpen = typeof documentSendOpens.$inferSelect;
|
||
export type NewDocumentSendOpen = typeof documentSendOpens.$inferInsert;
|