Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).
CRITICAL (3):
- C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
no longer silently drop interest links
- C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
- C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
callers must go through /stage with the override-guard chain
HIGH (14/15):
- H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
interests/documents/reservations/reminders/invoices (migration 0070)
- H-02 login page reads ?redirect= param with same-origin guard
- H-03 CRM invite token moves to URL fragment so it never lands in
nginx access logs / Referer headers
- H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
- H-05 toggleAccount writes an audit row
- H-06 upsertSetting masks any value whose key ends with _encrypted
- H-07 archiveClient cascade fires per-interest audit rows
- H-08 createSalesTransporter applies SMTP_TIMEOUTS
- H-09 AppShell stable children — viewport flip across breakpoint no
longer destroys in-progress form drafts
- H-10 portal documents page swaps Unicode glyph status icons for
Lucide CheckCircle2/XCircle/Circle + aria-labels
- H-12 list components swap alert(...) for toast.warning(...)
- H-13 5 icon-only buttons gain aria-label
- H-14 parseBody treats empty bodies as {}
- H-15 admin layout renders a 403 panel instead of silent bounce
- H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet
MEDIUM (28+):
- M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
WHEREs across custom-fields, notes (all 6 entity types x update +
delete), client-contacts, yacht ownerClient lookup, webhook reads
- M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
- M-EM01 portal-auth emails thread through portId
- M-EM02 sendEmail accepts cc/bcc params
- M-EM04 notification_digest catalog key
- M-IN01 portal presigned download URLs use 4h TTL
- M-IN02 OpenAI client lazy-instantiated
- M-IN04 stale pdfme refs updated to pdf-lib AcroForm
- M-IN05 umami.testConnection returns tagged union
- M-L01 reservations tenure_type unified with berths
- M-L02 report-generators canonicalize stage values
- M-AU01 audit log placeholder copy fixed
- M-AU04 outcome_set / outcome_cleared distinct audit verbs
- M-NEW-2 activity feed entity name+type separator
- M-R01 portal allowlist narrowed + portal_session backstop in proxy
- M-SC02 companies archived partial index
- M-SC04 audit_logs.searchText documented as DB-managed
- M-S01 storage_s3_access_key_encrypted admin field
- M-U01 audit log empty state uses <EmptyState>
- M-U09 invoice delete dialog -> <AlertDialog>
- M-U10 toast.success on ClientForm + InterestForm create/edit
- M-U11 settings-form-card logo preview alt text
- M-U14 mobile topbar title on clients/yachts/interests/berths
- M-U15 Invoices in mobile More-sheet
LOW (6/8):
- L-AU01 severity defaults for security-relevant verbs
- L-AU02 +13 missing actions in admin audit filter
- L-AU03 +7 missing entity types in admin audit filter
- L-AU04 dead listAuditLogs stubbed
- L-D02 CLAUDE.md Owner-wins chain tightened
Bonus — Document detail polish (#67 partial, 3/6 deliverables):
- state-aware action button per signer
- watcher Add UI with display-name resolution
- cleanSignerName cleanup
Prior session work bundled in:
- Documenso v2 webhook + envelope-ID normalization + sequential signing
- SigningProgress UI redesign (avatars, per-signer state, timestamps)
- env->admin settings registry + RegistryDrivenForm + encrypted creds
- Embedded-signing card + Test connection + setup help
- Dev-mode EMAIL_REDIRECT_TO banner
- Pipeline rules admin page
- Sales email config card
- Audit log details Sheet
- EOI tab: Finalising badge, absolute timestamps, sequential indicator
- Notes pipeline_stage_at_creation (migration 0069)
- Documenso numeric ID dual-key webhook (migration 0068)
- Dimensions criterion copy (migration 0067)
Tests: 1374/1374 vitest pass. tsc clean. lint clean.
See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
374 lines
15 KiB
TypeScript
374 lines
15 KiB
TypeScript
import {
|
|
pgTable,
|
|
text,
|
|
boolean,
|
|
integer,
|
|
numeric,
|
|
timestamp,
|
|
jsonb,
|
|
index,
|
|
uniqueIndex,
|
|
customType,
|
|
bigint,
|
|
} from 'drizzle-orm/pg-core';
|
|
import { ports } from './ports';
|
|
import { clients } from './clients';
|
|
|
|
// Drizzle doesn't ship a first-class tsvector type; declare a thin custom one.
|
|
const tsvector = customType<{ data: string; driverData: string }>({
|
|
dataType() {
|
|
return 'tsvector';
|
|
},
|
|
});
|
|
|
|
export const auditLogs = pgTable(
|
|
'audit_logs',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id').references(() => ports.id), // null for system-level events
|
|
userId: text('user_id'), // null for system-generated events
|
|
action: text('action').notNull(), // create, update, delete, archive, restore, merge, login, logout, revert
|
|
entityType: text('entity_type').notNull(), // client, interest, berth, expense, invoice, file, user, role, etc.
|
|
entityId: text('entity_id'),
|
|
fieldChanged: text('field_changed'),
|
|
oldValue: jsonb('old_value'),
|
|
newValue: jsonb('new_value'),
|
|
ipAddress: text('ip_address'),
|
|
userAgent: text('user_agent'),
|
|
revertedBy: text('reverted_by'), // user ID if this change was reverted
|
|
revertedAt: timestamp('reverted_at', { withTimezone: true }),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
revertOf: text('revert_of').references((): any => auditLogs.id),
|
|
metadata: jsonb('metadata').default({}),
|
|
/** 'info' | 'warning' | 'error' | 'critical' — drives the row badge
|
|
* in the inspector. Most user actions are 'info'. */
|
|
severity: text('severity').notNull().default('info'),
|
|
/** 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job' — lets the
|
|
* UI filter by event origin without grepping action names. */
|
|
source: text('source').notNull().default('user'),
|
|
/** Full-text search column. **Read-only / DB-managed**: the column is
|
|
* declared `GENERATED ALWAYS AS (...) STORED` in migration
|
|
* 0014_black_banshee.sql (covers action + entity_type + entity_id +
|
|
* user_id). Drizzle has no first-class marker for generated columns,
|
|
* so writes through this schema property would be rejected by
|
|
* Postgres at SQL level — never set this from application code.
|
|
* M-SC04: documented to prevent accidental write attempts. */
|
|
searchText: tsvector('search_text'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
index('idx_al_port').on(table.portId, table.createdAt),
|
|
index('idx_al_entity').on(table.entityType, table.entityId),
|
|
index('idx_al_user').on(table.userId, table.createdAt),
|
|
index('idx_al_created').on(table.createdAt),
|
|
index('idx_al_severity').on(table.portId, table.severity, table.createdAt),
|
|
index('idx_al_source').on(table.portId, table.source, table.createdAt),
|
|
],
|
|
);
|
|
|
|
export const tags = pgTable(
|
|
'tags',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
name: text('name').notNull(),
|
|
color: text('color').notNull().default('#6B7280'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
uniqueIndex('tags_port_name_idx').on(table.portId, table.name),
|
|
index('idx_tags_port').on(table.portId),
|
|
],
|
|
);
|
|
|
|
export const webhooks = pgTable(
|
|
'webhooks',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
name: text('name').notNull(),
|
|
url: text('url').notNull(),
|
|
secret: text('secret'),
|
|
events: text('events').array().notNull(),
|
|
isActive: boolean('is_active').notNull().default(true),
|
|
createdBy: text('created_by').notNull(),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [index('idx_webhooks_port').on(table.portId)],
|
|
);
|
|
|
|
export const webhookDeliveries = pgTable(
|
|
'webhook_deliveries',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
webhookId: text('webhook_id')
|
|
.notNull()
|
|
.references(() => webhooks.id, { onDelete: 'cascade' }),
|
|
eventType: text('event_type').notNull(),
|
|
payload: jsonb('payload').notNull(),
|
|
responseStatus: integer('response_status'),
|
|
responseBody: text('response_body'),
|
|
attempt: integer('attempt').notNull().default(1),
|
|
status: text('status').notNull().default('pending'), // pending, success, failed, dead_letter
|
|
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [index('idx_wd_webhook').on(table.webhookId, table.createdAt)],
|
|
);
|
|
|
|
export const systemSettings = pgTable(
|
|
'system_settings',
|
|
{
|
|
key: text('key').notNull(),
|
|
value: jsonb('value').notNull(),
|
|
portId: text('port_id').references(() => ports.id), // null for global settings
|
|
updatedBy: text('updated_by'),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
// Migration 0047 rebuilds this index with `NULLS NOT DISTINCT` so a
|
|
// global setting (port_id IS NULL) is unique by key alone — the
|
|
// default `NULLS DISTINCT` semantics let duplicates accumulate.
|
|
// Drizzle's `uniqueIndex` builder doesn't surface NULLS NOT DISTINCT,
|
|
// so the migration is the source of truth for that flag and
|
|
// `db:push` against an empty DB would skip it (matches the
|
|
// documented limitation for `berths.current_pdf_version_id`).
|
|
uniqueIndex('system_settings_key_port_idx').on(table.key, table.portId),
|
|
],
|
|
);
|
|
|
|
export const savedViews = pgTable(
|
|
'saved_views',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
userId: text('user_id').notNull(),
|
|
entityType: text('entity_type').notNull(), // clients, interests, berths, expenses, invoices
|
|
name: text('name').notNull(),
|
|
filters: jsonb('filters').notNull(),
|
|
sortConfig: jsonb('sort_config'),
|
|
columnConfig: jsonb('column_config'),
|
|
isShared: boolean('is_shared').notNull().default(false),
|
|
isDefault: boolean('is_default').notNull().default(false),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [index('idx_sv_user').on(table.userId, table.entityType)],
|
|
);
|
|
|
|
export const scratchpadNotes = pgTable(
|
|
'scratchpad_notes',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
userId: text('user_id').notNull(),
|
|
content: text('content').notNull(),
|
|
linkedClientId: text('linked_client_id').references(() => clients.id),
|
|
linkedAt: timestamp('linked_at', { withTimezone: true }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [index('idx_sp_user').on(table.userId)],
|
|
);
|
|
|
|
export const userNotificationPreferences = pgTable(
|
|
'user_notification_preferences',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
userId: text('user_id').notNull(),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
notificationType: text('notification_type').notNull(),
|
|
inApp: boolean('in_app').notNull().default(true),
|
|
email: boolean('email').notNull().default(true),
|
|
},
|
|
(table) => [
|
|
uniqueIndex('unp_user_port_type_idx').on(table.userId, table.portId, table.notificationType),
|
|
],
|
|
);
|
|
|
|
export const currencyRates = pgTable(
|
|
'currency_rates',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
baseCurrency: text('base_currency').notNull(),
|
|
targetCurrency: text('target_currency').notNull(),
|
|
rate: numeric('rate').notNull(),
|
|
source: text('source').notNull().default('frankfurter'), // frankfurter, manual
|
|
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
uniqueIndex('currency_rates_base_target_idx').on(table.baseCurrency, table.targetCurrency),
|
|
],
|
|
);
|
|
|
|
export const customFieldDefinitions = pgTable(
|
|
'custom_field_definitions',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
entityType: text('entity_type').notNull(), // client, interest, berth
|
|
fieldName: text('field_name').notNull(),
|
|
fieldLabel: text('field_label').notNull(),
|
|
fieldType: text('field_type').notNull(), // text, number, date, boolean, select
|
|
selectOptions: jsonb('select_options'), // for select type: ["option1", "option2"]
|
|
isRequired: boolean('is_required').notNull().default(false),
|
|
sortOrder: integer('sort_order').notNull().default(0),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
uniqueIndex('cfd_port_entity_name_idx').on(table.portId, table.entityType, table.fieldName),
|
|
index('idx_cfd_port').on(table.portId),
|
|
],
|
|
);
|
|
|
|
export const customFieldValues = pgTable(
|
|
'custom_field_values',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
fieldId: text('field_id')
|
|
.notNull()
|
|
.references(() => customFieldDefinitions.id, { onDelete: 'cascade' }),
|
|
entityId: text('entity_id').notNull(), // references the client/interest/berth ID
|
|
value: jsonb('value').notNull(),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
uniqueIndex('cfv_field_entity_idx').on(table.fieldId, table.entityId),
|
|
index('idx_cfv_entity').on(table.entityId),
|
|
],
|
|
);
|
|
|
|
/**
|
|
* Per-request error capture for the super-admin inspector.
|
|
*
|
|
* Every unhandled (5xx) failure inside a route handler writes one row
|
|
* here so a user who hit "Error ID: ab12-..." can paste that id to a
|
|
* super admin who pulls the full context (status, path, body excerpt,
|
|
* stack, log lines) without grepping through pino files.
|
|
*
|
|
* Pruning: rows older than 90 days are dropped by the maintenance worker.
|
|
* Row size is bounded by deliberately storing only short stack heads
|
|
* + pre-truncated request bodies (1 KB cap per row).
|
|
*/
|
|
export const errorEvents = pgTable(
|
|
'error_events',
|
|
{
|
|
/**
|
|
* Equal to the request id minted in `withAuth` and surfaced to the
|
|
* client via `X-Request-Id`. Acting as the PK lets us write
|
|
* idempotently when duplicate webhook events fire — `ON CONFLICT
|
|
* DO NOTHING` skips re-inserting the same error.
|
|
*/
|
|
requestId: text('request_id').primaryKey(),
|
|
/** Resolves null when the error fired pre-port (e.g. login flow). */
|
|
portId: text('port_id').references(() => ports.id, { onDelete: 'set null' }),
|
|
/** better-auth user id; null when error fired pre-auth. */
|
|
userId: text('user_id'),
|
|
statusCode: integer('status_code').notNull(),
|
|
method: text('method').notNull(),
|
|
/** Pathname only (no query string) — keeps PII and tokens out. */
|
|
path: text('path').notNull(),
|
|
errorName: text('error_name'),
|
|
errorMessage: text('error_message'),
|
|
/** First 4 KB of the stack — full stacks live in pino, this is for inspector readability. */
|
|
errorStack: text('error_stack'),
|
|
/** Sanitized request body (max 1 KB) — secret-sounding keys redacted. */
|
|
requestBodyExcerpt: text('request_body_excerpt'),
|
|
userAgent: text('user_agent'),
|
|
ipAddress: text('ip_address'),
|
|
/** Request duration in ms when error fired. */
|
|
durationMs: integer('duration_ms'),
|
|
/** Free-form bag (e.g. parsed zod issues, db error code). */
|
|
metadata: jsonb('metadata').default({}),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
index('idx_error_events_port_created').on(table.portId, table.createdAt),
|
|
index('idx_error_events_status_created').on(table.statusCode, table.createdAt),
|
|
],
|
|
);
|
|
|
|
export type AuditLog = typeof auditLogs.$inferSelect;
|
|
export type NewAuditLog = typeof auditLogs.$inferInsert;
|
|
export type ErrorEvent = typeof errorEvents.$inferSelect;
|
|
export type NewErrorEvent = typeof errorEvents.$inferInsert;
|
|
export type Tag = typeof tags.$inferSelect;
|
|
export type NewTag = typeof tags.$inferInsert;
|
|
export type Webhook = typeof webhooks.$inferSelect;
|
|
export type NewWebhook = typeof webhooks.$inferInsert;
|
|
export type WebhookDelivery = typeof webhookDeliveries.$inferSelect;
|
|
export type NewWebhookDelivery = typeof webhookDeliveries.$inferInsert;
|
|
export type SystemSetting = typeof systemSettings.$inferSelect;
|
|
export type NewSystemSetting = typeof systemSettings.$inferInsert;
|
|
export type SavedView = typeof savedViews.$inferSelect;
|
|
export type NewSavedView = typeof savedViews.$inferInsert;
|
|
export type ScratchpadNote = typeof scratchpadNotes.$inferSelect;
|
|
export type NewScratchpadNote = typeof scratchpadNotes.$inferInsert;
|
|
export type UserNotificationPreference = typeof userNotificationPreferences.$inferSelect;
|
|
export type NewUserNotificationPreference = typeof userNotificationPreferences.$inferInsert;
|
|
export type CurrencyRate = typeof currencyRates.$inferSelect;
|
|
export type NewCurrencyRate = typeof currencyRates.$inferInsert;
|
|
export type CustomFieldDefinition = typeof customFieldDefinitions.$inferSelect;
|
|
export type NewCustomFieldDefinition = typeof customFieldDefinitions.$inferInsert;
|
|
export type CustomFieldValue = typeof customFieldValues.$inferSelect;
|
|
export type NewCustomFieldValue = typeof customFieldValues.$inferInsert;
|
|
|
|
/**
|
|
* Backup-job ledger for the in-app backup admin. Each row tracks a
|
|
* single pg_dump invocation (success / failure / size / where the
|
|
* dump landed in storage). The actual dump runs via `runBackup()`
|
|
* in `@/lib/services/backup.service`; this table is the visible
|
|
* record used by `/admin/backup`.
|
|
*/
|
|
export const backupJobs = pgTable(
|
|
'backup_jobs',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
status: text('status').notNull().default('pending'), // pending | running | completed | failed
|
|
trigger: text('trigger').notNull().default('manual'), // manual | cron
|
|
triggeredBy: text('triggered_by'),
|
|
sizeBytes: bigint('size_bytes', { mode: 'number' }),
|
|
storagePath: text('storage_path'),
|
|
errorMessage: text('error_message'),
|
|
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
|
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
|
},
|
|
(table) => [index('idx_backup_jobs_started').on(table.startedAt)],
|
|
);
|
|
|
|
export type BackupJob = typeof backupJobs.$inferSelect;
|
|
export type NewBackupJob = typeof backupJobs.$inferInsert;
|