Working through the audit-v2 deferred backlog. Each round was tested
(typecheck + 1168/1168 vitest) before moving on.
Round 1 — DB performance + AI cost visibility:
- Add missing FK indexes Postgres doesn't auto-create on
berth_reservations.{interest_id, contract_file_id},
documents.{file_id, signed_file_id}, document_events.signer_id,
document_templates.source_file_id, form_submissions.{form_template_id,
client_id}, document_sends.{brochure_id, brochure_version_id,
sent_by_user_id}. Without these, RESTRICT-checks on parent delete +
reverse-lookups walk the child tables fully. Migration 0037.
- AI worker now writes one ai_usage_ledger row per OpenAI call so admins
can audit spend per port/user/feature and future per-port budgets have
history to read from. Failure to write is logged-not-thrown so the
user-facing email draft is unaffected.
Round 2 — Boot-time + transport hardening:
- S3 backend verifies the bucket exists at startup (or auto-creates
when MINIO_AUTO_CREATE_BUCKET=true). A typo'd bucket name now
surfaces with a clear boot error instead of a vague Minio error
inside the first user-facing request.
- Documenso v1 placeFields: 3-attempt exponential-backoff retry on 5xx
+ network errors, fail-fast on 4xx. Stops one transient flake from
leaving a document with a partial field set.
- FilesystemBackend logs a structured warn-once at boot when the dev
HMAC fallback is in effect, so two processes started with different
BETTER_AUTH_SECRET values are observable (random 401s on file
downloads otherwise).
- Logger redact paths extended to cover *.headers.{authorization,
cookie}, *.config.headers.authorization, encrypted-credential blobs
(secretKeyEncrypted, smtpPassEncrypted, etc.), the Documenso
X-Documenso-Secret header, and 2-level nested forms.
Round 3 — UI feedback + permission gates:
- Storage admin migrate dialog: success toast with row count + error
toast on both dryRun and migrate mutations.
- Invoice detail Send + Record-payment buttons wrapped in
PermissionGate (invoices.send / invoices.record_payment); both
mutations now toast on success/error.
- Admin user list Edit button wrapped in PermissionGate(admin.manage_users).
- Scan-receipt page surfaces an amber warning when OCR fails so reps
know they can fill the form manually instead of staring at a stalled
spinner; the editable form now also opens on scanMutation.isError
/ uploadedFile, not only on success.
- Email threads list now renders skeleton rows during load + shared
EmptyState for the empty case (was a single "Loading…" line).
Round 4 — Service / route correctness:
- documentSends.sent_by_user_id was a free-text NOT NULL column with no
FK. Now nullable + FK to user(id) ON DELETE SET NULL so the audit row
survives a user being hard-deleted. Migration 0038 with a defensive
null-out for any orphan ids before attaching the constraint.
- Saved-views route: documented why withAuth alone is correct (the
service strictly filters by (portId, userId) — owner-only by design).
- Public-interests audit log: replaced "userId: null as unknown as
string" cast with userId: null; AuditLogParams already accepts null
for system-generated events.
- EOI in-app PDF fill: extracted setBerthRange() that, when the
AcroForm field is missing AND the context has a non-empty range
string, logs a structured warn so the deployment gap (live Documenso
template needs the field) is observable instead of silently dropping
the multi-berth range.
Test status: 1168/1168 vitest. tsc clean. Two new migrations
(0037/0038) need pnpm db:push (or migration apply) on the dev DB.
Deferred-doc updated with the remaining open items (bigger refactors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
166 lines
7.1 KiB
TypeScript
166 lines
7.1 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'),
|
|
},
|
|
(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),
|
|
],
|
|
);
|
|
|
|
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;
|