301 lines
11 KiB
TypeScript
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: 'Mirror Diff', description: 'The mirror target 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);
|
|
}
|