diff --git a/src/app/api/badges/leaderboard/route.ts b/src/app/api/badges/leaderboard/route.ts new file mode 100644 index 00000000..ab7c470d --- /dev/null +++ b/src/app/api/badges/leaderboard/route.ts @@ -0,0 +1,334 @@ +import { apiJson } from "@/lib/api"; +import { bearerUserId } from "@/lib/api-auth"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +type BadgeRarityKey = "common" | "rare" | "epic" | "legendary" | "mythical" | "unique"; + +interface BadgeLeaderboardEntry { + userId: number; + username: string; + figure: string; + score: number; + rank: number; +} + +interface BadgeLeaderboardBoard { + entries: BadgeLeaderboardEntry[]; + totalPlayers: number; + viewerEntry?: Partial; +} + +interface BadgeLeaderboardStat { + badgeCode: string; + ownerCount: number; + rarity: BadgeRarityKey; +} + +const THRESHOLDS = { + commonMinOwners: 500, + rareMinOwners: 100, + epicMinOwners: 20, + legendaryMinOwners: 5, + mythicalMinOwners: 2, + uniqueOwners: 1, +}; + +function assignRarity(ownerCount: number): BadgeRarityKey { + if (ownerCount >= THRESHOLDS.commonMinOwners) return "common"; + if (ownerCount >= THRESHOLDS.rareMinOwners) return "rare"; + if (ownerCount >= THRESHOLDS.epicMinOwners) return "epic"; + if (ownerCount >= THRESHOLDS.legendaryMinOwners) return "legendary"; + if (ownerCount >= THRESHOLDS.mythicalMinOwners) return "mythical"; + return "unique"; +} + +function rankEntries(entries: BadgeLeaderboardEntry[]): BadgeLeaderboardEntry[] { + return entries + .sort((a, b) => b.score - a.score) + .map((e, i) => ({ ...e, rank: i + 1 })); +} + +async function loadTotalBadgesBoard(userId: number | null): Promise { + const rows = await prisma.$queryRawUnsafe>( + `SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt + FROM users_badges ub + JOIN users u ON u.id = ub.user_id + GROUP BY ub.user_id + ORDER BY cnt DESC + LIMIT 20` + ); + + const entries = rankEntries( + rows.map((r) => ({ + userId: Number(r.userId), + username: r.username, + figure: r.look, + score: Number(r.cnt), + rank: 0, + })) + ); + + const totalPlayers = entries.length > 0 + ? Number( + ( + await prisma.$queryRawUnsafe>( + `SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges` + ) + )[0]?.cnt ?? 0 + ) + : 0; + + let viewerEntry: Partial | undefined; + if (userId) { + const viewerRow = await prisma.$queryRawUnsafe>( + `SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ?`, + userId + ); + const viewerScore = Number(viewerRow[0]?.cnt ?? 0); + if (viewerScore > 0) { + const viewerRank = entries.length > 0 + ? Number( + ( + await prisma.$queryRawUnsafe>( + `SELECT COUNT(*) + 1 AS cnt + FROM (SELECT user_id, COUNT(*) AS total FROM users_badges GROUP BY user_id) t + WHERE t.total > ?`, + viewerScore + ) + )[0]?.cnt ?? entries.length + 1 + ) + : 1; + const viewerUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { username: true, look: true }, + }); + if (viewerUser) { + viewerEntry = { + userId, + username: viewerUser.username, + figure: viewerUser.look, + score: viewerScore, + rank: viewerRank, + }; + } + } + } + + return { entries, totalPlayers, viewerEntry }; +} + +async function loadAchievementBoard(userId: number | null): Promise { + const rows = await prisma.$queryRawUnsafe>( + `SELECT us.user_id AS userId, u.username, u.look, us.achievement_score AS score + FROM users_settings us + JOIN users u ON u.id = us.user_id + ORDER BY us.achievement_score DESC + LIMIT 20` + ); + + const entries = rankEntries( + rows.map((r) => ({ + userId: Number(r.userId), + username: r.username, + figure: r.look, + score: r.score, + rank: 0, + })) + ); + + const totalPlayers = entries.length > 0 + ? Number( + ( + await prisma.$queryRawUnsafe>( + `SELECT COUNT(*) AS cnt FROM users_settings WHERE achievement_score > 0` + ) + )[0]?.cnt ?? 0 + ) + : 0; + + let viewerEntry: Partial | undefined; + if (userId) { + const viewerUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { username: true, look: true }, + }); + if (viewerUser) { + const viewerSettings = await prisma.usersSettings.findUnique({ + where: { userId }, + select: { achievementScore: true }, + }); + if (viewerSettings && viewerSettings.achievementScore > 0) { + const viewerRank = Number( + ( + await prisma.$queryRawUnsafe>( + `SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ?`, + viewerSettings.achievementScore + ) + )[0]?.cnt ?? entries.length + 1 + ); + viewerEntry = { + userId, + username: viewerUser.username, + figure: viewerUser.look, + score: viewerSettings.achievementScore, + rank: viewerRank, + }; + } + } + } + + return { entries, totalPlayers, viewerEntry }; +} + +async function loadRarityBoard( + rarity: BadgeRarityKey, + badgeCodes: string[], + userId: number | null +): Promise { + if (badgeCodes.length === 0) return { entries: [], totalPlayers: 0 }; + + const placeholders = badgeCodes.map(() => "?").join(","); + const rows = await prisma.$queryRawUnsafe>( + `SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt + FROM users_badges ub + JOIN users u ON u.id = ub.user_id + WHERE ub.badge_code IN (${placeholders}) + GROUP BY ub.user_id + ORDER BY cnt DESC + LIMIT 20`, + ...badgeCodes + ); + + const entries = rankEntries( + rows.map((r) => ({ + userId: Number(r.userId), + username: r.username, + figure: r.look, + score: Number(r.cnt), + rank: 0, + })) + ); + + const totalPlayers = rows.length > 0 + ? Number( + ( + await prisma.$queryRawUnsafe>( + `SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges WHERE badge_code IN (${placeholders})`, + ...badgeCodes + ) + )[0]?.cnt ?? 0 + ) + : 0; + + let viewerEntry: Partial | undefined; + if (userId) { + const viewerRow = await prisma.$queryRawUnsafe>( + `SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ? AND badge_code IN (${placeholders})`, + userId, + ...badgeCodes + ); + const viewerScore = Number(viewerRow[0]?.cnt ?? 0); + if (viewerScore > 0) { + const viewerUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { username: true, look: true }, + }); + if (viewerUser) { + const viewerRank = Number( + ( + await prisma.$queryRawUnsafe>( + `SELECT COUNT(*) + 1 AS cnt + FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${placeholders}) GROUP BY user_id) t + WHERE t.total > ?`, + ...badgeCodes, + viewerScore + ) + )[0]?.cnt ?? entries.length + 1 + ); + viewerEntry = { + userId, + username: viewerUser.username, + figure: viewerUser.look, + score: viewerScore, + rank: viewerRank, + }; + } + } + } + + return { entries, totalPlayers, viewerEntry }; +} + +export async function GET(req: Request) { + try { + let userId: number | null = await bearerUserId(req); + if (!userId) { + const session = await auth(); + userId = session?.user?.id ? Number(session.user.id) : null; + } + + // Gather badge ownership stats + const badgeStatsRaw = await prisma.$queryRawUnsafe< + Array<{ badgeCode: string; ownerCount: bigint }> + >( + `SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount + FROM users_badges + GROUP BY badge_code` + ); + + const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({ + badgeCode: r.badgeCode, + ownerCount: Number(r.ownerCount), + rarity: assignRarity(Number(r.ownerCount)), + })); + + // Group badge codes by rarity + const badgesByRarity = new Map(); + for (const stat of badgeStats) { + const list = badgesByRarity.get(stat.rarity) ?? []; + list.push(stat.badgeCode); + badgesByRarity.set(stat.rarity, list); + } + + const rarityKeys: BadgeRarityKey[] = ["common", "rare", "epic", "legendary", "mythical", "unique"]; + + const [totalBadges, achievementLevel, ...rarityBoards] = await Promise.all([ + loadTotalBadgesBoard(userId), + loadAchievementBoard(userId), + ...rarityKeys.map((rk) => loadRarityBoard(rk, badgesByRarity.get(rk) ?? [], userId)), + ]); + + const rarity = Object.fromEntries( + rarityKeys.map((rk, i) => [rk, rarityBoards[i]]) + ) as Record; + + return apiJson({ + viewerUserId: userId ?? 0, + badgeStats, + thresholds: THRESHOLDS, + leaderboards: { totalBadges, achievementLevel, rarity }, + }); + } catch (err) { + console.error("Badge leaderboard error:", err); + return apiJson({ + viewerUserId: 0, + badgeStats: [], + thresholds: THRESHOLDS, + leaderboards: { + totalBadges: { entries: [], totalPlayers: 0 }, + achievementLevel: { entries: [], totalPlayers: 0 }, + rarity: { + common: { entries: [], totalPlayers: 0 }, + rare: { entries: [], totalPlayers: 0 }, + epic: { entries: [], totalPlayers: 0 }, + legendary: { entries: [], totalPlayers: 0 }, + mythical: { entries: [], totalPlayers: 0 }, + unique: { entries: [], totalPlayers: 0 }, + }, + }, + }); + } +}