feat: per-user module access, classification watchlists, watchlist persistence & move
**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.
This commit is contained in:
@@ -24,6 +24,7 @@ export interface UserRecord {
|
||||
created_at: string;
|
||||
is_admin: number;
|
||||
status: string;
|
||||
modules: string;
|
||||
}
|
||||
|
||||
export interface QueueHealthRow {
|
||||
@@ -70,15 +71,74 @@ export function requireAdminOrOwner(
|
||||
if (!row || !row.is_admin) throw new NotOwnerError('Actor is not admin and does not own the target.');
|
||||
}
|
||||
|
||||
/** list users — id, email, complexity, created_at, is_admin, status. Never returns pw_hash. */
|
||||
/** list users — id, email, complexity, created_at, is_admin, status, modules. Never returns pw_hash. */
|
||||
export function listUsers(db: DatabaseSync): UserRecord[] {
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT id, email, complexity, created_at, is_admin, status FROM users ORDER BY created_at ASC',
|
||||
'SELECT id, email, complexity, created_at, is_admin, status, modules FROM users ORDER BY created_at ASC',
|
||||
)
|
||||
.all() as unknown as UserRecord[];
|
||||
}
|
||||
|
||||
const VALID_MODULES = ['research', 'execution', 'analytics', 'settings'];
|
||||
|
||||
/** Set the allowed modules for a user. Validates module keys against the allowlist. */
|
||||
export function setUserModules(
|
||||
db: DatabaseSync,
|
||||
actorId: string | null,
|
||||
targetUserId: string,
|
||||
modules: string[],
|
||||
): { ok: boolean } {
|
||||
requireAdminOrOwner(db, actorId, targetUserId);
|
||||
const valid = modules.filter((m) => VALID_MODULES.includes(m));
|
||||
const unique = [...new Set(valid)];
|
||||
if (!unique.includes('settings')) unique.push('settings');
|
||||
db.prepare('UPDATE users SET modules=? WHERE id=?').run(JSON.stringify(unique), targetUserId);
|
||||
recordAudit(db, actorId ?? 'cli', 'users.set-modules', targetUserId, { modules: unique });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Disable a user account (status -> 'disabled'). Blocks login + protectedProcedure calls.
|
||||
* Refuses to disable the actor's own account or the last remaining admin. */
|
||||
export function disableUser(db: DatabaseSync, actorId: string | null, targetUserId: string): { ok: boolean } {
|
||||
if (actorId === targetUserId) throw new NotOwnerError('Cannot disable your own account.');
|
||||
const row = db.prepare('SELECT is_admin FROM users WHERE id=?').get(targetUserId) as { is_admin: number } | undefined;
|
||||
if (!row) throw new Error('admin: target user not found');
|
||||
if (row.is_admin) {
|
||||
const adminCount = (db.prepare('SELECT COUNT(*) AS c FROM users WHERE is_admin=1').get() as { c: number }).c;
|
||||
if (adminCount <= 1) throw new NotOwnerError('Cannot disable the last admin account.');
|
||||
}
|
||||
db.prepare('UPDATE users SET status=? WHERE id=?').run('disabled', targetUserId);
|
||||
db.prepare('DELETE FROM sessions WHERE user_id=?').run(targetUserId);
|
||||
recordAudit(db, actorId ?? 'cli', 'users.disable', targetUserId, null);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Re-enable a previously disabled account (status -> 'active'). */
|
||||
export function enableUser(db: DatabaseSync, actorId: string | null, targetUserId: string): { ok: boolean } {
|
||||
requireAdminOrOwner(db, actorId, targetUserId);
|
||||
db.prepare('UPDATE users SET status=? WHERE id=?').run('active', targetUserId);
|
||||
recordAudit(db, actorId ?? 'cli', 'users.enable', targetUserId, null);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Permanently delete a user and all owned data. Cascades via FK for most tables;
|
||||
* halt_state has no FK so it is cleaned up explicitly. Refuses to delete the actor's
|
||||
* own account or the last remaining admin. */
|
||||
export function deleteUser(db: DatabaseSync, actorId: string | null, targetUserId: string): { ok: boolean } {
|
||||
if (actorId === targetUserId) throw new NotOwnerError('Cannot delete your own account.');
|
||||
const row = db.prepare('SELECT is_admin FROM users WHERE id=?').get(targetUserId) as { is_admin: number } | undefined;
|
||||
if (!row) throw new Error('admin: target user not found');
|
||||
if (row.is_admin) {
|
||||
const adminCount = (db.prepare('SELECT COUNT(*) AS c FROM users WHERE is_admin=1').get() as { c: number }).c;
|
||||
if (adminCount <= 1) throw new NotOwnerError('Cannot delete the last admin account.');
|
||||
}
|
||||
db.prepare('DELETE FROM halt_state WHERE user_id=?').run(targetUserId);
|
||||
db.prepare('DELETE FROM users WHERE id=?').run(targetUserId);
|
||||
recordAudit(db, actorId ?? 'cli', 'users.delete', targetUserId, null);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Force a password reset: issues a fresh hash (the operator supplies a temp password;
|
||||
* the caller MUST communicate it out-of-band). Never logs the plaintext. */
|
||||
export function resetPassword(
|
||||
|
||||
@@ -145,11 +145,20 @@ export const SYMBOL_CONTEXT_OVERRIDES: Record<string, SymbolContextOverride> = {
|
||||
peers: ['CRWV', 'NBIS', 'APLD', 'VRT', 'SMCI', 'ANET', 'EQIX', 'DLR'],
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (AI infrastructure)',
|
||||
themeEtf: 'SMH',
|
||||
themeEtf: null,
|
||||
themeLabel: 'AI / hyperscaler infrastructure',
|
||||
classificationNote:
|
||||
'Vendor feeds often label IREN as Financial Services / Capital Markets (crypto-era bucket). Operating comparison uses AI infrastructure / GPU-cloud peers (e.g. CRWV, NBIS), not banks or pure crypto miners.',
|
||||
},
|
||||
SLNH: {
|
||||
peers: ['IREN', 'CIFR', 'WULF', 'CORZ', 'APLD'],
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (green data centers)',
|
||||
themeEtf: null,
|
||||
themeLabel: null,
|
||||
classificationNote:
|
||||
'Vendor feeds label SLNH as Financial Services; operating profile is green-energy data centers and crypto-mining infrastructure.',
|
||||
},
|
||||
CRWV: {
|
||||
peers: ['NBIS', 'IREN', 'APLD', 'VRT', 'SMCI', 'ANET', 'EQIX', 'DLR'],
|
||||
sectorEtf: 'XLK',
|
||||
@@ -168,8 +177,8 @@ export const SYMBOL_CONTEXT_OVERRIDES: Record<string, SymbolContextOverride> = {
|
||||
},
|
||||
CIFR: {
|
||||
peers: ['RIOT', 'MARA', 'CLSK', 'WULF', 'CORZ', 'HUT', 'BITF', 'IREN'],
|
||||
sectorEtf: 'XLE',
|
||||
sectorLabel: 'Energy-linked digital assets',
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (digital assets)',
|
||||
themeEtf: null,
|
||||
themeLabel: 'Digital-asset mining',
|
||||
classificationNote:
|
||||
@@ -177,24 +186,24 @@ export const SYMBOL_CONTEXT_OVERRIDES: Record<string, SymbolContextOverride> = {
|
||||
},
|
||||
RIOT: {
|
||||
peers: [...CRYPTO_MINING_PEERS],
|
||||
sectorEtf: 'XLE',
|
||||
sectorLabel: 'Energy-linked digital assets',
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (digital assets)',
|
||||
themeEtf: null,
|
||||
themeLabel: 'Digital-asset mining',
|
||||
classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.',
|
||||
},
|
||||
MARA: {
|
||||
peers: [...CRYPTO_MINING_PEERS],
|
||||
sectorEtf: 'XLE',
|
||||
sectorLabel: 'Energy-linked digital assets',
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (digital assets)',
|
||||
themeEtf: null,
|
||||
themeLabel: 'Digital-asset mining',
|
||||
classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.',
|
||||
},
|
||||
CLSK: {
|
||||
peers: [...CRYPTO_MINING_PEERS],
|
||||
sectorEtf: 'XLE',
|
||||
sectorLabel: 'Energy-linked digital assets',
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (digital assets)',
|
||||
themeEtf: null,
|
||||
themeLabel: 'Digital-asset mining',
|
||||
classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.',
|
||||
@@ -203,7 +212,7 @@ export const SYMBOL_CONTEXT_OVERRIDES: Record<string, SymbolContextOverride> = {
|
||||
peers: [...CRYPTO_MINING_PEERS],
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (compute infrastructure)',
|
||||
themeEtf: 'SMH',
|
||||
themeEtf: null,
|
||||
themeLabel: 'AI / HPC infrastructure',
|
||||
classificationNote: 'Mining + HPC/AI data-center transition; not a traditional financial.',
|
||||
},
|
||||
@@ -211,7 +220,7 @@ export const SYMBOL_CONTEXT_OVERRIDES: Record<string, SymbolContextOverride> = {
|
||||
peers: [...CRYPTO_MINING_PEERS],
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (compute infrastructure)',
|
||||
themeEtf: 'SMH',
|
||||
themeEtf: null,
|
||||
themeLabel: 'AI / HPC infrastructure',
|
||||
classificationNote: 'Mining + AI hosting; not a traditional financial.',
|
||||
},
|
||||
@@ -219,7 +228,7 @@ export const SYMBOL_CONTEXT_OVERRIDES: Record<string, SymbolContextOverride> = {
|
||||
peers: ['IREN', 'CIFR', 'CRWV', 'NBIS', 'EQIX', 'DLR', 'VRT', 'SMCI'],
|
||||
sectorEtf: 'XLK',
|
||||
sectorLabel: 'Technology (data centers)',
|
||||
themeEtf: 'SMH',
|
||||
themeEtf: null,
|
||||
themeLabel: 'AI / HPC infrastructure',
|
||||
classificationNote: 'AI data-center developer/operator peer set.',
|
||||
},
|
||||
|
||||
@@ -20,10 +20,10 @@ function freshDb(): DatabaseSync {
|
||||
// Apply the watchlists table.
|
||||
db.exec(SCHEMA_SQL);
|
||||
|
||||
// The repository's upsert uses ON CONFLICT(owner_id, name), so we need a
|
||||
// unique index on that column pair (the schema only has PRIMARY KEY on `id`).
|
||||
// The repository's upsert uses ON CONFLICT(owner_id, kind, name), so we need a
|
||||
// unique index on that column triple (the schema only has PRIMARY KEY on `id`).
|
||||
db.exec(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_watchlists_owner_name ON watchlists(owner_id, name);',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_watchlists_owner_kind_name ON watchlists(owner_id, kind, name);',
|
||||
);
|
||||
// Seed a users row so the FK constraint on watchlists.owner_id doesn't fire.
|
||||
db.prepare(
|
||||
@@ -147,13 +147,13 @@ test('removeSymbol returns false when symbol is not in watchlist', () => {
|
||||
test('removeSymbol cleans up the watchlist when last symbol is removed', () => {
|
||||
const db = freshDb();
|
||||
|
||||
addSymbol(db, 'user_1', 'AAPL');
|
||||
const removed = removeSymbol(db, 'user_1', 'AAPL');
|
||||
addSymbol(db, 'user_1', 'AAPL', undefined, 'my-list');
|
||||
const removed = removeSymbol(db, 'user_1', 'AAPL', 'my-list');
|
||||
|
||||
assert.equal(removed, true);
|
||||
|
||||
// The watchlist row should be deleted (cleaned up).
|
||||
const rows = db.prepare('SELECT * FROM watchlists WHERE owner_id = ?').all('user_1') as Array<{ name: string }>;
|
||||
// The watchlist row should be deleted (cleaned up) for non-default lists.
|
||||
const rows = db.prepare("SELECT * FROM watchlists WHERE owner_id = ? AND name = 'my-list'").all('user_1') as Array<{ name: string }>;
|
||||
assert.equal(rows.length, 0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -64,6 +64,13 @@ function runMigrations(db: DatabaseSync): void {
|
||||
`ALTER TABLE users ADD COLUMN backup_codes_hashed TEXT`,
|
||||
`ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE rotation_state ADD COLUMN signal_since TEXT`,
|
||||
`ALTER TABLE users ADD COLUMN modules TEXT NOT NULL DEFAULT '["research","settings"]'`,
|
||||
`UPDATE users SET modules='["research","execution","analytics","settings"]' WHERE modules='["research","settings"]'`,
|
||||
`ALTER TABLE watchlists ADD COLUMN kind TEXT NOT NULL DEFAULT 'user'`,
|
||||
`ALTER TABLE watchlists ADD COLUMN class_key TEXT`,
|
||||
`ALTER TABLE watchlists ADD COLUMN class_label TEXT`,
|
||||
`DROP INDEX IF EXISTS idx_watchlists_owner_name`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_watchlists_owner_kind_name ON watchlists(owner_id, kind, name)`,
|
||||
`CREATE TABLE IF NOT EXISTS rotation_state (
|
||||
id TEXT PRIMARY KEY DEFAULT 'singleton',
|
||||
signal TEXT NOT NULL DEFAULT 'none',
|
||||
|
||||
@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
convexity_posture TEXT NOT NULL DEFAULT 'off', -- off|covered_income|cash_secured_entry|insurance_sleeve|leaps_conviction
|
||||
backup_codes_hashed TEXT, -- JSON array of scrypt-hashed backup codes (slice 2)
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active|pending_approval|rejected (user provisioning)
|
||||
modules TEXT NOT NULL DEFAULT '["research","settings"]', -- JSON array of allowed module keys
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
@@ -253,16 +254,17 @@ CREATE TABLE IF NOT EXISTS symbol_demand (
|
||||
|
||||
-- ===== Tier C — Per-user (ownerId NOT NULL) =====
|
||||
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
|
||||
created_at TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
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
|
||||
created_at TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
kind TEXT NOT NULL DEFAULT 'user', -- 'user' | 'sector' | 'thematic' | 'style' | 'region'
|
||||
class_key TEXT, -- ETF symbol for derived lists (XLK, SMH...); NULL for user
|
||||
class_label TEXT -- human label synced with market outlook; NULL for user
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_watchlists_owner_name ON watchlists(owner_id, name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portfolio_holdings (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { resolveBusinessContext } from '../analysis/tickerContext.ts';
|
||||
|
||||
export interface WatchlistEntry {
|
||||
symbol: string;
|
||||
@@ -13,6 +14,9 @@ export interface WatchlistMeta {
|
||||
symbol_count: number;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
kind?: string;
|
||||
class_key?: string | null;
|
||||
class_label?: string | null;
|
||||
}
|
||||
|
||||
interface WatchlistRow {
|
||||
@@ -22,34 +26,70 @@ interface WatchlistRow {
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, COALESCE(?, 0))
|
||||
ON CONFLICT(owner_id, name) DO UPDATE SET
|
||||
`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`,
|
||||
sort_order = excluded.sort_order,
|
||||
class_key = excluded.class_key,
|
||||
class_label = excluded.class_label`,
|
||||
),
|
||||
selectByOwnerAndName: db.prepare(
|
||||
`SELECT id, owner_id, name, symbols, created_at, sort_order
|
||||
FROM watchlists WHERE owner_id = ? AND name = ?`,
|
||||
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
|
||||
`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`,
|
||||
),
|
||||
deleteByOwnerAndName: db.prepare(
|
||||
`DELETE FROM watchlists WHERE owner_id = ? AND name = ?`,
|
||||
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
|
||||
`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(
|
||||
@@ -59,9 +99,18 @@ function stmts(db: DatabaseSync) {
|
||||
`UPDATE watchlists SET sort_order = ? WHERE id = ? AND owner_id = ?`,
|
||||
),
|
||||
selectAllByOwner: db.prepare(
|
||||
`SELECT id, owner_id, name, symbols, created_at, sort_order
|
||||
`SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label
|
||||
FROM watchlists WHERE owner_id = ?
|
||||
ORDER BY sort_order ASC, created_at ASC`,
|
||||
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`,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -76,7 +125,7 @@ export function addSymbol(
|
||||
const s = stmts(db);
|
||||
const upper = symbol.toUpperCase();
|
||||
|
||||
const existing = readWatchlistRaw(db, userId, watchlistName);
|
||||
const existing = readWatchlistRaw(db, userId, watchlistName, 'user');
|
||||
|
||||
if (existing) {
|
||||
const alreadyExists = existing.symbols.some((sym) => {
|
||||
@@ -92,14 +141,14 @@ export function addSymbol(
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
s.upsert.run(existing.id, userId, watchlistName, JSON.stringify(existing.symbols), now, 0);
|
||||
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);
|
||||
s.upsert.run(id, userId, watchlistName, JSON.stringify(serialized), now, 0, 'user', null, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -112,7 +161,7 @@ export function removeSymbol(
|
||||
const s = stmts(db);
|
||||
const upper = symbol.toUpperCase();
|
||||
|
||||
const existing = readWatchlistRaw(db, userId, watchlistName);
|
||||
const existing = readWatchlistRaw(db, userId, watchlistName, 'user');
|
||||
if (!existing) return false;
|
||||
|
||||
const before = existing.symbols.length;
|
||||
@@ -126,7 +175,11 @@ export function removeSymbol(
|
||||
}
|
||||
|
||||
if (remaining.length === 0) {
|
||||
s.deleteByOwnerAndName.run(userId, watchlistName);
|
||||
if (watchlistName === 'default') {
|
||||
s.updateSymbols.run('[]', existing.id, userId);
|
||||
return true;
|
||||
}
|
||||
s.deleteByOwnerKindName.run(userId, 'user', watchlistName);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -134,12 +187,52 @@ export function removeSymbol(
|
||||
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.selectByOwner.all(userId) as unknown as WatchlistRow[];
|
||||
const rows = s.selectByOwnerKind.all(userId, 'user') as unknown as WatchlistRow[];
|
||||
|
||||
const entries: WatchlistEntry[] = [];
|
||||
|
||||
@@ -163,8 +256,9 @@ export function listSymbols(
|
||||
}
|
||||
|
||||
export function listWatchlists(db: DatabaseSync, userId: string): WatchlistMeta[] {
|
||||
materializeClassificationWatchlists(db, userId);
|
||||
const s = stmts(db);
|
||||
const rows = s.selectByOwner.all(userId) as unknown as WatchlistRow[];
|
||||
const rows = s.selectAllByOwner.all(userId) as unknown as WatchlistRow[];
|
||||
return rows.map((row) => {
|
||||
const parsed = safeParseSymbols(row.symbols);
|
||||
return {
|
||||
@@ -173,6 +267,9 @@ export function listWatchlists(db: DatabaseSync, userId: string): WatchlistMeta[
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -187,15 +284,16 @@ export function createWatchlist(
|
||||
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 };
|
||||
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);
|
||||
const existing = readWatchlistRaw(db, userId, name, 'user');
|
||||
if (!existing) return false;
|
||||
s.deleteByOwnerAndName.run(userId, name);
|
||||
s.deleteByOwnerKindName.run(userId, 'user', name);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -205,14 +303,15 @@ export function renameWatchlist(
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): boolean {
|
||||
if (oldName === 'default') return false;
|
||||
const s = stmts(db);
|
||||
const existing = readWatchlistRaw(db, userId, oldName);
|
||||
const existing = readWatchlistRaw(db, userId, oldName, 'user');
|
||||
if (!existing) return false;
|
||||
const conflict = readWatchlistRaw(db, userId, newName);
|
||||
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);
|
||||
s.deleteByOwnerAndName.run(userId, oldName);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -232,7 +331,7 @@ export function getSymbolsInWatchlist(
|
||||
userId: string,
|
||||
watchlistName: string = 'default',
|
||||
): string[] {
|
||||
const existing = readWatchlistRaw(db, userId, watchlistName);
|
||||
const existing = readWatchlistRaw(db, userId, watchlistName, 'user');
|
||||
if (!existing) return [];
|
||||
return existing.symbols.map((sym) => {
|
||||
if (typeof sym === 'string') return sym;
|
||||
@@ -258,9 +357,19 @@ export function listSymbolsByWatchlist(
|
||||
userId: string,
|
||||
watchlistName: string,
|
||||
): WatchlistEntry[] {
|
||||
const raw = readWatchlistRaw(db, userId, watchlistName);
|
||||
if (!raw) return [];
|
||||
|
||||
// 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') {
|
||||
@@ -281,10 +390,13 @@ function readWatchlistRaw(
|
||||
db: DatabaseSync,
|
||||
userId: string,
|
||||
name: string,
|
||||
kind?: 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[];
|
||||
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 };
|
||||
@@ -293,3 +405,86 @@ function readWatchlistRaw(
|
||||
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); }
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { ThesisEvent } from '../thesis/ThesisMonitor.ts';
|
||||
import type { OptionsUnlockState } from '../options/ConvexityGate.ts';
|
||||
import type { XCookieHealth } from '../adapters/XCookieAdapter.ts';
|
||||
import { emaFromCandles, rsi as rsiFn, relativeVolume, macd as macdFn } from '../analysis/indicators.ts';
|
||||
import { listUsers, resetPassword, gdprExport, queueHealth, resetQueueBackoff, NotOwnerError, listUserSessions, listAuditLog, queueSecFetch } from '../admin/admin.ts';
|
||||
import { listUsers, resetPassword, gdprExport, queueHealth, resetQueueBackoff, NotOwnerError, listUserSessions, listAuditLog, queueSecFetch, setUserModules, disableUser, enableUser, deleteUser } from '../admin/admin.ts';
|
||||
import { restartServers, type RestartTarget } from '../lib/restart.ts';
|
||||
import type { LintResult } from '../services/secDataFetcher.ts';
|
||||
import { EdgarAdapter } from '../adapters/EdgarAdapter.ts';
|
||||
@@ -101,9 +101,15 @@ const authRouter = router({
|
||||
}),
|
||||
me: publicProcedure.query(({ ctx }) => {
|
||||
if (!ctx.userId) return null;
|
||||
const u = ctx.db.prepare('SELECT id,email,complexity,risk_tolerance,convexity_posture FROM users WHERE id=?').get(ctx.userId) as { id: string; email: string; complexity: string; risk_tolerance: string; convexity_posture: string } | undefined;
|
||||
const u = ctx.db.prepare('SELECT id,email,complexity,risk_tolerance,convexity_posture,modules FROM users WHERE id=?').get(ctx.userId) as { id: string; email: string; complexity: string; risk_tolerance: string; convexity_posture: string; modules: string } | undefined;
|
||||
const wl = ctx.db.prepare('SELECT 1 FROM watchlists WHERE owner_id=? LIMIT 1').get(ctx.userId);
|
||||
return u ? { userId: u.id, email: u.email, complexity: u.complexity, riskTolerance: u.risk_tolerance, convexityPosture: u.convexity_posture, onboarded: !!wl } : null;
|
||||
let modules: string[] = ['research', 'settings'];
|
||||
try { modules = JSON.parse(u?.modules ?? '["research","settings"]'); } catch { /* keep default */ }
|
||||
if (u?.id) {
|
||||
const adminRow = ctx.db.prepare('SELECT is_admin FROM users WHERE id=?').get(u.id) as { is_admin: number } | undefined;
|
||||
if (adminRow?.is_admin && !modules.includes('admin')) modules.push('admin');
|
||||
}
|
||||
return u ? { userId: u.id, email: u.email, complexity: u.complexity, riskTolerance: u.risk_tolerance, convexityPosture: u.convexity_posture, onboarded: !!wl, modules } : null;
|
||||
}),
|
||||
enable2fa: protectedProcedure.input(z.object({})).mutation(async ({ ctx }) => {
|
||||
const userId = ctx.userId as string;
|
||||
@@ -190,6 +196,10 @@ const onboardingRouter = router({
|
||||
await ctx.cache.subscribe(sym, kind);
|
||||
queueSecFetch(ctx.db, sym);
|
||||
}
|
||||
try {
|
||||
const { materializeClassificationWatchlists } = await import('../db/watchlistRepository.ts');
|
||||
materializeClassificationWatchlists(ctx.db, userId);
|
||||
} catch { /* ignore — sector data not available yet, will materialize on first listWatchlists */ }
|
||||
if (input.portfolio) {
|
||||
const ins = ctx.db.prepare('INSERT INTO portfolio_holdings (id, owner_id, symbol, qty, avg_cost, acquired_at, status) VALUES (?,?,?,?,?,?,?)');
|
||||
for (const h of input.portfolio) ins.run(randomUUID(), userId, h.symbol.toUpperCase(), h.qty, h.avgCost, h.acquiredAt, 'open');
|
||||
@@ -1079,6 +1089,33 @@ function parseCandlesFromChart(raw: Record<string, unknown>): PriceCandle[] {
|
||||
const adminRouter = router({
|
||||
usersList: adminProcedure.query(({ ctx }) => listUsers(ctx.db)),
|
||||
|
||||
setUserModules: adminProcedure
|
||||
.input(z.object({ userId: z.string().uuid(), modules: z.array(z.string()) }))
|
||||
.mutation(({ ctx, input }) => {
|
||||
return setUserModules(ctx.db, ctx.userId, input.userId, input.modules);
|
||||
}),
|
||||
|
||||
disableUser: adminProcedure
|
||||
.input(z.object({ userId: z.string().uuid() }))
|
||||
.mutation(({ ctx, input }) => {
|
||||
try { return disableUser(ctx.db, ctx.userId, input.userId); }
|
||||
catch (e) { throw new TRPCError({ code: 'FORBIDDEN', message: e instanceof Error ? e.message : 'Failed to disable user.' }); }
|
||||
}),
|
||||
|
||||
enableUser: adminProcedure
|
||||
.input(z.object({ userId: z.string().uuid() }))
|
||||
.mutation(({ ctx, input }) => {
|
||||
try { return enableUser(ctx.db, ctx.userId, input.userId); }
|
||||
catch (e) { throw new TRPCError({ code: 'FORBIDDEN', message: e instanceof Error ? e.message : 'Failed to enable user.' }); }
|
||||
}),
|
||||
|
||||
deleteUser: adminProcedure
|
||||
.input(z.object({ userId: z.string().uuid() }))
|
||||
.mutation(({ ctx, input }) => {
|
||||
try { return deleteUser(ctx.db, ctx.userId, input.userId); }
|
||||
catch (e) { throw new TRPCError({ code: 'FORBIDDEN', message: e instanceof Error ? e.message : 'Failed to delete user.' }); }
|
||||
}),
|
||||
|
||||
resetPassword: adminProcedure
|
||||
.input(z.object({ email: z.string().email(), tempPassword: z.string().min(8) }))
|
||||
.mutation(({ ctx, input }) => {
|
||||
@@ -2076,14 +2113,13 @@ const watchlistRouter = router({
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.userId ?? 'anonymous';
|
||||
const { addSymbol } = await import('../db/watchlistRepository.ts');
|
||||
const { addSymbol, materializeClassificationWatchlists } = await import('../db/watchlistRepository.ts');
|
||||
const added = addSymbol(ctx.db, userId, input.symbol, input.notes, input.watchlistName);
|
||||
|
||||
if (added) {
|
||||
materializeClassificationWatchlists(ctx.db, userId);
|
||||
await ctx.cache.subscribe(input.symbol, 'equity');
|
||||
queueSecFetch(ctx.db, input.symbol);
|
||||
}
|
||||
|
||||
return { added };
|
||||
}),
|
||||
|
||||
@@ -2095,10 +2131,25 @@ const watchlistRouter = router({
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.userId ?? 'anonymous';
|
||||
const { removeSymbol } = await import('../db/watchlistRepository.ts');
|
||||
const { removeSymbol, materializeClassificationWatchlists } = await import('../db/watchlistRepository.ts');
|
||||
const removed = removeSymbol(ctx.db, userId, input.symbol, input.watchlistName);
|
||||
if (removed) materializeClassificationWatchlists(ctx.db, userId);
|
||||
return { removed };
|
||||
}),
|
||||
|
||||
/** Move a symbol from one watchlist to another (both default to 'default'). */
|
||||
moveSymbol: publicProcedure
|
||||
.input(z.object({
|
||||
symbol: z.string().toUpperCase(),
|
||||
fromWatchlist: z.string().optional(),
|
||||
toWatchlist: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.userId ?? 'anonymous';
|
||||
const { moveSymbol } = await import('../db/watchlistRepository.ts');
|
||||
const moved = moveSymbol(ctx.db, userId, input.symbol, input.fromWatchlist ?? 'default', input.toWatchlist ?? 'default');
|
||||
return { moved };
|
||||
}),
|
||||
});
|
||||
|
||||
// ─── Portfolio Router (Slice 10) ──────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user