Files
pn-new-crm/src/lib/db/schema/system.ts
Matt fe863a588e
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m52s
Build & Push Docker Images / build-and-push (push) Successful in 11m59s
feat(backup): full DR bundle export + admin-configurable offsite destinations
Backend-agnostic disaster-recovery backup engine that runs on the current
storage backend (no storage cutover required):

- Full-bundle export: db.dump (pg_dump custom) + every storage blob +
  manifest.json with per-object SHA-256, streamed as a tar. Entry points:
  admin UI download, GET /api/v1/admin/backup/export, scripts/create-full-backup.ts.
- Admin-configurable push destinations (backup_destinations table, migration
  0091): SFTP/SSH, S3-compatible (reuses the minio client), and mounted
  path/NAS behind one transport interface (test/push/prune). Secrets AES-GCM
  at rest; API returns only *IsSet markers.
- Opt-in per-destination AES-256 bundle encryption (scrypt KDF, streamed) +
  scripts/decrypt-backup.ts for restore.
- Wired the previously-dead database-backup cron to runScheduledBackupPush
  (push to enabled destinations, prune to retention, alert super-admins on
  failure).

Tests: 1608 unit/integration pass; tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:23:42 +02:00

414 lines
17 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;
/**
* 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;