**Module access control (admin)** - Added modules column to users table (JSON array of allowed module keys) - auth.me returns modules; admin.setUserModules/disableUser/enableUser/deleteUser procedures - UserActions dropdown with Reset Password, Manage Modules, Disable/Enable, Delete - useFeatureAccess hook + FeatureGate component for page-level gating - SidebarNav, CommandPalette, MobileTabNav filter by modules **Classification watchlists (auto-generated sector/thematic/style/region)** - watchlists schema: added kind, class_key, class_label columns - materializeClassificationWatchlists(): lazy idempotent materialization of system lists from Yahoo sector data - 11 GICS sector lists, Semiconductors, Biotech thematic lists, Small Cap style, Intl region - Materialization triggered on listWatchlists + addSymbol/removeSymbol/add - Sector thematic filter uses industry keywords (semiconductors, biotech) to avoid misclassification - Symbol overrides fixed: IREN removed from SMH theme, bitcoin miners XLE->XLK, SLNH added as Technology **Watchlist persistence & move** - active-watchlist-store.ts: Zustand persist store mirroring active-symbol-store pattern - moveSymbol procedure: removes from source, adds to target (idempotent, preserves empty lists) - Action menu (⋯) per row: Move to + Remove, click-outside close - Active watchlist survives navigation and page reloads **List protections** - default list: non-deletable, non-renamable, keeps empty row when pruned - System lists (sector/thematic etc.): read-only, add form hidden, no remove/move/delete **Per-user module restrictions** - ProtectedProcedure blocks non-active users - deleteUser refuses self-delete and last-admin-delete, cleans halt_state manually - Module gating on execution/analytics pages, sidebar, command palette, mobile nav Also fixed: schema.sql index moved to migration, materialize DB column fixes, test updates.
491 lines
17 KiB
TypeScript
491 lines
17 KiB
TypeScript
import type { DatabaseSync } from 'node:sqlite';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { resolveBusinessContext } from '../analysis/tickerContext.ts';
|
|
|
|
export interface WatchlistEntry {
|
|
symbol: string;
|
|
added_at: string;
|
|
notes?: string | null;
|
|
}
|
|
|
|
export interface WatchlistMeta {
|
|
id: string;
|
|
name: string;
|
|
symbol_count: number;
|
|
sort_order: number;
|
|
created_at: string;
|
|
kind?: string;
|
|
class_key?: string | null;
|
|
class_label?: string | null;
|
|
}
|
|
|
|
interface WatchlistRow {
|
|
id: string;
|
|
owner_id: string;
|
|
name: string;
|
|
symbols: string;
|
|
created_at: string;
|
|
sort_order: number;
|
|
kind: string;
|
|
class_key: string | null;
|
|
class_label: string | null;
|
|
}
|
|
|
|
// Mapping from ETF symbol → market-outlook display label (kept in sync with marketRotationRs.ts).
|
|
const ETF_TO_LABEL: Record<string, string> = {
|
|
XLK: 'Technology',
|
|
XLF: 'Financials',
|
|
XLE: 'Energy',
|
|
XLI: 'Industrials',
|
|
XLV: 'Healthcare',
|
|
XLY: 'Consumer Discretionary',
|
|
XLP: 'Consumer Staples',
|
|
XLU: 'Utilities',
|
|
XLRE: 'Real Estate',
|
|
XLC: 'Communication Services',
|
|
XLB: 'Materials',
|
|
SMH: 'Semiconductors',
|
|
XBI: 'Biotech',
|
|
IWM: 'Style: Small',
|
|
EFA: 'Region: Intl',
|
|
};
|
|
|
|
const ALLOWED_SECTOR_ETFS = new Set(Object.keys(ETF_TO_LABEL).filter((k) => k.startsWith('XL')));
|
|
const ALLOWED_THEME_ETFS = new Set(['SMH', 'XBI']);
|
|
const ALLOWED_STYLE_ETFS = new Set(['IWM']);
|
|
const ALLOWED_REGION_ETFS = new Set(['EFA']);
|
|
|
|
const US_EXCHANGES = new Set(['NASDAQ', 'NYSE', 'NYSEAMERICAN', 'NYSEARCA', 'BATS', 'NYSEMKT', 'OTC', 'PNK']);
|
|
|
|
function stmts(db: DatabaseSync) {
|
|
return {
|
|
upsert: db.prepare(
|
|
`INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label)
|
|
VALUES (?, ?, ?, ?, ?, COALESCE(?, 0), ?, ?, ?)
|
|
ON CONFLICT(owner_id, kind, name) DO UPDATE SET
|
|
symbols = excluded.symbols,
|
|
sort_order = excluded.sort_order,
|
|
class_key = excluded.class_key,
|
|
class_label = excluded.class_label`,
|
|
),
|
|
selectByOwnerKindName: db.prepare(
|
|
`SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label
|
|
FROM watchlists WHERE owner_id = ? AND kind = ? AND name = ?`,
|
|
),
|
|
selectByOwner: db.prepare(
|
|
`SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label
|
|
FROM watchlists WHERE owner_id = ?
|
|
ORDER BY kind DESC, sort_order ASC, created_at ASC`,
|
|
),
|
|
selectByOwnerKind: db.prepare(
|
|
`SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label
|
|
FROM watchlists WHERE owner_id = ? AND kind = ?
|
|
ORDER BY sort_order ASC, created_at ASC`,
|
|
),
|
|
deleteByOwnerKindName: db.prepare(
|
|
`DELETE FROM watchlists WHERE owner_id = ? AND kind = ? AND name = ?`,
|
|
),
|
|
updateSymbols: db.prepare(
|
|
`UPDATE watchlists SET symbols = ? WHERE id = ? AND owner_id = ?`,
|
|
),
|
|
selectById: db.prepare(
|
|
`SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label
|
|
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, kind, class_key, class_label
|
|
FROM watchlists WHERE owner_id = ?
|
|
ORDER BY kind DESC, sort_order ASC, created_at ASC`,
|
|
),
|
|
deleteSystemByClassKey: db.prepare(
|
|
`DELETE FROM watchlists WHERE owner_id = ? AND kind != 'user' AND class_key = ?`,
|
|
),
|
|
upsertSystem: db.prepare(
|
|
`INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label)
|
|
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)
|
|
ON CONFLICT(owner_id, kind, name) DO UPDATE SET
|
|
symbols = excluded.symbols, class_key = excluded.class_key, class_label = excluded.class_label`,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function addSymbol(
|
|
db: DatabaseSync,
|
|
userId: string,
|
|
symbol: string,
|
|
notes?: string,
|
|
watchlistName: string = 'default',
|
|
): boolean {
|
|
const s = stmts(db);
|
|
const upper = symbol.toUpperCase();
|
|
|
|
const existing = readWatchlistRaw(db, userId, watchlistName, 'user');
|
|
|
|
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();
|
|
s.upsert.run(existing.id, userId, watchlistName, JSON.stringify(existing.symbols), now, 0, 'user', null, null);
|
|
return true;
|
|
}
|
|
|
|
const serialized = notes ? [{ symbol: upper, notes }] : [upper];
|
|
const id = generateId();
|
|
const now = new Date().toISOString();
|
|
s.upsert.run(id, userId, watchlistName, JSON.stringify(serialized), now, 0, 'user', null, null);
|
|
return true;
|
|
}
|
|
|
|
export function removeSymbol(
|
|
db: DatabaseSync,
|
|
userId: string,
|
|
symbol: string,
|
|
watchlistName: string = 'default',
|
|
): boolean {
|
|
const s = stmts(db);
|
|
const upper = symbol.toUpperCase();
|
|
|
|
const existing = readWatchlistRaw(db, userId, watchlistName, 'user');
|
|
if (!existing) return false;
|
|
|
|
const before = existing.symbols.length;
|
|
const remaining = existing.symbols.filter((sym) => {
|
|
const symStr = typeof sym === 'string' ? sym : sym.symbol;
|
|
return symStr !== upper;
|
|
});
|
|
|
|
if (remaining.length === before) {
|
|
return false;
|
|
}
|
|
|
|
if (remaining.length === 0) {
|
|
if (watchlistName === 'default') {
|
|
s.updateSymbols.run('[]', existing.id, userId);
|
|
return true;
|
|
}
|
|
s.deleteByOwnerKindName.run(userId, 'user', watchlistName);
|
|
return true;
|
|
}
|
|
|
|
s.updateSymbols.run(JSON.stringify(remaining), existing.id, userId);
|
|
return true;
|
|
}
|
|
|
|
export function moveSymbol(
|
|
db: DatabaseSync,
|
|
userId: string,
|
|
symbol: string,
|
|
fromWatchlist: string,
|
|
toWatchlist: string,
|
|
): boolean {
|
|
if (fromWatchlist === toWatchlist) return false;
|
|
const sym = symbol.toUpperCase();
|
|
const src = readWatchlistRaw(db, userId, fromWatchlist, 'user');
|
|
if (!src) return false;
|
|
const dst = readWatchlistRaw(db, userId, toWatchlist, 'user');
|
|
if (!dst) return false;
|
|
|
|
const srcRemaining = src.symbols.filter((s) => {
|
|
const sStr = typeof s === 'string' ? s : s.symbol;
|
|
return sStr !== sym;
|
|
});
|
|
|
|
const alreadyInTarget = dst.symbols.some((s) => {
|
|
const sStr = typeof s === 'string' ? s : s.symbol;
|
|
return sStr === sym;
|
|
});
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
if (srcRemaining.length === 0 && fromWatchlist !== 'default') {
|
|
stmts(db).deleteByOwnerKindName.run(userId, 'user', fromWatchlist);
|
|
} else {
|
|
stmts(db).updateSymbols.run(JSON.stringify(srcRemaining), src.id, userId);
|
|
}
|
|
|
|
if (alreadyInTarget) return true;
|
|
|
|
const mergedSymbols = [...dst.symbols, sym];
|
|
stmts(db).updateSymbols.run(JSON.stringify(mergedSymbols), dst.id, userId);
|
|
materializeClassificationWatchlists(db, userId);
|
|
return true;
|
|
}
|
|
|
|
export function listSymbols(
|
|
db: DatabaseSync,
|
|
userId: string,
|
|
): WatchlistEntry[] {
|
|
const s = stmts(db);
|
|
const rows = s.selectByOwnerKind.all(userId, 'user') as unknown as WatchlistRow[];
|
|
|
|
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;
|
|
}
|
|
|
|
export function listWatchlists(db: DatabaseSync, userId: string): WatchlistMeta[] {
|
|
materializeClassificationWatchlists(db, userId);
|
|
const s = stmts(db);
|
|
const rows = s.selectAllByOwner.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,
|
|
kind: row.kind,
|
|
class_key: row.class_key,
|
|
class_label: row.class_label,
|
|
};
|
|
});
|
|
}
|
|
|
|
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, 'user', null, null);
|
|
return { id, name, symbol_count: (symbols ?? []).length, sort_order: 0, created_at: now, kind: 'user' };
|
|
}
|
|
|
|
export function deleteWatchlist(db: DatabaseSync, userId: string, name: string): boolean {
|
|
if (name === 'default') return false;
|
|
const s = stmts(db);
|
|
const existing = readWatchlistRaw(db, userId, name, 'user');
|
|
if (!existing) return false;
|
|
s.deleteByOwnerKindName.run(userId, 'user', name);
|
|
return true;
|
|
}
|
|
|
|
export function renameWatchlist(
|
|
db: DatabaseSync,
|
|
userId: string,
|
|
oldName: string,
|
|
newName: string,
|
|
): boolean {
|
|
if (oldName === 'default') return false;
|
|
const s = stmts(db);
|
|
const existing = readWatchlistRaw(db, userId, oldName, 'user');
|
|
if (!existing) return false;
|
|
const conflict = readWatchlistRaw(db, userId, newName, 'user');
|
|
if (conflict) return false;
|
|
const now = new Date().toISOString();
|
|
s.upsert.run(existing.id, userId, newName, JSON.stringify(existing.symbols), now, existing.sort_order, 'user', null, null);
|
|
s.deleteByOwnerKindName.run(userId, 'user', 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, 'user');
|
|
if (!existing) return [];
|
|
return existing.symbols.map((sym) => {
|
|
if (typeof sym === 'string') return sym;
|
|
return sym.symbol;
|
|
});
|
|
}
|
|
|
|
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 [];
|
|
}
|
|
|
|
export function listSymbolsByWatchlist(
|
|
db: DatabaseSync,
|
|
userId: string,
|
|
watchlistName: string,
|
|
): WatchlistEntry[] {
|
|
// Try user lists first, then fall back to system lists (for viewing sector lists).
|
|
let raw = readWatchlistRaw(db, userId, watchlistName, 'user');
|
|
if (!raw) {
|
|
const systemRow = db
|
|
.prepare(`SELECT id, symbols, kind, class_key, class_label FROM watchlists WHERE owner_id = ? AND kind != 'user' AND name = ?`)
|
|
.get(userId, watchlistName) as { id: string; symbols: string; kind: string; class_key: string | null; class_label: string | null } | undefined;
|
|
if (!systemRow) return [];
|
|
const parsed = safeParseSymbols(systemRow.symbols);
|
|
return parsed.map((item) => {
|
|
if (typeof item === 'string') return { symbol: item, added_at: '' };
|
|
return { symbol: (item.symbol ?? '').toUpperCase(), notes: (item as Record<string, unknown>).notes as string | null ?? null, added_at: '' };
|
|
});
|
|
}
|
|
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,
|
|
kind?: string,
|
|
): { id: string; symbols: Array<string | { symbol: string; notes?: string }>; sort_order: number } | null {
|
|
const s = stmts(db);
|
|
const rows = kind
|
|
? s.selectByOwnerKindName.all(userId, kind, name) as unknown as WatchlistRow[]
|
|
: s.selectByOwner.all(userId).filter((r) => r.name === name) 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 }>, sort_order: row.sort_order };
|
|
}
|
|
|
|
function generateId(): string {
|
|
return randomUUID();
|
|
}
|
|
|
|
/** Materialize system watchlists (sector, thematic, style, region) from user's symbol metadata. Idempotent; auto-prunes empty lists. */
|
|
export function materializeClassificationWatchlists(db: DatabaseSync, userId: string): void {
|
|
const s = stmts(db);
|
|
|
|
// 1. Gather all unique symbols across user's lists (kind='user').
|
|
const userRows = s.selectByOwnerKind.all(userId, 'user') as unknown as WatchlistRow[];
|
|
const symbolSet = new Set<string>();
|
|
for (const row of userRows) {
|
|
for (const item of safeParseSymbols(row.symbols)) {
|
|
const sym = typeof item === 'string' ? item.toUpperCase() : (item.symbol ?? '').toUpperCase();
|
|
if (sym) symbolSet.add(sym);
|
|
}
|
|
}
|
|
if (symbolSet.size === 0) {
|
|
// Prune all system lists for this user.
|
|
db.prepare(`DELETE FROM watchlists WHERE owner_id = ? AND kind != 'user'`).run(userId);
|
|
return;
|
|
}
|
|
|
|
// 2. Fetch symbol metadata from symbols table (market_cap/description not in schema).
|
|
const symbolMeta = new Map<string, { sector: string | null; industry: string | null; exchange: string | null }>();
|
|
for (const sym of symbolSet) {
|
|
const row = db.prepare('SELECT sector, industry, exchange FROM symbols WHERE symbol = ?').get(sym) as
|
|
| { sector: string | null; industry: string | null; exchange: string | null }
|
|
| undefined;
|
|
if (row) symbolMeta.set(sym, row);
|
|
}
|
|
|
|
// 3. Resolve classifications per symbol.
|
|
const groups = new Map<string, Set<string>>(); // class_key -> symbol set
|
|
for (const sym of symbolSet) {
|
|
const meta = symbolMeta.get(sym);
|
|
if (!meta) continue;
|
|
|
|
const ctx = resolveBusinessContext({
|
|
symbol: sym,
|
|
sector: meta.sector ?? null,
|
|
industry: meta.industry ?? null,
|
|
description: null,
|
|
});
|
|
|
|
// Sector
|
|
if (ctx.sectorEtf && ALLOWED_SECTOR_ETFS.has(ctx.sectorEtf)) {
|
|
groups.set(ctx.sectorEtf, (groups.get(ctx.sectorEtf) ?? new Set()).add(sym));
|
|
}
|
|
|
|
// Thematic (SMH=Semiconductors, XBI=Biotech) — only if industry actually matches the theme.
|
|
if (ctx.themeEtf && ALLOWED_THEME_ETFS.has(ctx.themeEtf)) {
|
|
const industry = (meta.industry ?? '').toLowerCase();
|
|
if ((ctx.themeEtf === 'SMH' && /semiconductor|chip|gpu|foundry|fabless|wafer|memory|processors?/.test(industry)) ||
|
|
(ctx.themeEtf === 'XBI' && /biotech|biotechnology|genomic|pharmaceut|drug|therapeutic/.test(industry))) {
|
|
groups.set(ctx.themeEtf, (groups.get(ctx.themeEtf) ?? new Set()).add(sym));
|
|
}
|
|
}
|
|
|
|
// Style: market_cap column does not exist in schema — skip Small Cap for now.
|
|
// Region: non-US exchange -> Intl (EFA)
|
|
if (meta.exchange && !US_EXCHANGES.has(meta.exchange.toUpperCase())) {
|
|
groups.set('EFA', (groups.get('EFA') ?? new Set()).add(sym));
|
|
}
|
|
}
|
|
|
|
// 4. Upsert system lists; delete orphaned ones.
|
|
const presentKeys = new Set<string>();
|
|
for (const [classKey, syms] of groups) {
|
|
presentKeys.add(classKey);
|
|
const label = ETF_TO_LABEL[classKey] ?? classKey;
|
|
const kind = classKey === 'SMH' || classKey === 'XBI' ? 'thematic' : classKey === 'IWM' ? 'style' : classKey === 'EFA' ? 'region' : 'sector';
|
|
const name = label;
|
|
const symbolsArr = Array.from(syms).map((s) => s);
|
|
const serialized = JSON.stringify(symbolsArr);
|
|
const now = new Date().toISOString();
|
|
const id = generateId();
|
|
s.upsertSystem.run(id, userId, name, serialized, now, kind, classKey, label);
|
|
}
|
|
|
|
// 5. Prune system lists for class keys no longer present.
|
|
for (const classKey of ALLOWED_SECTOR_ETFS) { if (!presentKeys.has(classKey)) s.deleteSystemByClassKey.run(userId, classKey); }
|
|
for (const classKey of ALLOWED_THEME_ETFS) { if (!presentKeys.has(classKey)) s.deleteSystemByClassKey.run(userId, classKey); }
|
|
for (const classKey of ALLOWED_STYLE_ETFS) { if (!presentKeys.has(classKey)) s.deleteSystemByClassKey.run(userId, classKey); }
|
|
for (const classKey of ALLOWED_REGION_ETFS) { if (!presentKeys.has(classKey)) s.deleteSystemByClassKey.run(userId, classKey); }
|
|
}
|