import { desc, eq, asc, and, isNotNull, ne, gte, inArray, or } from "drizzle-orm"; import type { BunRequest } from "bun"; import _logger from "@/utils/logging"; import config from "@/config"; import { db, tickets, ticketMessages, moderators, sessions, attachments } from "@/database"; import { getUser, getUsers, hydrateTickets } from "./discord"; const logger = _logger.child({ name: "server" }); const CLIENT_ID = config.discord.app_id; const CLIENT_SECRET = config.discord.client_secret; const HOST = Bun.env.NODE_ENV === "production" ? "https://tickets.vt3e.cat" : "http://127.0.0.1:3000"; const REDIRECT_URI = `${HOST}/auth/callback`; const FRONTEND_URL = process.env.FRONTEND_URL || "http://127.0.0.1:5173"; async function getSessionUser(req: BunRequest) { const cookieHeader = req.headers.get("Cookie"); if (!cookieHeader) return null; const sessionCookie = cookieHeader.split("; ").find((c) => c.startsWith("session=")); if (!sessionCookie) return null; const sessionId = sessionCookie.split("=")[1]; const sessionList = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1); if (sessionList.length === 0) return null; return { id: sessionList[0].userId, username: sessionList[0].username }; } const server = Bun.serve({ hostname: "127.0.0.1", routes: { "/login": () => { const redirectUri = `https://discord.com/oauth2/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=identify+guilds`; return new Response(null, { status: 302, headers: { Location: redirectUri, }, }); }, "/auth/callback": { GET: async (req) => { const url = new URL(req.url); const code = url.searchParams.get("code"); if (!code) return new Response("No code provided", { status: 400, statusText: "No code provided" }); const params = new URLSearchParams(); params.append("client_id", CLIENT_ID); params.append("client_secret", CLIENT_SECRET); params.append("grant_type", "authorization_code"); params.append("code", code); params.append("redirect_uri", REDIRECT_URI); const tokenResponse = await fetch("https://discord.com/api/oauth2/token", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded", }, }); const data = await tokenResponse.json(); if (data.error) return new Response(data.error_description, { status: 400, statusText: data.error }); const accessToken = data.access_token; const userResponse = await fetch("https://discord.com/api/users/@me", { headers: { Authorization: `Bearer ${accessToken}`, }, }); const userData = await userResponse.json(); const sessionId = crypto.randomUUID(); await db.insert(sessions).values({ id: sessionId, userId: userData.id, username: userData.username, }); return new Response(null, { status: 302, headers: { Location: `${FRONTEND_URL}/tickets`, "Set-Cookie": `session=${sessionId}; Path=/; HttpOnly; SameSite=Lax`, }, }); }, }, "/me": { 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); const isMod = modCheck.length > 0; const profile = await getUser(user.id); return new Response(JSON.stringify({ profile, isMod }), { headers: { "Content-Type": "application/json" }, }); }, }, "/tickets": { GET: async (req: BunRequest) => { const user = await getSessionUser(req); if (!user) return new Response(null, { status: 302, headers: { Location: "/login" } }); const modCheck = await db .select() .from(moderators) .where(eq(moderators.user_id, user.id)) .limit(1); if (modCheck.length === 0) { return new Response("Forbidden: You must be a moderator to view tickets.", { status: 403, }); } const url = new URL(req.url); const limitStr = url.searchParams.get("limit") || "20"; const limit = parseInt(limitStr, 10); const allTickets = await db .select() .from(tickets) .limit(limit) .orderBy(desc(tickets.createdAt)); const hydratedTickets = await hydrateTickets(allTickets); return new Response(JSON.stringify(hydratedTickets), { headers: { "Content-Type": "application/json" }, }); }, }, "/tickets/:id": { GET: async (req: BunRequest) => { const user = await getSessionUser(req); if (!user) return new Response(null, { status: 302, headers: { Location: "/login" } }); const modCheck = await db .select() .from(moderators) .where(eq(moderators.user_id, user.id)) .limit(1); if (modCheck.length === 0) return new Response("Forbidden: You must be a moderator to view tickets.", { status: 403, }); const { id } = req.params; const ticketId = parseInt(id, 10); if (isNaN(ticketId)) return new Response("Invalid ID", { status: 400 }); const ticketList = await db.select().from(tickets).where(eq(tickets.id, ticketId)).limit(1); const _ticket = ticketList[0]; if (!_ticket) return new Response("Ticket not found", { status: 404 }); const messages = await db .select() .from(ticketMessages) .where(eq(ticketMessages.ticketId, ticketId)) .orderBy(asc(ticketMessages.createdAt)); const mentionRegex = /<@!?(\d+)>/g; const mentionedIds = messages.flatMap((m) => [...m.content.matchAll(mentionRegex)].map((match) => match[1]), ); const idsToFetch = [ _ticket.authorId, _ticket.openedBy, _ticket.claimedBy, ...messages.map((m) => m.authorId), ...mentionedIds, ].filter(Boolean) as string[]; await getUsers(idsToFetch); const [ticket] = await hydrateTickets([_ticket]); const messageIds = messages.map((m) => m.id); const ticketAttachments = await db .select() .from(attachments) .where( or( and(eq(attachments.ownerType, "ticket"), eq(attachments.ownerId, ticketId)), messageIds.length > 0 ? and( eq(attachments.ownerType, "ticket_message"), inArray(attachments.ownerId, messageIds), ) : undefined, ), ); const newMessages = await Promise.all( messages.map(async (message) => { const author = await getUser(message.authorId); const msgAttachments = ticketAttachments.filter( (a) => a.ownerType === "ticket_message" && a.ownerId === message.id, ); const mentions: Record = {}; const matches = [...message.content.matchAll(/<@!?(\d+)>/g)]; for (const match of matches) { const id = match[1]; if (!mentions[id]) { mentions[id] = await getUser(id); } } return { ...message, author, attachments: msgAttachments, mentions }; }), ); const mainTicketAttachments = ticketAttachments.filter( (a) => a.ownerType === "ticket" && a.ownerId === ticketId, ); return new Response( JSON.stringify({ ticket: { ...ticket, attachments: mainTicketAttachments }, messages: newMessages, }), { headers: { "Content-Type": "application/json" }, }, ); }, DELETE: 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: You must be a moderator to delete tickets.", { status: 403, }); const { id } = req.params; const ticketId = parseInt(id, 10); if (isNaN(ticketId)) return new Response("Invalid ID", { status: 400 }); const deleted = await db.delete(tickets).where(eq(tickets.id, ticketId)).returning(); if (deleted.length === 0) return new Response("Ticket not found", { status: 404 }); return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" }, }); }, }, "/moderators": { 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 mods = await db.select().from(moderators); const ids = mods.map((mod) => mod.user_id); await getUsers(ids); const hydrated = await Promise.all( mods.map(async (mod) => { const profile = await getUser(mod.user_id); return { ...profile, pronouns: mod }; }), ); 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(); 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(); 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" }, }); }, }, }, }); const PORT = process.env.PORT || 3000; logger.info(`server is running at ${server.hostname}:${PORT}`);