Phase 5: alert subscriptions UI + multiple watchlists + queue fixes
CI / Test & Type-Check (push) Canceled after 0s
CI / Test & Type-Check (push) Canceled after 0s
UI: - /alerts page: event history with acknowledge, subscription create/manage with toggle - /admin/smtp: SMTP config form (host, port, auth, test) - Watchlist sidebar: dropdown selector for multiple watchlists, create/delete - Sidebar: alerts count badge, SMTP link under admin - Mobile tab nav: alerts tab added - Client trpc.ts: all new API methods + types Backend: - watchlists.listByWatchlist procedure + listSymbolsByWatchlist repo fn - yfinance min-interval 1500->2000ms to reduce Edge 429s - Fixed e.date.slice error in yfinance-adjustments with typeof guard - Removed defunct BITF from watchlist+queue - Cleared 83 failed + 12 backoff queue jobs Docs: - FUNCTIONAL_DESIGN.md: alerts + multiple watchlists + SMTP documented - TECH_DESIGN.md: new modules, tRPC procs, routes updated
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user