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
132 lines
4.8 KiB
TypeScript
132 lines
4.8 KiB
TypeScript
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;
|
|
}
|
|
}
|