From 290e3d7a657bbdeab66ddec21cacaa30ff7dd642 Mon Sep 17 00:00:00 2001 From: Antoine Lecompte <38678863+nutgood@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:31:52 -0400 Subject: [PATCH] feat(futo-backups-bot): allow up to three open tickets per user (#561) --- docs/discord-support.md | 5 ++-- packages/futo-backups-bot/src/env.ts | 1 + .../src/repositories/discord.repository.ts | 7 ++--- .../src/services/support.service.spec.ts | 26 ++++++++++++------- .../src/services/support.service.ts | 18 ++++++++----- packages/futo-backups-bot/test/mocks.ts | 2 +- 6 files changed, 37 insertions(+), 22 deletions(-) diff --git a/docs/discord-support.md b/docs/discord-support.md index 65085fe4..959cd8ba 100644 --- a/docs/discord-support.md +++ b/docs/discord-support.md @@ -70,8 +70,9 @@ retention. yucca-api's scope stays pure account-linking. dashboard, mirroring yuctl's view-dashboard; the dashboard itself is o11y-owned) and an account summary from **`GET /internal/discord/users/:userId/summary`** (email, connections, repository count, last seen) — staff see it via Manage Threads on the - support channel; the user cannot. One open ticket per user (membership scan - of active threads); a second submit points at the existing thread. + support channel; the user cannot. Up to `TICKET_USER_LIMIT` + (3) open tickets per user (membership scan of active threads); at the limit + a submit points at the existing threads. - **Close** (staff-only button): locks + archives the ticket thread and its staff sibling. The user keeps read access to their own closed ticket but cannot post or reopen; staff can unarchive via Manage Threads. diff --git a/packages/futo-backups-bot/src/env.ts b/packages/futo-backups-bot/src/env.ts index 9dad6a29..e5654151 100644 --- a/packages/futo-backups-bot/src/env.ts +++ b/packages/futo-backups-bot/src/env.ts @@ -19,6 +19,7 @@ const schema = z.object({ .transform((value) => value || 'https://grafana.futostatus.com'), TICKET_RETENTION_DAYS: z.coerce.number().default(14), + TICKET_USER_LIMIT: z.coerce.number().int().positive().default(3), TRANSCRIPT_S3_ENDPOINT: z.string().default(''), TRANSCRIPT_S3_BUCKET: z.string().default(''), diff --git a/packages/futo-backups-bot/src/repositories/discord.repository.ts b/packages/futo-backups-bot/src/repositories/discord.repository.ts index 15e7b945..d06cf66d 100644 --- a/packages/futo-backups-bot/src/repositories/discord.repository.ts +++ b/packages/futo-backups-bot/src/repositories/discord.repository.ts @@ -114,9 +114,10 @@ export class DiscordRepository { await thread.send(content); } - async findOpenTicketThread(discordUserId: string): Promise { + async listOpenTicketThreads(discordUserId: string): Promise { const channel = await this.supportChannel(); const active = await channel.threads.fetchActive(); + const open: ThreadChannel[] = []; for (const thread of active.threads.values()) { if (thread.parentId !== channel.id || !thread.name.startsWith('ticket-')) { continue; @@ -130,10 +131,10 @@ export class DiscordRepository { throw error; }); if (member) { - return thread; + open.push(thread); } } - return undefined; + return open; } async findSupportThreadByName(name: string): Promise { diff --git a/packages/futo-backups-bot/src/services/support.service.spec.ts b/packages/futo-backups-bot/src/services/support.service.spec.ts index eebbe195..bbf5171c 100644 --- a/packages/futo-backups-bot/src/services/support.service.spec.ts +++ b/packages/futo-backups-bot/src/services/support.service.spec.ts @@ -116,15 +116,19 @@ describe(SupportService.name, () => { }); describe('ticket modal', () => { - it('points a user with an open ticket at it instead of creating another', async () => { + it('blocks a fourth concurrent ticket', async () => { mocks.api.getLink.mockResolvedValue(link); - mocks.discord.findOpenTicketThread.mockResolvedValue(asThread({ id: 'thread-1', name: 'ticket-someone-6789' })); + mocks.discord.listOpenTicketThreads.mockResolvedValue([ + asThread({ id: 'thread-1', name: 'ticket-someone-6789-a' }), + asThread({ id: 'thread-2', name: 'ticket-someone-6789-b' }), + asThread({ id: 'thread-3', name: 'ticket-someone-6789-c' }), + ]); const interaction = newModalInteraction('My backups are failing.'); await sut.handleInteraction(interaction); expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith( - expect.objectContaining({ content: expect.stringContaining('thread-1') }), + expect.objectContaining({ content: expect.stringContaining('3 open tickets') }), ); expect(mocks.discord.createTicketThread).not.toHaveBeenCalled(); }); @@ -132,17 +136,19 @@ describe(SupportService.name, () => { it('creates the ticket thread with a staff thread and replies with a pointer', async () => { mocks.api.getLink.mockResolvedValue(link); mocks.api.getUserSummary.mockResolvedValue(summary); - mocks.discord.findOpenTicketThread.mockResolvedValue(void 0); const send = jest.fn(); mocks.discord.createTicketThread.mockResolvedValue(asThread({ id: 'thread-1', send })); const interaction = newModalInteraction('My backups are failing.'); await sut.handleInteraction(interaction); - expect(mocks.discord.createTicketThread).toHaveBeenCalledWith('ticket-someone-6789', '123456789'); + expect(mocks.discord.createTicketThread).toHaveBeenCalledWith( + expect.stringMatching(/^ticket-someone-6789-/), + '123456789', + ); expect(send).toHaveBeenCalled(); expect(mocks.discord.createStaffThread).toHaveBeenCalledWith( - 'staff-someone-6789', + expect.stringMatching(/^staff-someone-6789-/), expect.stringContaining('someone@example.test'), ); expect(mocks.discord.createStaffThread).toHaveBeenCalledWith( @@ -170,7 +176,6 @@ describe(SupportService.name, () => { it('opens a ticket for an unlinked user without requiring a link', async () => { mocks.api.getLink.mockResolvedValue(null); - mocks.discord.findOpenTicketThread.mockResolvedValue(void 0); const send = jest.fn(); mocks.discord.createTicketThread.mockResolvedValue(asThread({ id: 'thread-9', send })); const interaction = newCommandInteraction( @@ -181,9 +186,12 @@ describe(SupportService.name, () => { await sut.handleInteraction(interaction); expect(mocks.api.createLinkRequest).not.toHaveBeenCalled(); - expect(mocks.discord.createTicketThread).toHaveBeenCalledWith('ticket-guest-5000', '555000'); + expect(mocks.discord.createTicketThread).toHaveBeenCalledWith( + expect.stringMatching(/^ticket-guest-5000-/), + '555000', + ); expect(mocks.discord.createStaffThread).toHaveBeenCalledWith( - 'staff-guest-5000', + expect.stringMatching(/^staff-guest-5000-/), expect.stringContaining('No linked FUTO Backups account'), ); expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith( diff --git a/packages/futo-backups-bot/src/services/support.service.ts b/packages/futo-backups-bot/src/services/support.service.ts index 0b79a45b..cdb743a5 100644 --- a/packages/futo-backups-bot/src/services/support.service.ts +++ b/packages/futo-backups-bot/src/services/support.service.ts @@ -186,9 +186,11 @@ export class SupportService implements OnApplicationBootstrap { } this.creating.add(interaction.user.id); try { - const existing = await this.discord.findOpenTicketThread(interaction.user.id); - if (existing) { - await interaction.editReply({ content: `You already have an open ticket: <#${existing.id}>` }); + const open = await this.discord.listOpenTicketThreads(interaction.user.id); + if (open.length >= env.TICKET_USER_LIMIT) { + await interaction.editReply({ + content: `You already have ${open.length} open tickets: ${open.map((thread) => `<#${thread.id}>`).join(' ')}. Close one before opening another.`, + }); return; } @@ -215,9 +217,11 @@ export class SupportService implements OnApplicationBootstrap { } this.creating.add(target.id); try { - const existing = await this.discord.findOpenTicketThread(target.id); - if (existing) { - await interaction.editReply({ content: `<@${target.id}> already has an open ticket: <#${existing.id}>` }); + const open = await this.discord.listOpenTicketThreads(target.id); + if (open.length >= env.TICKET_USER_LIMIT) { + await interaction.editReply({ + content: `<@${target.id}> already has ${open.length} open tickets: ${open.map((thread) => `<#${thread.id}>`).join(' ')}.`, + }); return; } @@ -293,7 +297,7 @@ export class SupportService implements OnApplicationBootstrap { } private ticketSuffix(username: string, discordUserId: string): string { - return `${username.toLowerCase().replaceAll(/[^a-z0-9-]/g, '')}-${discordUserId.slice(-4)}`; + return `${username.toLowerCase().replaceAll(/[^a-z0-9-]/g, '')}-${discordUserId.slice(-4)}-${Date.now().toString(36)}`; } private staffNote(link: DiscordLink | null, summary: UserSummary | null): string { diff --git a/packages/futo-backups-bot/test/mocks.ts b/packages/futo-backups-bot/test/mocks.ts index 00a4e5bd..3b4a5901 100644 --- a/packages/futo-backups-bot/test/mocks.ts +++ b/packages/futo-backups-bot/test/mocks.ts @@ -22,7 +22,7 @@ export const newDiscordRepositoryMock = (): jest.Mocked