slice 3 COMPLETE: onboarding wizard (complexity/risk/drawdown + starter watchlist + demand subscribe)
starter.ts by ornith (9 symbols w/ ticker-kind + ADR-0007 reasons, defaults, 12 tests). onboarding.complete + onboarding.starter + me().onboarded by orchestrator; schema +drawdown_tolerance. Wizard UI (complexity picker, starter list, disclaimer, finish). onboarding subscribes starter symbols to the demand set (adapter fetches them). 87/87 backend + 2/2 lint green. Slice 3 done; slices 1-3 complete.
This commit is contained in:
@@ -6,6 +6,7 @@ import { hashPassword, verifyPassword, createSession, clearCookie, oauthStateCoo
|
||||
import { generateBase32Secret, totp as computeTotp, verifyTotp, otpauthUrl } from '../auth/totp.ts';
|
||||
import { generateBackupCodes, hashBackupCode } from '../auth/backup-codes.ts';
|
||||
import { buildAuthorizeUrl, generateState, exchangeCode, type OAuthProvider } from '../auth/oauth.ts';
|
||||
import { STARTER_WATCHLIST, defaultDrawdownTolerancePct, defaultRiskTolerance, ONBOARDING_DISCLAIMER, type Complexity } from '../onboarding/starter.ts';
|
||||
import type { Quote, PriceCandle, SymbolMeta } from '../cache/CacheRepository.ts';
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
@@ -57,7 +58,8 @@ 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;
|
||||
return u ? { userId: u.id, email: u.email, complexity: u.complexity, riskTolerance: u.risk_tolerance, convexityPosture: u.convexity_posture } : null;
|
||||
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;
|
||||
}),
|
||||
enable2fa: protectedProcedure.input(z.object({})).mutation(async ({ ctx }) => {
|
||||
const userId = ctx.userId as string;
|
||||
@@ -118,6 +120,39 @@ const authRouter = router({
|
||||
}),
|
||||
});
|
||||
|
||||
const onboardingRouter = router({
|
||||
// Public: the starter watchlist + disclaimer shown in the wizard before completing.
|
||||
starter: publicProcedure.query(() => ({ watchlist: STARTER_WATCHLIST, disclaimer: ONBOARDING_DISCLAIMER })),
|
||||
// Protected: write complexity/risk/drawdown + first watchlist + optional portfolio; subscribe symbols to demand.
|
||||
complete: protectedProcedure
|
||||
.input(z.object({
|
||||
complexity: z.enum(['beginner', 'intermediate', 'advanced']),
|
||||
riskTolerance: z.enum(['conservative', 'moderate', 'aggressive']).optional(),
|
||||
drawdownTolerancePct: z.number().optional(),
|
||||
firstWatchlistSymbols: z.array(z.string()).optional(),
|
||||
portfolio: z.array(z.object({ symbol: z.string(), qty: z.number(), avgCost: z.number(), acquiredAt: z.string() })).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.userId as string;
|
||||
const complexity = input.complexity as Complexity;
|
||||
const riskTolerance = input.riskTolerance ?? defaultRiskTolerance(complexity);
|
||||
const drawdown = input.drawdownTolerancePct ?? defaultDrawdownTolerancePct(complexity);
|
||||
ctx.db.prepare('UPDATE users SET complexity=?, risk_tolerance=?, drawdown_tolerance=? WHERE id=?').run(complexity, riskTolerance, drawdown, userId);
|
||||
const symbols = input.firstWatchlistSymbols ?? STARTER_WATCHLIST.map((s) => s.symbol);
|
||||
const wlId = randomUUID();
|
||||
ctx.db.prepare('INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?,?,?,?,?,?)').run(wlId, userId, 'Starter', JSON.stringify(symbols), new Date().toISOString(), 0);
|
||||
for (const sym of symbols) {
|
||||
const kind = (STARTER_WATCHLIST.find((s) => s.symbol === sym)?.tickerKind ?? 'equity') as 'equity' | 'crypto' | 'etf' | 'index';
|
||||
await ctx.cache.subscribe(sym, kind);
|
||||
}
|
||||
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');
|
||||
}
|
||||
return { ok: true, watchlistId: wlId };
|
||||
}),
|
||||
});
|
||||
|
||||
const marketRouter = router({
|
||||
snapshot: publicProcedure
|
||||
.input(z.object({ symbol: z.string().min(1) }))
|
||||
@@ -138,5 +173,5 @@ const marketRouter = router({
|
||||
}),
|
||||
});
|
||||
|
||||
export const appRouter = router({ auth: authRouter, market: marketRouter });
|
||||
export const appRouter = router({ auth: authRouter, onboarding: onboardingRouter, market: marketRouter });
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
Reference in New Issue
Block a user