import type { FastifyInstance } from "fastify"; import { and, creditTransactions, db, desc, eq, gameRounds, games, lt, sessions, userAchievements, userGameStats, userSettings, users, wallets, sql } from "@spinza/database"; import { levelForXp, settingsSchema, type LedgerEntry, type PublicUser, type RoundHistoryEntry, type UserSettings, type WalletView } from "@spinza/shared"; import { requireUser } from "../plugins/auth"; import { errors } from "../lib/errors"; import { z } from "zod"; export function toPublicUser(u: typeof users.$inferSelect): PublicUser { const lv = levelForXp(u.xp); return { id: u.id, username: u.username, level: lv.level, xp: u.xp, xpIntoLevel: lv.current, xpForNext: lv.next, createdAt: u.createdAt.toISOString(), lastLoginAt: u.lastLoginAt?.toISOString() ?? null, }; } export function toSettings(s: typeof userSettings.$inferSelect): UserSettings { return { soundEnabled: s.soundEnabled, musicVolume: Number(s.musicVolume), effectsVolume: Number(s.effectsVolume), masterVolume: Number(s.masterVolume), reduceMotion: s.reduceMotion, animationIntensity: s.animationIntensity as UserSettings["animationIntensity"], sessionReminderMinutes: s.sessionReminderMinutes, breakReminder: s.breakReminder, leaderboardOptIn: s.leaderboardOptIn, }; } export async function userRoutes(app: FastifyInstance) { app.get("/api/user", async (req) => { const user = requireUser(req); const [u, w, s] = await Promise.all([ db.query.users.findFirst({ where: eq(users.id, user.id) }), db.query.wallets.findFirst({ where: eq(wallets.userId, user.id) }), db.query.userSettings.findFirst({ where: eq(userSettings.userId, user.id) }), ]); if (!u || !w || !s) throw errors.notFound("User not found"); const wallet: WalletView = { balance: w.balance, lifetimeWagered: w.lifetimeWagered, lifetimeWon: w.lifetimeWon, updatedAt: w.updatedAt.toISOString() }; return { user: toPublicUser(u), wallet, settings: toSettings(s) }; }); app.get("/api/user/profile", async (req) => { const user = requireUser(req); const u = await db.query.users.findFirst({ where: eq(users.id, user.id) }); if (!u) throw errors.notFound(); const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, user.id) }); const fav = await db .select({ slug: games.slug, name: games.name, spins: userGameStats.spins, lastPlayedAt: userGameStats.lastPlayedAt, summary: games.summary }) .from(userGameStats) .innerJoin(games, eq(games.id, userGameStats.gameId)) .where(eq(userGameStats.userId, user.id)) .orderBy(desc(userGameStats.spins)) .limit(1); const achCount = await db.select({ n: sql`count(*)::int` }).from(userAchievements).where(eq(userAchievements.userId, user.id)); const bonuses = await db.select({ n: sql`coalesce(sum(bonuses),0)::int` }).from(userGameStats).where(eq(userGameStats.userId, user.id)); return { user: toPublicUser(u), wallet: { balance: w?.balance ?? 0, lifetimeWagered: w?.lifetimeWagered ?? 0, lifetimeWon: w?.lifetimeWon ?? 0 }, stats: { totalSpins: u.totalSpins, gamesPlayed: u.gamesPlayed, biggestWin: u.biggestWin, biggestMultiplier: Number(u.biggestMultiplier), bonuses: bonuses[0]?.n ?? 0, achievements: achCount[0]?.n ?? 0, }, favoriteGame: fav[0] ? { slug: fav[0].slug, name: fav[0].name, spins: fav[0].spins, palette: (fav[0].summary as { palette: unknown }).palette } : null, }; }); app.patch("/api/user/settings", async (req) => { const user = requireUser(req); const patch = settingsSchema.parse(req.body); const set: Partial = { updatedAt: new Date() }; if (patch.soundEnabled !== undefined) set.soundEnabled = patch.soundEnabled; if (patch.musicVolume !== undefined) set.musicVolume = patch.musicVolume.toFixed(2); if (patch.effectsVolume !== undefined) set.effectsVolume = patch.effectsVolume.toFixed(2); if (patch.masterVolume !== undefined) set.masterVolume = patch.masterVolume.toFixed(2); if (patch.reduceMotion !== undefined) set.reduceMotion = patch.reduceMotion; if (patch.animationIntensity !== undefined) set.animationIntensity = patch.animationIntensity; if (patch.sessionReminderMinutes !== undefined) set.sessionReminderMinutes = patch.sessionReminderMinutes; if (patch.breakReminder !== undefined) set.breakReminder = patch.breakReminder; if (patch.leaderboardOptIn !== undefined) set.leaderboardOptIn = patch.leaderboardOptIn; const [s] = await db.update(userSettings).set(set).where(eq(userSettings.userId, user.id)).returning(); return { settings: toSettings(s) }; }); app.get("/api/wallet", async (req) => { const user = requireUser(req); const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, user.id) }); if (!w) throw errors.notFound(); return { balance: w.balance, lifetimeWagered: w.lifetimeWagered, lifetimeWon: w.lifetimeWon, updatedAt: w.updatedAt.toISOString() } satisfies WalletView; }); app.get("/api/wallet/ledger", async (req) => { const user = requireUser(req); 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); const conds = [eq(creditTransactions.userId, user.id)]; if (q.before) conds.push(lt(creditTransactions.createdAt, new Date(q.before))); if (q.type) conds.push(eq(creditTransactions.type, q.type)); const rows = await db.select().from(creditTransactions).where(and(...conds)).orderBy(desc(creditTransactions.createdAt)).limit(q.limit); 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() })); return { entries, nextBefore: rows.length === q.limit ? rows[rows.length - 1].createdAt.toISOString() : null }; }); app.get("/api/user/history", async (req) => { const user = requireUser(req); 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); const conds = [eq(gameRounds.userId, user.id)]; if (q.before) conds.push(lt(gameRounds.createdAt, new Date(q.before))); if (q.game) conds.push(eq(gameRounds.gameSlug, q.game)); const rows = await db .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 }) .from(gameRounds) .innerJoin(games, eq(games.id, gameRounds.gameId)) .where(and(...conds)) .orderBy(desc(gameRounds.createdAt)) .limit(q.limit); const entries: (RoundHistoryEntry & { features: string[] })[] = rows.map((r) => ({ roundId: r.roundId, game: r.game, gameName: r.name, bet: r.bet, win: r.win, multiplier: Number(r.multiplier), balanceAfter: r.balanceAfter, createdAt: r.createdAt.toISOString(), features: r.features, })); return { entries, nextBefore: rows.length === q.limit ? rows[rows.length - 1].createdAt.toISOString() : null }; }); app.get("/api/user/rounds/:roundId", async (req) => { const user = requireUser(req); const { roundId } = req.params as { roundId: string }; const r = await db.query.gameRounds.findFirst({ where: and(eq(gameRounds.roundId, roundId), eq(gameRounds.userId, user.id)) }); if (!r) throw errors.notFound("Round not found"); 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 }; }); app.get("/api/user/sessions", async (req) => { const user = requireUser(req); const rows = await db.select().from(sessions).where(eq(sessions.userId, user.id)).orderBy(desc(sessions.lastSeenAt)); 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 })) }; }); app.delete("/api/user/sessions/:id", async (req) => { const user = requireUser(req); const { id } = req.params as { id: string }; await db.delete(sessions).where(and(eq(sessions.id, id), eq(sessions.userId, user.id))); return { ok: true }; }); }