Files
investor-flow/app/src/lib/trpc.ts
T

134 lines
4.7 KiB
TypeScript
Raw Normal View History

// Thin typed client over the tRPC backend (DESIGN.md §2). Slice 1 uses a fetch wrapper
// rather than @trpc/client to keep the Next app decoupled from the server's Node-native
// type graph; the server is a real tRPC server and full end-to-end client types can be
// layered later without changing this surface.
export interface Quote {
symbol: string;
price: number;
bid?: number | null;
ask?: number | null;
change?: number | null;
changePercent?: number | null;
iv?: number | null;
}
export interface PriceCandle {
ts: string;
o: number;
h: number;
l: number;
c: number;
v: number;
adjClose?: number | null;
}
export interface SymbolMeta {
symbol: string;
name?: string | null;
sector?: string | null;
industry?: string | null;
exchange?: string | null;
tickerKind: string;
peers?: string[] | null;
}
export interface Snapshot {
symbol: string;
quote: Quote | null;
candles: PriceCandle[] | null;
sector: SymbolMeta | null;
stale: { quote: boolean; candles: boolean; sector: boolean };
}
export interface AuthUser {
userId: string;
email?: string;
complexity?: string;
riskTolerance?: string;
convexityPosture?: string;
onboarded?: boolean;
}
// 8-second timeout for all tRPC calls to prevent hung requests.
const TRPC_TIMEOUT_MS = 8_000;
async function parse<T>(res: Response): Promise<T> {
if (!res.ok) {
const body = await res.text();
throw new Error(`tRPC ${res.status}: ${body}`);
}
const json = await res.json();
if (json?.error) {
throw new Error(`tRPC error: ${json.error.message ?? json.error.data?.code ?? "unknown"}`);
}
return json.result.data as T;
}
async function trpcQuery<T>(procedure: string, input?: unknown): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TRPC_TIMEOUT_MS);
try {
const search = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : "";
const res = await fetch(`/api/trpc/${procedure}${search}`, {
headers: { "content-type": "application/json" },
credentials: "include",
signal: controller.signal,
});
return parse<T>(res);
} finally {
clearTimeout(timer);
}
}
async function trpcMutate<T>(procedure: string, input: unknown): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TRPC_TIMEOUT_MS);
try {
const res = await fetch(`/api/trpc/${procedure}`, {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify(input),
signal: controller.signal,
});
return parse<T>(res);
} finally {
clearTimeout(timer);
}
}
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 interface IndicatorOptions {
ema?: number[];
rsi?: { period?: number };
relvol?: { period?: number };
}
export interface IndicatorResult {
ema?: Record<number, Array<{ ts: string; value: number }>>;
rsi?: Array<{ ts: string; value: number }>;
relvol?: Array<{ ts: string; value: number }>;
}
export const api = {
market: {
snapshot: (symbol: string) => trpcQuery<Snapshot>("market.snapshot", { symbol }),
candles: (symbol: string, timeframe = "1d") =>
trpcQuery<PriceCandle[]>("market.candles", { symbol, timeframe }),
indicators: (symbol: string, opts: IndicatorOptions) =>
trpcQuery<IndicatorResult>("market.indicators", { symbol, ...opts }),
},
auth: {
signup: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.signup", { email, password }),
login: (email: string, password: string, totp?: string) => trpcMutate<{ userId: string }>("auth.login", { email, password, ...(totp ? { totp } : {}) }),
logout: () => trpcMutate<{ ok: boolean }>("auth.logout", {}),
me: () => trpcQuery<AuthUser | null>("auth.me"),
enable2fa: () => trpcMutate<{ totpSecret: string; qrUrl: string; backupCodes: string[] }>("auth.enable2fa", {}),
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),
},
};