feat(server): allow users to view their own tickets
@@ -9,13 +9,7 @@ } from "discord.js";
import { and, eq } from "drizzle-orm"; import { nanoid } from "nanoid"; -import { - attachments, - db, - ticketMessages, - tickets, - userPreferences, -} from "@/database"; +import { attachments, db, ticketMessages, tickets, userPreferences } from "@/database"; import { getPublicUrl, s3 } from "@/utils/s3"; import { handleMessage } from "@/automod";@@ -44,9 +38,7 @@ // const allowed = await checkUwuifyRateLimit(message.author.id);
const allowed = true; if (!allowed) { const info = await getRateLimitInfo(message.author.id); - const resetTime = info?.resetAt - ? Math.floor(info.resetAt.getTime() / 1000) - : Date.now(); + const resetTime = info?.resetAt ? Math.floor(info.resetAt.getTime() / 1000) : Date.now(); try { await message.reply({@@ -67,14 +59,9 @@ try {
let replyReference = null; if (message.reference?.messageId) { try { - replyReference = await message.channel.messages.fetch( - message.reference.messageId, - ); + replyReference = await message.channel.messages.fetch(message.reference.messageId); } catch (error) { - logger.warn( - error, - "Failed to fetch referenced message for uwuified reply", - ); + logger.warn(error, "Failed to fetch referenced message for uwuified reply"); } }@@ -83,9 +70,7 @@ for (const attachment of message.attachments.values()) {
try { const resp = await fetch(attachment.url); if (!resp.ok) { - logger.warn( - `Failed to fetch attachment ${attachment.name}, status ${resp.status}`, - ); + logger.warn(`Failed to fetch attachment ${attachment.name}, status ${resp.status}`); continue; }@@ -101,9 +86,7 @@ }
message.delete(); - const contentParts = [ - `**<@${message.author.id}>**: ${uwuify(message.content)}`, - ]; + const contentParts = [`**<@${message.author.id}>**: ${uwuify(message.content)}`]; if (message.channel.isSendable()) { const sendOptions: {@@ -148,9 +131,7 @@
const triggers = ["grok is this true", "grok is this false "]; if ( message.channel.isSendable() && - triggers.some((trigger) => - message.content.toLowerCase().includes(trigger), - ) + triggers.some((trigger) => message.content.toLowerCase().includes(trigger)) ) { const chance = Math.random() * 100; if (chance < 25 || message.author.id === "470956810866655242") {@@ -161,10 +142,7 @@ message.channel.send("yeh");
} } - if ( - message.channel.isSendable() && - message.content.includes("gorque is this true") - ) { + if (message.channel.isSendable() && message.content.includes("gorque is this true")) { message.channel.send("oui 🇫🇷🥖"); }@@ -174,18 +152,19 @@ const ticket = db
.select() .from(tickets) .where( - and( - eq(tickets.channelId, message.channelId), - eq(tickets.guildId, message.guildId ?? ""), - ), + and(eq(tickets.channelId, message.channelId), eq(tickets.guildId, message.guildId ?? "")), ) .get(); if (!ticket) return; + const isSystem = message.author.id === client.user.id; + const isModerator = await hasManagerPermissions(message?.member as GuildMember); + const authorType = isSystem ? "system" : isModerator ? "staff" : "user"; + try { const isClaimed = ticket.claimedBy; - if (!isClaimed) { + if (!isClaimed && isModerator) { db.update(tickets) .set({ claimedBy: message.author.id, claimedAt: message.createdAt }) .where(eq(tickets.id, ticket.id));@@ -196,16 +175,7 @@ }
} catch {} try { - const isSystem = message.author.id === client.user.id; - const isModerator = await hasManagerPermissions( - message?.member as GuildMember, - ); - const authorType = isSystem ? "system" : isModerator ? "staff" : "user"; - - let anonymisedContent = message.content.replace( - new RegExp(ticket.authorId, "g"), - "Reporter", - ); + let anonymisedContent = message.content.replace(new RegExp(ticket.authorId, "g"), "Reporter"); for (const [index, userId] of (ticket.addedUsers || []).entries()) { anonymisedContent = anonymisedContent.replace(@@ -242,8 +212,7 @@
const formatSize = (size: number) => { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`; - if (size < 1024 * 1024 * 1024) - return `${(size / (1024 * 1024)).toFixed(2)} MB`; + if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(2)} MB`; return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`; };@@ -286,9 +255,7 @@ if (!statusMessage) continue;
try { statusList[i].status = "uploading"; - await statusMessage.edit( - `**Attachment upload status**\n${renderBoard()}`, - ); + await statusMessage.edit(`**Attachment upload status**\n${renderBoard()}`); const key = `${ticket.anonymousId}/${savedMessage.id}/${nanoid(8)}_${attachment.name}`; const file = s3.file(key);@@ -313,18 +280,11 @@ });
statusList[i].status = "done"; statusList[i].url = publicUrl; - await statusMessage.edit( - `**Attachment upload status**\n${renderBoard()}`, - ); + await statusMessage.edit(`**Attachment upload status**\n${renderBoard()}`); } catch (error) { - logger.error( - error, - `Failed to upload attachment ${attachment.name}`, - ); + logger.error(error, `Failed to upload attachment ${attachment.name}`); statusList[i].status = "failed"; - await statusMessage.edit( - `**Attachment upload status**\n${renderBoard()}`, - ); + await statusMessage.edit(`**Attachment upload status**\n${renderBoard()}`); } } }
@@ -1,26 +1,9 @@
-import { - desc, - eq, - asc, - and, - isNotNull, - ne, - gte, - inArray, - or, -} from "drizzle-orm"; +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 { db, tickets, ticketMessages, moderators, sessions, attachments } from "@/database"; import { getUser, getUsers, hydrateTickets } from "./discord"; const logger = _logger.child({ name: "server" });@@ -35,17 +18,11 @@
async function getSessionUser(req: BunRequest) { const cookieHeader = req.headers.get("Cookie"); if (!cookieHeader) return null; - const sessionCookie = cookieHeader - .split("; ") - .find((c) => c.startsWith("session=")); + 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); + 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 };@@ -82,16 +59,13 @@ 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 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)@@ -158,24 +132,27 @@ .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 isMod = modCheck.length > 0; const url = new URL(req.url); const limitStr = url.searchParams.get("limit") || "20"; const limit = Number.parseInt(limitStr, 10); - const allTickets = await db - .select() - .from(tickets) - .limit(limit) - .orderBy(desc(tickets.createdAt)); + 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);@@ -198,25 +175,21 @@ .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 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 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()@@ -248,10 +221,7 @@ .select()
.from(attachments) .where( or( - and( - eq(attachments.ownerType, "ticket"), - eq(attachments.ownerId, ticketId), - ), + and(eq(attachments.ownerType, "ticket"), eq(attachments.ownerId, ticketId)), messageIds.length > 0 ? and( eq(attachments.ownerType, "ticket_message"),@@ -265,8 +235,7 @@ 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, + (a) => a.ownerType === "ticket_message" && a.ownerId === message.id, ); const mentions: Record<string, any> = {};@@ -311,23 +280,16 @@ .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, - }, - ); + 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 }); + 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" },@@ -344,8 +306,7 @@ .select()
.from(moderators) .where(eq(moderators.user_id, user.id)) .limit(1); - if (modCheck.length === 0) - return new Response("Forbidden", { status: 403 }); + 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);@@ -370,9 +331,7 @@ 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 cutoffDate = days ? new Date(Date.now() - days * 24 * 60 * 60 * 1000) : null; const closedTickets = await db .select()@@ -399,9 +358,7 @@ 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; + sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; return { mean, median, count: durations.length }; };@@ -411,9 +368,7 @@ 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()); + hourlyDurations.get(hour)?.push(t.closedAt!.getTime() - t.createdAt.getTime()); } const hourly = [];@@ -436,9 +391,7 @@ 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 cutoffDate = days ? new Date(Date.now() - days * 24 * 60 * 60 * 1000) : null; const closedTickets = await db .select()@@ -459,9 +412,7 @@ 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()); + modDurations.get(modId)?.push(t.closedAt!.getTime() - t.createdAt.getTime()); } const getStats = (durations: number[]) => {@@ -471,22 +422,17 @@ 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; + 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 }; - }, - ); + 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 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);