all repos — stealth-developers @ 6c744f271cd810fefa95d8e39c0fb238f9414fa5

feat(tickets/gdpr): list & view active tickets
vi v@vt3e.cat
Thu, 26 Feb 2026 01:28:15 +0000
commit

6c744f271cd810fefa95d8e39c0fb238f9414fa5

parent

3c18edb41ee6535cb134d27febc94d7c36e1c45f

3 files changed, 455 insertions(+), 88 deletions(-)

jump to
M src/database/schema/tickets.tssrc/database/schema/tickets.ts

@@ -4,9 +4,10 @@

export const tickets = sqliteTable( "tickets", { - /** @internal this should never be exposed to an end user */ id: integer("id").primaryKey({ autoIncrement: true }), guildId: text("guild_id").notNull(), + ticketNumber: integer("ticket_number"), + channelId: text("channel_id"), authorId: text("author_id").notNull(),

@@ -23,6 +24,8 @@ createdAt: integer("created_at", { mode: "timestamp" })

.default(sql`(unixepoch())`) .notNull(), closedAt: integer("closed_at", { mode: "timestamp" }), + closedBy: text("closed_by"), + closeReason: text("close_reason"), privateReason: text("private_reason"), },
M src/interactions/buttons/tickets/ticket.tssrc/interactions/buttons/tickets/ticket.ts

@@ -9,6 +9,7 @@ type ChatInputCommandInteraction,

type Client, ContainerBuilder, FileBuilder, + type GuildMember, LabelBuilder, type ModalSubmitInteraction, PermissionFlagsBits,

@@ -20,7 +21,7 @@ TextInputBuilder,

TextInputStyle, } from "discord.js"; import { ModalBuilder } from "discord.js"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { type Guild,

@@ -33,10 +34,12 @@ } from "@/database";

import { makeComponentId, parseComponentId } from "@/utils/components"; import { formatMessage } from "@/utils/formatting"; import { loggers } from "@/utils/logging"; +import { hasManagerPermissions } from "@/utils/permissions"; import { getGuild, getManagerRoleIds, getTicket, + getTicketByNumericId, getTicketMessage, } from "@/utils/queries"; import { getPublicUrl } from "@/utils/s3";

