159 lines
4.3 KiB
TypeScript
159 lines
4.3 KiB
TypeScript
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,
|
|
searchQuery?: string,
|
|
) {
|
|
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, searchQuery);
|
|
}
|
|
|
|
export async function getMemberXpStats(userId: string) {
|
|
const database = getLeaderboardDatabase();
|
|
const [sourceRows, leaderboard] = await Promise.all([
|
|
database
|
|
.select({
|
|
level: userLevels.level,
|
|
totalXp: userLevels.xp,
|
|
})
|
|
.from(userLevels)
|
|
.where(eq(userLevels.userId, userId))
|
|
.limit(1),
|
|
getXpLeaderboardPage(1, userId),
|
|
]);
|
|
const rankedRow = leaderboard.rows.find((row) => row.userId === userId);
|
|
const sourceRow = sourceRows[0];
|
|
|
|
return {
|
|
level: sourceRow?.level ?? 0,
|
|
totalXp: sourceRow?.totalXp ?? "0",
|
|
rank: rankedRow?.rank ?? null,
|
|
};
|
|
}
|
|
|
|
export async function getVoiceLeaderboardPage(
|
|
currentChannelIds: string[],
|
|
requestedPage: number,
|
|
selectedChannelId?: string,
|
|
searchQuery?: string,
|
|
) {
|
|
const scopedChannelIds = selectedChannelId
|
|
? [selectedChannelId]
|
|
: currentChannelIds;
|
|
|
|
if (scopedChannelIds.length === 0) {
|
|
return buildVoiceLeaderboard([], currentChannelIds, requestedPage, selectedChannelId, searchQuery);
|
|
}
|
|
|
|
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,
|
|
searchQuery,
|
|
);
|
|
}
|
|
|
|
export async function getMemberVoiceStats(
|
|
userId: string,
|
|
currentChannelIds: string[],
|
|
) {
|
|
const leaderboard = await getVoiceLeaderboardPage(
|
|
currentChannelIds,
|
|
1,
|
|
undefined,
|
|
userId,
|
|
);
|
|
const rankedRow = leaderboard.rows.find((row) => row.userId === userId);
|
|
|
|
return {
|
|
totalSeconds: rankedRow?.totalSeconds ?? "0",
|
|
rank: rankedRow?.rank ?? null,
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|