diff --git a/app/server/src/db/watchlistRepository.ts b/app/server/src/db/watchlistRepository.ts new file mode 100644 index 0000000..0ce0932 --- /dev/null +++ b/app/server/src/db/watchlistRepository.ts @@ -0,0 +1,220 @@ +// 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. + const existing = readDefaultWatchlist(db, userId); + + const symbols: string[] = existing?.symbols ?? []; + if (symbols.includes(upper)) { + return false; // already present — no-op + } + + symbols.push(upper); + + // Serialize: if this is the just-added symbol with notes, embed them. + const serialized = symbols.map((s) => { + if (s === upper && notes) { + return JSON.stringify({ symbol: s, notes }); + } + return JSON.stringify(s); + }); + + const id = existing?.id ?? generateId(); + const now = new Date().toISOString(); + + // Upsert: insert-or-replace by (owner_id, name). + 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 = readDefaultWatchlist(db, userId); + if (!existing) return false; + + const before = existing.symbols.length; + const remaining = existing.symbols.filter((s) => s !== 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; + } + + const serialized = remaining.map((sym) => JSON.stringify(sym)); + s.updateSymbols.run(JSON.stringify(serialized), 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, + symbols: safeParseSymbols(String(row.symbols)).filter((s): s is string => typeof s === 'string') as string[], + }; +} + +/** Generate a simple unique id. */ +function generateId(): string { + return `wl_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +}