2026-06-30 13:22:02 -04:00
|
|
|
import type { DatabaseSync } from 'node:sqlite';
|
2026-07-23 20:51:47 -04:00
|
|
|
import { randomUUID } from 'node:crypto';
|
2026-06-30 13:22:02 -04:00
|
|
|
|
|
|
|
|
export interface WatchlistEntry {
|
|
|
|
|
symbol: string;
|
|
|
|
|
added_at: string;
|
|
|
|
|
notes?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 20:51:47 -04:00
|
|
|
export interface WatchlistMeta {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
symbol_count: number;
|
|
|
|
|
sort_order: number;
|
|
|
|
|
created_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 13:22:02 -04:00
|
|
|
interface WatchlistRow {
|
|
|
|
|
id: string;
|
|
|
|
|
owner_id: string;
|
|
|
|
|
name: string;
|
2026-07-23 20:51:47 -04:00
|
|
|
symbols: string;
|
2026-06-30 13:22:02 -04:00
|
|
|
created_at: string;
|
|
|
|
|
sort_order: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stmts(db: DatabaseSync) {
|
|
|
|
|
return {
|
|
|
|
|
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`,
|
|
|
|
|
),
|
|
|
|
|
selectByOwnerAndName: db.prepare(
|
|
|
|
|
`SELECT id, owner_id, name, symbols, created_at, sort_order
|
|
|
|
|
FROM watchlists WHERE owner_id = ? AND name = ?`,
|
|
|
|
|
),
|
|
|
|
|
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`,
|
|
|
|
|
),
|
|
|
|
|
deleteByOwnerAndName: db.prepare(
|
|
|
|
|
`DELETE FROM watchlists WHERE owner_id = ? AND name = ?`,
|
|
|
|
|
),
|
|
|
|
|
updateSymbols: db.prepare(
|
|
|
|
|
`UPDATE watchlists SET symbols = ? WHERE id = ? AND owner_id = ?`,
|
|
|
|
|
),
|
2026-07-23 20:51:47 -04:00
|
|
|
selectById: db.prepare(
|
|
|
|
|
`SELECT id, owner_id, name, symbols, created_at, sort_order
|
|
|
|
|
FROM watchlists WHERE id = ? AND owner_id = ?`,
|
|
|
|
|
),
|
|
|
|
|
deleteById: db.prepare(
|
|
|
|
|
`DELETE FROM watchlists WHERE id = ? AND owner_id = ?`,
|
|
|
|
|
),
|
|
|
|
|
updateSortOrder: db.prepare(
|
|
|
|
|
`UPDATE watchlists SET sort_order = ? WHERE id = ? AND owner_id = ?`,
|
|
|
|
|
),
|
|
|
|
|
selectAllByOwner: 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`,
|
|
|
|
|
),
|
2026-06-30 13:22:02 -04:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function addSymbol(
|
|
|
|
|
db: DatabaseSync,
|
|
|
|
|
userId: string,
|
|
|
|
|
symbol: string,
|
|
|
|
|
notes?: string,
|
2026-07-23 20:51:47 -04:00
|
|
|
watchlistName: string = 'default',
|
2026-06-30 13:22:02 -04:00
|
|
|
): boolean {
|
|
|
|
|
const s = stmts(db);
|
|
|
|
|
const upper = symbol.toUpperCase();
|
|
|
|
|
|
2026-07-23 20:51:47 -04:00
|
|
|
const existing = readWatchlistRaw(db, userId, watchlistName);
|
2026-06-30 13:22:02 -04:00
|
|
|
|
2026-06-30 17:54:01 -04:00
|
|
|
if (existing) {
|
|
|
|
|
const alreadyExists = existing.symbols.some((sym) => {
|
|
|
|
|
if (typeof sym === 'string') return sym === upper;
|
|
|
|
|
return sym.symbol === upper;
|
|
|
|
|
});
|
|
|
|
|
if (alreadyExists) return false;
|
|
|
|
|
|
|
|
|
|
if (notes) {
|
|
|
|
|
existing.symbols.push({ symbol: upper, notes });
|
|
|
|
|
} else {
|
|
|
|
|
existing.symbols.push(upper);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = new Date().toISOString();
|
2026-07-23 20:51:47 -04:00
|
|
|
s.upsert.run(existing.id, userId, watchlistName, JSON.stringify(existing.symbols), now, 0);
|
2026-06-30 17:54:01 -04:00
|
|
|
return true;
|
2026-06-30 13:22:02 -04:00
|
|
|
}
|
|
|
|
|
|
2026-06-30 17:54:01 -04:00
|
|
|
const serialized = notes ? [{ symbol: upper, notes }] : [upper];
|
|
|
|
|
const id = generateId();
|
2026-06-30 13:22:02 -04:00
|
|
|
const now = new Date().toISOString();
|
2026-07-23 20:51:47 -04:00
|
|
|
s.upsert.run(id, userId, watchlistName, JSON.stringify(serialized), now, 0);
|
2026-06-30 13:22:02 -04:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function removeSymbol(
|
|
|
|
|
db: DatabaseSync,
|
|
|
|
|
userId: string,
|
|
|
|
|
symbol: string,
|
2026-07-23 20:51:47 -04:00
|
|
|
watchlistName: string = 'default',
|
2026-06-30 13:22:02 -04:00
|
|
|
): boolean {
|
|
|
|
|
const s = stmts(db);
|
|
|
|
|
const upper = symbol.toUpperCase();
|
|
|
|
|
|
2026-07-23 20:51:47 -04:00
|
|
|
const existing = readWatchlistRaw(db, userId, watchlistName);
|
2026-06-30 13:22:02 -04:00
|
|
|
if (!existing) return false;
|
|
|
|
|
|
|
|
|
|
const before = existing.symbols.length;
|
2026-06-30 17:54:01 -04:00
|
|
|
const remaining = existing.symbols.filter((sym) => {
|
|
|
|
|
const symStr = typeof sym === 'string' ? sym : sym.symbol;
|
|
|
|
|
return symStr !== upper;
|
|
|
|
|
});
|
2026-06-30 13:22:02 -04:00
|
|
|
|
|
|
|
|
if (remaining.length === before) {
|
2026-07-23 20:51:47 -04:00
|
|
|
return false;
|
2026-06-30 13:22:02 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (remaining.length === 0) {
|
2026-07-23 20:51:47 -04:00
|
|
|
s.deleteByOwnerAndName.run(userId, watchlistName);
|
2026-06-30 13:22:02 -04:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 17:54:01 -04:00
|
|
|
s.updateSymbols.run(JSON.stringify(remaining), existing.id, userId);
|
2026-06-30 13:22:02 -04:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listSymbols(
|
|
|
|
|
db: DatabaseSync,
|
|
|
|
|
userId: string,
|
|
|
|
|
): WatchlistEntry[] {
|
|
|
|
|
const s = stmts(db);
|
2026-07-23 20:51:47 -04:00
|
|
|
const rows = s.selectByOwner.all(userId) as unknown as WatchlistRow[];
|
2026-06-30 13:22:02 -04:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 20:51:47 -04:00
|
|
|
export function listWatchlists(db: DatabaseSync, userId: string): WatchlistMeta[] {
|
|
|
|
|
const s = stmts(db);
|
|
|
|
|
const rows = s.selectByOwner.all(userId) as unknown as WatchlistRow[];
|
|
|
|
|
return rows.map((row) => {
|
|
|
|
|
const parsed = safeParseSymbols(row.symbols);
|
|
|
|
|
return {
|
|
|
|
|
id: row.id,
|
|
|
|
|
name: row.name,
|
|
|
|
|
symbol_count: parsed.length,
|
|
|
|
|
sort_order: row.sort_order,
|
|
|
|
|
created_at: row.created_at,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createWatchlist(
|
|
|
|
|
db: DatabaseSync,
|
|
|
|
|
userId: string,
|
|
|
|
|
name: string,
|
|
|
|
|
symbols?: string[],
|
|
|
|
|
): WatchlistMeta {
|
|
|
|
|
const s = stmts(db);
|
|
|
|
|
const id = generateId();
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
const serialized = JSON.stringify(symbols ?? []);
|
|
|
|
|
s.upsert.run(id, userId, name, serialized, now, 0);
|
|
|
|
|
return { id, name, symbol_count: (symbols ?? []).length, sort_order: 0, created_at: now };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function deleteWatchlist(db: DatabaseSync, userId: string, name: string): boolean {
|
|
|
|
|
const s = stmts(db);
|
|
|
|
|
const existing = readWatchlistRaw(db, userId, name);
|
|
|
|
|
if (!existing) return false;
|
|
|
|
|
s.deleteByOwnerAndName.run(userId, name);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function renameWatchlist(
|
|
|
|
|
db: DatabaseSync,
|
|
|
|
|
userId: string,
|
|
|
|
|
oldName: string,
|
|
|
|
|
newName: string,
|
|
|
|
|
): boolean {
|
|
|
|
|
const s = stmts(db);
|
|
|
|
|
const existing = readWatchlistRaw(db, userId, oldName);
|
|
|
|
|
if (!existing) return false;
|
|
|
|
|
const conflict = readWatchlistRaw(db, userId, newName);
|
|
|
|
|
if (conflict) return false;
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
s.upsert.run(existing.id, userId, newName, JSON.stringify(existing.symbols), now, existing.sort_order);
|
|
|
|
|
s.deleteByOwnerAndName.run(userId, oldName);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function reorderWatchlists(
|
|
|
|
|
db: DatabaseSync,
|
|
|
|
|
userId: string,
|
|
|
|
|
orders: { id: string; sort_order: number }[],
|
|
|
|
|
): void {
|
|
|
|
|
const s = stmts(db);
|
|
|
|
|
for (const { id, sort_order } of orders) {
|
|
|
|
|
s.updateSortOrder.run(sort_order, id, userId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getSymbolsInWatchlist(
|
|
|
|
|
db: DatabaseSync,
|
|
|
|
|
userId: string,
|
|
|
|
|
watchlistName: string = 'default',
|
|
|
|
|
): string[] {
|
|
|
|
|
const existing = readWatchlistRaw(db, userId, watchlistName);
|
|
|
|
|
if (!existing) return [];
|
|
|
|
|
return existing.symbols.map((sym) => {
|
|
|
|
|
if (typeof sym === 'string') return sym;
|
|
|
|
|
return sym.symbol;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-30 13:22:02 -04:00
|
|
|
|
|
|
|
|
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 [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 20:51:47 -04:00
|
|
|
export function listSymbolsByWatchlist(
|
2026-06-30 17:54:01 -04:00
|
|
|
db: DatabaseSync,
|
|
|
|
|
userId: string,
|
2026-07-23 20:51:47 -04:00
|
|
|
watchlistName: string,
|
|
|
|
|
): WatchlistEntry[] {
|
|
|
|
|
const raw = readWatchlistRaw(db, userId, watchlistName);
|
|
|
|
|
if (!raw) return [];
|
|
|
|
|
|
|
|
|
|
const entries: WatchlistEntry[] = [];
|
|
|
|
|
for (const item of raw.symbols) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readWatchlistRaw(
|
|
|
|
|
db: DatabaseSync,
|
|
|
|
|
userId: string,
|
|
|
|
|
name: string,
|
|
|
|
|
): { id: string; symbols: Array<string | { symbol: string; notes?: string }>; sort_order: number } | null {
|
|
|
|
|
const rows = stmts(db).selectByOwnerAndName.all(userId, name) as unknown as WatchlistRow[];
|
2026-06-30 17:54:01 -04:00
|
|
|
if (rows.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
const row = rows[0];
|
|
|
|
|
const rawSymbols = safeParseSymbols(String(row.symbols));
|
2026-07-23 20:51:47 -04:00
|
|
|
return { id: row.id, symbols: rawSymbols as Array<string | { symbol: string; notes?: string }>, sort_order: row.sort_order };
|
2026-06-30 17:54:01 -04:00
|
|
|
}
|
|
|
|
|
|
2026-06-30 13:22:02 -04:00
|
|
|
function generateId(): string {
|
2026-07-23 20:51:47 -04:00
|
|
|
return randomUUID();
|
2026-06-30 13:22:02 -04:00
|
|
|
}
|