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:
Investor Flow Build
2026-06-29 21:31:59 -04:00
parent 4816b19976
commit 965f8d3b1c
8 changed files with 352 additions and 28 deletions
+1
View File
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS users (
oauth_provider TEXT, -- 'github' | 'google' | NULL
complexity TEXT NOT NULL DEFAULT 'beginner', -- beginner|intermediate|advanced
risk_tolerance TEXT NOT NULL DEFAULT 'moderate',
drawdown_tolerance REAL, -- onboarding max-drawdown % (beginner -20, etc.)
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)
created_at TEXT NOT NULL
@@ -0,0 +1,110 @@
import { describe, it } from "node:test";
import * as assert from "node:assert/strict";
import {
STARTER_WATCHLIST,
ONBOARDING_DISCLAIMER,
defaultDrawdownTolerancePct,
defaultRiskTolerance,
type StarterSymbol,
} from "../starter.ts";
describe("onboarding/starter", () => {
describe("STARTER_WATCHLIST", () => {
it("has exactly 9 entries", () => {
assert.equal(STARTER_WATCHLIST.length, 9);
});
it("matches the prescribed symbol+tickerKind order", () => {
const expected: Array<{ symbol: string; tickerKind: string }> = [
{ symbol: "IREN", tickerKind: "equity" },
{ symbol: "CIFR", tickerKind: "equity" },
{ symbol: "ASST", tickerKind: "equity" },
{ symbol: "SLNH", tickerKind: "equity" },
{ symbol: "BKKT", tickerKind: "equity" },
{ symbol: "NUAI", tickerKind: "equity" },
{ symbol: "NVDA", tickerKind: "equity" },
{ symbol: "BTC", tickerKind: "crypto" },
{ symbol: "SATA", tickerKind: "equity" },
];
assert.equal(STARTER_WATCHLIST.length, expected.length);
for (let i = 0; i < expected.length; i++) {
const got: StarterSymbol = STARTER_WATCHLIST[i];
assert.equal(got.symbol, expected[i].symbol, `item #${i} symbol`);
assert.equal(got.tickerKind, expected[i].tickerKind, `item #${i} tickerKind`);
}
});
it("BTC has tickerKind 'crypto' while all others are 'equity'", () => {
for (const item of STARTER_WATCHLIST) {
if (item.symbol === "BTC") {
assert.equal(item.tickerKind, "crypto");
} else {
assert.equal(item.tickerKind, "equity");
}
}
});
it("every entry has a non-empty reason string", () => {
for (const item of STARTER_WATCHLIST) {
assert.ok(item.reason.length > 0, `reason for ${item.symbol} is empty`);
}
});
it("reasons contain no imperative trade verbs (ADR-0007 / P6)", () => {
const forbidden = [
/buy\b/i,
/sell\b/i,
/you should\b/i,
/add to your\b/i,
/rotate into\b/i,
/action needed\b/i,
];
for (const item of STARTER_WATCHLIST) {
const hit = forbidden.find((rx) => rx.test(item.reason));
assert.equal(hit, undefined, `forbidden verb in reason for ${item.symbol}`);
}
});
});
describe("defaultDrawdownTolerancePct", () => {
it("returns -20 for beginner", () => {
assert.equal(defaultDrawdownTolerancePct("beginner"), -20);
});
it("returns -30 for intermediate", () => {
assert.equal(defaultDrawdownTolerancePct("intermediate"), -30);
});
it("returns -40 for advanced", () => {
assert.equal(defaultDrawdownTolerancePct("advanced"), -40);
});
});
describe("defaultRiskTolerance", () => {
it("returns 'moderate' for beginner", () => {
assert.equal(defaultRiskTolerance("beginner"), "moderate");
});
it("returns 'moderate' for intermediate", () => {
assert.equal(defaultRiskTolerance("intermediate"), "moderate");
});
it("returns 'aggressive' for advanced", () => {
assert.equal(defaultRiskTolerance("advanced"), "aggressive");
});
});
describe("ONBOARDING_DISCLAIMER", () => {
it("contains the required keywords", () => {
assert.ok(
ONBOARDING_DISCLAIMER.toLowerCase().includes("educational"),
"missing 'educational'",
);
assert.ok(
ONBOARDING_DISCLAIMER.toLowerCase().includes("not financial advice"),
"missing 'not financial advice'",
);
});
});
});
+106
View File
@@ -0,0 +1,106 @@
// ADR-0007: Zero imperative trade verbs anywhere in this module.
// Pure data + helpers for the first-login onboarding wizard — no deps.
export type Complexity = "beginner" | "intermediate" | "advanced";
/**
* One entry for the starter watchlist shown to a new user during onboarding.
* The reason field is plain-English and purely educational/descriptive.
*/
export interface StarterSymbol {
symbol: string;
tickerKind: "equity" | "crypto" | "etf" | "index";
/** One-line plain-English description — factual, no trade verbs (P6 / ADR-0007). */
reason: string;
}
/**
* The starter watchlist presented to every new user.
* Exactly 9 entries, fixed order.
*/
export const STARTER_WATCHLIST: StarterSymbol[] = [
{
symbol: "IREN",
tickerKind: "equity",
reason:
"Iris Energy — a Bitcoin-mining and energy-infrastructure company focused on sustainable operations.",
},
{
symbol: "CIFR",
tickerKind: "equity",
reason:
"Cipher Mining — a digital-asset mining operator with large-scale energy infrastructure.",
},
{
symbol: "ASST",
tickerKind: "equity",
reason:
"Asseco Systems — an enterprise technology and blockchain-services provider.",
},
{
symbol: "SLNH",
tickerKind: "equity",
reason:
"Solena AI — a data-center infrastructure company supporting AI and compute workloads.",
},
{
symbol: "BKKT",
tickerKind: "equity",
reason:
"Bakkt — a regulated digital-asset exchange built on institutional-grade infrastructure.",
},
{
symbol: "NUAI",
tickerKind: "equity",
reason:
"NuAerospace — an aerospace-and-defense company pursuing advanced space systems.",
},
{
symbol: "NVDA",
tickerKind: "equity",
reason:
"NVIDIA — a semiconductor leader in GPUs and AI-accelerator hardware.",
},
{
symbol: "BTC",
tickerKind: "crypto",
reason:
"Bitcoin — the original cryptocurrency; price and sentiment tracked, SEC disclosure modules do not apply to on-chain assets.",
},
{
symbol: "SATA",
tickerKind: "equity",
reason:
"Solid Alpha Technology Acquisition — a SPAC holding a technology-focused portfolio.",
},
];
/** Default drawdown tolerance (percent) per complexity level. */
export function defaultDrawdownTolerancePct(complexity: Complexity): number {
switch (complexity) {
case "beginner":
return -20;
case "intermediate":
return -30;
case "advanced":
return -40;
}
}
/** Default risk tolerance per complexity level. */
export function defaultRiskTolerance(
complexity: Complexity,
): "conservative" | "moderate" | "aggressive" {
switch (complexity) {
case "beginner":
return "moderate";
case "intermediate":
return "moderate";
case "advanced":
return "aggressive";
}
}
/** Legal disclaimer shown during onboarding. */
export const ONBOARDING_DISCLAIMER =
"This is an educational tool, not financial advice.";
@@ -172,3 +172,21 @@ test('oauth: oauthStart returns a redirect URL + CSRF state cookie; oauthCallbac
delete process.env.GITHUB_CLIENT_ID; delete process.env.GITHUB_CLIENT_SECRET;
}
});
// --- Slice 3: onboarding ---
test('onboarding.complete writes complexity/risk/drawdown + starter watchlist + subscribes demand', async () => {
const { db, cache, freshCtx } = setup();
const signupCtx = freshCtx();
const { userId } = await appRouter.createCaller(signupCtx).auth.signup({ email: 'a@b.co', password: 'password123' });
const oCtx = { db, cache, resHeaders: new Headers(), userId, cookies: {} as Record<string, string> };
const res = await appRouter.createCaller(oCtx).onboarding.complete({ complexity: 'beginner' });
assert.ok(res.watchlistId);
const u = db.prepare('SELECT complexity, risk_tolerance, drawdown_tolerance FROM users WHERE id=?').get(userId) as { complexity: string; risk_tolerance: string; drawdown_tolerance: number };
assert.equal(u.complexity, 'beginner');
assert.equal(u.risk_tolerance, 'moderate');
assert.equal(u.drawdown_tolerance, -20);
const wl = db.prepare('SELECT symbols FROM watchlists WHERE owner_id=?').get(userId) as { symbols: string };
assert.equal(JSON.parse(wl.symbols).length, 9);
const demand = await cache.demandSet();
assert.ok(demand.includes('NVDA') && demand.includes('BTC'), 'starter symbols subscribed to demand set');
});
+37 -2
View File
@@ -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;