feat(futo-backups-bot): allow up to three open tickets per user (#561)

This commit is contained in:
Antoine Lecompte
2026-08-26 12:31:52 +00:00
committed by GitHub
parent 5eaf0f349d
commit 290e3d7a65
6 changed files with 37 additions and 22 deletions
+3 -2
View File
@@ -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.
+1
View File
@@ -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(''),
@@ -114,9 +114,10 @@ export class DiscordRepository {
await thread.send(content);
}
async findOpenTicketThread(discordUserId: string): Promise<ThreadChannel | undefined> {
async listOpenTicketThreads(discordUserId: string): Promise<ThreadChannel[]> {
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<AnyThreadChannel | undefined> {
@@ -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(
@@ -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 {
+1 -1
View File
@@ -22,7 +22,7 @@ export const newDiscordRepositoryMock = (): jest.Mocked<RepositoryInterface<Disc
ensurePinnedSupportMessage: jest.fn(),
createTicketThread: jest.fn(),
createStaffThread: jest.fn(),
findOpenTicketThread: jest.fn(),
listOpenTicketThreads: jest.fn().mockResolvedValue([]),
findSupportThreadByName: jest.fn(),
closeThread: jest.fn(),
listClosedTicketThreads: jest.fn().mockResolvedValue([]),