SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
8.6 KB · 161 lines typescript
Raw Blame History
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