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; /** * Admin-configurable destinations that scheduled/manual backups are pushed to. * Each row transports the exact full-bundle tar produced by * `createFullBackupTar()` (db.dump + blobs + manifest) — see * docs/superpowers/specs/2026-06-04-backup-destinations-design.md. * * `config` holds the type-specific connection settings; any secret inside it * (SFTP password / private key, S3 secret key) is AES-GCM-encrypted via * `@/lib/utils/encryption` before storage and never returned raw (the API * surfaces only `*IsSet` markers, mirroring the send-from-accounts pattern). */ export const backupDestinations = pgTable( 'backup_destinations', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), name: text('name').notNull(), type: text('type').notNull(), // 'sftp' | 's3' | 'filesystem' enabled: boolean('enabled').notNull().default(false), config: jsonb('config').notNull().default({}), /** Keep last N bundles at this destination; null = keep all. */ retentionCount: integer('retention_count'), /** Opt-in client-side AES-256 encryption of the bundle before push. */ encryptBundle: boolean('encrypt_bundle').notNull().default(false), /** The bundle passphrase, itself AES-GCM-encrypted at rest. */ encryptionKeyEncrypted: text('encryption_key_encrypted'), lastRunAt: timestamp('last_run_at', { withTimezone: true }), lastStatus: text('last_status'), // 'ok' | 'failed' lastError: text('last_error'), lastBackupBytes: bigint('last_backup_bytes', { mode: 'number' }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [index('idx_backup_destinations_enabled').on(table.enabled)], ); export type BackupDestination = typeof backupDestinations.$inferSelect; export type NewBackupDestination = typeof backupDestinations.$inferInsert;