slice 10a WatchlistRepository (ornith-35): addSymbol/removeSymbol/listSymbols, parameterized, idempotent, ADR-0007
Cross-review by qwopus35b pending.
This commit is contained in:
@@ -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<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,
|
||||
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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user