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