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:
Investor Flow Build
2026-07-25 11:38:23 -04:00
parent 63d9f80c09
commit 7492317ebb
27 changed files with 1187 additions and 387 deletions
+58 -7
View File
@@ -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) ──────────────────────────────────────────────