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
+194 -8
View File
@@ -184,7 +184,7 @@ const onboardingRouter = router({
ctx.db.prepare('UPDATE users SET complexity=?, risk_tolerance=?, drawdown_tolerance=? WHERE id=?').run(complexity, riskTolerance, drawdown, userId);
const symbols = input.firstWatchlistSymbols ?? STARTER_WATCHLIST.map((s) => s.symbol);
const wlId = randomUUID();
ctx.db.prepare('INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?,?,?,?,?,?)').run(wlId, userId, 'Starter', JSON.stringify(symbols), new Date().toISOString(), 0);
ctx.db.prepare('INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?,?,?,?,?,?)').run(wlId, userId, 'default', JSON.stringify(symbols), new Date().toISOString(), 0);
for (const sym of symbols) {
const kind = (STARTER_WATCHLIST.find((s) => s.symbol === sym)?.tickerKind ?? 'equity') as 'equity' | 'crypto' | 'etf' | 'index';
await ctx.cache.subscribe(sym, kind);
@@ -1362,6 +1362,73 @@ const adminRouter = router({
return { ok: true, ...status };
}),
/** Get SMTP config (admin-only). */
smtpConfig: adminProcedure.query(async ({ ctx }) => {
const { readSmtpConfig } = await import('../services/emailAlertService.ts');
return readSmtpConfig(ctx.db) ?? { enabled: false };
}),
/** Update SMTP config (admin-only). */
smtpConfigUpdate: adminProcedure
.input(z.object({
host: z.string().optional(),
port: z.number().int().optional(),
secure: z.boolean().optional(),
user: z.string().optional().nullable(),
pass: z.string().optional().nullable(),
fromName: z.string().optional(),
fromEmail: z.string().optional(),
enabled: z.boolean().optional(),
}))
.mutation(({ ctx, input }) => {
const config = ctx.db.prepare('SELECT * FROM smtp_config WHERE id = ?').get('singleton') as Record<string, unknown> | undefined;
const now = new Date().toISOString();
if (!config) {
ctx.db.prepare(
`INSERT INTO smtp_config (id, host, port, secure, user, pass_enc, from_name, from_email, enabled, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run('singleton', input.host ?? 'smtp.mail.me.com', input.port ?? 587, input.secure ? 1 : 0, input.user ?? null, input.pass ?? null, input.fromName ?? 'Investor Flow', input.fromEmail ?? '', input.enabled ? 1 : 0, now);
} else {
const updates: string[] = [];
const params: (string | number | null)[] = [];
if (input.host !== undefined) { updates.push('host = ?'); params.push(input.host); }
if (input.port !== undefined) { updates.push('port = ?'); params.push(input.port); }
if (input.secure !== undefined) { updates.push('secure = ?'); params.push(input.secure ? 1 : 0); }
if (input.user !== undefined) { updates.push('user = ?'); params.push(input.user); }
if (input.pass !== undefined) { updates.push('pass_enc = ?'); params.push(input.pass); }
if (input.fromName !== undefined) { updates.push('from_name = ?'); params.push(input.fromName); }
if (input.fromEmail !== undefined) { updates.push('from_email = ?'); params.push(input.fromEmail); }
if (input.enabled !== undefined) { updates.push('enabled = ?'); params.push(input.enabled ? 1 : 0); }
updates.push('updated_at = ?');
params.push(now);
params.push('singleton');
ctx.db.prepare(`UPDATE smtp_config SET ${updates.join(', ')} WHERE id = ?`).run(...params);
}
return { ok: true };
}),
/** Test SMTP config by sending a test email to the admin. */
smtpConfigTest: adminProcedure.mutation(async ({ ctx }) => {
const { readSmtpConfig, sendAlertEmail } = await import('../services/emailAlertService.ts');
const config = readSmtpConfig(ctx.db);
if (!config || !config.enabled) throw new TRPCError({ code: 'BAD_REQUEST', message: 'SMTP not configured or disabled.' });
const testAlert = {
id: 'test',
userId: ctx.userId as string,
type: 'rotation_incipient' as const,
severity: 'info' as const,
title: 'Test alert from Investor Flow',
description: 'This is a test email to verify SMTP configuration.',
symbol: undefined,
createdAt: new Date().toISOString(),
acknowledged: false,
dedupKey: 'test:' + Date.now(),
payload: {},
};
const sent = await sendAlertEmail(ctx.db, testAlert);
if (!sent) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to send test email.' });
return { ok: true };
}),
});
@@ -1457,6 +1524,64 @@ const alertsRouter = router({
).get(userId) as { count: number } | undefined;
return { count: row?.count ?? 0 };
}),
/** Create an alert subscription rule. */
createSubscription: protectedProcedure
.input(z.object({
alertType: z.string(),
watchlistId: z.string().optional(),
symbol: z.string().optional(),
params: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId as string;
const { createAlertSubscription } = await import('../db/alertSubscriptionRepository.ts');
const sub = createAlertSubscription(ctx.db, userId, {
alertType: input.alertType,
watchlistId: input.watchlistId,
symbol: input.symbol,
params: input.params,
});
return sub;
}),
/** List alert subscriptions (optionally filtered by symbol). */
listSubscriptions: protectedProcedure
.input(z.object({ symbol: z.string().optional() }).optional())
.query(async ({ ctx, input }) => {
const userId = ctx.userId as string;
const { listAlertSubscriptions } = await import('../db/alertSubscriptionRepository.ts');
return listAlertSubscriptions(ctx.db, userId, input?.symbol);
}),
/** Update an alert subscription (enable/disable, change params). */
updateSubscription: protectedProcedure
.input(z.object({
id: z.string(),
enabled: z.boolean().optional(),
params: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId as string;
const { updateAlertSubscription } = await import('../db/alertSubscriptionRepository.ts');
const sub = updateAlertSubscription(ctx.db, userId, input.id, {
enabled: input.enabled,
params: input.params,
});
if (!sub) throw new TRPCError({ code: 'NOT_FOUND', message: 'Subscription not found.' });
return sub;
}),
/** Delete an alert subscription. */
deleteSubscription: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId as string;
const { deleteAlertSubscription } = await import('../db/alertSubscriptionRepository.ts');
const deleted = deleteAlertSubscription(ctx.db, userId, input.id);
if (!deleted) throw new TRPCError({ code: 'NOT_FOUND', message: 'Subscription not found.' });
return { ok: true };
}),
});
// ─── Institutional Flow Router (Slice 7 / M4 + M5) ────────────────────────
@@ -1880,7 +2005,7 @@ const edgarRouter = router({
// ─── Watchlist Router (Slice 10) ─────────────────────────────────────────────
const watchlistRouter = router({
/** List all symbols in the user's default watchlist. */
/** List all symbols across all watchlists for the user. */
list: publicProcedure
.query(async ({ ctx }) => {
const userId = ctx.userId ?? 'anonymous';
@@ -1888,13 +2013,71 @@ const watchlistRouter = router({
return listSymbols(ctx.db, userId);
}),
/** Add a symbol to the default watchlist. */
/** List symbols for a specific watchlist. */
listByWatchlist: publicProcedure
.input(z.object({ name: z.string().min(1).max(100) }))
.query(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { listSymbolsByWatchlist } = await import('../db/watchlistRepository.ts');
return listSymbolsByWatchlist(ctx.db, userId, input.name);
}),
/** List watchlist metadata (name, symbol count, sort order). */
listWatchlists: publicProcedure
.query(async ({ ctx }) => {
const userId = ctx.userId ?? 'anonymous';
const { listWatchlists } = await import('../db/watchlistRepository.ts');
return listWatchlists(ctx.db, userId);
}),
/** Create a new named watchlist. */
create: publicProcedure
.input(z.object({ name: z.string().min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { createWatchlist } = await import('../db/watchlistRepository.ts');
return createWatchlist(ctx.db, userId, input.name);
}),
/** Delete a watchlist by name. */
delete: publicProcedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { deleteWatchlist } = await import('../db/watchlistRepository.ts');
return { deleted: deleteWatchlist(ctx.db, userId, input.name) };
}),
/** Rename a watchlist. */
rename: publicProcedure
.input(z.object({ oldName: z.string().min(1), newName: z.string().min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { renameWatchlist } = await import('../db/watchlistRepository.ts');
return { renamed: renameWatchlist(ctx.db, userId, input.oldName, input.newName) };
}),
/** Reorder watchlists (bulk update sort_order). */
reorder: publicProcedure
.input(z.object({ orders: z.array(z.object({ id: z.string(), sort_order: z.number().int() })) }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { reorderWatchlists } = await import('../db/watchlistRepository.ts');
reorderWatchlists(ctx.db, userId, input.orders);
return { ok: true };
}),
/** Add a symbol to a watchlist (defaults to 'default' watchlist). */
addSymbol: publicProcedure
.input(z.object({ symbol: z.string().toUpperCase() }))
.input(z.object({
symbol: z.string().toUpperCase(),
watchlistName: z.string().optional(),
notes: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { addSymbol } = await import('../db/watchlistRepository.ts');
const added = addSymbol(ctx.db, userId, input.symbol);
const added = addSymbol(ctx.db, userId, input.symbol, input.notes, input.watchlistName);
if (added) {
await ctx.cache.subscribe(input.symbol, 'equity');
@@ -1904,13 +2087,16 @@ const watchlistRouter = router({
return { added };
}),
/** Remove a symbol from the default watchlist. */
/** Remove a symbol from a watchlist (defaults to 'default'). */
removeSymbol: publicProcedure
.input(z.object({ symbol: z.string().toUpperCase() }))
.input(z.object({
symbol: z.string().toUpperCase(),
watchlistName: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { removeSymbol } = await import('../db/watchlistRepository.ts');
const removed = removeSymbol(ctx.db, userId, input.symbol);
const removed = removeSymbol(ctx.db, userId, input.symbol, input.watchlistName);
return { removed };
}),
});