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:
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user