import { db, tickets } from "@/database"; import { getTicketByNumericId } from "@/database/queries"; import { hasManagerPermissions } from "@/utils/discord/permissions"; import { loggers } from "@/utils/logging"; import { generateTranscript } from "@/utils/tickets/transcripts"; import { ActionRowBuilder, ButtonBuilder, type ButtonInteraction, ButtonStyle, type ChatInputCommandInteraction, type Client, ContainerBuilder, type GuildMember, SeparatorBuilder, TextDisplayBuilder, } from "discord.js"; import { eq, like, or } from "drizzle-orm"; import { PAGINATION_SIZE, getTicketContainer } from "./_shared"; const logger = loggers.interactions.child({ name: "ticket/gdpr" }); async function buildListContainerForUser(userId: string, page = 1) { const ticketsRes = await db .select() .from(tickets) .where( or(eq(tickets.authorId, userId), like(tickets.addedUsers, `%${userId}%`)), ); if (!ticketsRes || ticketsRes.length === 0) { const empty = new ContainerBuilder() .setAccentColor(0xff5a00) .addTextDisplayComponents( new TextDisplayBuilder().setContent( "## Your Tickets\n\nYou have no tickets.", ), ); return { container: empty, totalPages: 0, page: 1, total: 0 }; } const sortedTickets = ticketsRes.sort((a, b) => { const dateA = a.closedAt ?? a.createdAt; const dateB = b.closedAt ?? b.createdAt; return dateB.getTime() - dateA.getTime(); }); const total = sortedTickets.length; const totalPages = Math.max(1, Math.ceil(total / PAGINATION_SIZE)); const currentPage = Math.min(Math.max(1, page), totalPages); const paged = sortedTickets.slice( (currentPage - 1) * PAGINATION_SIZE, currentPage * PAGINATION_SIZE, ); const tsExact = (d: string | Date) => { const date = new Date(d); return ``; }; const tsRelative = (d: string | Date) => { const date = new Date(d); return ``; }; const ticketLines = paged.map((t) => { let id = `**#${t.anonymousId}**`; if (t.closedAt) id = `~~**${id}**~~ (closed)`; const created = `${tsExact(t.createdAt)} (${tsRelative(t.createdAt)})`; const closed = t.closedAt ? `\n * opened ${tsExact(t.closedAt)} (${tsRelative(t.closedAt)})` : ""; return `- ${id}\n * created ${created}${closed}`; }); const header = `## Your Tickets - ${total} total\n`; const container = new ContainerBuilder() .setAccentColor(0xff5a00) .addTextDisplayComponents( new TextDisplayBuilder().setContent(`${header}${ticketLines.join("\n")}`), ) .addSeparatorComponents( new SeparatorBuilder().setDivider(false).setSpacing(1), ); const controls = new ActionRowBuilder().addComponents( new ButtonBuilder() .setDisabled(true) .setLabel(`Page ${currentPage} of ${totalPages}`) .setStyle(ButtonStyle.Secondary) .setCustomId("ticket:list:page_info"), new ButtonBuilder() .setCustomId(`ticket:list:page:${Math.max(1, currentPage - 1)}`) .setLabel("Prev") .setStyle(ButtonStyle.Primary) .setDisabled(currentPage <= 1), new ButtonBuilder() .setCustomId(`ticket:list:page:${Math.min(totalPages, currentPage + 1)}`) .setLabel("Next") .setStyle(ButtonStyle.Primary) .setDisabled(currentPage >= totalPages), ); const nextPage = Math.min(currentPage + 1, totalPages); const prevPage = Math.max(currentPage - 1, 1); if (nextPage !== prevPage) container.addActionRowComponents(controls); return { container, totalPages, page: currentPage, total }; } export async function handleListPagination( _client: Client, interaction: ButtonInteraction, ) { const parts = interaction.customId.split(":"); const pagePart = parts[3]; const page = pagePart ? Number.parseInt(pagePart, 10) : 1; if (Number.isNaN(page) || page < 1) { try { await interaction.reply({ content: "Invalid page.", flags: ["Ephemeral"], }); } catch {} return; } const { container } = await buildListContainerForUser( interaction.user.id, page, ); try { await interaction.update({ components: [container] }); } catch (err) { try { await interaction.reply({ content: "Could not update page, try /ticket list again.", flags: ["Ephemeral"], }); } catch {} logger.error(err, "failed to update ticket list pagination"); } } export async function handleList( _client: Client, interaction: ChatInputCommandInteraction, ) { if (!interaction.guild) { await interaction.reply({ content: "❌ This command can only be used in a server.", flags: ["Ephemeral"], }); return; } const { container } = await buildListContainerForUser(interaction.user.id, 1); await interaction.reply({ components: [container], flags: ["IsComponentsV2", "Ephemeral"], }); } export async function handleViewTicket( _client: Client, interaction: ChatInputCommandInteraction, ) { await interaction.deferReply(); if (!interaction.guild) { await interaction.editReply({ content: "❌ This command can only be used in a server.", }); return; } const ticketId = interaction.options.getInteger("ticket_id", true); const { data: ticket, exists } = await getTicketByNumericId( interaction.guild.id, ticketId, ); if (!exists || !ticket) { await interaction.editReply({ content: `❌ Ticket with ID ${ticketId} not found.`, }); return; } const isStaff = await hasManagerPermissions( interaction.member as GuildMember, ); const isAuthor = ticket.authorId === interaction.user.id || ticket.addedUsers?.includes(interaction.user.id); if (!isStaff && !isAuthor) { await interaction.editReply({ content: "❌ You don't have permission to view this ticket.", }); return; } const { container, files } = await getTicketContainer( ticket, "view", !isStaff, ); await interaction.editReply({ components: [container], files: files, flags: ["IsComponentsV2"], }); } export async function handleExport( _client: Client, interaction: ChatInputCommandInteraction, ) { const _tickets = await db .select() .from(tickets) .where( or( eq(tickets.authorId, interaction.user.id), like(tickets.addedUsers, `%${interaction.user.id}%`), ), ); const transcripts = await Promise.all( _tickets.map(async (ticket) => { const [transcriptText] = await generateTranscript(ticket); return transcriptText; }), ); const combined = transcripts.join("\n\n\n\n"); await interaction.reply({ content: "Here is your exported ticket data:", files: [ { attachment: Buffer.from(combined, "utf-8"), name: `tickets-export-${interaction.user.id}.txt`, }, ], flags: ["Ephemeral"], }); }