bot,db: deleting & anonymising tickets en mass
vi did:web:vt3e.cat
Sun, 28 Jun 2026 03:05:56 +0100
3 files changed,
124 insertions(+),
6 deletions(-)
M
apps/bot/src/feats/tickets/README.md
→
apps/bot/src/feats/tickets/README.md
@@ -14,11 +14,11 @@ - [ ] participants
- [ ] add - [ ] remove - [ ] list - - [ ] manage + - [x] manage - [x] list - - [ ] export - - [ ] delete [ticketId] - - [ ] purge + - [x] export + - [x] delete [ticketId] + - [x] purge - [x] settings - [x] transcript-channel - [x] entrypoint-channel
M
apps/bot/src/feats/tickets/commands.ts
→
apps/bot/src/feats/tickets/commands.ts
@@ -5,12 +5,15 @@ getTicketByGuild,
getTicketByUser, getTicketsByUser, getTicketsByUserGDPR, + purgeTicketData, + purgeUserTicketData, } from "@stealth-developers/db"; import { ActionRowBuilder, ButtonBuilder, ContainerBuilder, TextDisplayBuilder } from "discord.js"; import { PublicError, errorMessage, getTextChannel, handleError, logger } from "@/lib"; import { useGetTicketData } from "@/middleware"; +import { getGuildActor } from "../guild-actor"; import * as actions from "./actions"; import { TicketButtonPresets, getTicketsListPayload } from "./buttons"; import { hasAccessToTicket } from "./lib";@@ -94,7 +97,8 @@ if (guild && actor) {
const ticket = await getTicketByGuild(guild.id, args.ticket); if (!ticket) return errorMessage(interaction, `Ticket with ID ${args.ticket} not found.`); - if (!hasAccessToTicket(actor, guild, ticket)) return; + if (!hasAccessToTicket(actor, guild, ticket)) + return errorMessage(interaction, "You don't have permission to view this ticket."); const container = actions.getTicketContainer(ticket, !actor.moderator); await interaction.reply({ components: [container], flags: ["Ephemeral", "IsComponentsV2"] });@@ -299,6 +303,52 @@ attachment: fileBuffer,
name: `tickets_${actor.id}.json`, }, ], + }); + }, + }); + + group.subcommand("purge", { + description: "Purge all your ticket data", + async run(interaction, _, { actor, guild }) { + if (!actor || !guild) + return errorMessage(interaction, "You must be in a server to run this command"); + + const gActor = getGuildActor(guild.discordId); + if (!gActor) return errorMessage(interaction, "Failed to get guild actor"); + + await purgeUserTicketData(actor.id, gActor.id); + + return interaction.reply({ + content: "Successfully purged your ticket data.", + flags: ["Ephemeral"], + }); + }, + }); + + group.subcommand("delete", { + description: "Delete an individual ticket", + options: { + ticket: option.number("The ID of the ticket to delete", { required: true }), + }, + async run(interaction, args, { actor, guild }) { + if (!actor || !guild) + return errorMessage(interaction, "You must be in a server to run this command"); + + const ticket = await getTicketByUser(actor.id, args.ticket); + if (!ticket) + return errorMessage( + interaction, + "Ticket not found or you don't have permission to delete it.", + ); + + const gActor = getGuildActor(guild.discordId); + if (!gActor) return errorMessage(interaction, "Failed to get guild actor"); + + await purgeTicketData(ticket.id, gActor.id); + + return interaction.reply({ + content: `Successfully deleted ticket #${args.ticket}'s data.`, + flags: ["Ephemeral"], }); }, });
M
pkgs/db/src/utils/ticket.ts
→
pkgs/db/src/utils/ticket.ts
@@ -1,6 +1,6 @@
import type { Embed, TopLevelComponent } from "discord.js"; -import { and, desc, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, inArray, or } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import db, {@@ -813,3 +813,71 @@ },
}, }); } + +// gdpr deletion +export async function purgeTicketData(ticketId: string, guildActorId: number) { + await db.transaction(async (tx) => { + await tx + .update(tickets) + .set({ + openedBy: guildActorId, + subject: guildActorId, + claimedBy: null, + closedBy: null, + openReason: null, + closeReason: null, + privateReason: null, + hasModeratorMessage: false, + hasAuthorMessage: false, + lastMessageAt: null, + }) + .where(eq(tickets.id, ticketId)); + + await tx.delete(ticketParticipants).where(eq(ticketParticipants.ticketId, ticketId)); + await tx.delete(ticketComments).where(eq(ticketComments.ticketId, ticketId)); + await tx.delete(ticketAttachments).where(eq(ticketAttachments.ticketId, ticketId)); + await tx.delete(ticketMessages).where(eq(ticketMessages.ticketId, ticketId)); + }); +} + +export async function purgeUserTicketData(actorId: number, guildActorId: number) { + await db.transaction(async (tx) => { + const userTickets = await tx + .select({ id: tickets.id }) + .from(tickets) + .where(or(eq(tickets.openedBy, actorId), eq(tickets.subject, actorId))); + + const ticketIds = userTickets.map((t) => t.id); + + if (ticketIds.length > 0) { + await tx + .update(tickets) + .set({ + openedBy: guildActorId, + subject: guildActorId, + claimedBy: null, + closedBy: null, + openReason: null, + closeReason: null, + privateReason: null, + hasModeratorMessage: false, + hasAuthorMessage: false, + lastMessageAt: null, + }) + .where(inArray(tickets.id, ticketIds)); + + await tx.delete(ticketParticipants).where(inArray(ticketParticipants.ticketId, ticketIds)); + await tx.delete(ticketComments).where(inArray(ticketComments.ticketId, ticketIds)); + await tx.delete(ticketAttachments).where(inArray(ticketAttachments.ticketId, ticketIds)); + await tx.delete(ticketMessages).where(inArray(ticketMessages.ticketId, ticketIds)); + } + + await tx.delete(ticketParticipants).where(eq(ticketParticipants.actorId, actorId)); + await tx.delete(ticketComments).where(eq(ticketComments.actorId, actorId)); + await tx.delete(ticketAttachments).where(eq(ticketAttachments.actorId, actorId)); + await tx.delete(ticketMessages).where(eq(ticketMessages.actorId, actorId)); + + await tx.update(tickets).set({ claimedBy: null }).where(eq(tickets.claimedBy, actorId)); + await tx.update(tickets).set({ closedBy: null }).where(eq(tickets.closedBy, actorId)); + }); +}