Phase 5: alert subscriptions UI + multiple watchlists + queue fixes
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:
Investor Flow Build
2026-07-23 20:51:47 -04:00
parent f71987c119
commit 5b9f770aa4
23 changed files with 1567 additions and 146 deletions
+37
View File
@@ -0,0 +1,37 @@
import type { DatabaseSync } from 'node:sqlite';
import type { Alert } from '../AlertEngine.ts';
import type { AlertProducer, ProducerFrequency } from './types.ts';
export type { AlertProducer, ProducerFrequency } from './types.ts';
const producers: AlertProducer[] = [];
/** Register a producer (called once at startup). */
export function registerProducer(p: AlertProducer): void {
producers.push(p);
}
/** Run all producers for a given frequency tier. */
export async function runProducers(
db: DatabaseSync,
frequency: ProducerFrequency,
): Promise<Alert[]> {
const results: Alert[] = [];
const tier = producers.filter((p) => p.frequency === frequency);
for (const producer of tier) {
try {
const alerts = await producer.check(db);
results.push(...alerts);
} catch (err) {
console.error(`[alert:${producer.alertType}] check failed:`, err);
}
}
return results;
}
/** Get all registered producer types. */
export function registeredAlertTypes(): string[] {
return producers.map((p) => p.alertType);
}
@@ -0,0 +1,88 @@
import type { DatabaseSync } from 'node:sqlite';
import type { Alert } from '../AlertEngine.ts';
import { createAlert } from '../AlertEngine.ts';
import type { AlertProducer } from './types.ts';
import { readComparisonState, writeComparisonState, getSubscribedUsers, persistAlert, isDuplicate } from './types.ts';
interface InsiderTx {
symbol: string;
tx_date: string;
classification: string;
shares: number;
}
function createProducer(alertType: 'informed_buy' | 'informed_sell', classification: string): AlertProducer {
return {
alertType,
frequency: 'batched' as const,
async check(db: DatabaseSync): Promise<Alert[]> {
const alerts: Alert[] = [];
// Find all symbols with new insider transactions.
const symbols = db.prepare(
`SELECT DISTINCT symbol FROM insider_transactions WHERE classification = ?`,
).all(classification) as { symbol: string }[];
for (const { symbol } of symbols) {
const prev = readComparisonState(db, symbol, alertType);
const prevState = prev ? JSON.parse(prev.state) : { lastTxDate: null, lastSeenIds: [] as string[] };
const rows = db.prepare(
`SELECT symbol, tx_date, classification, shares
FROM insider_transactions
WHERE symbol = ? AND classification = ?
ORDER BY tx_date DESC
LIMIT 10`,
).all(symbol, classification) as unknown as InsiderTx[];
if (rows.length === 0) continue;
const latestDate = rows[0].tx_date;
const newIds = rows.map((r) => `${r.tx_date}:${r.shares}`);
const prevIds = new Set<string>(prevState.lastSeenIds as string[]);
if (prevState.lastTxDate === latestDate && newIds.every((id) => prevIds.has(id))) {
writeComparisonState(db, symbol, alertType, { lastTxDate: latestDate, lastSeenIds: newIds });
continue;
}
const newRows = prevState.lastTxDate === null
? rows
: rows.filter((r) => r.tx_date > prevState.lastTxDate || (r.tx_date === prevState.lastTxDate && !prevIds.has(`${r.tx_date}:${r.shares}`)));
for (const row of newRows) {
const users = getSubscribedUsers(db, alertType, symbol);
for (const userId of users) {
const eventId = `${symbol}:${row.tx_date}:${row.shares}`;
const dedupKey = `${alertType}:${userId}:${eventId}`;
if (isDuplicate(db, dedupKey)) continue;
const description = classification === 'P'
? `An insider purchased ${row.shares} shares on ${row.tx_date}.`
: `An insider sold ${Math.abs(row.shares)} shares on ${row.tx_date}.`;
const alert = createAlert(
crypto.randomUUID(),
userId,
alertType,
symbol,
description,
eventId,
{ shares: row.shares, txDate: row.tx_date },
);
persistAlert(db, alert);
alerts.push(alert);
}
}
writeComparisonState(db, symbol, alertType, { lastTxDate: latestDate, lastSeenIds: newIds });
}
return alerts;
},
};
}
export const informedBuyProducer = createProducer('informed_buy', 'P');
export const informedSellProducer = createProducer('informed_sell', 'S');
@@ -0,0 +1,60 @@
import type { DatabaseSync } from 'node:sqlite';
import type { Alert } from '../AlertEngine.ts';
import { createAlert } from '../AlertEngine.ts';
import type { AlertProducer } from './types.ts';
import { readComparisonState, writeComparisonState, getSubscribedUsers, persistAlert, isDuplicate } from './types.ts';
export const new13daProducer: AlertProducer = {
alertType: 'new_13da',
frequency: 'batched',
async check(db: DatabaseSync): Promise<Alert[]> {
const alerts: Alert[] = [];
const symbols = db.prepare(
`SELECT DISTINCT symbol FROM institution_filings WHERE form LIKE '13D%' OR form LIKE '13G%'`,
).all() as { symbol: string }[];
for (const { symbol } of symbols) {
const prev = readComparisonState(db, symbol, 'new_13da');
const prevState = prev ? JSON.parse(prev.state) : { latestAccession: null };
const row = db.prepare(
`SELECT accession, reported_quarter, filer_name, put_call
FROM institution_filings
WHERE symbol = ? AND (form LIKE '13D%' OR form LIKE '13G%')
ORDER BY reported_quarter DESC
LIMIT 1`,
).get(symbol) as { accession: string; reported_quarter: string; filer_name: string; put_call: string | null } | undefined;
if (!row) continue;
if (row.accession === prevState.latestAccession) {
writeComparisonState(db, symbol, 'new_13da', { latestAccession: row.accession });
continue;
}
const users = getSubscribedUsers(db, 'new_13da', symbol);
for (const userId of users) {
const dedupKey = `new_13da:${userId}:${row.accession}`;
if (isDuplicate(db, dedupKey)) continue;
const alert = createAlert(
crypto.randomUUID(),
userId,
'new_13da',
symbol,
`${row.filer_name} reported a new position${row.put_call ? ` (${row.put_call})` : ''} for ${row.reported_quarter}.`,
row.accession,
{ filer: row.filer_name, quarter: row.reported_quarter, putCall: row.put_call },
);
persistAlert(db, alert);
alerts.push(alert);
}
writeComparisonState(db, symbol, 'new_13da', { latestAccession: row.accession });
}
return alerts;
},
};
+97
View File
@@ -0,0 +1,97 @@
import type { DatabaseSync } from 'node:sqlite';
import type { Alert } from '../AlertEngine.ts';
export type ProducerFrequency = 'per-fetch' | 'batched';
export interface AlertProducer {
readonly alertType: string;
readonly frequency: ProducerFrequency;
check(db: DatabaseSync): Promise<Alert[]>;
}
export interface ComparisonState {
symbol: string;
alertType: string;
state: string;
updatedAt: string;
}
export function readComparisonState(
db: DatabaseSync,
symbol: string,
alertType: string,
): ComparisonState | null {
const row = db.prepare(
`SELECT symbol, alert_type, state, updated_at
FROM alert_comparison_state
WHERE symbol = ? AND alert_type = ?`,
).get(symbol, alertType) as Record<string, unknown> | undefined;
if (!row) return null;
return {
symbol: row.symbol as string,
alertType: row.alert_type as string,
state: row.state as string,
updatedAt: row.updated_at as string,
};
}
export function writeComparisonState(
db: DatabaseSync,
symbol: string,
alertType: string,
state: Record<string, unknown>,
): void {
db.prepare(
`INSERT OR REPLACE INTO alert_comparison_state (symbol, alert_type, state, updated_at)
VALUES (?, ?, ?, ?)`,
).run(symbol, alertType, JSON.stringify(state), new Date().toISOString());
}
export function getSubscribedUsers(
db: DatabaseSync,
alertType: string,
symbol: string,
): string[] {
// Ticker-level subscriptions first.
const tickerRows = db.prepare(
`SELECT owner_id FROM alerts
WHERE alert_type = ? AND symbol = ? AND enabled = 1`,
).all(alertType, symbol) as { owner_id: string }[];
// Also find users with global subscriptions.
const globalRows = db.prepare(
`SELECT owner_id FROM alerts
WHERE alert_type = ? AND symbol IS NULL AND watchlist_id IS NULL AND enabled = 1`,
).all(alertType) as { owner_id: string }[];
const userIds = new Set<string>();
for (const r of tickerRows) userIds.add(r.owner_id);
for (const r of globalRows) userIds.add(r.owner_id);
return Array.from(userIds);
}
export function persistAlert(db: DatabaseSync, alert: Alert): void {
db.prepare(
`INSERT OR IGNORE INTO alert_events
(id, user_id, type, severity, title, description, symbol, created_at, acknowledged, dedup_key, payload)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
).run(
alert.id,
alert.userId,
alert.type,
alert.severity,
alert.title,
alert.description,
alert.symbol ?? null,
alert.createdAt,
alert.dedupKey,
JSON.stringify(alert.payload),
);
}
export function isDuplicate(db: DatabaseSync, dedupKey: string): boolean {
const row = db.prepare(
'SELECT 1 FROM alert_events WHERE dedup_key = ? LIMIT 1',
).get(dedupKey) as Record<string, unknown> | undefined;
return !!row;
}