"use server"; import { and, desc, eq, gt, inArray, lt, not, sql, count as sqlCount, } 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, settings, } from "@/lib/db/schema"; import { sse } from "@/lib/db/sse-endpoints"; import { checkSlip } from "@/lib/payment"; import { getPostHogClient } from "@/lib/posthog-server"; import { addSubmissionNote, calculatePrice, cancelSubmission, compactExpiredSubmissions, deleteSubmissionNote, isDuplicateSlipError, paySubmission, type RubgramActor, registerSubmission, requireAdmin, requireOwner, runAdminOperation, } from "./service"; const { DISCORD_WEBHOOK_URL } = process.env as Record; export async function wipe() { return runAdminOperation(getAdminActor(), async () => { 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() { await requireAdmin(getAdminActor()); 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) { await requireAdmin(getAdminActor()); 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() { await requireAdmin(getAdminActor()); 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) { await requireAdmin(getAdminActor()); 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) { await requireAdmin(getAdminActor()); 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) { await requireAdmin(getAdminActor()); 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) { await requireOwner(await getRubgramActor(), uid); 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 session = await getDiscordSession(); 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 = session?.uid; const queue = await registerSubmission( { name, server, service, user }, { getConfig: getEndgameConfig, removeExpired: removeExpiredSubmissions, findDuplicate: async (userId) => { const [existing] = await db .select({ queue: endgameSubmissions.queue, id: endgameSubmissions.id, }) .from(endgameSubmissions) .where( and( eq(endgameSubmissions.user, userId), not(endgameSubmissions.deleted), not(endgameSubmissions.checked), not(endgameSubmissions.paid), ), ) .limit(1); return existing; }, insert: async (values) => { const [inserted] = await db .insert(endgameSubmissions) .values(values) .returning({ queue: endgameSubmissions.queue, id: endgameSubmissions.id, }); return inserted; }, consumeFreeSlot: async () => { await db .update(endgameSettings) .set({ free: sql`${endgameSettings.free} - 1` }) .where(gt(endgameSettings.free, 0)); }, }, ); if (typeof queue === "string") return queue; 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 paySubmission( { id: sid, slip: buffer, type: slip.type }, await getRubgramActor(), { calculatePrice: calcPrice, checkSlip, isDuplicateSlipError, transaction: (run) => db.transaction(async (tx) => run({ findSubmission: async (id) => { const [submission] = await tx .select() .from(endgameSubmissions) .where( and( eq(endgameSubmissions.id, id), not(endgameSubmissions.deleted), ), ) .limit(1); return submission; }, findExpired: async (id) => { const [expired] = await tx .select() .from(endgameExpired) .where(eq(endgameExpired.id, id)) .limit(1); return expired; }, restoreExpired: async (expired, price) => { await tx.insert(endgameSubmissions).values({ ...expired, price }); }, insertSlip: async (input) => { const [{ id }] = await tx .insert(endgameSlips) .values({ slip: input.slip, ref: input.ref, amount: String(input.amount ?? 0), data: input.data as SlipokResponse, }) .returning({ id: endgameSlips.id }); return id; }, markPaid: async (submissionId, slipId) => { const [paid] = await tx .update(endgameSubmissions) .set({ slip: slipId }) .returning({ queue: endgameSubmissions.queue, id: endgameSubmissions.id, }) .where(eq(endgameSubmissions.id, submissionId)); return paid; }, }), ), }, ); revalidatePath("/rubgram/admin"); if (typeof queue !== "string") { sse.rubgram.pub("update", { type: "paid", sub: queue.id }); getPostHogClient().capture({ distinctId: sid, event: "rubgram_payment_completed", properties: { submission_id: queue.id, queue_position: queue.queue, }, }); } return 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]); } async function getRubgramActor(): Promise { const session = await getDiscordSession(); return { userId: session?.uid, isAdmin: async () => Boolean(await adminCheck()), }; } function getAdminActor(): RubgramActor { return { isAdmin: async () => Boolean(await adminCheck()) }; } export async function calcPrice(service: string[]) { return calculatePrice(service, await getEndgameConfig()); } export async function removeExpiredSubmissions() { return db.transaction(async (tx) => compactExpiredSubmissions({ findExpired: async (now) => tx .select() .from(endgameSubmissions) .where( and( not(endgameSubmissions.paid), lt(endgameSubmissions.expires, now), ), ) .orderBy(desc(endgameSubmissions.queue)), archive: async (submissions) => { if (submissions.length) await tx.insert(endgameExpired).values(submissions); }, remove: async (ids) => { if (ids.length) await tx .delete(endgameSubmissions) .where(inArray(endgameSubmissions.id, ids)); }, shiftAfter: async (queue) => { await tx .update(endgameSubmissions) .set({ queue: sql`${endgameSubmissions.queue} - 1` }) .where(gt(endgameSubmissions.queue, queue)); }, getMaxQueue: async () => { const [result] = await tx .select({ max: sql`MAX(${endgameSubmissions.queue})` }) .from(endgameSubmissions); return result?.max ?? null; }, setNextQueue: async (next) => { await tx.execute( sql`SELECT setval('endgame.submissions_queue_seq', ${next}, false)`, ); }, }), ); } export async function cancel(sid: string) { const cancelled = await cancelSubmission(sid, await getRubgramActor(), { findSubmission: async (id) => { const [submission] = await db .select({ user: endgameSubmissions.user }) .from(endgameSubmissions) .where( and(eq(endgameSubmissions.id, id), not(endgameSubmissions.deleted)), ) .limit(1); return submission; }, cancel: async (id) => { await db .update(endgameSubmissions) .set({ deleted: true }) .where(eq(endgameSubmissions.id, id)); }, }); if (!cancelled) return; sse.rubgram.pub("update", { type: "cancel" }); revalidatePath("/rubgram"); } export async function bulkDelete(ids: string[]) { return runAdminOperation(getAdminActor(), async () => { 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) { const note = await addSubmissionNote(sid, text, getAdminActor(), { getNotes: async (id) => { const [submission] = await db .select({ notes: endgameSubmissions.notes }) .from(endgameSubmissions) .where(eq(endgameSubmissions.id, id)) .limit(1); return submission?.notes ?? []; }, saveNotes: async (id, notes) => { await db .update(endgameSubmissions) .set({ notes }) .where(eq(endgameSubmissions.id, id)); }, createId: Bun.randomUUIDv7, now: () => new Date(), }); revalidatePath(`/rubgram/admin/${sid}`); return note; } export async function deleteNote(sid: string, noteId: string) { await deleteSubmissionNote(sid, noteId, getAdminActor(), { getNotes: async (id) => { const [submission] = await db .select({ notes: endgameSubmissions.notes }) .from(endgameSubmissions) .where(eq(endgameSubmissions.id, id)) .limit(1); return submission?.notes ?? []; }, saveNotes: async (id, notes) => { await db .update(endgameSubmissions) .set({ notes }) .where(eq(endgameSubmissions.id, id)); }, }); revalidatePath(`/rubgram/admin/${sid}`); } export async function discordCall(id: string) { await requireAdmin(getAdminActor()); 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) { await requireAdmin(getAdminActor()); 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; };