api: ticket get routes
vi did:web:vt3e.cat
Sun, 28 Jun 2026 23:14:13 +0100
9 files changed,
535 insertions(+),
112 deletions(-)
M
apps/api/src/index.ts
→
apps/api/src/index.ts
@@ -1,38 +1,10 @@
-import config, { IS_PROD } from "@stealth-developers/config"; -import db, { getSession, getUser, sessions, upsertUser } from "@stealth-developers/db"; -import { - RESTGetAPICurrentUserResult, - type RESTPostOAuth2AccessTokenResult, - RouteBases, - Routes, -} from "discord-api-types/v10"; -import { Elysia, t } from "elysia"; - -import { type OAuth2ErrorResponse, isError, isOAuth2Error, makeRequest } from "./discord"; - -const CLIENT_ID = config.discord.app_id; -const CLIENT_SECRET = config.discord.client_secret; - -const HOST = IS_PROD ? "https://tickets.vt3e.cat" : "http://127.0.0.1:3000"; -const REDIRECT_URI = `${HOST}/auth/callback`; -const FRONTEND_URL = IS_PROD ? HOST : "http://127.0.0.1:5173"; - -const authPlugin = new Elysia({ name: "auth" }) - .guard({ - as: "global", - cookie: t.Cookie({ - session_id: t.Optional(t.String()), - }), - }) - .resolve({ as: "global" }, async ({ cookie: { session_id } }) => { - const sessionId = session_id.value as string | undefined; - if (!sessionId) return { session: null }; - - const session = await getSession(sessionId); - if (!session || session.expiresAt < new Date()) return { session: null }; +import config from "@stealth-developers/config"; +import { getUser } from "@stealth-developers/db"; +import { Elysia } from "elysia"; - return { session }; - }); +import { authPlugin } from "./lib/auth"; +import { getLinkedActors } from "./lib/db"; +import * as routes from "./routes"; const app = new Elysia() .use(authPlugin)@@ -43,81 +15,7 @@
if (error instanceof Error) return { message: error.message ?? "Internal Server Error" }; return { message: "Unknown Error" }; }) - .group("/auth", (app) => - app - .get("/login", ({ redirect }) => { - const redirectUri = `https://discord.com/oauth2/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=identify+guilds`; - return redirect(redirectUri, 302); - }) - .get( - "/callback", - async ({ query: { code }, cookie: { session_id }, redirect }) => { - const params = new URLSearchParams({ - code, - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - grant_type: "authorization_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 tokenData = (await tokenResponse.json()) as - | OAuth2ErrorResponse - | RESTPostOAuth2AccessTokenResult; - if (isOAuth2Error(tokenData)) - throw new Error(tokenData.error_description ?? tokenData.error); - - const userData = await makeRequest<RESTGetAPICurrentUserResult>( - `${RouteBases.api}${Routes.user()}`, - tokenData.access_token, - ); - if (isError(userData)) throw new Error(userData.message); - - const upsertedUser = await upsertUser({ - avatar: userData.avatar, - username: userData.username, - displayName: userData.global_name, - id: userData.id, - }); - - const sessionId = crypto.randomUUID(); - const expiresAt = new Date().getTime() + tokenData.expires_in * 1_000; - - await db.insert(sessions).values({ - id: sessionId, - userId: upsertedUser.id, - - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - scope: tokenData.scope, - tokenType: tokenData.token_type, - - createdAt: new Date(), - expiresAt: new Date(expiresAt), - }); - - session_id.set({ - value: sessionId, - httpOnly: true, - path: "/", - secure: IS_PROD, - sameSite: "lax", - expires: new Date(expiresAt), - }); - - return redirect(FRONTEND_URL); - }, - { - query: t.Object({ - code: t.String(), - }), - }, - ), - ) + .use(routes.authRoutes) .group("/api", (app) => app .resolve(({ session, status }) => {@@ -125,9 +23,14 @@ if (!session) return status(401, { message: "unauthorized" });
return { session }; }) .get("/me", async ({ session }) => { - const user = await getUser(session.userId); - return user; - }), + const [user, actors] = await Promise.all([ + getUser(session.userId), + getLinkedActors(session.userId), + ]); + + return { user, actors }; + }) + .use(routes.guildRoutes), ) .listen(config.api.port);
A
apps/api/src/lib/access.ts
@@ -0,0 +1,15 @@
+import { Actor, TicketWithRelations } from "@stealth-developers/db"; + +export function hasAccessToTicket( + actor: Actor, + ticket: TicketWithRelations, + { requireModerator = false }: { requireModerator?: boolean } = {}, +) { + const isModerator = actor.moderator; + if (requireModerator) return isModerator; + + if (actor.discordId === ticket.subjectActor?.discordId) return true; + if (isModerator) return true; + + return false; +}
A
apps/api/src/lib/auth.ts
@@ -0,0 +1,22 @@
+import { getSession, getUser } from "@stealth-developers/db"; +import Elysia, { t } from "elysia"; + +export const authPlugin = new Elysia({ name: "auth" }) + .guard({ + as: "global", + cookie: t.Cookie({ + session_id: t.Optional(t.String()), + }), + }) + .resolve({ as: "global" }, async ({ cookie: { session_id } }) => { + const sessionId = session_id.value as string | undefined; + if (!sessionId) return { session: null }; + + const session = await getSession(sessionId); + if (!session || session.expiresAt < new Date()) return { session: null }; + + const user = await getUser(session.userId); + if (!user) return { session: null }; + + return { session, user }; + });
A
apps/api/src/lib/constants.ts
@@ -0,0 +1,8 @@
+import config, { IS_PROD } from "@stealth-developers/config"; + +export const CLIENT_ID = config.discord.app_id; +export const CLIENT_SECRET = config.discord.client_secret; + +export const HOST = IS_PROD ? "https://tickets.vt3e.cat" : "http://127.0.0.1:3000"; +export const REDIRECT_URI = `${HOST}/auth/callback`; +export const FRONTEND_URL = IS_PROD ? HOST : "http://127.0.0.1:5173";
A
apps/api/src/lib/db.ts
@@ -0,0 +1,40 @@
+import db, { TicketWithRelations, ticketWithRelations } from "@stealth-developers/db"; + +export interface PaginationOptions { + limit?: number; + offset?: number; +} + +export async function getTicketsByGuildId( + guildId: number, + userId: number | undefined, + options: PaginationOptions = {}, +): Promise<TicketWithRelations[]> { + const { limit, offset } = options; + + const ticketsList = await db.query.tickets.findMany({ + where: { + guildId: guildId, + ...(userId !== undefined ? { subject: userId } : {}), + }, + orderBy: (table, { desc }) => [desc(table.localId)], + limit: limit, + offset: offset, + with: ticketWithRelations, + }); + + return ticketsList; +} + +export async function getLinkedActors(userId: number) { + const actors = await db.query.actors.findMany({ + where: { + userId: userId, + }, + with: { + guild: true, + }, + }); + + return actors; +}
A
apps/api/src/lib/sanitise.ts
@@ -0,0 +1,225 @@
+import { + Actor, + Guild, + Ticket, + TicketAttachment, + TicketComment, + TicketMessage, + TicketParticipant, + TicketWithRelations, + User, +} from "@stealth-developers/db"; + +export interface SanitiseOptions { + isModerator: boolean; + anonymiseStaff?: boolean; +} + +// +export function sanitiseGuild(guild: Guild) { + return { + id: guild.id, + discordId: guild.discordId, + + name: guild.name, + icon: guild.icon, + + createdAt: guild.createdAt, + updatedAt: guild.updatedAt, + }; +} + +export function sanitiseGuilds(guilds: Guild[]) { + return guilds.map(sanitiseGuild); +} + +// +export function sanitiseUser(user: User) { + return { + id: user.id, + discordId: user.discordId, + + username: user.username, + displayName: user.displayName, + + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; +} + +export function sanitiseUsers(users: User[]) { + return users.map(sanitiseUser); +} + +// +export function sanitiseActor(actor: Actor) { + return { + actorId: actor.id, + guildId: actor.guildId, + userId: actor.userId, + discordId: actor.discordId, + + username: actor.username, + displayName: actor.displayName, + avatar: actor.avatar, + + createdAt: actor.createdAt, + updatedAt: actor.updatedAt, + }; +} + +export function sanitiseActors(actors: Actor[]) { + return actors.map(sanitiseActor); +} + +// +export function sanitiseTicket(ticket: TicketWithRelations, options: SanitiseOptions) { + const { isModerator, anonymiseStaff = false } = options; + const shouldAnonymise = !isModerator && anonymiseStaff; + + return { + id: ticket.id, + guildId: ticket.guildId, + localId: ticket.localId, + + status: ticket.status, + channelId: ticket.channelId, + threadId: ticket.threadId, + + closeReason: ticket.closeReason, + privateReason: isModerator ? ticket.privateReason : null, + + openedAt: ticket.openedAt, + closedAt: ticket.closedAt, + closedBy: shouldAnonymise ? null : ticket.closedBy, + + lastSubjectActivityAt: ticket.lastSubjectActivityAt, + lastClaimantActivityAt: isModerator ? ticket.lastClaimantActivityAt : null, + + openedBy: ticket.openedBy, + openReason: ticket.openReason, + subject: ticket.subject, + + claimedBy: shouldAnonymise ? null : ticket.claimedBy, + claimedAt: shouldAnonymise ? null : ticket.claimedAt, + + thankedAt: ticket.thankedAt, + warnedAt: ticket.warnedAt, + lastMessageAt: ticket.lastMessageAt, + + hasModeratorMessage: ticket.hasModeratorMessage, + hasAuthorMessage: ticket.hasAuthorMessage, + }; +} + +// +export function sanitiseTicketMessage(message: TicketMessage, options: SanitiseOptions) { + const { isModerator, anonymiseStaff = false } = options; + if (message.isPrivate && !isModerator) return null; + + const shouldAnonymise = !isModerator && anonymiseStaff && message.actorRole === "moderator"; + + return { + id: message.id, + messageId: message.messageId, + ticketId: message.ticketId, + + actorId: shouldAnonymise ? null : message.actorId, + actorRole: message.actorRole, + + isPrivate: message.isPrivate, + content: message.content, + + embeds: message.embeds, + components: message.components, + + createdAt: message.createdAt, + editedAt: message.editedAt, + deletedAt: message.deletedAt, + }; +} + +export function sanitiseTicketMessages(messages: TicketMessage[], options: SanitiseOptions) { + return messages + .map((msg) => sanitiseTicketMessage(msg, options)) + .filter((msg): msg is NonNullable<ReturnType<typeof sanitiseTicketMessage>> => msg !== null); +} + +// +export function sanitiseTicketParticipant( + participant: TicketParticipant, + options: SanitiseOptions, +) { + const { isModerator, anonymiseStaff = false } = options; + + const isStaffParticipant = participant.role === "moderator"; + const shouldAnonymise = !isModerator && anonymiseStaff && isStaffParticipant; + + if (shouldAnonymise) return null; + + return { + id: participant.id, + ticketId: participant.ticketId, + actorId: participant.actorId, + role: participant.role, + + reason: isModerator ? participant.reason : null, + createdAt: participant.createdAt, + }; +} + +export function sanitiseTicketParticipants( + participants: TicketParticipant[], + options: SanitiseOptions, +) { + return participants + .map((p) => sanitiseTicketParticipant(p, options)) + .filter((p): p is NonNullable<ReturnType<typeof sanitiseTicketParticipant>> => p !== null); +} + +// +export function sanitiseTicketComment(comment: TicketComment, options: SanitiseOptions) { + const { isModerator } = options; + + if (!isModerator) return null; + + return { + id: comment.id, + ticketId: comment.ticketId, + actorId: comment.actorId, + content: comment.content, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + }; +} + +export function sanitiseTicketComments(comments: TicketComment[], options: SanitiseOptions) { + return comments + .map((c) => sanitiseTicketComment(c, options)) + .filter((c): c is NonNullable<ReturnType<typeof sanitiseTicketComment>> => c !== null); +} + +// +export function sanitiseTicketAttachment(attachment: TicketAttachment, options: SanitiseOptions) { + const { isModerator, anonymiseStaff = false } = options; + + const shouldAnonymise = !isModerator && anonymiseStaff; + + return { + id: attachment.id, + messageId: attachment.messageId, + ticketId: attachment.ticketId, + actorId: shouldAnonymise ? null : attachment.actorId, + + fileName: attachment.fileName, + fileType: attachment.fileType, + fileSize: attachment.fileSize, + }; +} + +export function sanitiseTicketAttachments( + attachments: TicketAttachment[], + options: SanitiseOptions, +) { + return attachments.map((a) => sanitiseTicketAttachment(a, options)); +}
A
apps/api/src/routes/auth.ts
@@ -0,0 +1,88 @@
+import { IS_PROD } from "@stealth-developers/config"; +import db, { sessions, upsertUser } from "@stealth-developers/db"; +import { + RESTGetAPICurrentUserResult, + RESTPostOAuth2AccessTokenResult, + RouteBases, + Routes, +} from "discord.js"; +import Elysia, { t } from "elysia"; + +import { OAuth2ErrorResponse, isError, isOAuth2Error, makeRequest } from "../discord"; +import { CLIENT_ID, CLIENT_SECRET, FRONTEND_URL, REDIRECT_URI } from "../lib/constants"; + +export const authRoutes = new Elysia().group("/auth", (app) => + app + .get("/login", ({ redirect }) => { + const redirectUri = `https://discord.com/oauth2/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=identify+guilds`; + return redirect(redirectUri, 302); + }) + .get( + "/callback", + async ({ query: { code }, cookie: { session_id }, redirect }) => { + const params = new URLSearchParams({ + code, + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + grant_type: "authorization_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 tokenData = (await tokenResponse.json()) as + | OAuth2ErrorResponse + | RESTPostOAuth2AccessTokenResult; + if (isOAuth2Error(tokenData)) + throw new Error(tokenData.error_description ?? tokenData.error); + + const userData = await makeRequest<RESTGetAPICurrentUserResult>( + `${RouteBases.api}${Routes.user()}`, + tokenData.access_token, + ); + if (isError(userData)) throw new Error(userData.message); + + const upsertedUser = await upsertUser({ + avatar: userData.avatar, + username: userData.username, + displayName: userData.global_name, + id: userData.id, + }); + + const sessionId = crypto.randomUUID(); + const expiresAt = new Date().getTime() + tokenData.expires_in * 1_000; + + await db.insert(sessions).values({ + id: sessionId, + userId: upsertedUser.id, + + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + scope: tokenData.scope, + tokenType: tokenData.token_type, + + createdAt: new Date(), + expiresAt: new Date(expiresAt), + }); + + session_id.set({ + value: sessionId, + httpOnly: true, + path: "/", + secure: IS_PROD, + sameSite: "lax", + expires: new Date(expiresAt), + }); + + return redirect(FRONTEND_URL); + }, + { + query: t.Object({ + code: t.String(), + }), + }, + ), +);
A
apps/api/src/routes/guilds.ts
@@ -0,0 +1,120 @@
+import { + getActorByDiscordIdAndGuildDiscordId, + getGuildByDiscordId, + getTicketByGuild, +} from "@stealth-developers/db"; +import Elysia, { t } from "elysia"; + +import { hasAccessToTicket } from "../lib/access"; +import { authPlugin } from "../lib/auth"; +import { getTicketsByGuildId } from "../lib/db"; +import { + sanitiseTicket, + sanitiseTicketAttachments, + sanitiseTicketComments, + sanitiseTicketMessages, + sanitiseTicketParticipants, +} from "../lib/sanitise"; + +export const guildRoutes = new Elysia({ prefix: "/guilds/:guildId" }) + .use(authPlugin) + .resolve(async ({ params: { guildId }, session, status, user }) => { + if (!session || !user?.discordId) return status(401, { message: "Unauthorised" }); + + const guild = await getGuildByDiscordId(guildId); + if (!guild) return status(404, { message: "Guild not found" }); + + const actor = await getActorByDiscordIdAndGuildDiscordId(user.discordId, guildId); + if (!actor) return status(403, { message: "No access to this guild" }); + + return { actor, guild }; + }) + .get("/", ({ actor, guild }) => { + return { + id: guild.id, + discordId: guild.discordId, + name: guild.name, + icon: guild.icon, + actor: { + username: actor.username, + displayName: actor.displayName, + avatar: actor.avatar, + + actorId: actor.id, + userId: actor.userId, + discordId: actor.discordId, + }, + }; + }) + .group("/tickets", (app) => + app + .get( + "/", + async ({ query: { limit, offset }, guild, actor }) => { + const tickets = await getTicketsByGuildId( + guild.id, + actor.moderator ? undefined : actor.id, + { limit, offset }, + ); + + return { + tickets: tickets.map((ticket) => + sanitiseTicket(ticket, { isModerator: !!actor.moderator, anonymiseStaff: false }), + ), + count: tickets.length, + }; + }, + { + query: t.Object({ + limit: t.Number({ default: 50 }), + offset: t.Number({ default: 0 }), + }), + }, + ) + .group("/:ticketId", (ticketGroup) => + ticketGroup + .resolve(async ({ params: { ticketId }, guild, actor, status }) => { + const ticket = await getTicketByGuild(guild.id, Number(ticketId)); + if (!ticket) return status(404, { message: "Ticket not found" }); + + const hasAccess = hasAccessToTicket(actor, ticket); + if (!hasAccess) return status(403, { message: "No access to this ticket" }); + + return { ticket }; + }) + .get("/", ({ ticket, actor }) => { + return sanitiseTicket(ticket, { + isModerator: !!actor.moderator, + anonymiseStaff: false, + }); + }) + .get("/messages", ({ ticket, actor }) => { + const messages = ticket.messages ?? ticket.messages ?? []; + return sanitiseTicketMessages(messages, { + isModerator: !!actor.moderator, + anonymiseStaff: false, + }); + }) + .get("/participants", ({ ticket, actor }) => { + const participants = ticket.participants ?? ticket.participants ?? []; + return sanitiseTicketParticipants(participants, { + isModerator: !!actor.moderator, + anonymiseStaff: false, + }); + }) + .get("/comments", ({ ticket, actor }) => { + const comments = ticket.comments ?? ticket.comments ?? []; + return sanitiseTicketComments(comments, { + isModerator: !!actor.moderator, + anonymiseStaff: false, + }); + }) + .get("/attachments", ({ ticket, actor }) => { + const attachments = ticket.attachments ?? ticket.attachments ?? []; + return sanitiseTicketAttachments(attachments, { + isModerator: !!actor.moderator, + anonymiseStaff: false, + }); + }), + ), + );
A
apps/api/src/routes/index.ts
@@ -0,0 +1,2 @@
+export { authRoutes } from "./auth"; +export { guildRoutes } from "./guilds";