Files
investor-flow/app/server/src/db/alertSubscriptionRepository.ts
T
Investor Flow Build 90e1829d39
CI / Test (push) Canceled after 0s
CI / Build and push (push) Canceled after 0s
feat: Unraid deploy, dealer-flow heatmap, confluence zones, 13F capture
Ship Node production images, Unraid compose, and Gitea CI/CD (test then
push registry images; cron script if no runner). Rebuild dealer flow as a
heatmap-first map with integrity gates and chart helpers. Add confluence
zone rules, session clock, capture evidence, and tighter 13F/queue/options
paths, plus the matching UI and tests.
2026-08-18 14:10:02 -04:00

301 lines
11 KiB
TypeScript

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<string, unknown>): 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<string, unknown> | 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<string, unknown>[];
if (symbol) {
rows = s.selectByOwnerAndSymbol.all(userId, symbol) as Record<string, unknown>[];
} else {
rows = s.selectByOwner.all(userId) as Record<string, unknown>[];
}
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<string, unknown> | 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<string, unknown> | undefined;
if (row) return mapRow(row);
}
// 3. Global default.
const globalRow = s.selectGlobalDefault.get(userId, alertType) as Record<string, unknown> | 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<string, boolean>();
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);
}