export type RubgramActor = { userId?: string; isAdmin: () => Promise; }; export type RubgramType = { id: string; price: number; }; export type RubgramConfig = { locked: boolean; full: boolean; limit: number; free: number; allDiscount: number; types: RubgramType[]; }; export type RegistrationInput = { name: string; server: string; service: string[]; user?: string; }; export type QueueResult = { id: string; queue: number }; export type Submission = { id: string; user: string; service: string[]; price: number; }; export type ExpiredSubmission = Omit & { name: string; server: "as" | "eu" | "us" | "tw"; }; export type Note = { id: string; text: string; createdAt: string; }; export const messages = { incomplete: "กรุณากรอกข้อมูลให้ครบถ้วน", invalidServer: "เซิร์ฟไม่ถูกต้อง", invalidService: "บริการไม่ถูกต้อง", nameTooLong: "ชื่อยาวเกินไป ต้องไม่เกิน 32 ตัวอักษร", locked: "ปิดรับลงทะเบียนชั่วคราว เนื่องจากมีผู้ลงจำนวนมาก", notRegistered: "คุณยังไม่ได้ลงทะเบียน", duplicateSlip: "สลิปนี้ถูกใช้ไปแล้ว", unauthorized: "Unauthorized", } as const; export function calculatePrice( service: string[], config: Pick, ) { if (config.free > 0) return 0; const total = service.reduce( (price, id) => price + (config.types.find((type) => type.id === id)?.price ?? 0), 0, ); return ( total - (config.types.every((type) => service.includes(type.id)) ? config.allDiscount : 0) ); } export function validateRegistration( input: RegistrationInput, types: RubgramType[], ) { if (!input.name || !input.server || !input.service.length || !input.user) return messages.incomplete; if (!(["as", "eu", "us", "tw"] as string[]).includes(input.server)) return messages.invalidServer; const typeIds = new Set(types.map((type) => type.id)); if (input.service.some((service) => !typeIds.has(service))) return messages.invalidService; if (input.name.length > 32) return messages.nameTooLong; } export async function requireOwner(actor: RubgramActor, owner: string) { if (actor.userId === owner || (await actor.isAdmin())) return; throw new Error(messages.unauthorized); } export async function requireAdmin(actor: RubgramActor) { if (!(await actor.isAdmin())) throw new Error(messages.unauthorized); } export type RegistrationDependencies = { getConfig: () => Promise; removeExpired: () => Promise; findDuplicate: (user: string) => Promise; insert: (input: { user: string; name: string; server: "as" | "eu" | "us" | "tw"; service: string[]; price: number; }) => Promise; consumeFreeSlot: () => Promise; }; export async function registerSubmission( input: RegistrationInput, dependencies: RegistrationDependencies, ): Promise { let config = await dependencies.getConfig(); const validationError = validateRegistration(input, config.types); if (validationError) return validationError; if (config.locked) return messages.locked; await dependencies.removeExpired(); config = await dependencies.getConfig(); if (config.full) return `คิวลงทะเบียนเต็มแล้ว (${config.limit} ครั้ง)`; const duplicate = await dependencies.findDuplicate(input.user!); if (duplicate) return duplicate; const price = calculatePrice(input.service, config); const queue = await dependencies.insert({ user: input.user!, name: input.name, server: input.server as "as" | "eu" | "us" | "tw", service: input.service, price, }); if (price === 0 && config.free > 0) await dependencies.consumeFreeSlot(); return queue; } export type SlipResult = | { success: true; data: { transRef: string; amount?: number } } | { success: false; code: number; message: string }; export type PaymentRepository = { findSubmission: (id: string) => Promise; findExpired: (id: string) => Promise; restoreExpired: ( submission: ExpiredSubmission, price: number, ) => Promise; insertSlip: (input: { slip: Buffer; ref: string; amount?: number; data: SlipResult; }) => Promise; markPaid: (submissionId: string, slipId: string) => Promise; }; export type PaymentDependencies = { transaction: ( run: (repository: PaymentRepository) => Promise, ) => Promise; calculatePrice: (service: string[]) => Promise; checkSlip: ( buffer: Buffer, type: string, price: number, ) => Promise; isDuplicateSlipError: (error: unknown) => boolean; }; export async function paySubmission( input: { id: string; slip: Buffer; type: string }, actor: RubgramActor, dependencies: PaymentDependencies, ): Promise { return dependencies.transaction(async (repository) => { let submission = await repository.findSubmission(input.id); const expired = submission ? undefined : await repository.findExpired(input.id); if (!submission && !expired) return messages.notRegistered; await requireOwner(actor, (submission ?? expired)!.user); if (!submission) { const price = await dependencies.calculatePrice(expired!.service); await repository.restoreExpired(expired!, price); submission = { ...expired!, price }; } const processed = await dependencies.checkSlip( input.slip, input.type, submission.price, ); if (!processed.success) return `${processed.code}: ${processed.message}`; let slipId: string; try { slipId = await repository.insertSlip({ slip: input.slip, ref: processed.data.transRef, amount: processed.data.amount, data: processed, }); } catch (error) { if (dependencies.isDuplicateSlipError(error)) return messages.duplicateSlip; throw error; } return repository.markPaid(input.id, slipId); }); } export type ExpirationRepository = { findExpired: (now: Date) => Promise; archive: (submissions: T[]) => Promise; remove: (ids: string[]) => Promise; shiftAfter: (queue: number) => Promise; getMaxQueue: () => Promise; setNextQueue: (next: number) => Promise; }; export async function compactExpiredSubmissions< T extends { id: string; queue: number }, >(repository: ExpirationRepository, now = new Date()) { const expired = await repository.findExpired(now); if (expired.length) { await repository.archive(expired); await repository.remove(expired.map(({ id }) => id)); for (const { queue } of [...expired].sort((a, b) => b.queue - a.queue)) await repository.shiftAfter(queue); } const maxQueue = await repository.getMaxQueue(); await repository.setNextQueue((maxQueue ?? 0) + 1); return { removed: expired.length }; } export async function cancelSubmission( id: string, actor: RubgramActor, dependencies: { findSubmission: (id: string) => Promise<{ user: string } | undefined>; cancel: (id: string) => Promise; }, ) { const submission = await dependencies.findSubmission(id); if (!submission) return false; await requireOwner(actor, submission.user); await dependencies.cancel(id); return true; } export async function addSubmissionNote( id: string, text: string, actor: RubgramActor, dependencies: { getNotes: (id: string) => Promise; saveNotes: (id: string, notes: Note[]) => Promise; createId: () => string; now: () => Date; }, ) { await requireAdmin(actor); const note = { id: dependencies.createId(), text, createdAt: dependencies.now().toISOString(), }; await dependencies.saveNotes(id, [ ...(await dependencies.getNotes(id)), note, ]); return note; } export async function deleteSubmissionNote( id: string, noteId: string, actor: RubgramActor, dependencies: { getNotes: (id: string) => Promise; saveNotes: (id: string, notes: Note[]) => Promise; }, ) { await requireAdmin(actor); const notes = await dependencies.getNotes(id); await dependencies.saveNotes( id, notes.filter((note) => note.id !== noteId), ); } export async function runAdminOperation( actor: RubgramActor, operation: () => Promise, ) { await requireAdmin(actor); return operation(); } export function isDuplicateSlipError(error: unknown): boolean { if (!error || typeof error !== "object") return false; const candidate = error as { code?: string; constraint?: string; cause?: unknown; }; if (candidate.code === "23505") return candidate.constraint?.includes("slips_ref") ?? false; return candidate.cause ? isDuplicateSlipError(candidate.cause) : false; }