feat(server): stats routes
vi did:web:vt3e.cat
Fri, 08 May 2026 23:53:39 +0100
1 files changed,
150 insertions(+),
1 deletions(-)
jump to
M
src/server/index.ts
→
src/server/index.ts
@@ -1,4 +1,4 @@
-import { desc, eq, asc } from "drizzle-orm"; +import { desc, eq, asc, and, isNotNull, ne, gte } from "drizzle-orm"; import type { BunRequest } from "bun"; import _logger from "@/utils/logging";@@ -247,6 +247,155 @@ }),
); return new Response(JSON.stringify(hydrated), { + headers: { "Content-Type": "application/json" }, + }); + }, + }, + "/stats/overall": { + GET: async (req: BunRequest) => { + const user = await getSessionUser(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + + const modCheck = await db + .select() + .from(moderators) + .where(eq(moderators.user_id, user.id)) + .limit(1); + if (modCheck.length === 0) return new Response("Forbidden", { status: 403 }); + + const url = new URL(req.url); + const daysStr = url.searchParams.get("days"); + const days = daysStr ? parseInt(daysStr, 10) : null; + const guildId = url.searchParams.get("guildId"); + const cutoffDate = days ? new Date(Date.now() - days * 24 * 60 * 60 * 1000) : null; + + const closedTickets = await db + .select() + .from(tickets) + .where( + and( + isNotNull(tickets.closedAt), + isNotNull(tickets.closedBy), + ne(tickets.closedBy, "reporter"), + ne(tickets.closedBy, "system"), + guildId ? eq(tickets.guildId, guildId) : undefined, + cutoffDate ? gte(tickets.closedAt, cutoffDate) : undefined, + ), + ); + + const allDurations = closedTickets.map( + (t) => t.closedAt!.getTime() - t.createdAt.getTime(), + ); + + const getStats = (durations: number[]) => { + if (durations.length === 0) return { mean: 0, median: 0, count: 0 }; + const sum = durations.reduce((a, b) => a + b, 0); + const mean = sum / durations.length; + const sorted = [...durations].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; + return { mean, median, count: durations.length }; + }; + + const globalStats = getStats(allDurations); + + const hourlyDurations = new Map<number, number[]>(); + for (const t of closedTickets) { + const hour = t.closedAt!.getHours(); + if (!hourlyDurations.has(hour)) hourlyDurations.set(hour, []); + hourlyDurations.get(hour)?.push(t.closedAt!.getTime() - t.createdAt.getTime()); + } + + const hourly = []; + for (let i = 0; i < 24; i++) { + const durations = hourlyDurations.get(i) || []; + if (durations.length === 0) continue; + const stats = getStats(durations); + hourly.push({ hour: i, ...stats }); + } + + return new Response(JSON.stringify({ global: globalStats, hourly }), { + headers: { "Content-Type": "application/json" }, + }); + }, + }, + "/stats/leaderboard": { + GET: async (req: BunRequest) => { + const user = await getSessionUser(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + + const modCheck = await db + .select() + .from(moderators) + .where(eq(moderators.user_id, user.id)) + .limit(1); + if (modCheck.length === 0) return new Response("Forbidden", { status: 403 }); + + const url = new URL(req.url); + const daysStr = url.searchParams.get("days"); + const days = daysStr ? parseInt(daysStr, 10) : null; + const guildId = url.searchParams.get("guildId"); + const orderBy = url.searchParams.get("order_by") || "median"; + const cutoffDate = days ? new Date(Date.now() - days * 24 * 60 * 60 * 1000) : null; + + const closedTickets = await db + .select() + .from(tickets) + .where( + and( + isNotNull(tickets.closedAt), + isNotNull(tickets.closedBy), + ne(tickets.closedBy, "reporter"), + ne(tickets.closedBy, "system"), + guildId ? eq(tickets.guildId, guildId) : undefined, + cutoffDate ? gte(tickets.closedAt, cutoffDate) : undefined, + ), + ); + + const modDurations = new Map<string, number[]>(); + for (const t of closedTickets) { + const modId = t.closedBy; + if (!modId) continue; + if (!modDurations.has(modId)) modDurations.set(modId, []); + modDurations.get(modId)?.push(t.closedAt!.getTime() - t.createdAt.getTime()); + } + + const getStats = (durations: number[]) => { + if (durations.length === 0) return { mean: 0, median: 0, count: 0 }; + const sum = durations.reduce((a, b) => a + b, 0); + const mean = sum / durations.length; + const sorted = [...durations].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; + return { mean, median, count: durations.length }; + }; + + const modStats = Array.from(modDurations.entries()).map(([modId, durations]) => { + const stats = getStats(durations); + return { modId, ...stats }; + }); + + if (orderBy === "mean") modStats.sort((a, b) => a.mean - b.mean); + else if (orderBy === "count") modStats.sort((a, b) => b.count - a.count); + else modStats.sort((a, b) => a.median - b.median); + + const idsToFetch = modStats.map((mod) => mod.modId); + await getUsers(idsToFetch); + + const leaderboard = await Promise.all( + modStats.map(async (mod, i) => { + const profile = await getUser(mod.modId); + return { + rank: i + 1, + user: profile, + ...mod, + }; + }), + ); + + return new Response(JSON.stringify(leaderboard), { headers: { "Content-Type": "application/json" }, }); },