"use server"; import { and, count as sqlCount, desc, eq, gt, inArray, lt, not, sql, } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { actionLog } from "@/lib/api"; import { adminCheck } from "@/lib/auth"; import { db } from "@/lib/db"; import { endgameDiscord, endgameExpired, endgameSettings, endgameSlips, endgameSubmissions, endgameTypes, type Note, settings, } from "@/lib/db/schema"; import { sse } from "@/lib/db/sse-endpoints"; import { checkSlip } from "@/lib/payment"; import { getPostHogClient } from "@/lib/posthog-server"; const { DISCORD_WEBHOOK_URL, DISCORD_CLIENT_ID, BASE_URL } = process.env as Record; export async function wipe() { if (!(await adminCheck())) throw "Unauthorized"; await db.update(endgameSubmissions).set({ deleted: true }); revalidatePath("/rubgram/admin"); revalidatePath("/rubgram"); sse.rubgram.pub("update", { type: "wipe" }); await actionLog(`Deleted rubgram submissions`); } export async function random() { if (!(await adminCheck())) throw "Unauthorized"; const [sub] = await db .select() .from(endgameSubmissions) .where(endgameSubmissions.paid.getSQL()) .orderBy(sql`${endgameSubmissions.checked} ASC, RANDOM()`) .limit(1); if (sub) redirect(`/rubgram/admin/${sub.id}`); else throw "ไม่พบผู้ลงทะเบียนที่ยังไม่ตรวจสอบ"; } export async function toggleCheck(submissionId: string) { if (!(await adminCheck())) throw "Unauthorized"; await db .update(endgameSubmissions) .set({ checked: not(endgameSubmissions.checked), }) .where(eq(endgameSubmissions.id, submissionId)); revalidatePath("/rubgram/admin"); revalidatePath("/rubgram"); sse.rubgram.pub("update", { type: "toggleCheck" }); await actionLog(`Toggled an rubgram submission check mark`); } export async function toggleLock() { if (!(await adminCheck())) throw "Unauthorized"; const existing = await db .update(endgameSettings) .set({ locked: not(endgameSettings.locked), }) .returning({ locked: endgameSettings.locked }); if (existing.length === 0) await db.insert(endgameSettings).values({ locked: true }); revalidatePath("/rubgram/admin"); revalidatePath("/rubgram"); sse.rubgram.pub("update", { type: "toggleLock" }); await actionLog( `${(existing.length ? existing[0].locked : true) ? "Locked" : "Unlocked"} rubgram submission`, ); } export async function setLimit(limit: number) { if (!(await adminCheck())) throw "Unauthorized"; if ( ( await db .update(endgameSettings) .set({ limit, }) .returning({ id: endgameSettings.id }) ).length === 0 ) await db.insert(endgameSettings).values({ limit }); revalidatePath("/rubgram/admin"); revalidatePath("/rubgram"); sse.rubgram.pub("update", { type: "setLimit" }); await actionLog( `Set rubgram submit limit to ${limit < 0 ? "unlimited" : limit}`, ); } export async function setFree(free: number) { if (!(await adminCheck())) throw "Unauthorized"; if ( ( await db .update(endgameSettings) .set({ free, }) .returning({ id: endgameSettings.id }) ).length === 0 ) await db.insert(endgameSettings).values({ free }); revalidatePath("/rubgram/admin"); revalidatePath("/rubgram"); sse.rubgram.pub("update", { type: "setFree" }); await actionLog(`Set rubgram free submission amount to ${free}`); } export async function toggleMonth(month: string) { if (!(await adminCheck())) throw "Unauthorized"; const [s] = await db .select({ monthly: endgameSettings.monthly }) .from(endgameSettings) .limit(1); const monthly = { ...(s?.monthly ?? {}), [month]: !(s?.monthly?.[month] ?? false), }; await db .update(endgameSettings) .set({ monthly }) .where(eq(endgameSettings.id, true)); revalidatePath("/rubgram/admin/calendar"); } export async function getEndgameConfig() { const [ngm]: (typeof endgameSettings.$inferSelect | undefined)[] = await db .select() .from(endgameSettings) .limit(1); const [glob]: (typeof settings.$inferSelect | undefined)[] = await db .select() .from(settings) .limit(1); const [{ count }] = await db .select({ count: sqlCount() }) .from(endgameSubmissions) .where(not(endgameSubmissions.deleted)); const types = await db .select() .from(endgameTypes) .orderBy(endgameTypes.order); const full = ngm && ngm.limit !== -1 && count >= (ngm.limit || 0); return { // @ts-expect-error locked: false, // @ts-expect-error limit: -1, // @ts-expect-error enka: false, // @ts-expect-error free: 0, count, full, types, ...ngm, ...glob, }; } export async function getUserSubmissions(uid: string) { const types = await db.select().from(endgameTypes); const typeNames = Object.fromEntries(types.map((t) => [t.id, t.display])); const subs = await db .select({ id: endgameSubmissions.id, queue: endgameSubmissions.queue, name: endgameSubmissions.name, server: endgameSubmissions.server, price: endgameSubmissions.price, paid: endgameSubmissions.paid, checked: endgameSubmissions.checked, deleted: endgameSubmissions.deleted, expires: endgameSubmissions.expires, service: endgameSubmissions.service, submitDay: endgameSubmissions.submit_day, }) .from(endgameSubmissions) .where( and(eq(endgameSubmissions.user, uid), not(endgameSubmissions.deleted)), ) .orderBy(desc(endgameSubmissions.submit_day)) .limit(20); return subs.map((s) => ({ ...s, services: s.service.map((id) => typeNames[id] || id), })); } export async function submitEndgame(formData: FormData) { const { full, locked, limit, types, free } = await getEndgameConfig(); const name = formData.get("name") as string; const server = formData.get("server") as "as" | "eu" | "us" | "tw"; const service = formData.getAll("service") as string[]; const user = formData.get("user") as string; if (!name || !server || !service.length || !user) return "กรุณากรอกข้อมูลให้ครบถ้วน"; // ไม่ควรเกิดขึ้นแน่ๆ แต่กันไว้ก่อน if (!["as", "eu", "us", "tw"].includes(server)) return "เซิร์ฟไม่ถูกต้อง"; if (service.some((s) => !types.map((t) => t.id).includes(s))) return "บริการไม่ถูกต้อง"; if (name.length > 32) return "ชื่อยาวเกินไป ต้องไม่เกิน 32 ตัวอักษร"; if (locked) return "ปิดรับลงทะเบียนชั่วคราว เนื่องจากมีผู้ลงจำนวนมาก"; if (full) return `คิวลงทะเบียนเต็มแล้ว (${limit} ครั้ง)`; await removeExpiredSubmissions(); const [existing] = await db .select({ queue: endgameSubmissions.queue, id: endgameSubmissions.id }) .from(endgameSubmissions) .where( and( eq(endgameSubmissions.user, user), not(endgameSubmissions.deleted), not(endgameSubmissions.checked), not(endgameSubmissions.paid), ), ) .limit(1); if (existing) return existing; const [queue] = await db .insert(endgameSubmissions) .values({ user, name, server, service, price: await calcPrice(service), }) .returning({ queue: endgameSubmissions.queue, id: endgameSubmissions.id }); if (free > 0) await db.update(endgameSettings).set({ free: sql`${endgameSettings.free} - 1`, }); revalidatePath("/rubgram/admin"); sse.rubgram.pub("update", { type: "submit", sub: queue.id }); getPostHogClient().capture({ distinctId: user, event: "rubgram_registration_completed", properties: { queue_position: queue.queue, server, services: service }, }); return queue; } export async function submitEndgamePayment(formData: FormData) { const sid = formData.get("sid") as string; const slip = formData.get("slip") as File; if (!sid || !slip) return "กรุณาอัพโหลดสลิปให้ครบถ้วน"; const arrayBuffer = await slip.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const queue = await db.transaction(async (tx) => { const [s] = await tx .select() .from(endgameSubmissions) .where( and(eq(endgameSubmissions.id, sid), not(endgameSubmissions.deleted)), ) .limit(1); const [a] = await tx .select() .from(endgameExpired) .where(eq(endgameExpired.id, sid)) .limit(1); if (!s) { if (a) await tx.insert(endgameSubmissions).values({ ...a, price: await calcPrice(a.service), }); else return "คุณยังไม่ได้ลงทะเบียน"; } // const processed = { // success: true, // data: { transRef: "test-abc", amount: 12345 }, // } as const; const processed = await checkSlip(buffer, slip.type, (s || a).price); if (!processed.success) return `${processed.code}: ${processed.message}`; const [{ id: slipId }] = await tx .insert(endgameSlips) .values({ slip: buffer, ref: processed.data.transRef, amount: processed.data.amount?.toString(), data: processed, }) .returning({ id: endgameSlips.id }) .catch((e) => { console.log(e); return [{ id: "conflict" }]; }); if (slipId === "conflict") return "สลิปนี้ถูกใช้ไปแล้ว"; return await tx .update(endgameSubmissions) .set({ slip: slipId, }) .returning({ queue: endgameSubmissions.queue, id: endgameSubmissions.id }) .where(eq(endgameSubmissions.id, sid)); }); revalidatePath("/rubgram/admin"); if (Array.isArray(queue)) { sse.rubgram.pub("update", { type: "paid", sub: queue[0].id }); getPostHogClient().capture({ distinctId: sid, event: "rubgram_payment_completed", properties: { submission_id: queue[0].id, queue_position: queue[0].queue, }, }); } return Array.isArray(queue) ? queue[0] : queue; } // discord auth export async function getDiscordSession() { const c = await cookies(); const token = c.get("discord"); if (!token) return undefined; return await db .select() .from(endgameDiscord) .where(eq(endgameDiscord.token, token.value)) .then((v) => v[0]); } export async function loginDiscord() { redirect( `https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}&response_type=code&redirect_uri=${encodeURIComponent(`${BASE_URL}/rubgram/callback`)}&scope=identify+guilds.join+guilds`, ); } export async function calcPrice(service: string[]) { const { free, types, allDiscount } = await getEndgameConfig(); return free > 0 ? 0 : service.reduce( (p, s) => p + (types.find((t) => t.id === s)?.price || 0), 0, ) - (types.every((s) => service.includes(s.id)) ? allDiscount : 0); } export async function removeExpiredSubmissions() { // First, get count of items to be removed const expiredCond = and( not(endgameSubmissions.paid), lt(endgameSubmissions.expires, new Date()), ); const toRemove = await db .select({ count: sql`COUNT(*)` }) .from(endgameSubmissions) .where(expiredCond); // Delete expired submissions and update queue numbers in one transaction await db.transaction(async (tx) => { // Store the queue numbers before deletion const expiredQueues = await tx .select() .from(endgameSubmissions) .where(expiredCond) .orderBy(desc(endgameSubmissions.queue)); // Delete expired submissions if (expiredQueues.length) await tx.insert(endgameExpired).values(expiredQueues); await tx.delete(endgameSubmissions).where(expiredCond); // Update queue numbers for remaining submissions for (const { queue } of expiredQueues) { await tx .update(endgameSubmissions) .set({ queue: sql`${endgameSubmissions.queue} - 1`, }) .where(gt(endgameSubmissions.queue, queue)); } // Reset the serial sequence to the max queue number const maxQueue = await tx .select({ max: sql`MAX(${endgameSubmissions.queue})` }) .from(endgameSubmissions); const nextSerial = (maxQueue[0]?.max || 0) + 1; // Update the sequence - adjust schema name if needed await tx.execute( sql`SELECT setval('endgame.submissions_queue_seq', ${nextSerial}, false)`, ); }); return { removed: Number(toRemove[0]?.count || 0), }; } export async function cancel(sid: string) { await db .update(endgameSubmissions) .set({ deleted: true }) .where(eq(endgameSubmissions.id, sid)); sse.rubgram.pub("update", { type: "cancel" }); revalidatePath("/rubgram"); } export async function bulkDelete(ids: string[]) { if (!(await adminCheck())) throw "Unauthorized"; await db .update(endgameSubmissions) .set({ deleted: true }) .where(inArray(endgameSubmissions.id, ids)); revalidatePath("/rubgram/admin"); sse.rubgram.pub("update", { type: "wipe" }); await actionLog(`Bulk deleted ${ids.length} rubgram submissions`); } export async function addNote(sid: string, text: string) { if (!(await adminCheck())) throw "Unauthorized"; const note: Note = { id: Bun.randomUUIDv7(), text, createdAt: new Date().toISOString(), }; const [sub] = await db .select({ notes: endgameSubmissions.notes }) .from(endgameSubmissions) .where(eq(endgameSubmissions.id, sid)) .limit(1); await db .update(endgameSubmissions) .set({ notes: [...(sub?.notes || []), note] }) .where(eq(endgameSubmissions.id, sid)); revalidatePath(`/rubgram/admin/${sid}`); return note; } export async function deleteNote(sid: string, noteId: string) { if (!(await adminCheck())) throw "Unauthorized"; const [sub] = await db .select({ notes: endgameSubmissions.notes }) .from(endgameSubmissions) .where(eq(endgameSubmissions.id, sid)) .limit(1); await db .update(endgameSubmissions) .set({ notes: (sub?.notes || []).filter((n) => n.id !== noteId) }) .where(eq(endgameSubmissions.id, sid)); revalidatePath(`/rubgram/admin/${sid}`); } export async function discordCall(id: string) { if (!(await adminCheck())) throw "Unauthorized"; return await fetch(DISCORD_WEBHOOK_URL, { method: "POST", body: JSON.stringify({ content: `<@${id}> ถึงคิวแล้ว ทักหาบุส <@681741453382123521> ได้เลย`, allowed_mentions: { users: [id] }, }), headers: { "Content-Type": "application/json", }, }).then(async (r) => { console.log(`called`, await r.text()); return r.ok; }); } export async function debugUploadSlip(sid: string, image: File) { if (!(await adminCheck())) throw "Unauthorized"; const arrayBuffer = await image.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); await db.transaction(async (tx) => { const [{ price }] = await tx .select({ price: endgameSubmissions.price }) .from(endgameSubmissions) .where(eq(endgameSubmissions.id, sid)); const [{ id }] = await tx .insert(endgameSlips) .values({ slip: buffer, ref: `MANUAL-${Bun.randomUUIDv7()}`, amount: price.toString(), data: {} as SlipokResponse, }) .returning({ id: endgameSlips.id }); await tx .update(endgameSubmissions) .set({ slip: id }) .where(eq(endgameSubmissions.id, sid)); }); sse.rubgram.pub("update", { type: "uploadSlip" }); revalidatePath("/rubgram/admin"); } export type SlipokResponse = | { success: true; data: { success: true; message: string; rqUID: string; language: string; transRef: string; sendingBank: string; receivingBank: string; transDate: string; transTime: string; sender: { displayName: string; name: string; proxy: { type: null; value: null; }; account: { type: string; value: string; }; }; receiver: { displayName: string; name: string; proxy: { type: string; value: string; }; account: { type: string; value: string; }; }; amount: number; paidLocalAmount: number; paidLocalCurrency: string; countryCode: string; transFeeAmount: number; ref1: string; ref2: string; ref3: string; toMerchantId: string; }; } | { success: false; code: number; message: string; };