import type { DatabaseSync } from 'node:sqlite'; import { randomUUID } from 'node:crypto'; export interface AlertSubscription { id: string; owner_id: string; watchlist_id: string | null; symbol: string | null; alert_type: string; enabled: boolean; params: string; last_fired: string | null; created_at: string; } export interface CreateSubscriptionInput { watchlistId?: string; symbol?: string; alertType: string; params?: string; } /** Catalog of alert types shown in the per-type toggle list. */ export const ALERT_TYPE_CATALOG: Array<{ type: string; label: string; description: string }> = [ { type: 'informed_buy', label: 'Insider Buy', description: 'Insider Form 4 purchases outside 10b5-1 plans.' }, { type: 'informed_sell', label: 'Insider Sell', description: 'Insider Form 4 sales outside 10b5-1 plans.' }, { type: 'new_13da', label: 'New 13D/G Filing', description: 'New institutional 13D/13G positions and 13F-HR filings.' }, { type: 'rotation_incipient', label: 'Rotation Signal', description: 'Incipient sector rotation detected in the rotation engine.' }, { type: 'regime_shift', label: 'Regime Shift', description: 'Market regime transition affecting portfolio risk.' }, { type: 'conviction_unlock', label: 'Conviction Unlock', description: 'A new conviction tier unlocks from trading history.' }, { type: 'thesis_broken', label: 'Thesis Broken', description: 'Thesis invalidation criteria met.' }, { type: 'thesis_weakening', label: 'Thesis Weakening', description: 'Signals suggest a thesis may be weakening.' }, { type: 'cluster_breach', label: 'Cluster Breach', description: 'Cluster exposure exceeds the recommended cap.' }, { type: 'drawdown_halt', label: 'Drawdown Halt', description: 'Drawdown tolerance breached; circuit breaker paused entries.' }, { type: 'asymmetry_warning', label: 'Asymmetry Warning', description: 'Portfolio reward-to-risk below threshold.' }, { type: 'fund_capture', label: 'Fund Capture', description: 'A tracked fund posted a position update.' }, { type: 'fund_13f', label: 'Tracked Fund 13F', description: 'A tracked fund filed a new 13F.' }, { type: 'mirror_diff', label: 'Tracked Fund Book', description: "A tracked fund's disclosed book changed materially." }, { type: 'vix_level', label: 'VIX Level', description: 'VIX moves into a new historical volatility band.' }, { type: 'confluence_change', label: 'Confluence Picture Change', description: 'A symbol\'s confluence picture improved or became more cautious.' }, ]; export interface AlertTypeToggle { type: string; label: string; description: string; enabled: boolean; } function stmts(db: DatabaseSync) { return { insert: db.prepare( `INSERT INTO alerts (id, owner_id, watchlist_id, symbol, alert_type, enabled, params, created_at) VALUES (?, ?, ?, ?, ?, 1, ?, ?)`, ), selectById: db.prepare( `SELECT id, owner_id, watchlist_id, symbol, alert_type, enabled, params, last_fired, created_at FROM alerts WHERE id = ? AND owner_id = ?`, ), selectByOwner: db.prepare( `SELECT id, owner_id, watchlist_id, symbol, alert_type, enabled, params, last_fired, created_at FROM alerts WHERE owner_id = ? ORDER BY created_at DESC`, ), selectByOwnerAndSymbol: db.prepare( `SELECT id, owner_id, watchlist_id, symbol, alert_type, enabled, params, last_fired, created_at FROM alerts WHERE owner_id = ? AND symbol = ? ORDER BY created_at DESC`, ), selectTickerLevel: db.prepare( `SELECT id, owner_id, watchlist_id, symbol, alert_type, enabled, params, last_fired, created_at FROM alerts WHERE owner_id = ? AND alert_type = ? AND symbol = ? AND enabled = 1 LIMIT 1`, ), selectGlobalDefault: db.prepare( `SELECT id, owner_id, watchlist_id, symbol, alert_type, enabled, params, last_fired, created_at FROM alerts WHERE owner_id = ? AND alert_type = ? AND enabled = 1 AND symbol IS NULL AND watchlist_id IS NULL LIMIT 1`, ), updateEnabled: db.prepare( `UPDATE alerts SET enabled = ? WHERE id = ? AND owner_id = ?`, ), updateParams: db.prepare( `UPDATE alerts SET params = ? WHERE id = ? AND owner_id = ?`, ), delete: db.prepare( `DELETE FROM alerts WHERE id = ? AND owner_id = ?`, ), }; } function mapRow(row: Record): AlertSubscription { return { id: row.id as string, owner_id: row.owner_id as string, watchlist_id: row.watchlist_id as string | null, symbol: row.symbol as string | null, alert_type: row.alert_type as string, enabled: (row.enabled as number) === 1, params: row.params as string, last_fired: row.last_fired as string | null, created_at: row.created_at as string, }; } export function createAlertSubscription( db: DatabaseSync, userId: string, input: CreateSubscriptionInput, ): AlertSubscription { const s = stmts(db); const id = randomUUID(); const now = new Date().toISOString(); const params = input.params ?? '{}'; s.insert.run(id, userId, input.watchlistId ?? null, input.symbol ?? null, input.alertType, params, now); return getAlertSubscription(db, userId, id)!; } export function getAlertSubscription( db: DatabaseSync, userId: string, id: string, ): AlertSubscription | null { const s = stmts(db); const row = s.selectById.get(id, userId) as Record | undefined; if (!row) return null; return mapRow(row); } export function listAlertSubscriptions( db: DatabaseSync, userId: string, symbol?: string, ): AlertSubscription[] { const s = stmts(db); let rows: Record[]; if (symbol) { rows = s.selectByOwnerAndSymbol.all(userId, symbol) as Record[]; } else { rows = s.selectByOwner.all(userId) as Record[]; } return rows.map(mapRow); } export function updateAlertSubscription( db: DatabaseSync, userId: string, id: string, updates: { enabled?: boolean; params?: string }, ): AlertSubscription | null { const s = stmts(db); if (updates.enabled !== undefined) { s.updateEnabled.run(updates.enabled ? 1 : 0, id, userId); } if (updates.params !== undefined) { s.updateParams.run(updates.params, id, userId); } return getAlertSubscription(db, userId, id); } export function deleteAlertSubscription( db: DatabaseSync, userId: string, id: string, ): boolean { const s = stmts(db); const result = s.delete.run(id, userId); return (result as { changes: number }).changes > 0; } export function getEffectiveAlertSubscription( db: DatabaseSync, userId: string, symbol: string, alertType: string, watchlistIds: string[] = [], ): AlertSubscription | null { const s = stmts(db); // 1. Ticker-level wins. const tickerRow = s.selectTickerLevel.get(userId, alertType, symbol) as Record | undefined; if (tickerRow) return mapRow(tickerRow); // 2. Check list-level for each watchlist the symbol belongs to. for (const wlId of watchlistIds) { const s2 = db.prepare( `SELECT id, owner_id, watchlist_id, symbol, alert_type, enabled, params, last_fired, created_at FROM alerts WHERE owner_id = ? AND alert_type = ? AND watchlist_id = ? AND symbol IS NULL AND enabled = 1 LIMIT 1`, ); const row = s2.get(userId, alertType, wlId) as Record | undefined; if (row) return mapRow(row); } // 3. Global default. const globalRow = s.selectGlobalDefault.get(userId, alertType) as Record | undefined; if (globalRow) return mapRow(globalRow); return null; } /** * Seed a global default subscription (symbol NULL, watchlist NULL) for every * catalog alert type for admin users (is_admin=1). Idempotent: only creates a * default when the user has zero enabled subscriptions of that type, so a toggled * preference (any ticker/list/global row) is never overwritten. Runs on every * producer tick (cheap: no-op once seeded). */ export function seedAdminDefaultAlertSubscriptions(db: DatabaseSync): number { let inserted = 0; try { const admins = db.prepare( `SELECT id FROM users WHERE is_admin = 1 AND id NOT IN ('anonymous','system') AND email NOT LIKE 'anonymous@%'`, ).all() as Array<{ id: string }>; if (admins.length === 0) return 0; const insert = db.prepare( `INSERT OR IGNORE INTO alerts (id, owner_id, watchlist_id, symbol, alert_type, enabled, params, created_at) VALUES (?, ?, NULL, NULL, ?, 1, '{}', ?)`, ); const hasEnabled = db.prepare( `SELECT COUNT(*) AS n FROM alerts WHERE owner_id = ? AND alert_type = ? AND enabled = 1`, ); const now = new Date().toISOString(); for (const admin of admins) { for (const { type } of ALERT_TYPE_CATALOG) { const { n } = hasEnabled.get(admin.id, type) as { n: number }; if (n > 0) continue; const id = `auto-global-${admin.id}-${type}`; const r = insert.run(id, admin.id, type, now); if (Number(r.changes) > 0) inserted += 1; } } } catch { /* schema may lack tables in tests */ } return inserted; } /** * Per-type enable state for the current user. A type is "on" if any enabled * subscription of that type exists (ticker-level, list-level, or global). */ export function listAlertTypeToggles(db: DatabaseSync, userId: string): AlertTypeToggle[] { let rows: Array<{ alert_type: string; max_enabled: number }> = []; try { rows = db.prepare( `SELECT alert_type, MAX(enabled) AS max_enabled FROM alerts WHERE owner_id = ? GROUP BY alert_type`, ).all(userId) as Array<{ alert_type: string; max_enabled: number }>; } catch { /* schema may lack alerts table in tests */ } const enabledByType = new Map(); for (const r of rows) enabledByType.set(r.alert_type, r.max_enabled === 1); return ALERT_TYPE_CATALOG.map(({ type, label, description }) => ({ type, label, description, enabled: enabledByType.get(type) ?? false, })); } /** * Toggle a whole alert type for a user. Flipping ON creates a global default * subscription (symbol NULL, watchlist NULL) unless one exists; flipping OFF * disables every subscription the user has of that type (ticker + list + * global) so the toggle is authoritative. Never deletes rows. */ export function toggleAlertType( db: DatabaseSync, userId: string, alertType: string, enabled: boolean, ): AlertTypeToggle[] { const catalog = ALERT_TYPE_CATALOG.find((c) => c.type === alertType); if (!catalog) throw new Error(`Unknown alert type: ${alertType}`); if (enabled) { const existing = db.prepare( `SELECT id FROM alerts WHERE owner_id = ? AND alert_type = ? AND symbol IS NULL AND watchlist_id IS NULL LIMIT 1`, ).get(userId, alertType) as { id: string } | undefined; if (existing) { db.prepare('UPDATE alerts SET enabled = 1 WHERE id = ?').run(existing.id); } else { createAlertSubscription(db, userId, { alertType }); } } else { db.prepare( 'UPDATE alerts SET enabled = 0 WHERE owner_id = ? AND alert_type = ?', ).run(userId, alertType); } return listAlertTypeToggles(db, userId); }