Files
pn-new-crm/src/lib/db/schema/brochures.ts
Matt 449b9497ab fix(uat): batch — timeline overshoot, name-sync, reset-password, dashboard cleanup, queue/seed hygiene + alpha UAT findings doc
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>
2026-05-20 15:56:11 +02:00

211 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;