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