// 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'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** A single watchlist entry returned by listSymbols. */ export interface WatchlistEntry { symbol: string; added_at: string; notes?: string | null; } /** A full watchlist row (internal). */ interface WatchlistRow { id: string; owner_id: string; name: 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)) ON CONFLICT(owner_id, name) DO UPDATE SET 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 = ?`, ), }; } // --------------------------------------------------------------------------- // 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, ): 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); // 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; return sym.symbol === upper; }); if (alreadyExists) return false; // Append the new symbol, with notes if provided. if (notes) { existing.symbols.push({ symbol: upper, notes }); } else { existing.symbols.push(upper); } const now = new Date().toISOString(); s.upsert.run(existing.id, userId, 'default', 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); return true; } /** Remove a symbol from the user's default watchlist. Returns true if removed. */ export function removeSymbol( db: DatabaseSync, userId: string, symbol: string, ): boolean { const s = stmts(db); const upper = symbol.toUpperCase(); const existing = readDefaultWatchlistRaw(db, userId); 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 } if (remaining.length === 0) { // Clean up empty watchlist. s.deleteByOwnerAndName.run(userId, 'default'); 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 entries: WatchlistEntry[] = []; for (const row of rows) { const parsed = safeParseSymbols(row.symbols); for (const item of parsed) { 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; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Safely parse the JSON TEXT column into an array of strings or objects. */ function safeParseSymbols(value: string | null | undefined): (string | Record)[] { if (!value) return []; try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { return parsed; } } catch { /* ignore parse errors */ } 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( db: DatabaseSync, userId: string, ): { id: string; symbols: Array } | null { const rows = stmts(db).selectByOwnerAndName.all(userId, 'default') 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 }; } /** Generate a simple unique id. */ function generateId(): string { return `wl_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; }