Files
buzz/app/(ui)/rubgram/api.ts
T

644 lines
17 KiB
TypeScript
Executable File

"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<string, string>;
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<RubgramActor> {
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<number>`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;
};