Add /api/badges/leaderboard endpoint for Nitro v3 badge leaderboard
This commit is contained in:
1 parent
efb9758cba
commit
2f0233a0c0
1 file changed
+334
@@ -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<BadgeLeaderboardEntry>;
|
||||
}
|
||||
|
||||
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<BadgeLeaderboardBoard> {
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ userId: number; username: string; look: string; cnt: bigint }>>(
|
||||
`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<Array<{ cnt: bigint }>>(
|
||||
`SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges`
|
||||
)
|
||||
)[0]?.cnt ?? 0
|
||||
)
|
||||
: 0;
|
||||
|
||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
|
||||
if (userId) {
|
||||
const viewerRow = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
|
||||
`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<Array<{ cnt: bigint }>>(
|
||||
`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<BadgeLeaderboardBoard> {
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ userId: number; username: string; look: string; score: number }>>(
|
||||
`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<Array<{ cnt: bigint }>>(
|
||||
`SELECT COUNT(*) AS cnt FROM users_settings WHERE achievement_score > 0`
|
||||
)
|
||||
)[0]?.cnt ?? 0
|
||||
)
|
||||
: 0;
|
||||
|
||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | 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<Array<{ cnt: bigint }>>(
|
||||
`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<BadgeLeaderboardBoard> {
|
||||
if (badgeCodes.length === 0) return { entries: [], totalPlayers: 0 };
|
||||
|
||||
const placeholders = badgeCodes.map(() => "?").join(",");
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ userId: number; username: string; look: string; cnt: bigint }>>(
|
||||
`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<Array<{ cnt: bigint }>>(
|
||||
`SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges WHERE badge_code IN (${placeholders})`,
|
||||
...badgeCodes
|
||||
)
|
||||
)[0]?.cnt ?? 0
|
||||
)
|
||||
: 0;
|
||||
|
||||
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
|
||||
if (userId) {
|
||||
const viewerRow = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
|
||||
`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<Array<{ cnt: bigint }>>(
|
||||
`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<BadgeRarityKey, string[]>();
|
||||
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<BadgeRarityKey, BadgeLeaderboardBoard>;
|
||||
|
||||
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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user