feat: add realtime Discord leaderboards
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user