bot,db: exporting tickets as fiels
vi did:web:vt3e.cat
Sun, 28 Jun 2026 02:41:05 +0100
4 files changed,
238 insertions(+),
4 deletions(-)
M
apps/bot/src/feats/tickets/README.md
→
apps/bot/src/feats/tickets/README.md
@@ -15,8 +15,8 @@ - [ ] add
- [ ] remove - [ ] list - [ ] manage - - [ ] list - - [ ] export [ticketId] - defaults to all + - [x] list + - [ ] export - [ ] delete [ticketId] - [ ] purge - [x] settings
M
apps/bot/src/feats/tickets/commands.ts
→
apps/bot/src/feats/tickets/commands.ts
@@ -1,6 +1,11 @@
import { option } from "@purrkit/router"; import { Result } from "@sapphire/result"; -import { getTicketByGuild, getTicketByUser, getTicketsByUser } from "@stealth-developers/db"; +import { + getTicketByGuild, + getTicketByUser, + getTicketsByUser, + getTicketsByUserGDPR, +} from "@stealth-developers/db"; import { ActionRowBuilder, ButtonBuilder, ContainerBuilder, TextDisplayBuilder } from "discord.js"; import { PublicError, errorMessage, getTextChannel, handleError, logger } from "@/lib";@@ -271,6 +276,29 @@
return interaction.reply({ ...payload, flags: ["Ephemeral", "IsComponentsV2"], + }); + }, + }); + + group.subcommand("export", { + description: "Export your tickets", + async run(interaction, _, { actor }) { + if (!actor) return errorMessage(interaction, "You must be logged in to run this command"); + + const tickets = await getTicketsByUserGDPR(actor.id); + if (tickets.length === 0) return errorMessage(interaction, "You have no tickets"); + + const fileContent = JSON.stringify(tickets, null, 2); + const fileBuffer = Buffer.from(fileContent, "utf-8"); + + return interaction.reply({ + flags: ["Ephemeral"], + files: [ + { + attachment: fileBuffer, + name: `tickets_${actor.id}.json`, + }, + ], }); }, });
M
apps/bot/src/feats/tickets/listeners.ts
→
apps/bot/src/feats/tickets/listeners.ts
@@ -65,6 +65,8 @@ return;
if (!message.channel.name.match(/\d+$/)) return; if (!message.guild) return; + if (message.flags.has("Ephemeral")) return; + const isThread = message.channel.type === ChannelType.PrivateThread; const channelId = isThread ? message.channel.parentId! : message.channel.id;@@ -192,6 +194,7 @@ )
return; if (!message.channel.name.match(/\d+$/)) return; if (!message.guild) return; + if (message.flags.has("Ephemeral")) return; const dbMessage = await getTicketMessageByMessageId(message.id); if (!dbMessage) return;@@ -207,6 +210,7 @@ )
return; if (!message.channel.name.match(/\d+$/)) return; if (!message.guild) return; + if (message.flags.has("Ephemeral")) return; const isThread = message.channel.type === ChannelType.PrivateThread; const channelId = isThread ? message.channel.parentId! : message.channel.id;
M
pkgs/db/src/utils/ticket.ts
→
pkgs/db/src/utils/ticket.ts
@@ -1,10 +1,11 @@
import type { Embed, TopLevelComponent } from "discord.js"; -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, inArray } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import db, { type Ticket, + actors, ticketAttachments, ticketComments, ticketMessages,@@ -155,6 +156,207 @@ where: {
openedBy: userId, }, with: ticketWithRelations, + }); +} + +export async function getTicketsByUserGDPR(userId: number) { + const userActors = await db + .select({ id: actors.id }) + .from(actors) + .where(eq(actors.userId, userId)); + + const actorIds = userActors.map((a) => a.id); + + if (actorIds.length === 0) { + return []; + } + + const openedTickets = await db + .select({ id: tickets.id }) + .from(tickets) + .where(inArray(tickets.openedBy, actorIds)); + + const subjectTickets = await db + .select({ id: tickets.id }) + .from(tickets) + .where(inArray(tickets.subject, actorIds)); + + const participantTickets = await db + .select({ ticketId: ticketParticipants.ticketId }) + .from(ticketParticipants) + .where(inArray(ticketParticipants.actorId, actorIds)); + + const allTargetTicketIds = Array.from( + new Set([ + ...openedTickets.map((t) => t.id), + ...subjectTickets.map((t) => t.id), + ...participantTickets.map((t) => t.ticketId), + ]), + ); + + if (allTargetTicketIds.length === 0) { + return []; + } + + const userTickets = await db.query.tickets.findMany({ + where: { + id: { + in: allTargetTicketIds, + }, + }, + with: ticketWithRelations, + }); + + const sanitizeActor = (actor: TicketWithRelations["opener"]) => { + if (!actor) return null; + const isSelf = actor.userId === userId; + + return { + id: actor.id, + guildId: actor.guildId, + userId: actor.userId, + displayName: actor.displayName, + username: actor.username, + avatar: actor.avatar, + moderator: actor.moderator, + createdAt: actor.createdAt, + discordId: isSelf ? actor.discordId : undefined, + user: actor.user + ? { + id: actor.user.id, + displayName: actor.user.displayName, + username: actor.user.username, + avatar: actor.user.avatar, + + discordId: isSelf ? actor.user.discordId : undefined, + acceptedPrivacyPolicy: isSelf ? actor.user.acceptedPrivacyPolicy : undefined, + acceptedTermsOfService: isSelf ? actor.user.acceptedTermsOfService : undefined, + createdAt: isSelf ? actor.user.createdAt : undefined, + updatedAt: isSelf ? actor.user.updatedAt : undefined, + } + : null, + }; + }; + + const sanitizeGuild = (guild: TicketWithRelations["guild"]) => { + if (!guild) return null; + return { + id: guild.id, + name: guild.name, + icon: guild.icon, + }; + }; + + return userTickets.map((ticket) => { + const isModeratorOnTicket = + ticket.participants.some((p) => actorIds.includes(p.actorId) && p.role === "moderator") || + ticket.participants.some((p) => actorIds.includes(p.actorId) && p.actor?.moderator === true); + + const baseTicket = { + id: ticket.id, + guildId: ticket.guildId, + localId: ticket.localId, + status: ticket.status, + openedAt: ticket.openedAt, + closedAt: ticket.closedAt, + closeReason: ticket.closeReason, + openReason: ticket.openReason, + claimedAt: ticket.claimedAt, + guild: sanitizeGuild(ticket.guild), + opener: sanitizeActor(ticket.opener), + subjectActor: sanitizeActor(ticket.subjectActor), + claimedByActor: sanitizeActor(ticket.claimedByActor), + closedByActor: sanitizeActor(ticket.closedByActor), + }; + + if (isModeratorOnTicket) { + return { + ...baseTicket, + privateReason: ticket.privateReason, + participants: ticket.participants.map((p) => ({ + id: p.id, + role: p.role, + actorId: p.actorId, + createdAt: p.createdAt, + reason: p.reason, + actor: sanitizeActor(p.actor), + })), + messages: ticket.messages.map((msg) => ({ + id: msg.id, + messageId: msg.messageId, + actorId: msg.actorId, + actorRole: msg.actorRole, + content: msg.content, + embeds: msg.embeds, + components: msg.components, + createdAt: msg.createdAt, + editedAt: msg.editedAt, + isPrivate: msg.isPrivate, + deletedAt: msg.deletedAt, + actor: sanitizeActor(msg.actor), + })), + attachments: ticket.attachments.map((att) => ({ + id: att.id, + messageId: att.messageId, + fileName: att.fileName, + fileType: att.fileType, + fileSize: att.fileSize, + actorId: att.actorId, + actor: sanitizeActor(att.actor), + })), + comments: ticket.comments.map((c) => ({ + id: c.id, + content: c.content, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + actor: sanitizeActor(c.actor), + })), + }; + } + + const sanitizedMessages = ticket.messages + .filter((msg) => !msg.isPrivate && !msg.deletedAt) + .map((msg) => ({ + id: msg.id, + messageId: msg.messageId, + actorId: msg.actorId, + actorRole: msg.actorRole, + content: msg.content, + embeds: msg.embeds, + components: msg.components, + createdAt: msg.createdAt, + editedAt: msg.editedAt, + actor: sanitizeActor(msg.actor), + })); + + return { + ...baseTicket, + privateReason: null, + comments: [], + participants: ticket.participants.map((p) => ({ + id: p.id, + role: p.role, + actorId: p.actorId, + createdAt: p.createdAt, + reason: actorIds.includes(p.actorId) ? p.reason : null, + actor: sanitizeActor(p.actor), + })), + messages: sanitizedMessages, + attachments: ticket.attachments + .filter((att) => { + if (!att.messageId) return true; + return sanitizedMessages.some((msg) => msg.id === att.messageId); + }) + .map((att) => ({ + id: att.id, + messageId: att.messageId, + fileName: att.fileName, + fileType: att.fileType, + fileSize: att.fileSize, + actorId: att.actorId, + actor: sanitizeActor(att.actor), + })), + }; }); }