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:
@@ -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