247 lines
8.0 KiB
TypeScript
247 lines
8.0 KiB
TypeScript
// 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)}`;
|
|
}
|