TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import type { FastifyInstance } from "fastify";2import { and, creditTransactions, db, desc, eq, gameRounds, games, lt, sessions, userAchievements, userGameStats, userSettings, users, wallets, sql } from "@spinza/database";3import { levelForXp, settingsSchema, type LedgerEntry, type PublicUser, type RoundHistoryEntry, type UserSettings, type WalletView } from "@spinza/shared";4import { requireUser } from "../plugins/auth";5import { errors } from "../lib/errors";6import { z } from "zod";78export function toPublicUser(u: typeof users.$inferSelect): PublicUser {9 const lv = levelForXp(u.xp);10 return {11 id: u.id,12 username: u.username,13 level: lv.level,14 xp: u.xp,15 xpIntoLevel: lv.current,16 xpForNext: lv.next,17 createdAt: u.createdAt.toISOString(),18 lastLoginAt: u.lastLoginAt?.toISOString() ?? null,19 };20}2122export function toSettings(s: typeof userSettings.$inferSelect): UserSettings {23 return {24 soundEnabled: s.soundEnabled,25 musicVolume: Number(s.musicVolume),26 effectsVolume: Number(s.effectsVolume),27 masterVolume: Number(s.masterVolume),28 reduceMotion: s.reduceMotion,29 animationIntensity: s.animationIntensity as UserSettings["animationIntensity"],30 sessionReminderMinutes: s.sessionReminderMinutes,31 breakReminder: s.breakReminder,32 leaderboardOptIn: s.leaderboardOptIn,33 };34}3536export async function userRoutes(app: FastifyInstance) {37 app.get("/api/user", async (req) => {38 const user = requireUser(req);39 const [u, w, s] = await Promise.all([40 db.query.users.findFirst({ where: eq(users.id, user.id) }),41 db.query.wallets.findFirst({ where: eq(wallets.userId, user.id) }),42 db.query.userSettings.findFirst({ where: eq(userSettings.userId, user.id) }),43 ]);44 if (!u || !w || !s) throw errors.notFound("User not found");45 const wallet: WalletView = { balance: w.balance, lifetimeWagered: w.lifetimeWagered, lifetimeWon: w.lifetimeWon, updatedAt: w.updatedAt.toISOString() };46 return { user: toPublicUser(u), wallet, settings: toSettings(s) };47 });4849 app.get("/api/user/profile", async (req) => {50 const user = requireUser(req);51 const u = await db.query.users.findFirst({ where: eq(users.id, user.id) });52 if (!u) throw errors.notFound();53 const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, user.id) });54 const fav = await db55 .select({ slug: games.slug, name: games.name, spins: userGameStats.spins, lastPlayedAt: userGameStats.lastPlayedAt, summary: games.summary })56 .from(userGameStats)57 .innerJoin(games, eq(games.id, userGameStats.gameId))58 .where(eq(userGameStats.userId, user.id))59 .orderBy(desc(userGameStats.spins))60 .limit(1);61 const achCount = await db.select({ n: sql<number>`count(*)::int` }).from(userAchievements).where(eq(userAchievements.userId, user.id));62 const bonuses = await db.select({ n: sql<number>`coalesce(sum(bonuses),0)::int` }).from(userGameStats).where(eq(userGameStats.userId, user.id));63 return {64 user: toPublicUser(u),65 wallet: { balance: w?.balance ?? 0, lifetimeWagered: w?.lifetimeWagered ?? 0, lifetimeWon: w?.lifetimeWon ?? 0 },66 stats: {67 totalSpins: u.totalSpins,68 gamesPlayed: u.gamesPlayed,69 biggestWin: u.biggestWin,70 biggestMultiplier: Number(u.biggestMultiplier),71 bonuses: bonuses[0]?.n ?? 0,72 achievements: achCount[0]?.n ?? 0,73 },74 favoriteGame: fav[0] ? { slug: fav[0].slug, name: fav[0].name, spins: fav[0].spins, palette: (fav[0].summary as { palette: unknown }).palette } : null,75 };76 });7778 app.patch("/api/user/settings", async (req) => {79 const user = requireUser(req);80 const patch = settingsSchema.parse(req.body);81 const set: Partial<typeof userSettings.$inferInsert> = { updatedAt: new Date() };82 if (patch.soundEnabled !== undefined) set.soundEnabled = patch.soundEnabled;83 if (patch.musicVolume !== undefined) set.musicVolume = patch.musicVolume.toFixed(2);84 if (patch.effectsVolume !== undefined) set.effectsVolume = patch.effectsVolume.toFixed(2);85 if (patch.masterVolume !== undefined) set.masterVolume = patch.masterVolume.toFixed(2);86 if (patch.reduceMotion !== undefined) set.reduceMotion = patch.reduceMotion;87 if (patch.animationIntensity !== undefined) set.animationIntensity = patch.animationIntensity;88 if (patch.sessionReminderMinutes !== undefined) set.sessionReminderMinutes = patch.sessionReminderMinutes;89 if (patch.breakReminder !== undefined) set.breakReminder = patch.breakReminder;90 if (patch.leaderboardOptIn !== undefined) set.leaderboardOptIn = patch.leaderboardOptIn;91 const [s] = await db.update(userSettings).set(set).where(eq(userSettings.userId, user.id)).returning();92 return { settings: toSettings(s) };93 });9495 app.get("/api/wallet", async (req) => {96 const user = requireUser(req);97 const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, user.id) });98 if (!w) throw errors.notFound();99 return { balance: w.balance, lifetimeWagered: w.lifetimeWagered, lifetimeWon: w.lifetimeWon, updatedAt: w.updatedAt.toISOString() } satisfies WalletView;100 });101102 app.get("/api/wallet/ledger", async (req) => {103 const user = requireUser(req);104 const q = z.object({ before: z.string().datetime().optional(), limit: z.coerce.number().int().min(1).max(100).default(50), type: z.string().optional() }).parse(req.query);105 const conds = [eq(creditTransactions.userId, user.id)];106 if (q.before) conds.push(lt(creditTransactions.createdAt, new Date(q.before)));107 if (q.type) conds.push(eq(creditTransactions.type, q.type));108 const rows = await db.select().from(creditTransactions).where(and(...conds)).orderBy(desc(creditTransactions.createdAt)).limit(q.limit);109 const entries: LedgerEntry[] = rows.map((r) => ({ id: r.id, type: r.type as LedgerEntry["type"], amount: r.amount, balanceAfter: r.balanceAfter, reference: r.reference, createdAt: r.createdAt.toISOString() }));110 return { entries, nextBefore: rows.length === q.limit ? rows[rows.length - 1].createdAt.toISOString() : null };111 });112113 app.get("/api/user/history", async (req) => {114 const user = requireUser(req);115 const q = z.object({ before: z.string().datetime().optional(), limit: z.coerce.number().int().min(1).max(100).default(40), game: z.string().optional() }).parse(req.query);116 const conds = [eq(gameRounds.userId, user.id)];117 if (q.before) conds.push(lt(gameRounds.createdAt, new Date(q.before)));118 if (q.game) conds.push(eq(gameRounds.gameSlug, q.game));119 const rows = await db120 .select({ roundId: gameRounds.roundId, game: gameRounds.gameSlug, bet: gameRounds.bet, win: gameRounds.win, multiplier: gameRounds.multiplier, balanceAfter: gameRounds.balanceAfter, createdAt: gameRounds.createdAt, features: gameRounds.features, name: games.name })121 .from(gameRounds)122 .innerJoin(games, eq(games.id, gameRounds.gameId))123 .where(and(...conds))124 .orderBy(desc(gameRounds.createdAt))125 .limit(q.limit);126 const entries: (RoundHistoryEntry & { features: string[] })[] = rows.map((r) => ({127 roundId: r.roundId,128 game: r.game,129 gameName: r.name,130 bet: r.bet,131 win: r.win,132 multiplier: Number(r.multiplier),133 balanceAfter: r.balanceAfter,134 createdAt: r.createdAt.toISOString(),135 features: r.features,136 }));137 return { entries, nextBefore: rows.length === q.limit ? rows[rows.length - 1].createdAt.toISOString() : null };138 });139140 app.get("/api/user/rounds/:roundId", async (req) => {141 const user = requireUser(req);142 const { roundId } = req.params as { roundId: string };143 const r = await db.query.gameRounds.findFirst({ where: and(eq(gameRounds.roundId, roundId), eq(gameRounds.userId, user.id)) });144 if (!r) throw errors.notFound("Round not found");145 return { roundId: r.roundId, game: r.gameSlug, version: r.gameVersion, bet: r.bet, win: r.win, multiplier: Number(r.multiplier), balanceAfter: r.balanceAfter, createdAt: r.createdAt.toISOString(), result: r.result, features: r.features };146 });147148 app.get("/api/user/sessions", async (req) => {149 const user = requireUser(req);150 const rows = await db.select().from(sessions).where(eq(sessions.userId, user.id)).orderBy(desc(sessions.lastSeenAt));151 return { sessions: rows.map((s) => ({ id: s.id, current: s.id === req.sessionId, createdAt: s.createdAt.toISOString(), lastSeenAt: s.lastSeenAt.toISOString(), userAgent: s.userAgent, ip: s.ip ? s.ip.replace(/\.\d+$/, ".x") : null })) };152 });153154 app.delete("/api/user/sessions/:id", async (req) => {155 const user = requireUser(req);156 const { id } = req.params as { id: string };157 await db.delete(sessions).where(and(eq(sessions.id, id), eq(sessions.userId, user.id)));158 return { ok: true };159 });160}161