@@ -54,11 +57,6 @@ .setDescription("Create a new ticket"),

) .addSubcommand( new SlashCommandSubcommandBuilder() - .setName("message") - .setDescription("Set the initial message sent in new tickets"), - ) - .addSubcommand( - new SlashCommandSubcommandBuilder() .setName("close") .setDescription("Close the ticket in the current channel") .addStringOption((option) =>

@@ -92,6 +90,31 @@ .setName("transcript")

.setDescription( "Get the transcript of the ticket in the current channel", ), + ) + + // gdpr related + .addSubcommand( + new SlashCommandSubcommandBuilder() + .setName("list") + .setDescription("Lists all of your current and past tickets"), + ) + .addSubcommand( + new SlashCommandSubcommandBuilder() + .setName("view") + .setDescription("View details about a specific ticket") + .addIntegerOption((option) => + option + .setName("ticket_id") + .setDescription("The ID of the ticket you want to view") + .setRequired(true), + ), + ) + .addSubcommand( + new SlashCommandSubcommandBuilder() + .setName("export") + .setDescription( + "Export all of your ticket data in a machine readable format", + ), ); const MODAL_IDS = {

@@ -163,6 +186,101 @@ if (staff) modal.addLabelComponents(privateReason);

return modal; } +function constructTicketContainer( + ticket: Ticket, + attachmentsString: string, + file: AttachmentBuilder, + _context: "closed" | "view" = "closed", + isPublic = true, +) { + console.log(ticket.closedAt); + const closedByReporter = ticket.closedBy === "reporter"; + const closedByString = ticket.closedAt + ? closedByReporter + ? "reporter" + : `<@${ticket.closedBy}> (staff)` + : "N/A"; + + const reasons = { + publicReason: ticket.closeReason ?? undefined, + privateReason: ticket.privateReason ?? undefined, + }; + + const reasonContructor = (title: string, reason?: string) => + reason + ? `### ${title} Reason\n${reason}` + : `### ${title} Reason\n-# No ${title.toLowerCase()} reason provided.`; + + const container = new ContainerBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + [ + `## Ticket #${ticket.id} (${ticket.anonymousId}) - ${ticket.status}`, + `**Closed By**: ${closedByString}`, + ].join("\n"), + ), + ) + .addSeparatorComponents(new SeparatorBuilder().setDivider(false)) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + [ + reasonContructor("Public", reasons?.publicReason), + isPublic ? null : reasonContructor("Private", reasons?.privateReason), + ] + .filter(Boolean) + .join("\n"), + ), + ) + .addSeparatorComponents( + new SeparatorBuilder().setDivider(false).setSpacing(2), + ) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + [ + attachmentsString.length > 0 + ? `-# **Attachments:** ${attachmentsString}` + : "-# **Attachments:** no attachments", + ] + .filter(Boolean) + .join("\n"), + ), + ) + .addFileComponents(new FileBuilder().setURL(`attachment://${file.name}`)); + + return container; +} + +async function getTicketContainer( + ticket: Ticket, + context: "closed" | "view" = "closed", + isPublic = true, +) { + const [transcriptText, attachments] = await generateTranscript(ticket); + const file = new AttachmentBuilder(Buffer.from(transcriptText, "utf-8"), { + name: `transcript-${ticket.id}.txt`, + }); + + const attachmentsString = attachments + .map( + (att: ticketAttachment, idx: number) => + `[Attachment #${idx + 1} (${att.fileName.split(".").slice(-1)[0].toUpperCase()})](<${getPublicUrl(att.key)}>)`, + ) + .join(", "); + + return { + container: constructTicketContainer( + ticket, + attachmentsString, + file, + context, + isPublic, + ), + transcriptText, + attachments, + files: [file], + }; +} + // ==================================== // interaction handlers // ====================================

@@ -185,11 +303,14 @@ const modal = await getTicketMessageModal(interaction.guildId);

await interaction.showModal(modal); return; } - if (action === "create") { await handleCreate(client, interaction); return; } + if (action === "list") { + await handleList(client, interaction); + return; + } const { data: guild } = await getGuild(interaction.guildId); const { data: ticket } = await getTicket(

@@ -226,6 +347,7 @@ if (action === "reopen") await handleReopen(client, interaction, ticket);

else if (action === "delete") await handleDelete(client, interaction, ticket); else if (action === "transcript") await handleTranscript(client, interaction, ticket); + else if (action === "view") await handleViewTicket(client, interaction); else await interaction.editReply("❌ Invalid or unhandled action."); }

@@ -242,12 +364,16 @@ });

