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"; import { intoApiAttachment } from "./api"; import type { TicketResponse, TicketsResponse, MeResponse, ModeratorsResponse, OverallStatsResponse, LeaderboardResponse, Message, User, Statistic, } from "@stealth-developers/api"; 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 authenticate(req: BunRequest) { const cookieHeader = req.headers.get("Cookie"); if (!cookieHeader) return { user: null, isMod: false }; const sessionCookie = cookieHeader.split("; ").find((c) => c.startsWith("session=")); if (!sessionCookie) return { user: null, isMod: false }; const sessionId = sessionCookie.split("=")[1]; const sessionList = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1); if (sessionList.length === 0) return { user: null, isMod: false }; const userId = sessionList[0].userId; const modCheck = await db .select() .from(moderators) .where(eq(moderators.user_id, userId)) .limit(1); return { user: { id: userId, username: sessionList[0].username }, isMod: modCheck.length > 0, }; } function calculateStats(durations: number[]): { mean: number; median: number; count: number } { if (durations.length === 0) return { mean: 0, median: 0, count: 0 }; const sum = durations.reduce((a, b) => a + b, 0); const sorted = [...durations].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return { mean: sum / durations.length, median: sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2, count: durations.length, }; } const server = Bun.serve({ hostname: "127.0.0.1", port: process.env.PORT || 3000, 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 Response.redirect(redirectUri, 302); }, "/auth/callback": { GET: async (req: BunRequest) => { const url = new URL(req.url); const code = url.searchParams.get("code"); if (!code) return new Response("No code provided", { status: 400 }); const params = new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, grant_type: "authorization_code", code, 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 }); const userResponse = await fetch("https://discord.com/api/users/@me", { headers: { Authorization: `Bearer ${data.access_token}` }, }); 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, isMod } = await authenticate(req); if (!user) return new Response("Unauthorized", { status: 401 }); const profile = await getUser(user.id); if (!profile) return new Response("Profile not found", { status: 404 }); // Ensures the return strictly conforms to `MeResponse` (alias for User) const response: MeResponse = { ...profile, isModerator: isMod }; return Response.json(response); }, }, "/api/tickets": { GET: async (req: BunRequest) => { const { user, isMod } = await authenticate(req); if (!user) return Response.redirect("/login", 302); const url = new URL(req.url); const limit = Number.parseInt(url.searchParams.get("limit") || "20", 10); const query = db.select().from(tickets).limit(limit).orderBy(desc(tickets.createdAt)); const allTickets = isMod ? await query : await query.where(eq(tickets.authorId, user.id)); const hydratedTickets = await hydrateTickets(allTickets); const response: TicketsResponse = { tickets: hydratedTickets }; return Response.json(response); }, }, "/api/tickets/:id": { GET: async (req: BunRequest) => { const { user, isMod } = await authenticate(req); if (!user) return Response.redirect("/login", 302); const ticketId = Number.parseInt(req.params.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", { 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, ]; await getUsers(idsToFetch); const [hydratedTicket] = 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: Message[] = await Promise.all( messages.map(async (message) => { const author = await getUser(message.authorId); const _attachments = ticketAttachments.filter( (a) => a.ownerType === "ticket_message" && a.ownerId === message.id, ); const mentions: Record = {}; for (const match of message.content.matchAll(mentionRegex)) { const id = match[1]; if (!mentions[id]) { const matchedUser = await getUser(id); if (matchedUser) mentions[id] = matchedUser; } } return { id: message.id, messageId: message.messageId, createdAt: new Date(message.createdAt), updatedAt: message.editedAt ? new Date(message.editedAt) : null, deletedAt: message.deletedAt ? new Date(message.deletedAt) : null, author: author as User, authorType: message.authorType as "user" | "staff" | "system", content: message.content, attachments: await Promise.all(_attachments.map(intoApiAttachment)), mentions: Object.values(mentions), }; }), ); const response: TicketResponse = { ticket: hydratedTicket, messages: newMessages, }; return Response.json(response); }, DELETE: async (req: BunRequest) => { const { user, isMod } = await authenticate(req); if (!user) return new Response("Unauthorized", { status: 401 }); if (!isMod) return new Response("Forbidden", { status: 403 }); const ticketId = Number.parseInt(req.params.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 Response.json({ success: true }); }, }, "/api/moderators": { GET: async (req: BunRequest) => { const { user, isMod } = await authenticate(req); if (!user) return new Response("Unauthorized", { status: 401 }); if (!isMod) return new Response("Forbidden", { status: 403 }); const mods = await db.select().from(moderators); await getUsers(mods.map((mod) => mod.user_id)); const response: ModeratorsResponse = await Promise.all( mods.map(async (mod) => (await getUser(mod.user_id)) as User), ); return Response.json(response); }, }, "/api/stats/overall": { GET: async (req: BunRequest) => { const url = new URL(req.url); const days = Number.parseInt(url.searchParams.get("days") || "0", 10); const guildId = url.searchParams.get("guildId"); const cutoffDate = days ? new Date(Date.now() - days * 86400000) : 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 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 response: OverallStatsResponse = { global: calculateStats(allDurations), hourly: Array.from( { length: 24 }, (_, hour) => ({ hour, ...calculateStats(hourlyDurations.get(hour) || []), }) as Statistic & { hour: number }, ).filter((h) => h.count > 0), }; return Response.json(response); }, }, "/api/stats/leaderboard": { GET: async (req: BunRequest) => { const url = new URL(req.url); const days = Number.parseInt(url.searchParams.get("days") || "0", 10); const guildId = url.searchParams.get("guildId"); const orderBy = url.searchParams.get("order_by") || "median"; const cutoffDate = days ? new Date(Date.now() - days * 86400000) : 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 (!modDurations.has(modId)) modDurations.set(modId, []); modDurations.get(modId)!.push(t.closedAt!.getTime() - t.createdAt.getTime()); } const modStats = Array.from(modDurations.entries()).map(([modId, durations]) => ({ modId, ...calculateStats(durations), })); 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); await getUsers(modStats.map((m) => m.modId)); const response: LeaderboardResponse = await Promise.all( modStats.map(async (mod, i) => ({ rank: i + 1, user: (await getUser(mod.modId)) as User, mean: mod.mean, median: mod.median, count: mod.count, })), ); return Response.json(response); }, }, "/api/*": () => new Response(null, { status: 404 }), "/*": async (request: BunRequest) => { const { pathname } = new URL(request.url); const file = Bun.file(`../frontend/dist/${pathname}`); if (await file.exists()) return new Response(file); return new Response(Bun.file(`../frontend/dist/index.html`)); }, }, }); logger.info(`server is running at ${server.hostname}:${server.port}`);