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