feat: add realtime Discord leaderboards

This commit is contained in:
2026-07-25 00:06:52 +07:00 Unverified
parent 7a9e67e283
commit bb6740fcee
39 changed files with 1917 additions and 119 deletions
+112
View File
@@ -0,0 +1,112 @@
import "server-only";
import { and, eq, gte, inArray, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { integer, numeric, pgTable, text } from "drizzle-orm/pg-core";
import postgres from "postgres";
import {
buildVoiceLeaderboard,
buildXpLeaderboard,
type VoiceSourceRow,
} from "@/lib/leaderboard/ranking";
const userLevels = pgTable("user_levels", {
userId: text("user_id").notNull(),
username: text("username").notNull(),
displayName: text("display_name"),
xp: numeric("xp", { precision: 40, scale: 0 }).notNull(),
level: integer("level").notNull(),
});
const voiceTime = pgTable("voice_time", {
userId: text("user_id").notNull(),
channelId: text("channel_id").notNull(),
totalSeconds: numeric("total_seconds", { precision: 40, scale: 0 }).notNull(),
});
type LeaderboardClient = ReturnType<typeof postgres>;
const globalForLeaderboard = globalThis as typeof globalThis & {
erikaLeaderboardReadOnlyClient?: LeaderboardClient;
};
function getLeaderboardDatabase() {
const connectionString = process.env.LEADERBOARD_DATABASE_URL;
if (!connectionString) {
throw new Error("LEADERBOARD_DATABASE_URL is not configured.");
}
if (!globalForLeaderboard.erikaLeaderboardReadOnlyClient) {
globalForLeaderboard.erikaLeaderboardReadOnlyClient = postgres(connectionString, {
max: 3,
idle_timeout: 20,
connect_timeout: 5,
connection: {
application_name: "erika-leaderboard",
default_transaction_read_only: true,
statement_timeout: 15_000,
},
});
}
return drizzle(globalForLeaderboard.erikaLeaderboardReadOnlyClient);
}
export async function getXpLeaderboardPage(requestedPage: number) {
const rows = await getLeaderboardDatabase()
.select({
userId: userLevels.userId,
username: userLevels.username,
displayName: userLevels.displayName,
level: userLevels.level,
totalXp: userLevels.xp,
})
.from(userLevels)
.where(gte(userLevels.level, 1));
return buildXpLeaderboard(rows, requestedPage);
}
export async function getVoiceLeaderboardPage(
currentChannelIds: string[],
requestedPage: number,
selectedChannelId?: string,
) {
const scopedChannelIds = selectedChannelId
? [selectedChannelId]
: currentChannelIds;
if (scopedChannelIds.length === 0) {
return buildVoiceLeaderboard([], currentChannelIds, requestedPage, selectedChannelId);
}
const rows: VoiceSourceRow[] = await getLeaderboardDatabase()
.select({
userId: voiceTime.userId,
channelId: voiceTime.channelId,
totalSeconds: voiceTime.totalSeconds,
username: userLevels.username,
displayName: userLevels.displayName,
})
.from(voiceTime)
.leftJoin(userLevels, eq(voiceTime.userId, userLevels.userId))
.where(
and(
inArray(voiceTime.channelId, scopedChannelIds),
gte(voiceTime.totalSeconds, "0"),
),
);
return buildVoiceLeaderboard(
rows,
currentChannelIds,
requestedPage,
selectedChannelId,
);
}
export async function getLeaderboardDatabaseReadOnlyStatus() {
const result = await getLeaderboardDatabase().execute<{
transaction_read_only: string;
}>(sql`show transaction_read_only`);
return result[0]?.transaction_read_only ?? null;
}