/** * Phase B — operational insight surfaces. * * - `alerts`: rule-engine-fired actionable cards. The fingerprint column * dedupes re-evaluations of the same condition; the partial unique * index keeps a single open row per `(port, fingerprint)` while * resolved/dismissed history accumulates. * - `analytics_snapshots`: cached aggregate JSON keyed by metric+range, * refreshed by a recurring job so dashboard hits don't recompute. */ import { pgTable, text, timestamp, jsonb, index, uniqueIndex } from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; import { ports } from './ports'; import { user } from './users'; export const alerts = pgTable( 'alerts', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), portId: text('port_id') .notNull() .references(() => ports.id, { onDelete: 'cascade' }), /** Stable rule identifier: 'reservation.no_agreement', 'interest.stale', ... */ ruleId: text('rule_id').notNull(), /** 'info' | 'warning' | 'critical' */ severity: text('severity').notNull(), title: text('title').notNull(), body: text('body'), /** Relative path the card deep-links to. */ link: text('link').notNull(), /** Optional FK target: 'interest', 'reservation', 'document', 'expense', ... */ entityType: text('entity_type'), entityId: text('entity_id'), /** Hash of (rule_id + entity_type + entity_id) — dedupes re-evaluations. */ fingerprint: text('fingerprint').notNull(), firedAt: timestamp('fired_at', { withTimezone: true }).notNull().defaultNow(), dismissedAt: timestamp('dismissed_at', { withTimezone: true }), dismissedBy: text('dismissed_by').references(() => user.id), /** "Someone is on it" — alert stays visible but stops nagging. */ acknowledgedAt: timestamp('acknowledged_at', { withTimezone: true }), acknowledgedBy: text('acknowledged_by').references(() => user.id), /** Set by the engine when the underlying condition no longer fires. */ resolvedAt: timestamp('resolved_at', { withTimezone: true }), /** Per-rule extras: days_stale, amount_at_risk, etc. */ metadata: jsonb('metadata').default({}), }, (table) => [ // Only one open alert per (port, fingerprint) — re-evaluation upserts. uniqueIndex('idx_alerts_fingerprint_open') .on(table.portId, table.fingerprint) .where(sql`resolved_at IS NULL`), index('idx_alerts_port_fired').on(table.portId, table.firedAt), index('idx_alerts_port_severity_open') .on(table.portId, table.severity) .where(sql`resolved_at IS NULL AND dismissed_at IS NULL`), ], ); export type Alert = typeof alerts.$inferSelect; export type NewAlert = typeof alerts.$inferInsert; export const analyticsSnapshots = pgTable( 'analytics_snapshots', { portId: text('port_id') .notNull() .references(() => ports.id, { onDelete: 'cascade' }), /** Composite key: e.g. 'pipeline_funnel.30d', 'occupancy_timeline.90d'. */ metricId: text('metric_id').notNull(), computedAt: timestamp('computed_at', { withTimezone: true }).notNull().defaultNow(), /** Pre-shaped chart data. */ data: jsonb('data').notNull(), }, (table) => [uniqueIndex('idx_analytics_pk').on(table.portId, table.metricId)], ); export type AnalyticsSnapshot = typeof analyticsSnapshots.$inferSelect; export type NewAnalyticsSnapshot = typeof analyticsSnapshots.$inferInsert; /** Severity literal type for callers that want a typed enum. */ export type AlertSeverity = 'info' | 'warning' | 'critical'; /** Rule IDs in the v1 catalog — keep in sync with `alert-rules.ts`. */ export const ALERT_RULES = [ 'reservation.no_agreement', 'interest.stale', 'document.expiring_soon', 'document.signer_overdue', 'berth.under_offer_stalled', 'expense.duplicate', 'expense.unscanned', 'interest.high_value_silent', 'eoi.unsigned_long', 'audit.suspicious_login', ] as const; export type AlertRuleId = (typeof ALERT_RULES)[number];