Files
investor-flow/app/server/src/db/watchlistRepository.ts
T

247 lines
8.0 KiB
TypeScript
Raw Normal View History

// 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<string, unknown>)[] {
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<string | { symbol: string; notes?: string }> } | 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<string | { symbol: string; notes?: string }> };
}
/** Generate a simple unique id. */
function generateId(): string {
return `wl_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}