export interface TicketReplyRecord { id: bigint; userId: number; content: string; createdAt: Date | null; } export interface TicketReplyDb { findTicket( ticketId: bigint, ): Promise<{ id: bigint; userId: number | null } | null>; createReply(data: { ticketId: bigint; userId: number; content: string; createdAt: Date; updatedAt: Date; }): Promise; touchTicket(ticketId: bigint, updatedAt: Date): Promise; } export const MAX_UNSIGNED_TICKET_ID = 18_446_744_073_709_551_615n; function boundedDecimalPattern(maximum: string): RegExp { const alternatives = [`[1-9]\\d{0,${maximum.length - 2}}`]; for (let index = 0; index < maximum.length; index += 1) { const maximumDigit = Number(maximum[index]); const minimumDigit = index === 0 ? 1 : 0; const upperDigit = maximumDigit - 1; if (upperDigit < minimumDigit) continue; const digit = upperDigit === minimumDigit ? String(minimumDigit) : `[${minimumDigit}-${upperDigit}]`; alternatives.push( `${maximum.slice(0, index)}${digit}\\d{${maximum.length - index - 1}}`, ); } alternatives.push(maximum); return new RegExp(`^(?:${alternatives.join("|")})$`, "u"); } export const CANONICAL_TICKET_ID_PATTERN = boundedDecimalPattern( MAX_UNSIGNED_TICKET_ID.toString(), ); // Canonicalize a SQL BIGINT identifier without passing through Number. export function canonicalTicketId(value: string | number | bigint): bigint { let parsed: bigint; try { if (typeof value === "bigint") { parsed = value; } else if (typeof value === "string") { if (!CANONICAL_TICKET_ID_PATTERN.test(value)) { throw new Error("noncanonical identifier"); } parsed = BigInt(value); } else if (Number.isSafeInteger(value) && value > 0) { parsed = BigInt(value); } else { throw new Error("unsafe identifier"); } } catch { throw new Error("invalid ticket identifier"); } if (parsed <= 0n || parsed > MAX_UNSIGNED_TICKET_ID) { throw new Error("invalid ticket identifier"); } return parsed; } export async function createOwnedTicketReply( db: TicketReplyDb, input: { ticketId: bigint; userId: number; content: string }, ): Promise { const ticket = await db.findTicket(input.ticketId); if (!ticket || ticket.userId !== input.userId) return null; const now = new Date(); const reply = await db.createReply({ ...input, createdAt: now, updatedAt: now, }); await db.touchTicket(input.ticketId, now); return reply; }