diff --git a/src/features/tickets/id-allocation.ts b/src/features/tickets/id-allocation.ts new file mode 100644 index 00000000..cf72df95 --- /dev/null +++ b/src/features/tickets/id-allocation.ts @@ -0,0 +1,62 @@ +/* +Ticket-Bot is licensed under the GNU Affero General Public License, +version 3 only ("AGPL-3.0-only"). See LICENSE.md for the full license text. + +Additional Term under GNU AGPL v3, Section 7(b): + +You are required to preserve and display, in a location clearly visible +to end users interacting with the bot (such as bot embeds, the bot's +"Bio" Discord profile, status, or equivalent), a notice that the +software is powered by Ticket-Bot, including a link to the original +project repository or to its website. + +This notice must not be removed, obscured, or replaced. +*/ + +import { sql } from "drizzle-orm"; +import type { BotApp } from "@/core/types"; +import { appMetaTable, ticketsTable } from "@/db/schema"; + +const TICKET_ID_SEQUENCE_KEY = "ticketIdSequence"; + +export async function reserveTicketId(app: Pick) { + const updatedAt = Date.now(); + const rows = await app.db + .insert(appMetaTable) + .values({ + key: TICKET_ID_SEQUENCE_KEY, + value: sql`CAST(COALESCE((SELECT MAX(${ticketsTable.id}) FROM ${ticketsTable}), 0) + 1 AS TEXT)`, + updatedAt + }) + .onConflictDoUpdate({ + target: appMetaTable.key, + set: { + // One upsert both catches up with existing rows and serializes concurrent reservations. + value: sql`CAST(MAX(CAST(${appMetaTable.value} AS INTEGER), COALESCE((SELECT MAX(${ticketsTable.id}) FROM ${ticketsTable}), 0)) + 1 AS TEXT)`, + updatedAt + } + }) + .returning({ value: appMetaTable.value }); + const ticketId = Number(rows[0]?.value); + + if (!Number.isSafeInteger(ticketId) || ticketId < 1) { + throw new Error("Failed to reserve a valid ticket ID."); + } + + return ticketId; +} + +/* +Ticket-Bot is licensed under the GNU Affero General Public License, +version 3 only ("AGPL-3.0-only"). See LICENSE.md for the full license text. + +Additional Term under GNU AGPL v3, Section 7(b): + +You are required to preserve and display, in a location clearly visible +to end users interacting with the bot (such as bot embeds, the bot's +"Bio" Discord profile, status, or equivalent), a notice that the +software is powered by Ticket-Bot, including a link to the original +project repository or to its website. + +This notice must not be removed, obscured, or replaced. +*/ diff --git a/src/features/tickets/ticket-workflow.ts b/src/features/tickets/ticket-workflow.ts index f522488f..dc5f6bc3 100644 --- a/src/features/tickets/ticket-workflow.ts +++ b/src/features/tickets/ticket-workflow.ts @@ -49,6 +49,7 @@ import { userCanAccessTicketType } from "@/features/tickets/config-access"; import { TICKET_ACCESS_ALLOW } from "@/features/tickets/constants"; +import { reserveTicketId } from "@/features/tickets/id-allocation"; import { appendMessageText, finalizeMessageTemplate, @@ -280,7 +281,9 @@ async function createTicket( } const user = getInteractionUser(interaction); - const ticketNumber = (await getNextTicketNumber(app)).toString(); + // Reserve before Discord work so every rendered number matches the eventual database primary key. + const ticketId = await reserveTicketId(app); + const ticketNumber = ticketId.toString(); const createdAt = Date.now(); const channelName = renderChannelName(ticketType.channelNameTemplate ?? app.config.tickets.channelNameTemplate, { createdById: user.id, @@ -318,6 +321,7 @@ async function createTicket( await pinTicketWelcomeMessage(app, channel.id, ticketMessage.id); await app.db.insert(ticketsTable).values({ + id: ticketId, channelId: channel.id, creationMessageId: ticketMessage.id, type: ticketTypeKey, @@ -610,11 +614,6 @@ async function getUserOpenTicketCount(app: BotApp, userId: string) { return Number(rows[0]?.count ?? 0); } -async function getNextTicketNumber(app: BotApp) { - const rows = await app.db.select({ count: count() }).from(ticketsTable); - return Number(rows[0]?.count ?? 0) + 1; -} - function createDefaultTicketOpenReason(app: BotApp): TicketOpenReasonData { return { answers: [],