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, { isProd } 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 = isProd ? "https://tickets.vt3e.cat" : "http://127.0.0.1:3000"; const REDIRECT_URI = `${HOST}/auth/callback`; const FRONTEND_URL = isProd ? HOST : "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: { "/api/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`, }, }); }, }, "/api/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" }, }); }, }, "/api/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); const isMod = modCheck.length > 0; const url = new URL(req.url); const limitStr = url.searchParams.get("limit") || "20"; const limit = Number.parseInt(limitStr, 10); let allTickets; if (isMod) { allTickets = await db .select() .from(tickets) .limit(limit) .orderBy(desc(tickets.createdAt)); } else { allTickets = await db .select() .from(tickets) .where(eq(tickets.authorId, user.id)) .limit(limit) .orderBy(desc(tickets.createdAt)); } const hydratedTickets = await hydrateTickets(allTickets); return new Response(JSON.stringify(hydratedTickets), { headers: { "Content-Type": "application/json" }, }); }, }, "/api/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); const isMod = modCheck.length > 0; const { id } = req.params; const ticketId = Number.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 }); if (!isMod && _ticket.authorId !== user.id) { return new Response( "Forbidden: You do not have permission to view this ticket.", { status: 403, }, ); } 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 = Number.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" }, }); }, }, "/api/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" }, }); }, }, "/api/stats/overall": { GET: async (req: BunRequest) => { const url = new URL(req.url); const daysStr = url.searchParams.get("days"); const days = daysStr ? Number.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" }, }); }, }, "/api/stats/leaderboard": { GET: async (req: BunRequest) => { const url = new URL(req.url); const daysStr = url.searchParams.get("days"); const days = daysStr ? Number.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" }, }); }, }, "/api/*": () => { return new Response(null, { status: 404 }); }, "/*": async (request) => { const { pathname } = new URL(request.url); const file = Bun.file(`frontend/dist/${pathname}`); if (await file.exists()) return new Response(file, { status: 200 }); const indexFile = Bun.file(`frontend/dist/index.html`); return new Response(indexFile, { status: 200 }); }, }, }); const PORT = process.env.PORT || 3000; logger.info(`server is running at ${server.hostname}:${PORT}`);