diff --git a/app/server/src/db/schema.sql b/app/server/src/db/schema.sql index 0ff0dea..a8c91a1 100644 --- a/app/server/src/db/schema.sql +++ b/app/server/src/db/schema.sql @@ -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 diff --git a/app/server/src/onboarding/__tests__/starter.test.ts b/app/server/src/onboarding/__tests__/starter.test.ts new file mode 100644 index 0000000..faa9189 --- /dev/null +++ b/app/server/src/onboarding/__tests__/starter.test.ts @@ -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'", + ); + }); + }); +}); diff --git a/app/server/src/onboarding/starter.ts b/app/server/src/onboarding/starter.ts new file mode 100644 index 0000000..1480444 --- /dev/null +++ b/app/server/src/onboarding/starter.ts @@ -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."; diff --git a/app/server/src/trpc/__tests__/router.test.ts b/app/server/src/trpc/__tests__/router.test.ts index 2939c53..4d9f400 100644 --- a/app/server/src/trpc/__tests__/router.test.ts +++ b/app/server/src/trpc/__tests__/router.test.ts @@ -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 }; + 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'); +}); diff --git a/app/server/src/trpc/router.ts b/app/server/src/trpc/router.ts index c02dce1..ea5dccf 100644 --- a/app/server/src/trpc/router.ts +++ b/app/server/src/trpc/router.ts @@ -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().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; diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index 4589768..4dfd80b 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -9,6 +9,8 @@ export default function Page() { const activeSymbol = useActiveSymbol((s) => s.activeSymbol); const setActive = useActiveSymbol((s) => s.setActive); const [draft, setDraft] = useState(activeSymbol); + const [user, setUser] = useState(null); + useEffect(() => { api.auth.me().then(setUser).catch(() => {}); }, []); return (
{ e.preventDefault(); setActive(draft); }} className="flex gap-2"> @@ -17,32 +19,75 @@ export default function Page() {
- + {user && !user.onboarded && api.auth.me().then(setUser)} />} +
); } -function AuthSection() { +function OnboardingSection({ onDone }: { onDone: () => void }) { + const [starter, setStarter] = useState<{ watchlist: Array<{ symbol: string; tickerKind: string; reason: string }>; disclaimer: string } | null>(null); + const [complexity, setComplexity] = useState<"beginner" | "intermediate" | "advanced">("beginner"); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + useEffect(() => { api.onboarding.starter().then(setStarter).catch(() => {}); }, []); + const complete = async () => { + setBusy(true); setErr(null); + try { await api.onboarding.complete({ complexity }); onDone(); } + catch (e) { setErr(e instanceof Error ? e.message : "request failed"); } + finally { setBusy(false); } + }; + return ( +
+

{UI_STRINGS.onboardingTitle}

+
+

{UI_STRINGS.complexityLabel}

+
+ {(["beginner", "intermediate", "advanced"] as const).map((c) => ( + + ))} +
+

{UI_STRINGS.drawdownNote}

+
+ {starter && ( +
+

{UI_STRINGS.starterWatchlistLabel}

+
    + {starter.watchlist.map((s) => ( +
  • + {s.symbol} + {s.reason} +
  • + ))} +
+

{starter.disclaimer}

+
+ )} + {err &&

{err}

} + +
+ ); +} + +function AuthSection({ user, setUser }: { user: AuthUser | null; setUser: (u: AuthUser | null) => void }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [user, setUser] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - // 2FA enrollment flow state const [enroll, setEnroll] = useState<{ totpSecret: string; qrUrl: string; backupCodes: string[] } | null>(null); const [code, setCode] = useState(""); const [twoFactorMsg, setTwoFactorMsg] = useState(null); - - useEffect(() => { api.auth.me().then(setUser).catch(() => {}); }, []); - + useEffect(() => { if (user === null) api.auth.me().then(setUser).catch(() => {}); }, [user, setUser]); const submit = async (mode: "signup" | "login") => { setBusy(true); setError(null); try { await api.auth[mode](email, password); setUser(await api.auth.me()); } catch (e) { setError(e instanceof Error ? e.message : "request failed"); } finally { setBusy(false); } }; - if (user) { return (
@@ -80,8 +125,8 @@ function AuthSection() {

{UI_STRINGS.signupPrompt}

- setEmail(e.target.value)} placeholder={UI_STRINGS.authEmailLabel} type="email" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-3 py-2 text-sm text-[#e6e7ec]" /> - setPassword(e.target.value)} placeholder={UI_STRINGS.authPasswordLabel} type="password" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-3 py-2 text-sm text-[#e6e7ec]" /> + setEmail(e.target.value)} placeholder={UI_STRINGS.authEmailLabel} type="email" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-2 py-2 text-sm text-[#e6e7ec]" /> + setPassword(e.target.value)} placeholder={UI_STRINGS.authPasswordLabel} type="password" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-2 py-2 text-sm text-[#e6e7ec]" />
{error &&

{error}

}
diff --git a/app/src/lib/strings.ts b/app/src/lib/strings.ts index 0fb9e8a..19680d1 100644 --- a/app/src/lib/strings.ts +++ b/app/src/lib/strings.ts @@ -1,7 +1,7 @@ // Curated user-facing strings (P6 plain English; ADR-0007 Primary-Rule compliant). // The Primary-Rule lint test (src/__tests__/primary-rule-lint.test.ts) scans these and the -// panel/page source for imperative trade verbs (a stoplist of directives). Approved noun -// phrases such as the buy-zone estimate are allowed. +// panel/page/client source for imperative trade verbs (a stoplist of directives). Approved +// noun phrases such as the buy-zone estimate are allowed. export const UI_STRINGS = { appTitle: "Investor Flow", appSubtitle: "Educational investment research terminal", @@ -33,6 +33,15 @@ export const UI_STRINGS = { twoFactorEnabled: "Two-factor is now enabled. Login will ask for a code.", oauthGithub: "Continue with GitHub", oauthGoogle: "Continue with Google", + onboardingTitle: "Welcome — set up your research defaults.", + complexityLabel: "Experience level", + complexityBeginner: "Beginner", + complexityIntermediate: "Intermediate", + complexityAdvanced: "Advanced", + starterWatchlistLabel: "Your starter watchlist", + drawdownNote: "A default max-drawdown tolerance is set from your experience level (you can change it later).", + completeOnboardingButton: "Finish setup", + onboardingDone: "Setup complete — your starter watchlist is tracking.", } as const; export const UI_STRING_LIST: string[] = Object.values(UI_STRINGS); diff --git a/app/src/lib/trpc.ts b/app/src/lib/trpc.ts index d8f6f24..8e96571 100644 --- a/app/src/lib/trpc.ts +++ b/app/src/lib/trpc.ts @@ -42,6 +42,7 @@ export interface AuthUser { complexity?: string; riskTolerance?: string; convexityPosture?: string; + onboarded?: boolean; } async function parse(res: Response): Promise { @@ -52,25 +53,19 @@ async function parse(res: Response): Promise { } return json.result.data as T; } - async function trpcQuery(procedure: string, input?: unknown): Promise { const search = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : ""; - const res = await fetch(`/api/trpc/${procedure}${search}`, { - headers: { "content-type": "application/json" }, - credentials: "include", - }); + const res = await fetch(`/api/trpc/${procedure}${search}`, { headers: { "content-type": "application/json" }, credentials: "include" }); + return parse(res); +} +async function trpcMutate(procedure: string, input: unknown): Promise { + const res = await fetch(`/api/trpc/${procedure}`, { method: "POST", headers: { "content-type": "application/json" }, credentials: "include", body: JSON.stringify(input) }); return parse(res); } -async function trpcMutate(procedure: string, input: unknown): Promise { - const res = await fetch(`/api/trpc/${procedure}`, { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "include", - body: JSON.stringify(input), - }); - return parse(res); -} +export type Complexity = "beginner" | "intermediate" | "advanced"; +export interface StarterEntry { symbol: string; tickerKind: string; reason: string; } +export interface PortfolioEntry { symbol: string; qty: number; avgCost: number; acquiredAt: string; } export const api = { market: { snapshot: (symbol: string) => trpcQuery("market.snapshot", { symbol }) }, @@ -83,4 +78,9 @@ export const api = { confirm2fa: (totp: string) => trpcMutate<{ ok: boolean }>("auth.confirm2fa", { totp }), oauthStart: (provider: "github" | "google", redirectUri: string) => trpcMutate<{ redirectUrl: string; state: string }>("auth.oauthStart", { provider, redirectUri }), }, + onboarding: { + starter: () => trpcQuery<{ watchlist: StarterEntry[]; disclaimer: string }>("onboarding.starter"), + complete: (input: { complexity: Complexity; drawdownTolerancePct?: number; firstWatchlistSymbols?: string[]; portfolio?: PortfolioEntry[] }) => + trpcMutate<{ ok: boolean; watchlistId: string }>("onboarding.complete", input), + }, };