return; } + if (action === "list") { + await handleListPagination(client, interaction); + return; + } + if (action === "create") { await handleCreate(client, interaction); return; } - const { data: guild } = await getGuild(interaction.guildId); const { data: ticket } = await getTicket( interaction.guildId, interaction.channelId,

@@ -352,8 +478,18 @@ const anonymousId = crypto.randomUUID().split("-")[0];

const managerRoleIds = await getManagerRoleIds(interaction.guild.id); const parent = guildData.ticket_category_id ?? undefined; + if (!interaction.guild) throw new Error("Guild is not available"); + + const [{ count }] = await db + .select({ count: sql<number>`COUNT(*)`.mapWith(Number) }) + .from(tickets) + .where(eq(tickets.guildId, interaction.guild?.id)); + + const ticketNumber = (count ?? 0) + 1; + const paddedTicketNumber = String(ticketNumber).padStart(4, "0"); + const channel = await interaction.guild.channels.create({ - name: `ticket-${anonymousId}`, + name: `ticket-${paddedTicketNumber}`, type: ChannelType.GuildText, parent, permissionOverwrites: [

@@ -392,6 +528,7 @@ .insert(tickets)

.values({ guildId: interaction.guild.id, channelId: channel.id, + ticketNumber: ticketNumber, authorId: interaction.user.id, anonymousId: anonymousId, type: "general",

@@ -430,15 +567,22 @@

async function handleClose( client: Client, interaction: ChatInputCommandInteraction | ModalSubmitInteraction, - ticket: Ticket, + _ticket: Ticket, guild: Guild, reasons?: { publicReason?: string; privateReason?: string }, ) { - if (ticket.status === "closed") { + if (!interaction.guildId || !interaction.channelId) { + await interaction.editReply({ + content: "This command can only be used in a server.", + }); + return; + } + + if (_ticket.status === "closed") { await interaction.editReply("❌ This ticket is already closed."); return; } - const closedByReporter = interaction.user.id === ticket.authorId; + const closedByReporter = interaction.user.id === _ticket.authorId; const channel = interaction.channel; if (!channel || !("permissionOverwrites" in channel)) return;

@@ -452,8 +596,21 @@ status: "closed",

closedAt: new Date(), closeReason: reasons?.publicReason, privateReason: reasons?.privateReason, + closedBy: closedByReporter ? "reporter" : interaction.user.id, }) - .where(eq(tickets.id, ticket.id)); + .where(eq(tickets.id, _ticket.id)); + + const { data: ticket, exists } = await getTicket( + interaction.guildId, + interaction.channelId, + ); + + if (!exists || !ticket) { + await interaction.editReply({ + content: "❌ This channel is not a valid ticket.", + }); + return; + } try { const member = await interaction.guild?.members

@@ -491,79 +648,17 @@ components: [controls],

}); await interaction.editReply("✅ Ticket closed."); - - const [transcriptText, attachments] = await generateTranscript(ticket); - const file = new AttachmentBuilder(Buffer.from(transcriptText, "utf-8"), { - name: `transcript-${ticket.anonymousId}.txt`, - }); - const author = await client.users.fetch(ticket.authorId); - const attachmentsString = attachments - .map( - (att: ticketAttachment, idx: number) => - `[Attachment #${idx + 1} (${att.fileName.split(".").slice(-1)[0].toUpperCase()})](<${getPublicUrl(att.key)}>)`, - ) - .join(", "); - - const constructContainer = (includePrivateReason = false) => { - const reasonContructor = (title: string, reason?: string) => - reason - ? `### ${title} Reason\n${reason}` - : `### ${title} Reason\nNo ${title.toLowerCase()} reason provided.`; - - const container = new ContainerBuilder() - .addTextDisplayComponents( - new TextDisplayBuilder().setContent( - [ - `## Ticket #${ticket.anonymousId} closed`, - `**Closed By**: <@${interaction.user.id}> (${closedByReporter ? "reporter" : "staff"})`, - ].join("\n"), - ), - ) - .addSeparatorComponents(new SeparatorBuilder().setDivider(false)) - .addTextDisplayComponents( - new TextDisplayBuilder().setContent( - [ - reasonContructor("Public", reasons?.publicReason), - includePrivateReason - ? reasonContructor("Private", reasons?.privateReason) - : null, - ] - .filter(Boolean) - .join("\n"), - ), - ) - .addSeparatorComponents( - new SeparatorBuilder().setDivider(false).setSpacing(2), - ) - .addTextDisplayComponents( - new TextDisplayBuilder().setContent( - [ - attachments.length > 0 - ? `-# **Attachments:** ${attachmentsString}` - : "-# **Attachments:** no attachments", - ] - .filter(Boolean) - .join("\n"), - ), - ) - .addFileComponents(new FileBuilder().setURL(`attachment://${file.name}`)); - - return container; - }; + const publicMessage = await getTicketContainer(ticket, "closed", true); + const staffMessage = await getTicketContainer(ticket, "closed", false); // send message to the author try { await author.send({ - components: [constructContainer(false)], + components: [publicMessage.container], flags: ["IsComponentsV2"], - files: [ - { - attachment: Buffer.from(transcriptText, "utf-8"), - name: `transcript-${ticket.anonymousId}.txt`, - }, - ], + files: publicMessage.files, allowedMentions: { users: [], },

@@ -592,14 +687,9 @@ return;

} await transcriptChannel.send({ - components: [constructContainer(true)], + components: [staffMessage.container], flags: ["IsComponentsV2"], - files: [ - { - attachment: Buffer.from(transcriptText, "utf-8"), - name: `transcript-${ticket.anonymousId}.txt`, - }, - ], + files: staffMessage.files, allowedMentions: { users: [], },

@@ -668,6 +758,16 @@ _client: Client,

interaction: ButtonInteraction | ChatInputCommandInteraction, ticket: Ticket, ) { + const isStaff = await hasManagerPermissions( + interaction.member as GuildMember, + ); + if (!isStaff) { + await interaction.editReply( + "❌ You don't have permission to delete this ticket, you can only close it.", + ); + return; + } + await db .update(tickets) .set({ status: "archived" })

@@ -693,7 +793,7 @@ await interaction.channel?.send({

files: [ { attachment: Buffer.from(transcriptText, "utf-8"), - name: `transcript-${ticket.anonymousId}.txt`, + name: `transcript-${ticket.id}.txt`, }, ], });

@@ -701,6 +801,206 @@

await interaction.editReply("✅ Transcript sent."); } +// ==================================== +// gdpr related +// ==================================== +const PAGINATION_SIZE = 10; + +async function buildListContainerForUser(userId: string, page = 1) { + const ticketsRes = await db + .select() + .from(tickets) + .where(eq(tickets.authorId, 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 `<t:${Math.floor(date.getTime() / 1000)}:f>`; + }; + const tsRelative = (d: string | Date) => { + const date = new Date(d); + return `<t:${Math.floor(date.getTime() / 1000)}:R>`; + }; + + 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 nextPage = Math.min(currentPage + 1, totalPages); + const prevPage = Math.max(currentPage - 1, 1); + + const controls = new ActionRowBuilder<ButtonBuilder>().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), + ); + + if (nextPage !== prevPage) container.addActionRowComponents(controls); + return { container, totalPages, page: currentPage, total }; +} + +async function handleListPagination( + _client: Client, + interaction: ButtonInteraction, +) { + // ticket:list:page:<n> + 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"); + } +} + +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"], + }); +} + +async function handleViewTicket( + _client: Client, + interaction: ChatInputCommandInteraction, +) { + 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; + + 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"], + }); +} + +// exports export default { data: commandData, execute,
M src/utils/queries.tssrc/utils/queries.ts

@@ -90,6 +90,70 @@ data: ticket[0],

}; } +export async function getTicketById( + guildId: Snowflake, + id: number, +): Promise<QueryResult<Ticket, false>> { + const guild = await getGuild(guildId); + if (!guild || !guild.data.ticket_message) { + return { + exists: false, + data: null, + }; + } + + const ticket = await db + .select() + .from(tickets) + .where(and(eq(tickets.guildId, guildId), eq(tickets.id, id))) + .execute(); + + if (!ticket || ticket.length === 0) { + return { + exists: false, + data: null, + }; + } + + return { + exists: true, + data: ticket[0], + }; +} + +export async function getTicketByNumericId( + guildId: Snowflake, + numericId: number, +): Promise<QueryResult<Ticket, false>> { + const guild = await getGuild(guildId); + if (!guild || !guild.data.ticket_message) { + return { + exists: false, + data: null, + }; + } + + const ticket = await db + .select() + .from(tickets) + .where( + and(eq(tickets.guildId, guildId), eq(tickets.ticketNumber, numericId)), + ) + .execute(); + + if (!ticket || ticket.length === 0) { + return { + exists: false, + data: null, + }; + } + + return { + exists: true, + data: ticket[0], + }; +} + export async function getManagerRoleIds( guildId: Snowflake, ): Promise<Snowflake[]> {