88 lines
2.4 KiB
TypeScript
88 lines
2.4 KiB
TypeScript
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<TicketReplyRecord>;
|
|
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
|
|
}
|
|
|
|
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<TicketReplyRecord | null> {
|
|
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;
|
|
}
|