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,131 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Alert } from '../alerts/AlertEngine.ts';
|
||||
|
||||
interface SmtpConfigRow {
|
||||
id: string;
|
||||
host: string;
|
||||
port: number;
|
||||
secure: number;
|
||||
user: string | null;
|
||||
pass_enc: string | null;
|
||||
from_name: string;
|
||||
from_email: string;
|
||||
enabled: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user: string | null;
|
||||
passEnc: string | null;
|
||||
fromName: string;
|
||||
fromEmail: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function readSmtpConfig(db: DatabaseSync): SmtpConfig | null {
|
||||
const row = db.prepare('SELECT * FROM smtp_config WHERE id = ?').get('singleton') as SmtpConfigRow | undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
secure: row.secure === 1,
|
||||
user: row.user ?? null,
|
||||
passEnc: row.pass_enc ?? null,
|
||||
fromName: row.from_name,
|
||||
fromEmail: row.from_email,
|
||||
enabled: row.enabled === 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAlertEmailHtml(alert: Alert): string {
|
||||
const severityColors: Record<string, string> = {
|
||||
info: '#3b82f6',
|
||||
warning: '#f59e0b',
|
||||
critical: '#ef4444',
|
||||
};
|
||||
const color = severityColors[alert.severity] ?? '#6b7280';
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;margin:0;padding:0;background:#f9fafb">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:20px">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1)">
|
||||
<tr><td style="padding:24px;border-bottom:3px solid ${color}">
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td><h1 style="margin:0;font-size:20px;color:#111827">Investor Flow</h1></td>
|
||||
<td align="right"><span style="display:inline-block;padding:4px 12px;border-radius:12px;font-size:12px;font-weight:600;color:#fff;background:${color};text-transform:uppercase">${alert.severity}</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td style="padding:24px">
|
||||
<h2 style="margin:0 0 8px;font-size:18px;color:#111827">${alert.title}</h2>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#6b7280;line-height:1.5">${alert.description.replace(/\n/g, '<br>')}</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="font-size:13px;color:#6b7280">
|
||||
${alert.symbol ? `<tr><td style="padding:4px 0">Symbol:</td><td style="padding:4px 0;font-weight:600;color:#111827">${alert.symbol}</td></tr>` : ''}
|
||||
<tr><td style="padding:4px 0">Time:</td><td style="padding:4px 0;font-weight:600;color:#111827">${new Date(alert.createdAt).toLocaleString()}</td></tr>
|
||||
<tr><td style="padding:4px 0">Type:</td><td style="padding:4px 0;font-weight:600;color:#111827">${alert.type.replace(/_/g, ' ')}</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td style="padding:16px 24px;background:#f9fafb;border-top:1px solid #e5e7eb;font-size:12px;color:#9ca3af;text-align:center">
|
||||
<p style="margin:0">This alert was sent by Investor Flow. You can manage your alert subscriptions in the app.</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function getUserEmail(db: DatabaseSync, userId: string): string | null {
|
||||
const row = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
|
||||
return row?.email ?? null;
|
||||
}
|
||||
|
||||
export async function sendAlertEmail(
|
||||
db: DatabaseSync,
|
||||
alert: Alert,
|
||||
): Promise<boolean> {
|
||||
const config = readSmtpConfig(db);
|
||||
if (!config || !config.enabled) return false;
|
||||
|
||||
const userEmail = getUserEmail(db, alert.userId);
|
||||
if (!userEmail) return false;
|
||||
|
||||
// Check rate limit: 1 per alert_type per symbol per hour.
|
||||
const hourAgo = new Date(Date.now() - 3600000).toISOString();
|
||||
const sent = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM alert_events
|
||||
WHERE user_id = ? AND type = ? AND symbol = ? AND created_at > ?`,
|
||||
).get(alert.userId, alert.type, alert.symbol ?? null, hourAgo) as { count: number };
|
||||
if (sent.count > 1) return false;
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.user && config.passEnc
|
||||
? { user: config.user, pass: config.passEnc }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${config.fromName}" <${config.fromEmail}>`,
|
||||
to: userEmail,
|
||||
subject: `[${alert.severity.toUpperCase()}] ${alert.title}`,
|
||||
html: buildAlertEmailHtml(alert),
|
||||
});
|
||||
|
||||
console.log(`[alert:email] sent ${alert.type} alert to ${userEmail}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[alert:email] failed to send to ${userEmail}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user