diff --git a/HANDOFF.md b/HANDOFF.md index 8ec339c..eaa63b8 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,32 +1,9 @@ -# Handoff pointer (updated 2026-07-18) +# Handoff pointer (updated 2026-07-23) -This file previously held a 2026-06-30 multi-agent orchestrator snapshot. That snapshot is **obsolete**. +Full handoff saved externally — not in repo. -## Read these instead - -1. **`docs/FUNCTIONAL_DESIGN.md`** — what is Live / Backend-ready / UI-local / Stub / Missing -2. **`docs/TECH_DESIGN.md`** — stack, tRPC map, engines, schema debt, test counts -3. **`CONTEXT.md`** — domain glossary (not status) -4. **Obsidian** `investor-flow.md` — session orientation wiki - -## Active gap tasks (created 2026-07-18) - -| Priority | Task | -|----------|------| -| P0 | `fix-macro-commentary-tests` | -| P0 | `wire-sizing-risk-trpc-m20` | -| P1 | `persist-trade-plan-execution-loop` | -| P1 | `frontend-api-client-coverage` | -| P1 | `wire-emotion-logger-backend` | -| P2 | `strategy-lab-backtest-ui` | -| P2 | `screener-ui-filter-strategy` | -| P2 | `thesis-monitor-derisking-ui` | -| P2 | `options-convexity-sleeve-ui` | -| P3 | `reports-research-note-ui` | -| P3 | `schema-strategies-dedupe` | - -## Quick verify - -```bash -cd app/server && npm test # expect 2 known MacroRegime failures until P0 fixed ``` +Read /tmp/handoff-investor-flow-20260723.md for full context +``` + +Short version: Phase 5 alert UI done, queue errors fixed, automaton removed. See docs/ for as-built status. diff --git a/app/server/src/admin/admin.ts b/app/server/src/admin/admin.ts index 08c10e1..2c1795a 100644 --- a/app/server/src/admin/admin.ts +++ b/app/server/src/admin/admin.ts @@ -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( diff --git a/app/server/src/analysis/tickerContext.ts b/app/server/src/analysis/tickerContext.ts index 7667608..ec17520 100644 --- a/app/server/src/analysis/tickerContext.ts +++ b/app/server/src/analysis/tickerContext.ts @@ -145,11 +145,20 @@ export const SYMBOL_CONTEXT_OVERRIDES: Record = { 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 = { }, 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 = { }, 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 = { 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 = { 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 = { 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.', }, diff --git a/app/server/src/db/__tests__/watchlistRepository.test.ts b/app/server/src/db/__tests__/watchlistRepository.test.ts index 37344c4..7ba80bc 100644 --- a/app/server/src/db/__tests__/watchlistRepository.test.ts +++ b/app/server/src/db/__tests__/watchlistRepository.test.ts @@ -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(); diff --git a/app/server/src/db/client.ts b/app/server/src/db/client.ts index 946e5f8..4b4a4dd 100644 --- a/app/server/src/db/client.ts +++ b/app/server/src/db/client.ts @@ -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', diff --git a/app/server/src/db/schema.sql b/app/server/src/db/schema.sql index 5fa2fc1..492d268 100644 --- a/app/server/src/db/schema.sql +++ b/app/server/src/db/schema.sql @@ -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, diff --git a/app/server/src/db/watchlistRepository.ts b/app/server/src/db/watchlistRepository.ts index e0fac76..f32d6df 100644 --- a/app/server/src/db/watchlistRepository.ts +++ b/app/server/src/db/watchlistRepository.ts @@ -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 = { + 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).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; 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, 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(); + 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(); + 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>(); // 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(); + 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); } +} diff --git a/app/server/src/trpc/router.ts b/app/server/src/trpc/router.ts index e822c3f..a8c2d42 100644 --- a/app/server/src/trpc/router.ts +++ b/app/server/src/trpc/router.ts @@ -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): 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) ────────────────────────────────────────────── diff --git a/app/src/app/admin/users/page.tsx b/app/src/app/admin/users/page.tsx index 4f9cd28..d0904fd 100644 --- a/app/src/app/admin/users/page.tsx +++ b/app/src/app/admin/users/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { AdminLayout } from "@/components/AdminLayout"; import { api } from "@/lib/trpc"; @@ -10,13 +10,7 @@ interface UserRow { created_at: string; is_admin: number; status: string; -} - -interface SessionRow { - id: string; - user_id: string; - expires_at: string; - created_at: string; + modules: string; } function Spinner() { @@ -35,14 +29,6 @@ function ErrorBanner({ message }: { message: string }) { ); } -function SuccessBanner({ message }: { message: string }) { - return ( -
- {message} -
- ); -} - function Modal({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) { useEffect(() => { if (!open) return; @@ -91,7 +77,7 @@ function ResetPasswordModal({ open, onClose, email, onReset }: { return ( { setConfirmText(""); setResult(null); onClose(); }}> -
+

Reset Password

User: {email}

@@ -143,156 +129,249 @@ function ResetPasswordModal({ open, onClose, email, onReset }: { ); } -function SessionsPanel({ userId, onClose }: { userId: string; onClose: () => void }) { - const [sessions, setSessions] = useState([]); - const [loading, setLoading] = useState(true); +const ALL_MODULES = ["research", "execution", "analytics", "settings"] as const; - useEffect(() => { - let cancelled = false; - api.admin.userSessions(userId) - .then((data) => { if (!cancelled) setSessions(data); }) - .catch(() => {}) - .finally(() => { if (!cancelled) setLoading(false); }); - return () => { cancelled = true; }; - }, [userId]); +function ModulesModal({ open, onClose, user, onUpdated }: { open: boolean; onClose: () => void; user: UserRow; onUpdated: () => void }) { + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [selected, setSelected] = useState>(() => { + try { return new Set(JSON.parse(user.modules)); } catch { return new Set(["research", "settings"]); } + }); - function formatTime(ts: string): string { + async function handleSave() { + setSaving(true); setError(null); try { - const d = new Date(ts); - return d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); - } catch { - return ts.slice(0, 16); + await api.admin.setUserModules(user.id, Array.from(selected)); + onUpdated(); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to update modules"); + } finally { + setSaving(false); } } - function truncateId(id: string, max = 8): string { - if (id.length <= max) return id; - return id.slice(0, max - 2) + ".."; - } - - if (loading && sessions.length === 0) { - return
; - } - return ( -
-
- - Active Sessions ({sessions.length}) - - -
+ +
+

Module Access

+

User: {user.email}

- {sessions.length === 0 ? ( -

No active sessions

- ) : ( - - - - - - - - - - {sessions.map((s) => ( - - - - - + {error && ( +
{error}
+ )} + +
+ +
+ {ALL_MODULES.map((mod) => ( + ))} -
-
Session IDExpires AtCreated At
{truncateId(s.id)}{formatTime(s.expires_at)}{formatTime(s.created_at)}
- )} -
+
+ +
+ + +
+
+
+
); } -function UserTableRow({ user, onReset }: { user: UserRow; onReset: (email: string) => void }) { - const [showSessions, setShowSessions] = useState(false); - const [complexityLabel] = useState(() => { - switch (user.complexity) { - case "beginner": return Beginner; - case "intermediate": return Intermediate; - case "advanced": return Advanced; - default: return {user.complexity}; - } - }); +function ConfirmModal({ open, onClose, title, message, confirmLabel, danger, onConfirm, loading }: { + open: boolean; onClose: () => void; title: string; message: string; + confirmLabel: string; danger?: boolean; onConfirm: () => Promise; loading: boolean; +}) { + return ( + +
+

{title}

+

{message}

- function formatTime(ts: string): string { - try { - const d = new Date(ts); - return d.toLocaleString("en-US", { month: "short", day: "numeric", year: "numeric" }); - } catch { - return ts.slice(0, 10); +
+
+ + +
+
+
+
+ ); +} + +function UserActions({ user, onUpdated }: { user: UserRow; onUpdated: () => void }) { + const [open, setOpen] = useState(false); + const [resetOpen, setResetOpen] = useState(false); + const [modulesOpen, setModulesOpen] = useState(false); + const [disableOpen, setDisableOpen] = useState(false); + const [enableOpen, setEnableOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const dropdownRef = useRef(null); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setOpen(false); + } } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + const isPending = user.status === "pending_approval"; + const isDisabled = user.status === "disabled"; + const isValidId = user.id && /^[0-9a-fA-F]{8}-/.test(user.id); + + async function doResetPassword() { + setResetOpen(false); + setLoading(true); setError(null); + try { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let tempPassword = ""; + for (let i = 0; i < 12; i++) tempPassword += chars.charAt(Math.floor(Math.random() * chars.length)); + await api.admin.resetPassword(user.email, tempPassword); + setError(null); + window.alert(`Password reset successfully. Temp password: ${tempPassword}`); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to reset password"); + } finally { setLoading(false); } + } + + async function doDisable() { + setDisableOpen(false); + setLoading(true); setError(null); + try { await api.admin.disableUser(user.id); onUpdated(); } + catch (e) { setError(e instanceof Error ? e.message : "Failed to disable"); } + finally { setLoading(false); } + } + + async function doEnable() { + setEnableOpen(false); + setLoading(true); setError(null); + try { await api.admin.enableUser(user.id); onUpdated(); } + catch (e) { setError(e instanceof Error ? e.message : "Failed to enable"); } + finally { setLoading(false); } + } + + async function doDelete() { + setDeleteOpen(false); + setLoading(true); setError(null); + try { await api.admin.deleteUser(user.id); onUpdated(); } + catch (e) { setError(e instanceof Error ? e.message : "Failed to delete"); } + finally { setLoading(false); } } return ( <> - - {user.email} - {complexityLabel} - - {user.is_admin === 1 ? ( - Admin - ) : ( - User - )} - - - {user.status === 'pending_approval' ? ( - Pending - ) : user.status === 'rejected' ? ( - Rejected - ) : ( - Active - )} - - {formatTime(user.created_at)} - - - - - - - - {showSessions && ( - - - setShowSessions(false)} /> - - +
+ + {open && ( +
+ {!isPending && ( + <> + + +
+ {isDisabled ? ( + + ) : ( + + )} + + + )} + {isPending && ( + Pending approval + )} +
)} +
+ + setResetOpen(false)} email={user.email} onReset={doResetPassword} /> + setModulesOpen(false)} user={user} onUpdated={onUpdated} /> + setDisableOpen(false)} title="Disable User" + message={`Disable ${user.email}? They won't be able to sign in.`} + confirmLabel="Disable" danger onConfirm={doDisable} loading={loading} + /> + setEnableOpen(false)} title="Enable User" + message={`Re-enable ${user.email}?`} + confirmLabel="Enable" onConfirm={doEnable} loading={loading} + /> + setDeleteOpen(false)} title="Delete User" + message={`Permanently delete ${user.email}? This cannot be undone.`} + confirmLabel="Delete" danger onConfirm={doDelete} loading={loading} + /> + + {error &&
{error}
} ); } +const VALID_MODULES = ["research", "execution", "analytics", "settings"]; + export default function AdminUsersPage() { const [users, setUsers] = useState([]); const [pending, setPending] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [resetModal, setResetModal] = useState<{ open: boolean; email: string }>({ open: false, email: "" }); - const [successMsg, setSuccessMsg] = useState(null); const [approveBusy, setApproveBusy] = useState(null); const load = useCallback(() => { setLoading(true); setError(null); Promise.all([ api.admin.usersList().catch((e: Error) => { setError(e.message); return []; }), - api.admin.pendingUsers().catch(() => []), + api.admin.pendingUsers().catch(() => []).then((list: Array<{id:string;email:string;complexity:string;created_at:string}>) => + list.map((u) => ({ ...u, is_admin: 0, status: "pending_approval", modules: '["research","settings"]' })) + ), ]).then(([u, p]) => { setUsers(u); setPending(p); }).finally(() => setLoading(false)); }, []); @@ -301,15 +380,15 @@ export default function AdminUsersPage() { async function handleApprove(userId: string) { setApproveBusy(userId); try { await api.admin.approveUser(userId); load(); } - catch (e) { setError(e instanceof Error ? e.message : 'Failed to approve.'); } + catch (e) { setError(e instanceof Error ? e.message : "Failed to approve."); } finally { setApproveBusy(null); } } async function handleReject(userId: string) { - if (!confirm('Reject this user?')) return; + if (!confirm("Reject this user?")) return; setApproveBusy(userId); try { await api.admin.rejectUser(userId); load(); } - catch (e) { setError(e instanceof Error ? e.message : 'Failed to reject.'); } + catch (e) { setError(e instanceof Error ? e.message : "Failed to reject."); } finally { setApproveBusy(null); } } @@ -322,36 +401,6 @@ export default function AdminUsersPage() { return () => { cancelled = true; }; }, []); - function openResetModal(email: string) { - setSuccessMsg(null); - setResetModal({ open: true, email }); - } - - async function handlePasswordReset(confirmed: boolean) { - if (!confirmed || !resetModal.email) throw new Error("Confirmation required"); - - const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let tempPassword = ""; - for (let i = 0; i < 12; i++) { - tempPassword += chars.charAt(Math.floor(Math.random() * chars.length)); - } - - await api.admin.resetPassword(resetModal.email, tempPassword); - setSuccessMsg(`Temporary password generated and reset successfully.`); - - // Display the password in the modal via a custom event since we can't pass props back easily - setTimeout(() => { - const el = document.getElementById("temp-password-display"); - if (el) el.textContent = tempPassword; - }, 50); - - setResetModal({ open: false, email: "" }); - } - - function closeResetModal() { - setResetModal({ open: false, email: "" }); - } - if (loading) return ; return ( @@ -359,11 +408,10 @@ export default function AdminUsersPage() {

User Management

-

View users, manage sessions, and reset passwords

+

View users, manage sessions, reset passwords, and control module access

{error && } - {successMsg && } {/* Pending Approvals */} {pending.length > 0 && ( @@ -386,25 +434,17 @@ export default function AdminUsersPage() { {u.email} - {u.complexity === 'beginner' ? 'Beginner' : u.complexity === 'intermediate' ? 'Intermediate' : 'Advanced'} + {u.complexity === "beginner" ? "Beginner" : u.complexity === "intermediate" ? "Intermediate" : "Advanced"} {new Date(u.created_at).toLocaleDateString()}
- -
@@ -416,7 +456,8 @@ export default function AdminUsersPage() { )} -
+ {/* Users Table */} +

Users

{users.length} user{users.length !== 1 ? "s" : ""} @@ -427,36 +468,51 @@ export default function AdminUsersPage() {

No users found

) : ( - <> - - - - - - - - - - +
EmailComplexityRoleStatusCreated AtSessionsActions
+ + + + + + + + + + + {users.map((user) => ( + + + + + + - - - {users.map((user) => ( - - ))} - -
EmailRoleStatusCreated AtActions
{user.email} + {user.is_admin === 1 ? ( + Admin + ) : ( + User + )} + + {user.status === "pending_approval" ? ( + Pending + ) : user.status === "rejected" ? ( + Rejected + ) : user.status === "disabled" ? ( + Disabled + ) : ( + Active + )} + + {new Date(user.created_at).toLocaleDateString()} + + +
- + ))} + + )}
- -
); -} +} \ No newline at end of file diff --git a/app/src/app/alerts/page.tsx b/app/src/app/alerts/page.tsx index 7bfb2d5..1108551 100644 --- a/app/src/app/alerts/page.tsx +++ b/app/src/app/alerts/page.tsx @@ -1,5 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { LayoutShell } from "@/components/LayoutShell"; +import { FeatureGate } from "@/components/FeatureGate"; import { api, type AlertEventRow, type AlertSubscriptionRow } from "@/lib/trpc"; const SEVERITY_COLORS: Record = { @@ -8,7 +10,7 @@ const SEVERITY_COLORS: Record = { critical: "text-red-400 bg-red-950/30 border-red-500/30", }; -export default function AlertsPage() { +function AlertsPageInner() { const [tab, setTab] = useState<"events" | "subscriptions">("events"); const [events, setEvents] = useState([]); const [subscriptions, setSubscriptions] = useState([]); @@ -64,8 +66,8 @@ export default function AlertsPage() { const unacknowledged = events.filter((e) => !e.acknowledged); return ( -
-
+ +

Alerts

@@ -167,6 +169,14 @@ export default function AlertsPage() {
)}
-
+ + ); +} + +export default function AlertsPage() { + return ( + + + ); } diff --git a/app/src/app/daily-focus/page.tsx b/app/src/app/daily-focus/page.tsx index 5cb63c5..e3b94c7 100644 --- a/app/src/app/daily-focus/page.tsx +++ b/app/src/app/daily-focus/page.tsx @@ -5,8 +5,9 @@ import { useState, useRef } from 'react'; import { useGoalsStore } from '@/stores/goals-store'; import type { MonthlyGoal, WeeklyGoal } from '@/types'; import { LayoutShell } from '@/components/LayoutShell'; +import { FeatureGate } from '@/components/FeatureGate'; -export default function DailyFocusPage() { +function DailyFocusPageInner() { const store = useGoalsStore(); const [showAddMonthly, setShowAddMonthly] = useState(false); @@ -241,3 +242,11 @@ function MonthlyGoalItem({ goal, onToggle, onDelete }: { goal: { id: string; des
); } + +export default function DailyFocusPage() { + return ( + + + + ); +} diff --git a/app/src/app/execution/page.tsx b/app/src/app/execution/page.tsx index 7541f21..d025bca 100644 --- a/app/src/app/execution/page.tsx +++ b/app/src/app/execution/page.tsx @@ -6,6 +6,7 @@ import { useExecutionStore } from '@/stores/execution-store'; import { EmotionLogger } from '@/components/EmotionLogger'; import type { ExecutionPlaybook, EmotionLogState, MistakeStage, ScaleEntry } from '@/types'; import { LayoutShell } from '@/components/LayoutShell'; +import { FeatureGate } from '@/components/FeatureGate'; const PLAYBOOK_LABELS: Record = { mean_reversion: 'Mean Reversion (MR)', @@ -14,7 +15,7 @@ const PLAYBOOK_LABELS: Record = { const EXECUTION_PATTERNS = Object.keys(PLAYBOOK_LABELS) as ExecutionPlaybook[]; -export default function ExecutionPage() { +function ExecutionPageInner() { const store = useExecutionStore(); const plan = store.executionPlaybook; @@ -377,3 +378,11 @@ function KPICard({ label, value, color }: { label: string; value: string; color: ); } + +export default function ExecutionPage() { + return ( + + + + ); +} diff --git a/app/src/app/filings/page.tsx b/app/src/app/filings/page.tsx index f9b17ea..a938366 100644 --- a/app/src/app/filings/page.tsx +++ b/app/src/app/filings/page.tsx @@ -1,11 +1,20 @@ "use client"; import { LayoutShell } from "@/components/LayoutShell"; +import { FeatureGate } from "@/components/FeatureGate"; import { FilingsPanel } from "@/components/FilingsPanel"; -export default function FilingsPage() { +function FilingsPageInner() { return ( ); } + +export default function FilingsPage() { + return ( + + + + ); +} diff --git a/app/src/app/options-dd/page.tsx b/app/src/app/options-dd/page.tsx index d6cbbde..2459e48 100644 --- a/app/src/app/options-dd/page.tsx +++ b/app/src/app/options-dd/page.tsx @@ -1,11 +1,20 @@ "use client"; import { LayoutShell } from "@/components/LayoutShell"; +import { FeatureGate } from "@/components/FeatureGate"; import { OptionsDDPanel } from "@/components/OptionsDDPanel"; -export default function OptionsDDPage() { +function OptionsDDPageInner() { return ( ); } + +export default function OptionsDDPage() { + return ( + + + + ); +} diff --git a/app/src/app/risk/page.tsx b/app/src/app/risk/page.tsx index 57132ef..f52815c 100644 --- a/app/src/app/risk/page.tsx +++ b/app/src/app/risk/page.tsx @@ -1,14 +1,23 @@ "use client"; import { LayoutShell } from "@/components/LayoutShell"; +import { FeatureGate } from "@/components/FeatureGate"; import { RiskPosturePanel } from "@/components/RiskPosturePanel"; /** * M20 Risk Posture — single surface for portfolio risk picture + sizing math. */ -export default function RiskPage() { +function RiskPageInner() { return ( ); } + +export default function RiskPage() { + return ( + + + + ); +} diff --git a/app/src/app/trade-closure/page.tsx b/app/src/app/trade-closure/page.tsx index 9981bb1..10d0bdf 100644 --- a/app/src/app/trade-closure/page.tsx +++ b/app/src/app/trade-closure/page.tsx @@ -1,11 +1,20 @@ "use client"; import { LayoutShell } from "@/components/LayoutShell"; +import { FeatureGate } from "@/components/FeatureGate"; import { TradeClosureView } from "@/components/trade-closure/TradeClosureView"; -export default function TradeClosurePage() { +function TradeClosurePageInner() { return ( ); } + +export default function TradeClosurePage() { + return ( + + + + ); +} diff --git a/app/src/app/trade-plan/page.tsx b/app/src/app/trade-plan/page.tsx index deed84f..64b5f58 100644 --- a/app/src/app/trade-plan/page.tsx +++ b/app/src/app/trade-plan/page.tsx @@ -4,6 +4,7 @@ export const dynamic = 'force-dynamic'; import { useTradePlanStore } from '@/stores/trade-plan-store'; import type { ConfluenceType, SetupTier, TradePlan } from '@/types'; import { LayoutShell } from '@/components/LayoutShell'; +import { FeatureGate } from '@/components/FeatureGate'; // --- Plan List Sidebar --- function PlanListSidebar({ plans }: { plans: TradePlan[] }) { @@ -147,7 +148,7 @@ function AddTargetForm({ onAdd, entryPrice }: { onAdd: (price: number, reason: s } // --- Main Component --- -export default function TradePlanPage() { +function TradePlanPageInner() { const store = useTradePlanStore(); const plan = store.currentPlanId ? store.activePlans.find(p => p.id === store.currentPlanId) @@ -454,5 +455,13 @@ export default function TradePlanPage() { ); } +export default function TradePlanPage() { + return ( + + + + ); +} + // --- Helpers --- import { useState } from 'react'; diff --git a/app/src/components/CommandPalette.tsx b/app/src/components/CommandPalette.tsx index f9cb15e..e6725c8 100644 --- a/app/src/components/CommandPalette.tsx +++ b/app/src/components/CommandPalette.tsx @@ -24,6 +24,7 @@ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { api } from "@/lib/trpc"; import { useActiveSymbol } from "@/stores/active-symbol-store"; +import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess"; interface NavCommand { label: string; @@ -31,25 +32,26 @@ interface NavCommand { icon: LucideIcon; goTo?: string; group: "Page"; + module?: ModuleKey; } const NAV_COMMANDS: NavCommand[] = [ - { label: "Overview", href: "/", icon: Home, goTo: "o", group: "Page" }, - { label: "Charts", href: "/chart-lab", icon: LineChart, goTo: "c", group: "Page" }, - { label: "Institutional", href: "/institutional", icon: Building2, goTo: "i", group: "Page" }, - { label: "Market Outlook", href: "/market-outlook", icon: TrendingUp, goTo: "m", group: "Page" }, - { label: "Trade Plan", href: "/trade-plan", icon: ClipboardList, goTo: "t", group: "Page" }, - { label: "Execution", href: "/execution", icon: PlayCircle, goTo: "e", group: "Page" }, - { label: "Trade Closure", href: "/trade-closure", icon: XCircle, goTo: "x", group: "Page" }, - { label: "Daily Focus", href: "/daily-focus", icon: Target, goTo: "d", group: "Page" }, - { label: "Risk Posture", href: "/risk", icon: Shield, goTo: "r", group: "Page" }, - { label: "Filings", href: "/filings", icon: FileText, goTo: "f", group: "Page" }, - { label: "Options DD", href: "/options-dd", icon: BarChart3, goTo: "p", group: "Page" }, - { label: "Settings", href: "/settings", icon: Settings, goTo: "s", group: "Page" }, - { label: "Admin", href: "/admin", icon: Shield, goTo: "a", group: "Page" }, - { label: "Admin · Users", href: "/admin/users", icon: Users, group: "Page" }, - { label: "Admin · Queue", href: "/admin/queue", icon: Activity, group: "Page" }, - { label: "Admin · Audit Logs", href: "/admin/audit-logs", icon: ScrollText, group: "Page" }, + { label: "Overview", href: "/", icon: Home, goTo: "o", group: "Page", module: "research" }, + { label: "Charts", href: "/chart-lab", icon: LineChart, goTo: "c", group: "Page", module: "research" }, + { label: "Institutional", href: "/institutional", icon: Building2, goTo: "i", group: "Page", module: "research" }, + { label: "Market Outlook", href: "/market-outlook", icon: TrendingUp, goTo: "m", group: "Page", module: "research" }, + { label: "Trade Plan", href: "/trade-plan", icon: ClipboardList, goTo: "t", group: "Page", module: "execution" }, + { label: "Execution", href: "/execution", icon: PlayCircle, goTo: "e", group: "Page", module: "execution" }, + { label: "Trade Closure", href: "/trade-closure", icon: XCircle, goTo: "x", group: "Page", module: "execution" }, + { label: "Daily Focus", href: "/daily-focus", icon: Target, goTo: "d", group: "Page", module: "execution" }, + { label: "Risk Posture", href: "/risk", icon: Shield, goTo: "r", group: "Page", module: "analytics" }, + { label: "Filings", href: "/filings", icon: FileText, goTo: "f", group: "Page", module: "analytics" }, + { label: "Options DD", href: "/options-dd", icon: BarChart3, goTo: "p", group: "Page", module: "analytics" }, + { label: "Settings", href: "/settings", icon: Settings, goTo: "s", group: "Page", module: "settings" }, + { label: "Admin", href: "/admin", icon: Shield, goTo: "a", group: "Page", module: "admin" }, + { label: "Admin · Users", href: "/admin/users", icon: Users, group: "Page", module: "admin" }, + { label: "Admin · Queue", href: "/admin/queue", icon: Activity, group: "Page", module: "admin" }, + { label: "Admin · Audit Logs", href: "/admin/audit-logs", icon: ScrollText, group: "Page", module: "admin" }, ]; interface Command { @@ -84,6 +86,7 @@ function fuzzyScore(query: string, text: string): number { export function CommandPalette() { const router = useRouter(); + const { modules } = useFeatureAccess(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); @@ -124,16 +127,26 @@ export function CommandPalette() { const typing = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement)?.isContentEditable; if (open || typing || e.metaKey || e.ctrlKey || e.altKey) return; if (gPressedAt.current && Date.now() - gPressedAt.current < 1200) { - const map: Record = { - o: "/", c: "/chart-lab", i: "/institutional", m: "/market-outlook", - t: "/trade-plan", e: "/execution", x: "/trade-closure", d: "/daily-focus", - r: "/risk", f: "/filings", p: "/options-dd", s: "/settings", a: "/admin", - }; - const href = map[e.key.toLowerCase()]; - if (href) { + const goToMap: Array<{ key: string; href: string; module?: ModuleKey }> = [ + { key: "o", href: "/", module: "research" }, + { key: "c", href: "/chart-lab", module: "research" }, + { key: "i", href: "/institutional", module: "research" }, + { key: "m", href: "/market-outlook", module: "research" }, + { key: "t", href: "/trade-plan", module: "execution" }, + { key: "e", href: "/execution", module: "execution" }, + { key: "x", href: "/trade-closure", module: "execution" }, + { key: "d", href: "/daily-focus", module: "execution" }, + { key: "r", href: "/risk", module: "analytics" }, + { key: "f", href: "/filings", module: "analytics" }, + { key: "p", href: "/options-dd", module: "analytics" }, + { key: "s", href: "/settings", module: "settings" }, + { key: "a", href: "/admin", module: "admin" }, + ]; + const entry = goToMap.find((m) => m.key === e.key.toLowerCase()); + if (entry && (!entry.module || modules.includes(entry.module))) { e.preventDefault(); gPressedAt.current = 0; - router.push(href); + router.push(entry.href); } return; } @@ -143,17 +156,19 @@ export function CommandPalette() { }; window.addEventListener("keydown", onGoTo); return () => window.removeEventListener("keydown", onGoTo); - }, [open, router]); + }, [open, router, modules]); const commands = useMemo(() => { - const pageCmds: Command[] = NAV_COMMANDS.map((c) => ({ - id: `page:${c.href}`, - label: c.label, - hint: c.goTo ? `g ${c.goTo}` : undefined, - group: c.group, - icon: c.icon, - run: () => router.push(c.href), - })); + const pageCmds: Command[] = NAV_COMMANDS + .filter((c) => !c.module || modules.includes(c.module)) + .map((c) => ({ + id: `page:${c.href}`, + label: c.label, + hint: c.goTo ? `g ${c.goTo}` : undefined, + group: c.group, + icon: c.icon, + run: () => router.push(c.href), + })); const symbolCmds: Command[] = symbols.map((sym) => ({ id: `sym:${sym}`, label: `Open ${sym}`, @@ -166,7 +181,7 @@ export function CommandPalette() { }, })); return [...pageCmds, ...symbolCmds]; - }, [symbols, router]); + }, [symbols, router, modules]); const results = useMemo(() => { if (!query.trim()) return commands; diff --git a/app/src/components/FeatureGate.tsx b/app/src/components/FeatureGate.tsx new file mode 100644 index 0000000..46cc778 --- /dev/null +++ b/app/src/components/FeatureGate.tsx @@ -0,0 +1,42 @@ +"use client"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess"; + +interface FeatureGateProps { + module: ModuleKey; + children: React.ReactNode; + fallback?: React.ReactNode; +} + +export function FeatureGate({ module, children, fallback }: FeatureGateProps) { + const { modules, loading } = useFeatureAccess(); + const router = useRouter(); + const allowed = modules.includes(module); + + useEffect(() => { + if (!loading && !allowed) { + router.replace("/"); + } + }, [loading, allowed, router]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!allowed) { + if (fallback) return <>{fallback}; + return ( +
+

Access Restricted

+

You don't have access to this module.

+
+ ); + } + + return <>{children}; +} diff --git a/app/src/components/MobileTabNav.tsx b/app/src/components/MobileTabNav.tsx index 9c819e4..0c410d3 100644 --- a/app/src/components/MobileTabNav.tsx +++ b/app/src/components/MobileTabNav.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess"; /** * Mobile tab navigation bar. @@ -12,24 +13,27 @@ interface TabItem { label: string; href: string; icon: string; + module: ModuleKey; } const TABS: TabItem[] = [ - { label: "Overview", href: "/", icon: "📊" }, - { label: "Filings", href: "/filings", icon: "📄" }, - { label: "Options", href: "/options-dd", icon: "🔮" }, - { label: "Alerts", href: "/alerts", icon: "🔔" }, - { label: "Execution", href: "/execution", icon: "⚡" }, - { label: "Trade Plan", href: "/trade-plan", icon: "📋" }, - { label: "Settings", href: "/settings", icon: "⚙️" }, + { label: "Overview", href: "/", icon: "📊", module: "research" }, + { label: "Filings", href: "/filings", icon: "📄", module: "analytics" }, + { label: "Options", href: "/options-dd", icon: "🔮", module: "analytics" }, + { label: "Alerts", href: "/alerts", icon: "🔔", module: "analytics" }, + { label: "Execution", href: "/execution", icon: "⚡", module: "execution" }, + { label: "Trade Plan", href: "/trade-plan", icon: "📋", module: "execution" }, + { label: "Settings", href: "/settings", icon: "⚙️", module: "settings" }, ]; export function MobileTabNav() { const pathname = usePathname(); + const { modules } = useFeatureAccess(); + const visibleTabs = TABS.filter((t) => modules.includes(t.module)); return (
diff --git a/app/src/components/WatchlistSidebar.tsx b/app/src/components/WatchlistSidebar.tsx index 5f44b91..e74e8e4 100644 --- a/app/src/components/WatchlistSidebar.tsx +++ b/app/src/components/WatchlistSidebar.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; import { useActiveSymbol } from "@/stores/active-symbol-store"; +import { useActiveWatchlist } from "@/stores/active-watchlist-store"; import { api, type WatchlistEntry, type Quote, type PortfolioHolding } from "@/lib/trpc"; import { chart as CHART } from "@/lib/chart-theme"; import { UI_STRINGS } from "@/lib/strings"; @@ -15,6 +16,9 @@ interface WatchlistMeta { symbol_count: number; sort_order: number; created_at: string; + kind?: string; + class_key?: string | null; + class_label?: string | null; } interface WatchlistSidebarProps { @@ -30,11 +34,19 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) { const [loading, setLoading] = useState(true); const [holdings, setHoldings] = useState([]); const [watchlists, setWatchlists] = useState([]); - const [activeWatchlist, setActiveWatchlist] = useState("default"); + const activeWatchlist = useActiveWatchlist((s) => s.activeWatchlist); + const setActiveWatchlist = useActiveWatchlist((s) => s.setActiveWatchlist); const [showListPicker, setShowListPicker] = useState(false); const [newListName, setNewListName] = useState(""); + const [menuOpenFor, setMenuOpenFor] = useState(null); + const menuRef = useRef(null); const pickerRef = useRef(null); + const userLists = watchlists.filter((w) => w.kind !== 'sector' && w.kind !== 'thematic' && w.kind !== 'style' && w.kind !== 'region'); + const systemLists = watchlists.filter((w) => w.kind === 'sector' || w.kind === 'thematic' || w.kind === 'style' || w.kind === 'region'); + const isSystemList = (name: string) => systemLists.some((w) => w.name === name); + const activeUserList = userLists.find((w) => w.name === activeWatchlist) ? activeWatchlist : 'default'; + // Portfolio summary useEffect(() => { api.portfolio.holdings().then(setHoldings).catch(() => {}); @@ -46,8 +58,10 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) { try { const wls = await api.watchlists.listWatchlists(); setWatchlists(wls); - if (wls.length > 0 && !wls.find((w) => w.name === activeWatchlist)) { - setActiveWatchlist(wls[0].name); + const allNames = wls.map((w) => w.name); + if (activeWatchlist && !allNames.includes(activeWatchlist)) { + const fallback = wls.find((w) => w.kind === 'user')?.name ?? 'default'; + setActiveWatchlist(fallback); } } catch { /* ignore */ } }; @@ -87,6 +101,7 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) { const add = async () => { if (!draft.trim()) return; + if (isSystemList(activeWatchlist)) return; const sym = draft.trim().toUpperCase(); await api.watchlists.addSymbol(sym, activeWatchlist === "default" ? undefined : activeWatchlist); setDraft(""); @@ -95,11 +110,20 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) { }; const remove = async (symbol: string) => { + if (isSystemList(activeWatchlist)) return; await api.watchlists.removeSymbol(symbol, activeWatchlist === "default" ? undefined : activeWatchlist); load(); loadWatchlists(); }; + const moveSymbol = async (symbol: string, toWatchlist: string) => { + if (isSystemList(activeWatchlist)) return; + await api.watchlists.moveSymbol(symbol, activeWatchlist === "default" ? undefined : activeWatchlist, toWatchlist === "default" ? undefined : toWatchlist); + load(); + loadWatchlists(); + setMenuOpenFor(null); + }; + const createWatchlist = async () => { if (!newListName.trim()) return; await api.watchlists.create(newListName.trim()); @@ -110,6 +134,7 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) { }; const deleteWatchlist = async (name: string) => { + if (name === "default" || isSystemList(name)) return; await api.watchlists.delete(name); loadWatchlists(); if (activeWatchlist === name) { @@ -130,6 +155,18 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) { } }, [showListPicker]); + // Close action menu on outside click + useEffect(() => { + if (!menuOpenFor) return; + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpenFor(null); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [menuOpenFor]); + const containerClass = compact ? "w-full border-b border-line bg-surface-raised flex-shrink-0" : "w-56 border-r border-line bg-surface-raised flex-shrink-0 hidden lg:block"; @@ -175,9 +212,22 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) { {showListPicker && ( -
-
- {watchlists.map((wl) => ( +
+ {systemLists.length > 0 && ( +
+
Sectors & Themes
+ {systemLists.map((wl) => ( +
{ setActiveWatchlist(wl.name); setShowListPicker(false); }}> + {wl.name} + {wl.symbol_count} + ● +
+ ))} +
+ )} +
0 ? "border-t border-line" : ""}> +
My Lists
+ {userLists.map((wl) => (
{ setActiveWatchlist(wl.name); setShowListPicker(false); }}> {wl.name} {wl.symbol_count} @@ -208,8 +258,8 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
)} - {/* Add symbol form */} - {!compact && ( + {/* Add symbol form — hidden for read-only system/sector lists */} + {!compact && !isSystemList(activeWatchlist) && (
{ e.preventDefault(); add(); }} className="flex gap-1"> )} - {!compact && ( - + {!compact && !isSystemList(activeWatchlist) && ( +
+ + {menuOpenFor === e.symbol && ( +
+ {userLists.filter((w) => w.name !== activeWatchlist).length > 0 ? ( + <> +
Move to…
+ {userLists.filter((w) => w.name !== activeWatchlist).map((wl) => ( + + ))} +
+ + + ) : ( + + )} +
+ )} +
)}
); diff --git a/app/src/lib/trpc.ts b/app/src/lib/trpc.ts index 058eadf..6b4a215 100644 --- a/app/src/lib/trpc.ts +++ b/app/src/lib/trpc.ts @@ -367,6 +367,7 @@ export interface AuthUser { riskTolerance?: string; convexityPosture?: string; onboarded?: boolean; + modules?: string[]; } // 8-second timeout for all tRPC calls to prevent hung requests. @@ -540,11 +541,13 @@ export const api = { watchlists: { list: () => trpcQuery("watchlists.list"), listByWatchlist: (name: string) => trpcQuery("watchlists.listByWatchlist", { name }), - listWatchlists: () => trpcQuery>("watchlists.listWatchlists"), - create: (name: string) => trpcMutate<{ id: string; name: string; symbol_count: number; sort_order: number; created_at: string }>("watchlists.create", { name }), + listWatchlists: () => trpcQuery>("watchlists.listWatchlists"), + create: (name: string) => trpcMutate<{ id: string; name: string; symbol_count: number; sort_order: number; created_at: string; kind?: string }>("watchlists.create", { name }), delete: (name: string) => trpcMutate<{ deleted: boolean }>("watchlists.delete", { name }), rename: (oldName: string, newName: string) => trpcMutate<{ renamed: boolean }>("watchlists.rename", { oldName, newName }), reorder: (orders: { id: string; sort_order: number }[]) => trpcMutate<{ ok: boolean }>("watchlists.reorder", { orders }), + moveSymbol: (symbol: string, fromWatchlist?: string, toWatchlist?: string) => + trpcMutate<{ moved: boolean }>("watchlists.moveSymbol", { symbol, ...(fromWatchlist ? { fromWatchlist } : {}), ...(toWatchlist ? { toWatchlist } : {}) }), addSymbol: (symbol: string, watchlistName?: string, notes?: string) => trpcMutate<{ added: boolean }>("watchlists.addSymbol", { symbol, ...(watchlistName ? { watchlistName } : {}), ...(notes ? { notes } : {}) }), removeSymbol: (symbol: string, watchlistName?: string) => @@ -638,7 +641,11 @@ export const api = { accountsForSymbol: (symbol: string) => trpcQuery>("x.accountsForSymbol", { symbol }), }, admin: { - usersList: () => trpcQuery>("admin.usersList"), + usersList: () => trpcQuery>("admin.usersList"), + setUserModules: (userId: string, modules: string[]) => trpcMutate<{ ok: boolean }>("admin.setUserModules", { userId, modules }), + disableUser: (userId: string) => trpcMutate<{ ok: boolean }>("admin.disableUser", { userId }), + enableUser: (userId: string) => trpcMutate<{ ok: boolean }>("admin.enableUser", { userId }), + deleteUser: (userId: string) => trpcMutate<{ ok: boolean }>("admin.deleteUser", { userId }), userSessions: (userId: string) => trpcQuery>("admin.userSessions", { userId }), resetPassword: (email: string, tempPassword: string) => trpcMutate<{ userId: string }>("admin.resetPassword", { email, tempPassword }), queueHealth: () => trpcQuery>("admin.queueHealth"), diff --git a/app/src/lib/useFeatureAccess.ts b/app/src/lib/useFeatureAccess.ts new file mode 100644 index 0000000..e9cfe71 --- /dev/null +++ b/app/src/lib/useFeatureAccess.ts @@ -0,0 +1,45 @@ +"use client"; +import { useState, useEffect, useCallback } from "react"; +import { api, type AuthUser } from "@/lib/trpc"; + +export type ModuleKey = "research" | "execution" | "analytics" | "settings" | "admin"; + +export interface FeatureAccess { + user: AuthUser | null; + loading: boolean; + modules: ModuleKey[]; + hasModule: (mod: ModuleKey) => boolean; + refresh: () => void; +} + +const ALL_MODULES: ModuleKey[] = ["research", "execution", "analytics", "settings", "admin"]; +const DEFAULT_MODULES: ModuleKey[] = ["research", "settings"]; + +export function useFeatureAccess(): FeatureAccess { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchUser = useCallback(() => { + setLoading(true); + api.auth + .me() + .then((u) => setUser(u)) + .catch(() => setUser(null)) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + fetchUser(); + }, [fetchUser]); + + const modules: ModuleKey[] = user?.modules + ? (user.modules.filter((m) => ALL_MODULES.includes(m as ModuleKey)) as ModuleKey[]) + : DEFAULT_MODULES; + + const hasModule = useCallback( + (mod: ModuleKey) => modules.includes(mod), + [modules], + ); + + return { user, loading, modules, hasModule, refresh: fetchUser }; +} diff --git a/app/src/stores/active-watchlist-store.ts b/app/src/stores/active-watchlist-store.ts new file mode 100644 index 0000000..5313609 --- /dev/null +++ b/app/src/stores/active-watchlist-store.ts @@ -0,0 +1,31 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface ActiveWatchlistState { + activeWatchlist: string; + _hydrated: boolean; + setActiveWatchlist: (s: string) => void; +} + +export const useActiveWatchlist = create()( + persist( + (set) => ({ + activeWatchlist: "default", + _hydrated: false, + setActiveWatchlist: (s) => set({ activeWatchlist: s || "default" }), + }), + { + name: "iflow-active-watchlist", + partialize: (state) => ({ activeWatchlist: state.activeWatchlist }), + onRehydrateStorage: () => (state) => { + if (state) state._hydrated = true; + }, + }, + ), +); + +/** Returns the active watchlist only after hydration; null before to avoid flash of default. */ +export function useActiveWatchlistGuarded(): string | null { + const { activeWatchlist, _hydrated } = useActiveWatchlist(); + return _hydrated ? activeWatchlist : null; +}