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
+21
View File
@@ -9,11 +9,13 @@
"version": "0.1.0",
"dependencies": {
"@trpc/server": "^11.0.0",
"nodemailer": "^9.0.3",
"yahoo-finance2": "^3.15.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/nodemailer": "^8.0.1",
"typescript": "^5.0.0"
},
"engines": {
@@ -113,6 +115,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -905,6 +917,15 @@
"node": ">= 0.6"
}
},
"node_modules/nodemailer": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-url": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz",
+2
View File
@@ -16,11 +16,13 @@
},
"dependencies": {
"@trpc/server": "^11.0.0",
"nodemailer": "^9.0.3",
"yahoo-finance2": "^3.15.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/nodemailer": "^8.0.1",
"typescript": "^5.0.0"
}
}
@@ -19,8 +19,8 @@ export interface PriceAdjustment {
ratio: number;
}
type SplitEvent = { date: string; numerator: number; denominator: number; splitRatio?: string };
type DividendEvent = { date: string; amount: number };
type SplitEvent = { date: string | number; numerator: number; denominator: number; splitRatio?: string };
type DividendEvent = { date: string | number; amount: number };
/**
* Parse a yahoo-finance2 v3 chart response's `events` object into a flat list of
@@ -64,21 +64,23 @@ function emitFrom(
if (type === "split") {
const e = entry as SplitEvent;
if (!e.date) continue;
const dateStr = typeof e.date === 'string' ? e.date : String(e.date);
const numerator = typeof e.numerator === "number" ? e.numerator : 1;
const denominator = typeof e.denominator === "number" && e.denominator !== 0 ? e.denominator : 1;
out.push({
symbol,
exDate: e.date.slice(0, 10),
exDate: dateStr.slice(0, 10),
type: "split",
ratio: numerator / denominator,
});
} else {
const e = entry as DividendEvent;
if (!e.date) continue;
const dateStr = typeof e.date === 'string' ? e.date : String(e.date);
const amount = typeof e.amount === "number" ? e.amount : 0;
out.push({
symbol,
exDate: e.date.slice(0, 10),
exDate: dateStr.slice(0, 10),
type: "dividend",
ratio: amount,
});
+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;
}
@@ -0,0 +1,173 @@
import type { DatabaseSync } from 'node:sqlite';
import { randomUUID } from 'node:crypto';
export interface AlertSubscription {
id: string;
owner_id: string;
watchlist_id: string | null;
symbol: string | null;
alert_type: string;
enabled: boolean;
params: string;
last_fired: string | null;
created_at: string;
}
export interface CreateSubscriptionInput {
watchlistId?: string;
symbol?: string;
alertType: string;
params?: string;
}
function stmts(db: DatabaseSync) {
return {
insert: db.prepare(
`INSERT INTO alerts (id, owner_id, watchlist_id, symbol, alert_type, enabled, params, created_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?)`,
),
selectById: db.prepare(
`SELECT id, owner_id, watchlist_id, symbol, alert_type, enabled, params, last_fired, created_at
FROM alerts WHERE id = ? AND owner_id = ?`,
),
selectByOwner: db.prepare(
`SELECT id, owner_id, watchlist_id, symbol, alert_type, enabled, params, last_fired, created_at
FROM alerts WHERE owner_id = ?
ORDER BY created_at DESC`,
),
selectByOwnerAndSymbol: db.prepare(
`SELECT id, owner_id, watchlist_id, symbol, alert_type, enabled, params, last_fired, created_at
FROM alerts WHERE owner_id = ? AND symbol = ?
ORDER BY created_at DESC`,
),
selectTickerLevel: db.prepare(
`SELECT id, owner_id, watchlist_id, symbol, alert_type, enabled, params, last_fired, created_at
FROM alerts
WHERE owner_id = ? AND alert_type = ? AND symbol = ? AND enabled = 1
LIMIT 1`,
),
selectGlobalDefault: db.prepare(
`SELECT id, owner_id, watchlist_id, symbol, alert_type, enabled, params, last_fired, created_at
FROM alerts
WHERE owner_id = ? AND alert_type = ? AND enabled = 1
AND symbol IS NULL AND watchlist_id IS NULL
LIMIT 1`,
),
updateEnabled: db.prepare(
`UPDATE alerts SET enabled = ? WHERE id = ? AND owner_id = ?`,
),
updateParams: db.prepare(
`UPDATE alerts SET params = ? WHERE id = ? AND owner_id = ?`,
),
delete: db.prepare(
`DELETE FROM alerts WHERE id = ? AND owner_id = ?`,
),
};
}
function mapRow(row: Record<string, unknown>): AlertSubscription {
return {
id: row.id as string,
owner_id: row.owner_id as string,
watchlist_id: row.watchlist_id as string | null,
symbol: row.symbol as string | null,
alert_type: row.alert_type as string,
enabled: (row.enabled as number) === 1,
params: row.params as string,
last_fired: row.last_fired as string | null,
created_at: row.created_at as string,
};
}
export function createAlertSubscription(
db: DatabaseSync,
userId: string,
input: CreateSubscriptionInput,
): AlertSubscription {
const s = stmts(db);
const id = randomUUID();
const now = new Date().toISOString();
const params = input.params ?? '{}';
s.insert.run(id, userId, input.watchlistId ?? null, input.symbol ?? null, input.alertType, params, now);
return getAlertSubscription(db, userId, id)!;
}
export function getAlertSubscription(
db: DatabaseSync,
userId: string,
id: string,
): AlertSubscription | null {
const s = stmts(db);
const row = s.selectById.get(id, userId) as Record<string, unknown> | undefined;
if (!row) return null;
return mapRow(row);
}
export function listAlertSubscriptions(
db: DatabaseSync,
userId: string,
symbol?: string,
): AlertSubscription[] {
const s = stmts(db);
let rows: Record<string, unknown>[];
if (symbol) {
rows = s.selectByOwnerAndSymbol.all(userId, symbol) as Record<string, unknown>[];
} else {
rows = s.selectByOwner.all(userId) as Record<string, unknown>[];
}
return rows.map(mapRow);
}
export function updateAlertSubscription(
db: DatabaseSync,
userId: string,
id: string,
updates: { enabled?: boolean; params?: string },
): AlertSubscription | null {
const s = stmts(db);
if (updates.enabled !== undefined) {
s.updateEnabled.run(updates.enabled ? 1 : 0, id, userId);
}
if (updates.params !== undefined) {
s.updateParams.run(updates.params, id, userId);
}
return getAlertSubscription(db, userId, id);
}
export function deleteAlertSubscription(
db: DatabaseSync,
userId: string,
id: string,
): boolean {
const s = stmts(db);
const result = s.delete.run(id, userId);
return (result as { changes: number }).changes > 0;
}
export function getEffectiveAlertSubscription(
db: DatabaseSync,
userId: string,
symbol: string,
alertType: string,
watchlistIds: string[] = [],
): AlertSubscription | null {
const s = stmts(db);
// 1. Ticker-level wins.
const tickerRow = s.selectTickerLevel.get(userId, alertType, symbol) as Record<string, unknown> | undefined;
if (tickerRow) return mapRow(tickerRow);
// 2. Check list-level for each watchlist the symbol belongs to.
for (const wlId of watchlistIds) {
const s2 = db.prepare(
`SELECT id, owner_id, watchlist_id, symbol, alert_type, enabled, params, last_fired, created_at
FROM alerts
WHERE owner_id = ? AND alert_type = ? AND watchlist_id = ? AND symbol IS NULL AND enabled = 1
LIMIT 1`,
);
const row = s2.get(userId, alertType, wlId) as Record<string, unknown> | undefined;
if (row) return mapRow(row);
}
// 3. Global default.
const globalRow = s.selectGlobalDefault.get(userId, alertType) as Record<string, unknown> | undefined;
if (globalRow) return mapRow(globalRow);
return null;
}
+42 -2
View File
@@ -134,7 +134,12 @@ function runMigrations(db: DatabaseSync): void {
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_inst_filings ON institution_filings(filer_cik, symbol, reported_quarter, form)`);
} catch { /* ignore */ }
// 4. Data-quality lint table.
// 4. Rename legacy 'Starter' watchlist to 'default'.
try {
db.exec(`UPDATE watchlists SET name='default' WHERE name='Starter'`);
} catch { /* ignore */ }
// 5. Data-quality lint table.
try {
db.exec(`
CREATE TABLE IF NOT EXISTS data_quality (
@@ -152,7 +157,7 @@ function runMigrations(db: DatabaseSync): void {
`);
} catch { /* ignore */ }
// 5. Analyst ratings / upgrades & downgrades (yahoo-finance2).
// 6. Analyst ratings / upgrades & downgrades (yahoo-finance2).
try { db.exec(`ALTER TABLE analyst_ratings ADD COLUMN target_from REAL`); } catch { /* ignore */ }
try { db.exec(`ALTER TABLE analyst_ratings ADD COLUMN target_to REAL`); } catch { /* ignore */ }
try {
@@ -184,6 +189,41 @@ function runMigrations(db: DatabaseSync): void {
)
`);
} catch { /* ignore */ }
// 7. Alert subscriptions: add watchlist_id + enabled columns (existing DBs).
try { db.exec(`ALTER TABLE alerts ADD COLUMN watchlist_id TEXT`); } catch { /* ignore */ }
try { db.exec(`ALTER TABLE alerts ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1`); } catch { /* ignore */ }
// 8. Alert comparison state table.
try {
db.exec(`
CREATE TABLE IF NOT EXISTS alert_comparison_state (
symbol TEXT NOT NULL,
alert_type TEXT NOT NULL,
state TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL,
PRIMARY KEY (symbol, alert_type)
)
`);
} catch { /* ignore */ }
// 9. SMTP config table for email alerts.
try {
db.exec(`
CREATE TABLE IF NOT EXISTS smtp_config (
id TEXT PRIMARY KEY DEFAULT 'singleton',
host TEXT NOT NULL DEFAULT 'smtp.mail.me.com',
port INTEGER NOT NULL DEFAULT 587,
secure INTEGER NOT NULL DEFAULT 0,
user TEXT,
pass_enc TEXT,
from_name TEXT NOT NULL DEFAULT 'Investor Flow',
from_email TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
)
`);
} catch { /* ignore */ }
}
let _db: DatabaseSync | null = null;
+28 -2
View File
@@ -348,12 +348,15 @@ CREATE TABLE IF NOT EXISTS reports (
generated_at TEXT NOT NULL
);
-- ===== Alert subscriptions (user-defined rules for which alerts to receive) =====
CREATE TABLE IF NOT EXISTS alerts (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
symbol TEXT,
watchlist_id TEXT, -- NULL = global default, non-NULL = list-level
symbol TEXT, -- NULL = list-level, non-NULL = ticker-level
alert_type TEXT NOT NULL, -- informed_buy|informed_sell|new_13da|rotation_incipient|regime_shift|conviction_unlock|...
params TEXT NOT NULL, -- JSON thresholds
enabled INTEGER NOT NULL DEFAULT 1,
params TEXT NOT NULL DEFAULT '{}', -- JSON thresholds
last_fired TEXT,
created_at TEXT NOT NULL
);
@@ -566,6 +569,29 @@ CREATE INDEX IF NOT EXISTS idx_alert_events_user ON alert_events(user_id, create
CREATE INDEX IF NOT EXISTS idx_alert_events_dedup ON alert_events(dedup_key);
CREATE INDEX IF NOT EXISTS idx_alert_events_throttle ON alert_events(throttle_key);
-- ===== Alert comparison state (post-fetch change detection) =====
CREATE TABLE IF NOT EXISTS alert_comparison_state (
symbol TEXT NOT NULL,
alert_type TEXT NOT NULL,
state TEXT NOT NULL DEFAULT '{}', -- JSON snapshot
updated_at TEXT NOT NULL,
PRIMARY KEY (symbol, alert_type)
);
-- ===== SMTP config for email alerts =====
CREATE TABLE IF NOT EXISTS smtp_config (
id TEXT PRIMARY KEY DEFAULT 'singleton',
host TEXT NOT NULL DEFAULT 'smtp.mail.me.com',
port INTEGER NOT NULL DEFAULT 587,
secure INTEGER NOT NULL DEFAULT 0,
user TEXT,
pass_enc TEXT,
from_name TEXT NOT NULL DEFAULT 'Investor Flow',
from_email TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
-- ===== Slice 12 — Strategy Lab + Backtest =====
CREATE TABLE IF NOT EXISTS strategies (
id TEXT PRIMARY KEY,
+141 -92
View File
@@ -1,49 +1,31 @@
// Investor Flow — Watchlist Repository (Slice 10: watchlist-portfolio-shell-panels)
//
// Thin data-access layer over the `watchlists` table. Read/write only — no trade verbs
// per ADR-0007 (Primary-Rule: no imperative-trade-verb in any string). Notes are the
// user's own neutral text, never generated by the system.
//
// Schema (schema.sql):
// CREATE TABLE IF NOT EXISTS watchlists (
// id TEXT PRIMARY KEY,
// owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
// name TEXT NOT NULL,
// symbols TEXT NOT NULL, -- JSON array of symbol strings
// created_at TEXT NOT NULL,
// sort_order INTEGER NOT NULL DEFAULT 0
// );
import type { DatabaseSync } from 'node:sqlite';
import { randomUUID } from 'node:crypto';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A single watchlist entry returned by listSymbols. */
export interface WatchlistEntry {
symbol: string;
added_at: string;
notes?: string | null;
}
/** A full watchlist row (internal). */
export interface WatchlistMeta {
id: string;
name: string;
symbol_count: number;
sort_order: number;
created_at: string;
}
interface WatchlistRow {
id: string;
owner_id: string;
name: string;
symbols: string[];
symbols: string;
created_at: string;
sort_order: number;
}
// ---------------------------------------------------------------------------
// Prepared statements (lazy, one per method)
// ---------------------------------------------------------------------------
function stmts(db: DatabaseSync) {
return {
/** Upsert a watchlist row (idempotent by owner_id + name). */
upsert: db.prepare(
`INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order)
VALUES (?, ?, ?, ?, ?, COALESCE(?, 0))
@@ -51,56 +33,51 @@ function stmts(db: DatabaseSync) {
symbols = excluded.symbols,
sort_order = excluded.sort_order`,
),
/** Select a single watchlist by owner+name. */
selectByOwnerAndName: db.prepare(
`SELECT id, owner_id, name, symbols, created_at, sort_order
FROM watchlists WHERE owner_id = ? AND name = ?`,
),
/** Select all watchlists for a user. */
selectByOwner: db.prepare(
`SELECT id, owner_id, name, symbols, created_at, sort_order
FROM watchlists WHERE owner_id = ?
ORDER BY sort_order ASC, created_at ASC`,
),
/** Delete a watchlist by owner+name. */
deleteByOwnerAndName: db.prepare(
`DELETE FROM watchlists WHERE owner_id = ? AND name = ?`,
),
/** Update just the symbols array. */
updateSymbols: db.prepare(
`UPDATE watchlists SET symbols = ? WHERE id = ? AND owner_id = ?`,
),
selectById: db.prepare(
`SELECT id, owner_id, name, symbols, created_at, sort_order
FROM watchlists WHERE id = ? AND owner_id = ?`,
),
deleteById: db.prepare(
`DELETE FROM watchlists WHERE id = ? AND owner_id = ?`,
),
updateSortOrder: db.prepare(
`UPDATE watchlists SET sort_order = ? WHERE id = ? AND owner_id = ?`,
),
selectAllByOwner: db.prepare(
`SELECT id, owner_id, name, symbols, created_at, sort_order
FROM watchlists WHERE owner_id = ?
ORDER BY sort_order ASC, created_at ASC`,
),
};
}
// ---------------------------------------------------------------------------
// Repository — public API (all methods parameterized, no string interpolation)
// ---------------------------------------------------------------------------
/**
* Add a symbol to the user's default watchlist. Idempotent — adding an already-
* present symbol is a no-op. Returns true if the symbol was newly added.
*
* Per ADR-0007, notes are the user's own neutral text; the system never generates
* directional/trade-verb language.
*/
export function addSymbol(
db: DatabaseSync,
userId: string,
symbol: string,
notes?: string,
watchlistName: string = 'default',
): boolean {
const s = stmts(db);
const upper = symbol.toUpperCase();
// Read existing default watchlist — get raw symbols preserving any existing notes.
const existing = readDefaultWatchlistRaw(db, userId);
const existing = readWatchlistRaw(db, userId, watchlistName);
// Check if symbol already exists (as plain string or inside an object).
if (existing) {
const alreadyExists = existing.symbols.some((sym) => {
if (typeof sym === 'string') return sym === upper;
@@ -108,7 +85,6 @@ export function addSymbol(
});
if (alreadyExists) return false;
// Append the new symbol, with notes if provided.
if (notes) {
existing.symbols.push({ symbol: upper, notes });
} else {
@@ -116,60 +92,54 @@ export function addSymbol(
}
const now = new Date().toISOString();
s.upsert.run(existing.id, userId, 'default', JSON.stringify(existing.symbols), now, 0);
s.upsert.run(existing.id, userId, watchlistName, JSON.stringify(existing.symbols), now, 0);
return true;
}
// No existing watchlist — create a new one.
const serialized = notes ? [{ symbol: upper, notes }] : [upper];
const id = generateId();
const now = new Date().toISOString();
s.upsert.run(id, userId, 'default', JSON.stringify(serialized), now, 0);
s.upsert.run(id, userId, watchlistName, JSON.stringify(serialized), now, 0);
return true;
}
/** Remove a symbol from the user's default watchlist. Returns true if removed. */
export function removeSymbol(
db: DatabaseSync,
userId: string,
symbol: string,
watchlistName: string = 'default',
): boolean {
const s = stmts(db);
const upper = symbol.toUpperCase();
const existing = readDefaultWatchlistRaw(db, userId);
const existing = readWatchlistRaw(db, userId, watchlistName);
if (!existing) return false;
const before = existing.symbols.length;
// Filter by symbol value (whether stored as string or {symbol, notes} object).
const remaining = existing.symbols.filter((sym) => {
const symStr = typeof sym === 'string' ? sym : sym.symbol;
return symStr !== upper;
});
if (remaining.length === before) {
return false; // symbol not found
return false;
}
if (remaining.length === 0) {
// Clean up empty watchlist.
s.deleteByOwnerAndName.run(userId, 'default');
s.deleteByOwnerAndName.run(userId, watchlistName);
return true;
}
// Single JSON.stringify — preserves existing notes on remaining symbols.
s.updateSymbols.run(JSON.stringify(remaining), existing.id, userId);
return true;
}
/** List all symbols across all watchlists for a user. */
export function listSymbols(
db: DatabaseSync,
userId: string,
): WatchlistEntry[] {
const s = stmts(db);
const rows = s.selectByOwner.all(userId) as unknown as Array<{ symbols: string }>;
const rows = s.selectByOwner.all(userId) as unknown as WatchlistRow[];
const entries: WatchlistEntry[] = [];
@@ -192,11 +162,84 @@ export function listSymbols(
return entries;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function listWatchlists(db: DatabaseSync, userId: string): WatchlistMeta[] {
const s = stmts(db);
const rows = s.selectByOwner.all(userId) as unknown as WatchlistRow[];
return rows.map((row) => {
const parsed = safeParseSymbols(row.symbols);
return {
id: row.id,
name: row.name,
symbol_count: parsed.length,
sort_order: row.sort_order,
created_at: row.created_at,
};
});
}
export function createWatchlist(
db: DatabaseSync,
userId: string,
name: string,
symbols?: string[],
): WatchlistMeta {
const s = stmts(db);
const id = generateId();
const now = new Date().toISOString();
const serialized = JSON.stringify(symbols ?? []);
s.upsert.run(id, userId, name, serialized, now, 0);
return { id, name, symbol_count: (symbols ?? []).length, sort_order: 0, created_at: now };
}
export function deleteWatchlist(db: DatabaseSync, userId: string, name: string): boolean {
const s = stmts(db);
const existing = readWatchlistRaw(db, userId, name);
if (!existing) return false;
s.deleteByOwnerAndName.run(userId, name);
return true;
}
export function renameWatchlist(
db: DatabaseSync,
userId: string,
oldName: string,
newName: string,
): boolean {
const s = stmts(db);
const existing = readWatchlistRaw(db, userId, oldName);
if (!existing) return false;
const conflict = readWatchlistRaw(db, userId, newName);
if (conflict) return false;
const now = new Date().toISOString();
s.upsert.run(existing.id, userId, newName, JSON.stringify(existing.symbols), now, existing.sort_order);
s.deleteByOwnerAndName.run(userId, oldName);
return true;
}
export function reorderWatchlists(
db: DatabaseSync,
userId: string,
orders: { id: string; sort_order: number }[],
): void {
const s = stmts(db);
for (const { id, sort_order } of orders) {
s.updateSortOrder.run(sort_order, id, userId);
}
}
export function getSymbolsInWatchlist(
db: DatabaseSync,
userId: string,
watchlistName: string = 'default',
): string[] {
const existing = readWatchlistRaw(db, userId, watchlistName);
if (!existing) return [];
return existing.symbols.map((sym) => {
if (typeof sym === 'string') return sym;
return sym.symbol;
});
}
/** Safely parse the JSON TEXT column into an array of strings or objects. */
function safeParseSymbols(value: string | null | undefined): (string | Record<string, unknown>)[] {
if (!value) return [];
try {
@@ -210,37 +253,43 @@ function safeParseSymbols(value: string | null | undefined): (string | Record<st
return [];
}
/** Read the default watchlist for a user. Returns null if not found. */
function readDefaultWatchlist(db: DatabaseSync, userId: string): WatchlistRow | null {
const rows = stmts(db).selectByOwnerAndName.all(userId, 'default') as unknown as WatchlistRow[];
if (rows.length === 0) return null;
const row = rows[0];
return {
...row,
// Extract plain symbol strings from the mixed array (handles both legacy strings and {symbol,notes} objects).
symbols: safeParseSymbols(String(row.symbols)).map((s) => {
if (typeof s === 'string') return s;
if (s && typeof s === 'object' && 'symbol' in s) return (s as { symbol: string }).symbol;
return '';
}).filter((s): s is string => s.length > 0),
};
}
/** Read the default watchlist raw symbols (preserving {symbol, notes} objects). */
function readDefaultWatchlistRaw(
export function listSymbolsByWatchlist(
db: DatabaseSync,
userId: string,
): { id: string; symbols: Array<string | { symbol: string; notes?: string }> } | null {
const rows = stmts(db).selectByOwnerAndName.all(userId, 'default') as unknown as WatchlistRow[];
watchlistName: string,
): WatchlistEntry[] {
const raw = readWatchlistRaw(db, userId, watchlistName);
if (!raw) return [];
const entries: WatchlistEntry[] = [];
for (const item of raw.symbols) {
if (typeof item === 'string') {
entries.push({ symbol: item, added_at: '' });
} else if (typeof item === 'object' && item !== null) {
const obj = item as { symbol?: string; notes?: string };
entries.push({
symbol: (obj.symbol ?? '').toUpperCase(),
notes: obj.notes ?? null,
added_at: '',
});
}
}
return entries;
}
function readWatchlistRaw(
db: DatabaseSync,
userId: string,
name: string,
): { id: string; symbols: Array<string | { symbol: string; notes?: string }>; sort_order: number } | null {
const rows = stmts(db).selectByOwnerAndName.all(userId, name) as unknown as WatchlistRow[];
if (rows.length === 0) return null;
const row = rows[0];
const rawSymbols = safeParseSymbols(String(row.symbols));
return { id: row.id, symbols: rawSymbols as Array<string | { symbol: string; notes?: string }> };
return { id: row.id, symbols: rawSymbols as Array<string | { symbol: string; notes?: string }>, sort_order: row.sort_order };
}
/** Generate a simple unique id. */
function generateId(): string {
return `wl_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
return randomUUID();
}
+40
View File
@@ -66,6 +66,46 @@ const SCHEDULE_MS = 30_000;
const scheduleTimer = setInterval(() => { queue.enqueueDueSchedules().catch((e) => console.error('[schedule error]', e)); }, SCHEDULE_MS);
scheduleTimer.unref();
// Per-fetch alert check: run alongside the schedule cycle for critical alert types.
const perFetchAlertTimer = setInterval(async () => {
try {
const alerts = await runProducers(database, 'per-fetch');
if (alerts.length > 0) {
console.log(`[alert] ${alerts.length} per-fetch alert(s) created`);
const { sendAlertEmail } = await import('./services/emailAlertService.ts');
alerts.forEach((a) => sendAlertEmail(database, a).catch(() => {}));
}
} catch (e) {
console.error('[alert] per-fetch check failed:', e);
}
}, SCHEDULE_MS);
perFetchAlertTimer.unref();
// Register alert producers.
import { registerProducer, runProducers } from './alerts/producers/index.ts';
import { informedBuyProducer, informedSellProducer } from './alerts/producers/insiderProducer.ts';
import { new13daProducer } from './alerts/producers/new13daProducer.ts';
registerProducer(informedBuyProducer);
registerProducer(informedSellProducer);
registerProducer(new13daProducer);
// Batched alert check: run every 10 minutes for non-critical producers.
const ALERT_BATCH_MS = 10 * 60 * 1000;
const alertBatchTimer = setInterval(async () => {
try {
const alerts = await runProducers(database, 'batched');
if (alerts.length > 0) console.log(`[alert] ${alerts.length} batch alert(s) created`);
// Send email for each new alert.
const { sendAlertEmail } = await import('./services/emailAlertService.ts');
for (const alert of alerts) {
sendAlertEmail(database, alert).catch((e) => console.error('[alert:email] send error:', e));
}
} catch (e) {
console.error('[alert] batch check failed:', e);
}
}, ALERT_BATCH_MS);
alertBatchTimer.unref();
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
+1 -1
View File
@@ -10,7 +10,7 @@ import type { SourceKind } from '../cache/CacheRepository.ts';
/** Steady-state min gap between successful fetches for a source (ms). */
export const DEFAULT_SOURCE_MIN_INTERVAL_MS: Record<SourceKind, number> = {
yfinance: 1500,
yfinance: 2000,
sec: 150,
'sec-fetch': 1200,
reddit: 2000,
@@ -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;
}
}
+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 };
}),
});
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from "react";
import { AdminLayout } from "@/components/AdminLayout";
import { api } from "@/lib/trpc";
export default function SmtpAdminPage() {
const [config, setConfig] = useState<{ host: string; port: number; secure: boolean; user: string | null; passEnc: string | null; fromName: string; fromEmail: string; enabled: boolean } | null>(null);
const [host, setHost] = useState("smtp.mail.me.com");
const [port, setPort] = useState(587);
const [secure, setSecure] = useState(false);
const [user, setUser] = useState("");
const [pass, setPass] = useState("");
const [fromName, setFromName] = useState("Investor Flow");
const [fromEmail, setFromEmail] = useState("");
const [enabled, setEnabled] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
useEffect(() => {
api.admin.smtpConfig().then((c) => {
setConfig(c as any);
setHost((c as any).host ?? "smtp.mail.me.com");
setPort((c as any).port ?? 587);
setSecure((c as any).secure ?? false);
setUser((c as any).user ?? "");
setFromName((c as any).fromName ?? "Investor Flow");
setFromEmail((c as any).fromEmail ?? "");
setEnabled((c as any).enabled ?? false);
}).catch(() => {});
}, []);
const save = async () => {
setSaving(true);
setMsg(null);
try {
await api.admin.smtpConfigUpdate({ host, port, secure, user: user || null, pass: pass || undefined, fromName, fromEmail, enabled });
setMsg({ type: "success", text: "SMTP config saved." });
} catch (e) {
setMsg({ type: "error", text: e instanceof Error ? e.message : "Failed to save." });
}
setSaving(false);
};
const test = async () => {
setTesting(true);
setMsg(null);
try {
await api.admin.smtpConfigTest();
setMsg({ type: "success", text: "Test email sent! Check your inbox." });
} catch (e) {
setMsg({ type: "error", text: e instanceof Error ? e.message : "Test failed." });
}
setTesting(false);
};
return (
<AdminLayout>
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">SMTP Configuration</h1>
{msg && (
<div className={`mb-4 rounded-lg border px-4 py-3 text-sm ${msg.type === "success" ? "border-green-500/30 bg-green-950/20 text-up" : "border-red-500/30 bg-red-950/20 text-danger"}`}>
{msg.text}
</div>
)}
<div className="space-y-4">
<label className="block">
<span className="text-sm text-fg-faint">Host</span>
<input className="mt-1 w-full rounded-lg border border-border bg-surface-dark px-3 py-2 text-fg" value={host} onChange={(e) => setHost(e.target.value)} />
</label>
<label className="block">
<span className="text-sm text-fg-faint">Port</span>
<input className="mt-1 w-full rounded-lg border border-border bg-surface-dark px-3 py-2 text-fg" type="number" value={port} onChange={(e) => setPort(Number(e.target.value))} />
</label>
<label className="flex items-center gap-2">
<input type="checkbox" checked={secure} onChange={(e) => setSecure(e.target.checked)} className="rounded border-border bg-surface-dark" />
<span className="text-sm text-fg-faint">Use TLS/SSL (secure)</span>
</label>
<label className="block">
<span className="text-sm text-fg-faint">Username</span>
<input className="mt-1 w-full rounded-lg border border-border bg-surface-dark px-3 py-2 text-fg" value={user} onChange={(e) => setUser(e.target.value)} />
</label>
<label className="block">
<span className="text-sm text-fg-faint">Password</span>
<input className="mt-1 w-full rounded-lg border border-border bg-surface-dark px-3 py-2 text-fg" type="password" value={pass} onChange={(e) => setPass(e.target.value)} placeholder="Leave blank to keep existing" />
</label>
<label className="block">
<span className="text-sm text-fg-faint">From Name</span>
<input className="mt-1 w-full rounded-lg border border-border bg-surface-dark px-3 py-2 text-fg" value={fromName} onChange={(e) => setFromName(e.target.value)} />
</label>
<label className="block">
<span className="text-sm text-fg-faint">From Email</span>
<input className="mt-1 w-full rounded-lg border border-border bg-surface-dark px-3 py-2 text-fg" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} />
</label>
<label className="flex items-center gap-2">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="rounded border-border bg-surface-dark" />
<span className="text-sm text-fg-faint">Enable email alerts</span>
</label>
<div className="flex gap-3 pt-4">
<button onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/80 disabled:opacity-50">
{saving ? "Saving..." : "Save"}
</button>
<button onClick={test} disabled={testing || !enabled} className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-fg hover:bg-surface-dark disabled:opacity-50">
{testing ? "Sending..." : "Send Test Email"}
</button>
</div>
</div>
</div>
</AdminLayout>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useEffect, useState } from "react";
import { api, type AlertEventRow, type AlertSubscriptionRow } from "@/lib/trpc";
const SEVERITY_COLORS: Record<string, string> = {
info: "text-blue-400 bg-blue-950/30 border-blue-500/30",
warning: "text-amber-400 bg-amber-950/30 border-amber-500/30",
critical: "text-red-400 bg-red-950/30 border-red-500/30",
};
export default function AlertsPage() {
const [tab, setTab] = useState<"events" | "subscriptions">("events");
const [events, setEvents] = useState<AlertEventRow[]>([]);
const [subscriptions, setSubscriptions] = useState<AlertSubscriptionRow[]>([]);
const [alertType, setAlertType] = useState("informed_buy");
const [symbol, setSymbol] = useState("");
const [loading, setLoading] = useState(true);
const loadEvents = async () => {
try {
const data = await api.alerts.list(100);
setEvents(data);
} catch { /* ignore */ }
};
const loadSubscriptions = async () => {
try {
const data = await api.alerts.listSubscriptions();
setSubscriptions(data);
} catch { /* ignore */ }
};
useEffect(() => {
Promise.all([loadEvents(), loadSubscriptions()]).finally(() => setLoading(false));
}, []);
const acknowledge = async (id: string) => {
await api.alerts.acknowledge(id);
loadEvents();
};
const acknowledgeAll = async () => {
await api.alerts.acknowledgeAll();
loadEvents();
};
const addSubscription = async () => {
const sym = symbol.trim().toUpperCase();
await api.alerts.createSubscription({ alertType, symbol: sym || undefined });
setSymbol("");
loadSubscriptions();
};
const toggleSubscription = async (id: string, enabled: boolean) => {
await api.alerts.updateSubscription(id, { enabled: !enabled });
loadSubscriptions();
};
const deleteSubscription = async (id: string) => {
await api.alerts.deleteSubscription(id);
loadSubscriptions();
};
const unacknowledged = events.filter((e) => !e.acknowledged);
return (
<div className="min-h-screen bg-surface text-fg p-6">
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-4">Alerts</h1>
<div className="flex gap-1 mb-6 border-b border-border">
<button onClick={() => setTab("events")} className={`px-4 py-2 text-sm font-medium ${tab === "events" ? "border-b-2 border-accent text-fg" : "text-fg-faint hover:text-fg"}`}>
Events {unacknowledged.length > 0 && <span className="ml-1.5 rounded-full bg-accent px-1.5 py-0.5 text-xs text-white">{unacknowledged.length}</span>}
</button>
<button onClick={() => setTab("subscriptions")} className={`px-4 py-2 text-sm font-medium ${tab === "subscriptions" ? "border-b-2 border-accent text-fg" : "text-fg-faint hover:text-fg"}`}>
Subscriptions
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent border-t-transparent" />
</div>
) : tab === "events" ? (
<div>
{unacknowledged.length > 0 && (
<button onClick={acknowledgeAll} className="mb-4 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-fg-faint hover:text-fg">
Acknowledge all ({unacknowledged.length})
</button>
)}
{events.length === 0 ? (
<p className="text-fg-faint text-sm">No alerts yet.</p>
) : (
<div className="space-y-2">
{events.map((e) => (
<div key={e.id} className={`rounded-lg border p-4 ${e.acknowledged ? "border-border opacity-50" : "border-border"}`}>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`rounded border px-1.5 py-0.5 text-xs font-medium ${SEVERITY_COLORS[e.severity] ?? ""}`}>{e.severity}</span>
<span className="text-sm font-medium text-fg">{e.title}</span>
{e.symbol && <span className="rounded bg-surface-dark px-1.5 py-0.5 text-xs text-fg-faint">{e.symbol}</span>}
</div>
<p className="text-sm text-fg-faint whitespace-pre-wrap">{e.description}</p>
<p className="mt-1 text-xs text-fg-faint/60">{new Date(e.createdAt).toLocaleString()}</p>
</div>
{!e.acknowledged && (
<button onClick={() => acknowledge(e.id)} className="shrink-0 rounded border border-border px-2 py-1 text-xs text-fg-faint hover:text-fg">
Dismiss
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
) : (
<div>
<div className="mb-6 rounded-lg border border-border p-4">
<h3 className="text-sm font-medium mb-3">New Subscription</h3>
<div className="flex flex-wrap gap-3">
<select value={alertType} onChange={(e) => setAlertType(e.target.value)} className="rounded-lg border border-border bg-surface-dark px-3 py-2 text-sm text-fg">
<option value="informed_buy">Insider Buy</option>
<option value="informed_sell">Insider Sell</option>
<option value="new_13da">New 13D/G Filing</option>
<option value="rotation_incipient">Rotation Signal</option>
<option value="regime_shift">Regime Shift</option>
<option value="conviction_unlock">Conviction Unlock</option>
<option value="thesis_broken">Thesis Broken</option>
<option value="thesis_weakening">Thesis Weakening</option>
<option value="cluster_breach">Cluster Breach</option>
<option value="drawdown_halt">Drawdown Halt</option>
<option value="asymmetry_warning">Asymmetry Warning</option>
</select>
<input value={symbol} onChange={(e) => setSymbol(e.target.value)} placeholder="Symbol (optional)" className="rounded-lg border border-border bg-surface-dark px-3 py-2 text-sm text-fg w-32" />
<button onClick={addSubscription} disabled={!alertType} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/80 disabled:opacity-50">
Add
</button>
</div>
</div>
{subscriptions.length === 0 ? (
<p className="text-fg-faint text-sm">No subscriptions configured.</p>
) : (
<div className="space-y-2">
{subscriptions.map((s) => (
<div key={s.id} className="flex items-center justify-between rounded-lg border border-border p-3">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-fg">{s.alert_type.replace(/_/g, " ")}</span>
{s.symbol && <span className="rounded bg-surface-dark px-1.5 py-0.5 text-xs text-fg-faint">{s.symbol}</span>}
{!s.symbol && !s.watchlist_id && <span className="text-xs text-fg-faint">global</span>}
</div>
<div className="flex items-center gap-2">
<label className="relative inline-flex cursor-pointer items-center">
<input type="checkbox" checked={s.enabled} onChange={() => toggleSubscription(s.id, s.enabled)} className="peer sr-only" />
<div className="h-5 w-9 rounded-full bg-surface-dark after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-fg-faint after:transition-all peer-checked:bg-accent peer-checked:after:translate-x-full peer-checked:after:bg-white" />
</label>
<button onClick={() => deleteSubscription(s.id)} className="rounded border border-border px-2 py-1 text-xs text-danger hover:bg-red-950/20">
Remove
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
+1
View File
@@ -18,6 +18,7 @@ const TABS: TabItem[] = [
{ label: "Overview", href: "/", icon: "📊" },
{ label: "Filings", href: "/filings", icon: "📄" },
{ label: "Options", href: "/options-dd", icon: "🔮" },
{ label: "Alerts", href: "/alerts", icon: "🔔" },
{ label: "Execution", href: "/execution", icon: "⚡" },
{ label: "Trade Plan", href: "/trade-plan", icon: "📋" },
{ label: "Settings", href: "/settings", icon: "⚙️" },
+16 -1
View File
@@ -10,8 +10,10 @@ import {
Settings,
ChevronDown,
Search,
Bell,
type LucideIcon,
} from "lucide-react";
import { api } from "@/lib/trpc";
/**
* Left sidebar navigation with collapsible sections.
@@ -58,6 +60,7 @@ const SECTIONS: NavSection[] = [
{ label: "Risk Posture", href: "/risk" },
{ label: "Filings", href: "/filings" },
{ label: "Options DD", href: "/options-dd" },
{ label: "Alerts", href: "/alerts" },
],
},
{
@@ -69,6 +72,7 @@ const SECTIONS: NavSection[] = [
{ label: "Queue", href: "/admin/queue" },
{ label: "Audit Logs", href: "/admin/audit-logs" },
{ label: "X Accounts", href: "/admin/x-accounts" },
{ label: "SMTP", href: "/admin/smtp" },
],
},
{
@@ -101,6 +105,11 @@ export function SidebarNav() {
const normalizedPath = (pathname || "/").replace(/\/+$/, "") || "/";
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [initialized, setInitialized] = useState(false);
const [unackedCount, setUnackedCount] = useState(0);
useEffect(() => {
api.alerts.unackedCount().then((r) => setUnackedCount(r.count)).catch(() => {});
}, []);
useEffect(() => {
setCollapsed(getInitialCollapsed(pathname));
@@ -146,6 +155,7 @@ export function SidebarNav() {
<div className="mt-0.5 space-y-0.5">
{section.items.map((item) => {
const isActive = normalizedPath === item.href;
const showBadge = item.label === "Alerts" && unackedCount > 0;
return (
<Link
key={item.label}
@@ -156,7 +166,12 @@ export function SidebarNav() {
: "text-fg-muted hover:text-fg hover:bg-surface-overlay border-transparent"
}`}
>
{item.label}
<span className="flex items-center justify-between">
<span>{item.label}</span>
{showBadge && (
<span className="rounded-full bg-accent px-1.5 py-0.5 text-2xs text-white">{unackedCount}</span>
)}
</span>
</Link>
);
})}
+114 -16
View File
@@ -1,19 +1,22 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useActiveSymbol } from "@/stores/active-symbol-store";
import { api, type WatchlistEntry, type Quote, type PortfolioHolding } from "@/lib/trpc";
import { chart as CHART } from "@/lib/chart-theme";
import { UI_STRINGS } from "@/lib/strings";
/**
* Compact watchlist sidebar with mini sparklines and quick symbol switching.
* Shows portfolio summary at top. Replaces the full WatchlistPanel on the main page.
*/
interface MiniQuote extends Quote {
sparkline?: number[];
}
interface WatchlistMeta {
id: string;
name: string;
symbol_count: number;
sort_order: number;
created_at: string;
}
interface WatchlistSidebarProps {
compact?: boolean;
}
@@ -26,6 +29,11 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
const [draft, setDraft] = useState("");
const [loading, setLoading] = useState(true);
const [holdings, setHoldings] = useState<PortfolioHolding[]>([]);
const [watchlists, setWatchlists] = useState<WatchlistMeta[]>([]);
const [activeWatchlist, setActiveWatchlist] = useState("default");
const [showListPicker, setShowListPicker] = useState(false);
const [newListName, setNewListName] = useState("");
const pickerRef = useRef<HTMLDivElement>(null);
// Portfolio summary
useEffect(() => {
@@ -34,14 +42,30 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
const totalValue = (holdings ?? []).reduce((sum, h) => sum + h.shares * h.avg_cost, 0);
const loadWatchlists = async () => {
try {
const wls = await api.watchlists.listWatchlists();
setWatchlists(wls);
if (wls.length > 0 && !wls.find((w) => w.name === activeWatchlist)) {
setActiveWatchlist(wls[0].name);
}
} catch { /* ignore */ }
};
const load = async () => {
try {
setEntries(await api.watchlists.list());
setEntries(await api.watchlists.listByWatchlist(activeWatchlist));
} catch { /* ignore */ }
setLoading(false);
};
useEffect(() => { load(); }, []);
useEffect(() => {
loadWatchlists();
}, []);
useEffect(() => {
load();
}, [activeWatchlist]);
// Fetch quotes for watchlist symbols
useEffect(() => {
@@ -64,16 +88,48 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
const add = async () => {
if (!draft.trim()) return;
const sym = draft.trim().toUpperCase();
await api.watchlists.addSymbol(sym);
await api.watchlists.addSymbol(sym, activeWatchlist === "default" ? undefined : activeWatchlist);
setDraft("");
load();
loadWatchlists();
};
const remove = async (symbol: string) => {
await api.watchlists.removeSymbol(symbol);
await api.watchlists.removeSymbol(symbol, activeWatchlist === "default" ? undefined : activeWatchlist);
load();
loadWatchlists();
};
const createWatchlist = async () => {
if (!newListName.trim()) return;
await api.watchlists.create(newListName.trim());
setNewListName("");
setActiveWatchlist(newListName.trim());
setShowListPicker(false);
loadWatchlists();
};
const deleteWatchlist = async (name: string) => {
await api.watchlists.delete(name);
loadWatchlists();
if (activeWatchlist === name) {
setActiveWatchlist("default");
}
};
// Close picker on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowListPicker(false);
}
};
if (showListPicker) {
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}
}, [showListPicker]);
const containerClass = compact
? "w-full border-b border-line bg-surface-raised flex-shrink-0"
: "w-56 border-r border-line bg-surface-raised flex-shrink-0 hidden lg:block";
@@ -83,7 +139,7 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
return (
<aside className={containerClass}>
<div className={contentClass}>
{/* Portfolio Summary — compact mode shows minimal */}
{/* Watchlist selector + Portfolio summary */}
{compact ? (
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] uppercase tracking-wider text-fg-muted">Portfolio</span>
@@ -95,7 +151,6 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
<p className="text-[10px] uppercase tracking-wider text-fg-muted mb-1">Portfolio</p>
<p className="text-sm font-bold text-fg">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</p>
<div className="flex gap-2 mt-1">
<span className="text-[10px] text-fg-muted">{entries.length} watchlist</span>
<span className="text-[10px] text-fg-muted">{holdings?.length ?? 0} holdings</span>
</div>
<a
@@ -107,7 +162,53 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
</div>
)}
{/* Watchlist symbols only — not portfolio positions */}
{/* Watchlist selector dropdown */}
{!compact && (
<div className="relative" ref={pickerRef}>
<button
onClick={() => setShowListPicker(!showListPicker)}
className="w-full flex items-center gap-2 rounded-lg border border-line bg-surface px-2 py-1.5 text-xs text-fg hover:bg-surface-sunken"
>
<span className="flex-1 text-left truncate">{activeWatchlist}</span>
<span className="text-fg-faint">{watchlists.length > 0 ? watchlists.find((w) => w.name === activeWatchlist)?.symbol_count ?? entries.length : entries.length}</span>
<svg className="h-3 w-3 text-fg-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
{showListPicker && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border border-line bg-surface-raised shadow-xl overflow-hidden">
<div className="max-h-48 overflow-y-auto">
{watchlists.map((wl) => (
<div key={wl.id} className="flex items-center gap-1 px-2 py-1.5 hover:bg-surface-sunken cursor-pointer text-xs" onClick={() => { setActiveWatchlist(wl.name); setShowListPicker(false); }}>
<span className={`flex-1 truncate ${wl.name === activeWatchlist ? "text-accent font-medium" : "text-fg"}`}>{wl.name}</span>
<span className="text-fg-faint">{wl.symbol_count}</span>
{wl.name !== "default" && (
<button
onClick={(e) => { e.stopPropagation(); deleteWatchlist(wl.name); }}
className="text-fg-muted hover:text-danger ml-1"
>
✕
</button>
)}
</div>
))}
</div>
<div className="border-t border-line p-2">
<form onSubmit={(e) => { e.preventDefault(); createWatchlist(); }} className="flex gap-1">
<input
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
placeholder="New list..."
className="flex-1 bg-surface border border-line rounded px-1.5 py-1 text-[10px] text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent"
/>
<button type="submit" className="px-1.5 py-1 rounded text-[10px] bg-accent text-white">+</button>
</form>
</div>
</div>
)}
</div>
)}
{/* Add symbol form */}
{!compact && (
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-1">
<label className="sr-only" htmlFor="watchlist-add-symbol">Add watchlist symbol</label>
@@ -157,11 +258,9 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
: "hover:bg-surface-sunken"
}`}
>
{/* Symbol — fixed width for alignment */}
<span className={`font-mono font-bold whitespace-nowrap w-12 text-left ${isActive ? "text-accent" : "text-fg"}`}>
{e.symbol}
</span>
{/* Mini sparkline */}
{quote && quote.sparkline && quote.sparkline.length > 1 && (
<span className="flex items-center gap-0.5 flex-shrink-0" aria-hidden="true">
<svg width="20" height="12" className="flex-shrink-0">
@@ -177,7 +276,6 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
</span>
</span>
)}
{/* Change percent — fixed width for alignment */}
{quote ? (
<span className="flex items-center gap-0.5 font-mono text-[9px] whitespace-nowrap w-14 justify-end">
<span className={up ? "text-up" : "text-warn"} aria-hidden="true">{up ? "▲" : "▼"}</span>
+56 -2
View File
@@ -103,6 +103,40 @@ export interface AlertEventRow {
payload: Record<string, unknown>;
}
export interface AlertSubscriptionRow {
id: string;
owner_id: string;
watchlist_id: string | null;
symbol: string | null;
alert_type: string;
enabled: boolean;
params: string;
last_fired: string | null;
created_at: string;
}
export interface SmtpConfigRow {
host: string;
port: number;
secure: boolean;
user: string | null;
passEnc: string | null;
fromName: string;
fromEmail: string;
enabled: boolean;
}
export interface SmtpConfigUpdate {
host?: string;
port?: number;
secure?: boolean;
user?: string | null;
pass?: string | null;
fromName?: string;
fromEmail?: string;
enabled?: boolean;
}
export interface RotationData {
sectors: SectorRotationItem[];
signal: {
@@ -505,8 +539,16 @@ export const api = {
},
watchlists: {
list: () => trpcQuery<WatchlistEntry[]>("watchlists.list"),
addSymbol: (symbol: string) => trpcMutate<{ added: boolean }>("watchlists.addSymbol", { symbol }),
removeSymbol: (symbol: string) => trpcMutate<{ removed: boolean }>("watchlists.removeSymbol", { symbol }),
listByWatchlist: (name: string) => trpcQuery<WatchlistEntry[]>("watchlists.listByWatchlist", { name }),
listWatchlists: () => trpcQuery<Array<{ id: string; name: string; symbol_count: number; sort_order: number; created_at: string }>>("watchlists.listWatchlists"),
create: (name: string) => trpcMutate<{ id: string; name: string; symbol_count: number; sort_order: number; created_at: string }>("watchlists.create", { name }),
delete: (name: string) => trpcMutate<{ deleted: boolean }>("watchlists.delete", { name }),
rename: (oldName: string, newName: string) => trpcMutate<{ renamed: boolean }>("watchlists.rename", { oldName, newName }),
reorder: (orders: { id: string; sort_order: number }[]) => trpcMutate<{ ok: boolean }>("watchlists.reorder", { orders }),
addSymbol: (symbol: string, watchlistName?: string, notes?: string) =>
trpcMutate<{ added: boolean }>("watchlists.addSymbol", { symbol, ...(watchlistName ? { watchlistName } : {}), ...(notes ? { notes } : {}) }),
removeSymbol: (symbol: string, watchlistName?: string) =>
trpcMutate<{ removed: boolean }>("watchlists.removeSymbol", { symbol, ...(watchlistName ? { watchlistName } : {}) }),
},
options: {
chain: (symbol: string, opts?: { expiry?: string }) =>
@@ -581,6 +623,14 @@ export const api = {
acknowledge: (alertId: string) => trpcMutate<{ ok: boolean }>("alerts.acknowledge", { alertId }),
unackedCount: () => trpcQuery<{ count: number }>("alerts.unackedCount"),
acknowledgeAll: () => trpcMutate<{ ok: boolean }>("alerts.acknowledgeAll", {}),
createSubscription: (input: { alertType: string; watchlistId?: string; symbol?: string; params?: string }) =>
trpcMutate<AlertSubscriptionRow>("alerts.createSubscription", input),
listSubscriptions: (symbol?: string) =>
trpcQuery<AlertSubscriptionRow[]>("alerts.listSubscriptions", symbol ? { symbol } : {}),
updateSubscription: (id: string, updates: { enabled?: boolean; params?: string }) =>
trpcMutate<AlertSubscriptionRow>("alerts.updateSubscription", { id, ...updates }),
deleteSubscription: (id: string) =>
trpcMutate<{ ok: boolean }>("alerts.deleteSubscription", { id }),
},
x: {
cashtag_search: (symbol: string, limit = 30) => trpcQuery<{ posts: XPost[]; configured: boolean }>("x.cashtag_search", { symbol, limit }),
@@ -625,6 +675,10 @@ export const api = {
trpcQuery<DataQualityRow[]>("admin.dataQualityList", input ?? {}),
serverRestart: (target: 'backend' | 'frontend' | 'all' = 'all') =>
trpcMutate<{ ok: boolean; backend: string; frontend: string }>("admin.serverRestart", { target }),
smtpConfig: () => trpcQuery<SmtpConfigRow>("admin.smtpConfig"),
smtpConfigUpdate: (input: SmtpConfigUpdate) =>
trpcMutate<{ ok: boolean }>("admin.smtpConfigUpdate", input),
smtpConfigTest: () => trpcMutate<{ ok: boolean }>("admin.smtpConfigTest", {}),
},
};
+10 -5
View File
@@ -1,6 +1,6 @@
# Investor Flow — Functional Design (as built)
**Audit date:** 2026-07-18
**Audit date:** 2026-07-23
**Source of truth for status:** this file + code under `app/`. Older slice SPECs, HANDOFF.md, and Automaton WORKFLOW.md are historical.
**Product intent:** Beginner-first multi-tenant educational research terminal for conviction investing (not day trading). Legal posture: educational publisher (ADR-0007).
@@ -37,6 +37,7 @@
| Surface | Route | Status | Data path |
|---------|-------|--------|-----------|
| Symbol overview | `/` | **Live** | `market.snapshot`, company meta, X feed, collapsible filings + options |
| Multiple watchlists | sidebar | **Live** | Create/delete/rename/reorder, dropdown selector in sidebar, per-watchlist symbols |
| Charts | `/chart-lab` | **Live** | candles, indicators, institutional buy markers |
| Institutional dashboard | `/institutional` | **Live** | dashboard rollup, flow, insider stream, filings; Q-o-Q / M-o-M |
| Filings | `/filings` | **Live** | EDGAR index + 13F + Form 4 detail |
@@ -94,9 +95,12 @@
| Capability | Status | Notes |
|------------|--------|-------|
| Alert list / ack / unacked count | **Live** | Used on mobile + rotation alert chip |
| Rotation signal → alert create | **Live** | `market.rotationCheckForAlert` |
| Full hybrid AlertEngine (Form4, thesis, cluster, halt…) | **Partial** | Engine types/tests exist; not all event producers wired continuously |
| Alert list / ack / unacked count | **Live** | `/alerts` page with event history + subscription management |
| Alert subscriptions (create/list/update/delete) | **Live** | Per-user with ticker-level / list-level / global inheritance |
| Alert producers: informed_buy, informed_sell | **Live** | Insider tx comparison state, batched every 10 min |
| Alert producer: new_13da | **Live** | New 13D/13G filing detection, batched every 10 min |
| Alert producers: 7 more types (rotation, regime, conviction, thesis, cluster, drawdown, asymmetry) | **Stub** | Engine types exist; producers not wired |
| Email delivery (SMTP) | **Live** | Nodemailer, admin SMTP config page, rate limit 1/hr per type/symbol/user |
| SSE push | **Missing** | Design called for SSE; poll/list only |
| Mobile companion `/mobile` | **Live (thin)** | Auth, holdings glance, unacked alerts — not full M19 job A vision |
@@ -110,6 +114,7 @@
| SEC queue fetch + lint holders/insiders + data quality | **Live** |
| X credentials, accounts, prune | **Live** |
| FRED key | **Live** |
| SMTP config (host, port, auth, test) | **Live** | Admin page at `/admin/smtp` |
| Audit log | **Live** |
| Server restart control | **Live** |
| GDPR export | **Backend-ready** (admin API) |
@@ -180,7 +185,7 @@ Unchanged from domain model / ADRs:
| P2 | Derisking + thesis monitor product surfaces | Process depth |
| P2 | Options convexity sleeve UI | M17 incomplete |
| P3 | Reports UI | Export pedagogy |
| P3 | Schema cleanup (duplicate `strategies` DDL) | Maintainability |
| P3 | Wire remaining 7 alert producers | rotation, regime, conviction, thesis, cluster, drawdown, asymmetry |
| P3 | SSE alerts / continuous event producers | Ops polish |
| P3 | Full LLM Gateway as designed | Provenance enforcement completeness |
+9 -6
View File
@@ -1,6 +1,6 @@
# Investor Flow — Technical Design (as built)
**Audit date:** 2026-07-18
**Audit date:** 2026-07-23
**Companion:** [`FUNCTIONAL_DESIGN.md`](./FUNCTIONAL_DESIGN.md)
**Canonical code roots:** `app/src` (Next frontend), `app/server/src` (Node backend)
@@ -83,7 +83,7 @@ app/server/src/
trpc/router.ts ~2k LOC — all routers
trpc/context.ts session, cookies, db, cache, queue injection
db/schema.sql DDL (note: duplicate strategies blocks — debt)
db/*Repository.ts watchlist, portfolio, emotion logs
db/*Repository.ts watchlist, portfolio, emotion logs, alertSubscription
cache/CacheRepository.ts content-addressed cache API
queue/AdapterQueue.ts schedule, pause, retry, source cool-downs (ADR-0009)
queue/sourceRatePolicy.ts 429 detect, cool-down ladders, min-intervals
@@ -92,6 +92,8 @@ app/server/src/
admin/ operator functions + CLI
auth/ totp, oauth, backup codes
alerts/AlertEngine.ts
alerts/producers/ types.ts, index.ts (registry), insiderProducer.ts, new13daProducer.ts
services/emailAlertService.ts nodemailer SMTP delivery + rate limit
risk/RiskEngine.ts + haltCircuitBreaker.ts ← pure; NOT on tRPC
sizing/SizingEngine.ts + convictionUnlock + twoAxisMatrix ← pure; NOT on tRPC
strategy/BacktestEngine.ts
@@ -114,11 +116,11 @@ app/server/src/
| `onboarding` | starter, complete |
| `market` | snapshot, candles, indicators, truckSales, manufacturingPmi, rotation, rotationCheckForAlert, sectorHoldings |
| `dashboard` | rollup |
| `admin` | users, sessions, passwords, queue*, lint, data quality, X creds/accounts/prune, FRED key, pending/approve/reject, audit, serverRestart, gdprExport |
| `alerts` | list, acknowledge, acknowledgeAll, unackedCount |
| `admin` | users, sessions, passwords, queue*, lint, data quality, X creds/accounts/prune, FRED key, pending/approve/reject, audit, serverRestart, gdprExport, smtpConfig, smtpConfigUpdate, smtpConfigTest |
| `alerts` | list, acknowledge, acknowledgeAll, unackedCount, createSubscription, listSubscriptions, updateSubscription, deleteSubscription |
| `institutional` | flow, insiderStream, ownershipHistory, buyEvents |
| `edgar` | filings_index, company_facts, filer_cik_meta, full_text_search, form13f_holdings, form4_tx |
| `watchlists` | list, addSymbol, removeSymbol |
| `watchlists` | list, listByWatchlist, listWatchlists, create, delete, rename, reorder, addSymbol, removeSymbol |
| `portfolio` | holdings, addHolding, removeHolding |
| `options` | chain, greeks |
| `reports` | generate |
@@ -168,9 +170,10 @@ app/src/
| `/options-dd` | Options DD |
| `/market-outlook` | Macro / rotation / notes |
| `/trade-plan`, `/execution`, `/trade-closure`, `/daily-focus` | Process (local) |
| `/alerts` | Alert events + subscriptions |
| `/settings` | Auth + onboarding |
| `/mobile` | Thin companion |
| `/admin`, `/users`, `/queue`, `/audit-logs`, `/x-accounts` | Admin |
| `/admin`, `/users`, `/queue`, `/audit-logs`, `/x-accounts`, `/smtp` | Admin |
### 5.2 Client coverage gap