import db, { and, eq, getActorByDiscordIdAndGuildDiscordId, getGuildByDiscordId, getTicketByGuild, gte, isNotNull, tickets, } 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 { sanitiseActor, sanitiseTicket, sanitiseTicketAttachments, sanitiseTicketComments, sanitiseTicketMessages, sanitiseTicketParticipants, } from "../lib/sanitise"; import { ErrorSchema, GuildAccessSchema, LeaderboardResponseSchema, OverallStatsSchema, TicketAttachmentSchema, TicketCommentSchema, TicketMessageSchema, TicketParticipantSchema, TicketSchema, TicketsResponseSchema, } from "../lib/schemas"; 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, }, }; }, { response: { 200: GuildAccessSchema, 401: ErrorSchema, 403: ErrorSchema, 404: ErrorSchema, }, tags: ["guilds"], }, ) .group("/stats", (app) => app .get( "/overall", async ({ query: { days }, guild }) => { const cutoffDate = days ? new Date(Date.now() - days * 24 * 60 * 60 * 1000) : null; const overallConditions = [ eq(tickets.guildId, guild.id), eq(tickets.status, "closed"), isNotNull(tickets.closedAt), ]; if (cutoffDate) { overallConditions.push(gte(tickets.closedAt, cutoffDate)); } const closedTickets = await db .select({ openedAt: tickets.openedAt, closedAt: tickets.closedAt, }) .from(tickets) .where(and(...overallConditions)); const durations = closedTickets .map((t) => { const closedTime = t.closedAt ? t.closedAt.getTime() : null; const openedTime = t.openedAt ? t.openedAt.getTime() : null; if (closedTime === null || openedTime === null) return null; return closedTime - openedTime; }) .filter((d): d is number => d !== null); const totalClosed = durations.length; let mean = 0; let median = 0; if (totalClosed > 0) { const sum = durations.reduce((a, b) => a + b, 0); mean = sum / totalClosed; const sorted = [...durations].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); const val1 = sorted[mid]; const val2 = sorted[mid - 1]; if (sorted.length % 2 !== 0) { median = val1 ?? 0; } else { median = val1 !== undefined && val2 !== undefined ? (val1 + val2) / 2 : 0; } } return { mean, median, totalClosed, }; }, { query: t.Object({ days: t.Optional(t.Numeric()), }), response: { 200: OverallStatsSchema, 401: ErrorSchema, 403: ErrorSchema, 404: ErrorSchema, }, tags: ["guilds", "stats"], }, ) .get( "/leaderboard", async ({ query: { days }, guild }) => { const cutoffDate = days ? new Date(Date.now() - days * 24 * 60 * 60 * 1000) : null; const closedTicketsWithActors = await db.query.tickets.findMany({ where: { guildId: guild.id, status: "closed", closedBy: { isNotNull: true }, closedAt: cutoffDate ? { gte: cutoffDate } : { isNotNull: true }, }, with: { closedByActor: true, }, }); const modStats: Record< number, { actor: any; durations: number[]; } > = {}; for (const ticket of closedTicketsWithActors) { const modId = ticket.closedBy; if (modId === null) continue; if (!modStats[modId]) { modStats[modId] = { actor: ticket.closedByActor, durations: [], }; } if (ticket.closedAt && ticket.openedAt) { const duration = ticket.closedAt.getTime() - ticket.openedAt.getTime(); modStats[modId].durations.push(duration); } } const leaderboard = Object.values(modStats).map(({ actor, durations }) => { const closedCount = durations.length; let mean = 0; let median = 0; if (closedCount > 0) { const sum = durations.reduce((a, b) => a + b, 0); mean = sum / closedCount; const sorted = [...durations].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); const val1 = sorted[mid]; const val2 = sorted[mid - 1]; if (sorted.length % 2 !== 0) { median = val1 ?? 0; } else { median = val1 !== undefined && val2 !== undefined ? (val1 + val2) / 2 : 0; } } return { actor: actor ? sanitiseActor(actor as any) : null, closedCount, mean, median, }; }); leaderboard.sort((a, b) => b.closedCount - a.closedCount); return leaderboard; }, { query: t.Object({ days: t.Optional(t.Numeric()), }), response: { 200: LeaderboardResponseSchema, 401: ErrorSchema, 403: ErrorSchema, 404: ErrorSchema, }, tags: ["guilds", "stats"], }, ), ) .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, }; }, { tags: ["tickets"], query: t.Object({ limit: t.Number({ default: 50 }), offset: t.Number({ default: 0 }), }), response: { 200: TicketsResponseSchema, 401: ErrorSchema, 403: ErrorSchema, 404: ErrorSchema, }, }, ) .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, }); }, { response: { 200: TicketSchema, 401: ErrorSchema, 403: ErrorSchema, 404: ErrorSchema, }, tags: ["tickets"], }, ) .get( "/messages", ({ ticket, actor }) => { const messages = ticket.messages ?? []; return sanitiseTicketMessages(messages, { isModerator: !!actor.moderator, anonymiseStaff: false, }); }, { response: { 200: t.Array(TicketMessageSchema), 401: ErrorSchema, 403: ErrorSchema, 404: ErrorSchema, }, tags: ["tickets"], }, ) .get( "/participants", ({ ticket, actor }) => { const participants = ticket.participants ?? []; return sanitiseTicketParticipants(participants, { isModerator: !!actor.moderator, anonymiseStaff: false, }); }, { response: { 200: t.Array(TicketParticipantSchema), 401: ErrorSchema, 403: ErrorSchema, 404: ErrorSchema, }, tags: ["tickets"], }, ) .get( "/comments", ({ ticket, actor }) => { const comments = ticket.comments ?? []; return sanitiseTicketComments(comments, { isModerator: !!actor.moderator, anonymiseStaff: false, }); }, { response: { 200: t.Array(TicketCommentSchema), 401: ErrorSchema, 403: ErrorSchema, 404: ErrorSchema, }, tags: ["tickets"], }, ) .get( "/attachments", ({ ticket, actor }) => { const attachments = ticket.attachments ?? []; return sanitiseTicketAttachments(attachments, { isModerator: !!actor.moderator, anonymiseStaff: false, }); }, { response: { 200: t.Array(TicketAttachmentSchema), 401: ErrorSchema, 403: ErrorSchema, 404: ErrorSchema, }, tags: ["tickets"], }, ), ), );