feat: add extensive test suite
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { adminCheck } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import type { TAccessKey } from "@/lib/db/schema";
|
||||
import { slipSync } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { retrieveMobileUploadService } from "./service";
|
||||
|
||||
export async function startMobileUpload() {
|
||||
return await db
|
||||
@@ -14,18 +16,13 @@ export async function startMobileUpload() {
|
||||
}
|
||||
|
||||
export async function retrieveMobileUpload(accessKey: TAccessKey) {
|
||||
const object = await db
|
||||
.delete(slipSync)
|
||||
.where(eq(slipSync.accessKey, accessKey))
|
||||
.returning()
|
||||
.then((r) => r.pop()!);
|
||||
const fd = new FormData();
|
||||
fd.set(
|
||||
"file",
|
||||
new File([Buffer.from(object.data!)], object.name!, {
|
||||
type: object.type!,
|
||||
}),
|
||||
);
|
||||
fd.set("name", object.name!)
|
||||
return fd;
|
||||
return retrieveMobileUploadService(accessKey, {
|
||||
adminCheck,
|
||||
consume: async (key) =>
|
||||
db
|
||||
.delete(slipSync)
|
||||
.where(eq(slipSync.accessKey, key as TAccessKey))
|
||||
.returning()
|
||||
.then((rows) => rows.pop()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { type MobileUpload, retrieveMobileUploadService } from "./service";
|
||||
|
||||
describe("retrieveMobileUploadService", () => {
|
||||
test("requires admin before consuming an upload", async () => {
|
||||
const consume = mock(async (_key: string) => undefined);
|
||||
expect(
|
||||
retrieveMobileUploadService("key", {
|
||||
adminCheck: async () => false,
|
||||
consume,
|
||||
}),
|
||||
).rejects.toBe("Unauthorized");
|
||||
expect(consume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns the uploaded file once and rejects later retrieval", async () => {
|
||||
let upload: MobileUpload | undefined = {
|
||||
data: Uint8Array.from([1, 2, 3]),
|
||||
name: "slip.png",
|
||||
type: "image/png",
|
||||
};
|
||||
const dependencies = {
|
||||
adminCheck: async () => true,
|
||||
consume: async () => {
|
||||
const result = upload;
|
||||
upload = undefined;
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
const formData = await retrieveMobileUploadService("key", dependencies);
|
||||
const file = formData.get("file");
|
||||
expect(file).toBeInstanceOf(File);
|
||||
expect((file as File).name).toBe("slip.png");
|
||||
expect((file as File).type).toBe("image/png");
|
||||
expect(formData.get("name")).toBe("slip.png");
|
||||
expect(retrieveMobileUploadService("key", dependencies)).rejects.toThrow(
|
||||
"already retrieved",
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ data: null, name: "slip.png", type: "image/png" },
|
||||
{ data: Uint8Array.from([1]), name: null, type: "image/png" },
|
||||
{ data: Uint8Array.from([1]), name: "slip.png", type: null },
|
||||
])("rejects incomplete upload metadata: %p", async (upload) => {
|
||||
expect(
|
||||
retrieveMobileUploadService("key", {
|
||||
adminCheck: async () => true,
|
||||
consume: async () => upload,
|
||||
}),
|
||||
).rejects.toThrow("unavailable");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
export type MobileUpload = {
|
||||
data: Uint8Array | Buffer | null;
|
||||
name: string | null;
|
||||
type: string | null;
|
||||
};
|
||||
|
||||
export async function retrieveMobileUploadService(
|
||||
accessKey: string,
|
||||
dependencies: {
|
||||
adminCheck: () => Promise<unknown>;
|
||||
consume: (accessKey: string) => Promise<MobileUpload | undefined>;
|
||||
},
|
||||
) {
|
||||
if (!(await dependencies.adminCheck())) throw "Unauthorized";
|
||||
|
||||
const upload = await dependencies.consume(accessKey);
|
||||
if (!upload?.data || !upload.name || !upload.type) {
|
||||
throw new Error("Mobile upload is unavailable or already retrieved.");
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set(
|
||||
"file",
|
||||
new File([Uint8Array.from(upload.data)], upload.name, {
|
||||
type: upload.type,
|
||||
}),
|
||||
);
|
||||
formData.set("name", upload.name);
|
||||
return formData;
|
||||
}
|
||||
+262
-226
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
and,
|
||||
count as sqlCount,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
lt,
|
||||
not,
|
||||
sql,
|
||||
count as sqlCount,
|
||||
} from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
@@ -24,29 +24,42 @@ import {
|
||||
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";
|
||||
import {
|
||||
addSubmissionNote,
|
||||
calculatePrice,
|
||||
cancelSubmission,
|
||||
compactExpiredSubmissions,
|
||||
deleteSubmissionNote,
|
||||
isDuplicateSlipError,
|
||||
paySubmission,
|
||||
type RubgramActor,
|
||||
registerSubmission,
|
||||
requireAdmin,
|
||||
requireOwner,
|
||||
runAdminOperation,
|
||||
} from "./service";
|
||||
|
||||
const { DISCORD_WEBHOOK_URL, DISCORD_CLIENT_ID, BASE_URL } =
|
||||
process.env as Record<string, string>;
|
||||
const { DISCORD_WEBHOOK_URL } = process.env as Record<string, string>;
|
||||
|
||||
export async function wipe() {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await db.update(endgameSubmissions).set({ deleted: true });
|
||||
revalidatePath("/rubgram/admin");
|
||||
revalidatePath("/rubgram");
|
||||
return runAdminOperation(getAdminActor(), async () => {
|
||||
await db.update(endgameSubmissions).set({ deleted: true });
|
||||
revalidatePath("/rubgram/admin");
|
||||
revalidatePath("/rubgram");
|
||||
|
||||
sse.rubgram.pub("update", { type: "wipe" });
|
||||
sse.rubgram.pub("update", { type: "wipe" });
|
||||
|
||||
await actionLog(`Deleted rubgram submissions`);
|
||||
await actionLog(`Deleted rubgram submissions`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function random() {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await requireAdmin(getAdminActor());
|
||||
const [sub] = await db
|
||||
.select()
|
||||
.from(endgameSubmissions)
|
||||
@@ -58,7 +71,7 @@ export async function random() {
|
||||
}
|
||||
|
||||
export async function toggleCheck(submissionId: string) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await requireAdmin(getAdminActor());
|
||||
await db
|
||||
.update(endgameSubmissions)
|
||||
.set({
|
||||
@@ -74,7 +87,7 @@ export async function toggleCheck(submissionId: string) {
|
||||
}
|
||||
|
||||
export async function toggleLock() {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await requireAdmin(getAdminActor());
|
||||
const existing = await db
|
||||
.update(endgameSettings)
|
||||
.set({
|
||||
@@ -94,7 +107,7 @@ export async function toggleLock() {
|
||||
}
|
||||
|
||||
export async function setLimit(limit: number) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await requireAdmin(getAdminActor());
|
||||
|
||||
if (
|
||||
(
|
||||
@@ -118,7 +131,7 @@ export async function setLimit(limit: number) {
|
||||
}
|
||||
|
||||
export async function setFree(free: number) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await requireAdmin(getAdminActor());
|
||||
|
||||
if (
|
||||
(
|
||||
@@ -140,7 +153,7 @@ export async function setFree(free: number) {
|
||||
}
|
||||
|
||||
export async function toggleMonth(month: string) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await requireAdmin(getAdminActor());
|
||||
const [s] = await db
|
||||
.select({ monthly: endgameSettings.monthly })
|
||||
.from(endgameSettings)
|
||||
@@ -193,6 +206,7 @@ export async function getEndgameConfig() {
|
||||
}
|
||||
|
||||
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]));
|
||||
|
||||
@@ -224,54 +238,57 @@ export async function getUserSubmissions(uid: string) {
|
||||
}
|
||||
|
||||
export async function submitEndgame(formData: FormData) {
|
||||
const { full, locked, limit, types, free } = await getEndgameConfig();
|
||||
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 = 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`,
|
||||
});
|
||||
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,
|
||||
distinctId: user!,
|
||||
event: "rubgram_registration_completed",
|
||||
properties: { queue_position: queue.queue, server, services: service },
|
||||
});
|
||||
@@ -286,73 +303,80 @@ export async function submitEndgamePayment(formData: FormData) {
|
||||
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));
|
||||
});
|
||||
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 (Array.isArray(queue)) {
|
||||
sse.rubgram.pub("update", { type: "paid", sub: queue[0].id });
|
||||
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[0].id,
|
||||
queue_position: queue[0].queue,
|
||||
submission_id: queue.id,
|
||||
queue_position: queue.queue,
|
||||
},
|
||||
});
|
||||
}
|
||||
return Array.isArray(queue) ? queue[0] : queue;
|
||||
return queue;
|
||||
}
|
||||
|
||||
// discord auth
|
||||
@@ -367,80 +391,87 @@ export async function getDiscordSession() {
|
||||
.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<number>`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<number>`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)`,
|
||||
);
|
||||
});
|
||||
|
||||
async function getRubgramActor(): Promise<RubgramActor> {
|
||||
const session = await getDiscordSession();
|
||||
return {
|
||||
removed: Number(toRemove[0]?.count || 0),
|
||||
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) {
|
||||
await db
|
||||
.update(endgameSubmissions)
|
||||
.set({ deleted: true })
|
||||
.where(eq(endgameSubmissions.id, sid));
|
||||
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" });
|
||||
|
||||
@@ -448,38 +479,39 @@ export async function cancel(sid: string) {
|
||||
}
|
||||
|
||||
export async function bulkDelete(ids: string[]) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await db
|
||||
.update(endgameSubmissions)
|
||||
.set({ deleted: true })
|
||||
.where(inArray(endgameSubmissions.id, ids));
|
||||
return runAdminOperation(getAdminActor(), async () => {
|
||||
await db
|
||||
.update(endgameSubmissions)
|
||||
.set({ deleted: true })
|
||||
.where(inArray(endgameSubmissions.id, ids));
|
||||
|
||||
revalidatePath("/rubgram/admin");
|
||||
revalidatePath("/rubgram/admin");
|
||||
|
||||
sse.rubgram.pub("update", { type: "wipe" });
|
||||
sse.rubgram.pub("update", { type: "wipe" });
|
||||
|
||||
await actionLog(`Bulk deleted ${ids.length} rubgram submissions`);
|
||||
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));
|
||||
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}`);
|
||||
|
||||
@@ -487,24 +519,28 @@ export async function addNote(sid: string, text: string) {
|
||||
}
|
||||
|
||||
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));
|
||||
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) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await requireAdmin(getAdminActor());
|
||||
|
||||
return await fetch(DISCORD_WEBHOOK_URL, {
|
||||
method: "POST",
|
||||
@@ -522,7 +558,7 @@ export async function discordCall(id: string) {
|
||||
}
|
||||
|
||||
export async function debugUploadSlip(sid: string, image: File) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await requireAdmin(getAdminActor());
|
||||
|
||||
const arrayBuffer = await image.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { createRubgramCallbackHandler } from "./handler";
|
||||
|
||||
const config = {
|
||||
clientId: "client",
|
||||
clientSecret: "secret",
|
||||
redirectUri: "https://buzz.test/rubgram/callback",
|
||||
guildId: "guild",
|
||||
botToken: "bot",
|
||||
};
|
||||
|
||||
function dependencies() {
|
||||
const responses = [
|
||||
Response.json({ access_token: "access" }),
|
||||
Response.json({ id: "user-1", username: "user", global_name: "Display" }),
|
||||
new Response(null, { status: 200 }),
|
||||
];
|
||||
return {
|
||||
config,
|
||||
fetch: mock(
|
||||
async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
responses.shift()!,
|
||||
),
|
||||
getState: mock((): string | undefined => "expected"),
|
||||
clearState: mock(() => {}),
|
||||
setSession: mock((_token: string) => {}),
|
||||
saveUser: mock(
|
||||
async (_user: { uid: string; display: string; username: string }) =>
|
||||
"session",
|
||||
),
|
||||
redirect: mock(
|
||||
(path: string) =>
|
||||
new Response(null, { status: 307, headers: { Location: path } }),
|
||||
),
|
||||
logError: mock((_message: string, _error?: unknown) => {}),
|
||||
};
|
||||
}
|
||||
|
||||
const request = (query: string) =>
|
||||
new Request(`https://buzz.test/rubgram/callback?${query}`);
|
||||
|
||||
describe("Rubgram Discord callback", () => {
|
||||
test("redirects an OAuth denial without exchanging a token", async () => {
|
||||
const deps = dependencies();
|
||||
const response = await createRubgramCallbackHandler(deps)(
|
||||
request("error=access_denied&state=expected"),
|
||||
);
|
||||
expect(response.status).toBe(307);
|
||||
expect(deps.clearState).toHaveBeenCalledTimes(1);
|
||||
expect(deps.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("requires an authorization code", async () => {
|
||||
const deps = dependencies();
|
||||
const response = await createRubgramCallbackHandler(deps)(
|
||||
request("state=expected"),
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ error: "No code provided" });
|
||||
});
|
||||
|
||||
test("rejects missing, mismatched, and unissued OAuth state", async () => {
|
||||
for (const [query, expected] of [
|
||||
["code=abc", "expected"],
|
||||
["code=abc&state=wrong", "expected"],
|
||||
["code=abc&state=expected", undefined],
|
||||
] as const) {
|
||||
const deps = dependencies();
|
||||
deps.getState.mockReturnValue(expected);
|
||||
const response = await createRubgramCallbackHandler(deps)(request(query));
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ error: "Invalid OAuth state" });
|
||||
expect(deps.clearState).toHaveBeenCalledTimes(1);
|
||||
expect(deps.fetch).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test("exchanges a valid callback, stores the user, and sets the session", async () => {
|
||||
const deps = dependencies();
|
||||
const response = await createRubgramCallbackHandler(deps)(
|
||||
request("code=abc&state=expected"),
|
||||
);
|
||||
expect(response.status).toBe(307);
|
||||
expect(deps.fetch).toHaveBeenCalledTimes(3);
|
||||
expect(deps.fetch.mock.calls[0]?.[0]).toBe(
|
||||
"https://discord.com/api/oauth2/token",
|
||||
);
|
||||
const tokenInit = deps.fetch.mock.calls[0]?.[1];
|
||||
expect(tokenInit?.method).toBe("POST");
|
||||
expect((tokenInit?.body as URLSearchParams).get("code")).toBe("abc");
|
||||
expect(deps.saveUser).toHaveBeenCalledWith({
|
||||
uid: "user-1",
|
||||
username: "user",
|
||||
display: "Display",
|
||||
});
|
||||
expect(deps.setSession).toHaveBeenCalledWith("session");
|
||||
expect(deps.redirect).toHaveBeenCalledWith("/rubgram");
|
||||
});
|
||||
|
||||
test("adds a user who is not already a guild member", async () => {
|
||||
const deps = dependencies();
|
||||
deps.fetch
|
||||
.mockResolvedValueOnce(Response.json({ access_token: "access" }))
|
||||
.mockResolvedValueOnce(Response.json({ id: "user-1", username: "user" }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 201 }));
|
||||
await createRubgramCallbackHandler(deps)(
|
||||
request("code=abc&state=expected"),
|
||||
);
|
||||
expect(deps.fetch).toHaveBeenCalledTimes(4);
|
||||
expect(deps.fetch.mock.calls[3]?.[0]).toBe(
|
||||
"https://discord.com/api/guilds/guild/members/user-1",
|
||||
);
|
||||
expect(deps.fetch.mock.calls[3]?.[1]?.method).toBe("PUT");
|
||||
});
|
||||
|
||||
test("returns a stable error without storing a user when Discord fails", async () => {
|
||||
const deps = dependencies();
|
||||
deps.fetch.mockReset();
|
||||
deps.fetch.mockResolvedValue(new Response("bad", { status: 401 }));
|
||||
const response = await createRubgramCallbackHandler(deps)(
|
||||
request("code=abc&state=expected"),
|
||||
);
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({ error: "Authentication failed" });
|
||||
expect(deps.saveUser).not.toHaveBeenCalled();
|
||||
expect(deps.setSession).not.toHaveBeenCalled();
|
||||
expect(deps.logError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
type OAuthConfig = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
guildId: string;
|
||||
botToken: string;
|
||||
};
|
||||
|
||||
type OAuthUser = { id: string; username: string; global_name?: string | null };
|
||||
|
||||
type CallbackDependencies = {
|
||||
config: OAuthConfig;
|
||||
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
getState: () => string | undefined;
|
||||
clearState: () => void;
|
||||
setSession: (token: string) => void;
|
||||
saveUser: (user: {
|
||||
uid: string;
|
||||
display: string;
|
||||
username: string;
|
||||
}) => Promise<string>;
|
||||
redirect: (path: string) => Response;
|
||||
logError?: (message: string, error?: unknown) => void;
|
||||
};
|
||||
|
||||
export function createRubgramCallbackHandler(
|
||||
dependencies: CallbackDependencies,
|
||||
) {
|
||||
return async function GET(request: Request): Promise<Response> {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const oauthError = searchParams.has("error");
|
||||
const code = searchParams.get("code");
|
||||
if (!code && !oauthError)
|
||||
return Response.json({ error: "No code provided" }, { status: 400 });
|
||||
|
||||
const expectedState = dependencies.getState();
|
||||
const state = searchParams.get("state");
|
||||
dependencies.clearState();
|
||||
if (!expectedState || !state || state !== expectedState)
|
||||
return Response.json({ error: "Invalid OAuth state" }, { status: 400 });
|
||||
if (oauthError) return dependencies.redirect("/rubgram");
|
||||
|
||||
try {
|
||||
const tokenResponse = await dependencies.fetch(
|
||||
"https://discord.com/api/oauth2/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: dependencies.config.clientId,
|
||||
client_secret: dependencies.config.clientSecret,
|
||||
grant_type: "authorization_code",
|
||||
code: code!,
|
||||
redirect_uri: dependencies.config.redirectUri,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!tokenResponse.ok)
|
||||
throw new Error(`Failed to exchange code: ${tokenResponse.status}`);
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as {
|
||||
access_token?: string;
|
||||
};
|
||||
if (!tokenData.access_token)
|
||||
throw new Error("Discord omitted access token");
|
||||
const accessToken = tokenData.access_token;
|
||||
|
||||
const userResponse = await dependencies.fetch(
|
||||
"https://discord.com/api/users/@me",
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
if (!userResponse.ok) throw new Error("Failed to fetch user data");
|
||||
const user = (await userResponse.json()) as OAuthUser;
|
||||
|
||||
const memberResponse = await dependencies.fetch(
|
||||
`https://discord.com/api/users/@me/guilds/${dependencies.config.guildId}/member`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
if (!memberResponse.ok) {
|
||||
const addResponse = await dependencies.fetch(
|
||||
`https://discord.com/api/guilds/${dependencies.config.guildId}/members/${user.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bot ${dependencies.config.botToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ access_token: accessToken }),
|
||||
},
|
||||
);
|
||||
if (!addResponse.ok)
|
||||
dependencies.logError?.("Failed to add user to guild");
|
||||
}
|
||||
|
||||
const token = await dependencies.saveUser({
|
||||
uid: user.id,
|
||||
display: user.global_name || user.username,
|
||||
username: user.username,
|
||||
});
|
||||
dependencies.setSession(token);
|
||||
return dependencies.redirect("/rubgram");
|
||||
} catch (error) {
|
||||
dependencies.logError?.("Discord OAuth error", error);
|
||||
return Response.json({ error: "Authentication failed" }, { status: 500 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,129 +1,37 @@
|
||||
// app/api/auth/discord/callback/route.ts
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { stringify } from "yaml";
|
||||
import { db } from "@/lib/db"; // your drizzle db instance
|
||||
import { endgameDiscord } from "@/lib/db/schema"; // your schema
|
||||
import { db } from "@/lib/db";
|
||||
import { endgameDiscord } from "@/lib/db/schema";
|
||||
import { createRubgramCallbackHandler } from "./handler";
|
||||
|
||||
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID!;
|
||||
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET!;
|
||||
const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI!;
|
||||
const DISCORD_GUILD_ID = process.env.DISCORD_GUILD_ID!;
|
||||
const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN!;
|
||||
const config = {
|
||||
clientId: process.env.DISCORD_CLIENT_ID!,
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
|
||||
redirectUri: process.env.DISCORD_REDIRECT_URI!,
|
||||
guildId: process.env.DISCORD_GUILD_ID!,
|
||||
botToken: process.env.DISCORD_BOT_TOKEN!,
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
export async function GET(request: Request) {
|
||||
const cookieStore = await cookies();
|
||||
const code = searchParams.get("code");
|
||||
|
||||
if (!code) {
|
||||
if (searchParams.has("error")) redirect("/rubgram");
|
||||
return NextResponse.json({ error: "No code provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for access token
|
||||
const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
// Authorization: `Basic ${btoa(`${DISCORD_CLIENT_ID}:${DISCORD_CLIENT_SECRET}`)}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: DISCORD_CLIENT_ID,
|
||||
client_secret: DISCORD_CLIENT_SECRET,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: DISCORD_REDIRECT_URI,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to exchange code for token: ${tokenResponse.status}\n${stringify(await tokenResponse.json())}`,
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
const accessToken = tokenData.access_token;
|
||||
|
||||
// Get user information
|
||||
const userResponse = await fetch("https://discord.com/api/users/@me", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userResponse.ok) {
|
||||
throw new Error("Failed to fetch user data");
|
||||
}
|
||||
|
||||
const userData = await userResponse.json();
|
||||
|
||||
// Check if user is in guild
|
||||
const guildMemberResponse = await fetch(
|
||||
`https://discord.com/api/users/@me/guilds/${DISCORD_GUILD_ID}/member`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const isInGuild = guildMemberResponse.ok;
|
||||
|
||||
// If not in guild, add them using bot token
|
||||
if (!isInGuild) {
|
||||
const addMemberResponse = await fetch(
|
||||
`https://discord.com/api/guilds/${DISCORD_GUILD_ID}/members/${userData.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
access_token: accessToken,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!addMemberResponse.ok) {
|
||||
console.error("Failed to add user to guild");
|
||||
// Continue anyway - they might join manually
|
||||
}
|
||||
}
|
||||
|
||||
// Store/update user in database
|
||||
const display = userData.global_name || userData.username;
|
||||
|
||||
const [{ token }] = await db
|
||||
.insert(endgameDiscord)
|
||||
.values({
|
||||
uid: userData.id,
|
||||
display,
|
||||
username: userData.username,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: endgameDiscord.uid,
|
||||
set: {
|
||||
display,
|
||||
username: userData.username,
|
||||
},
|
||||
})
|
||||
.returning({ token: endgameDiscord.token });
|
||||
|
||||
// Redirect to success page or dashboard
|
||||
cookieStore.set("discord", token);
|
||||
} catch (error) {
|
||||
console.error("Discord OAuth error:", (error as Error)?.message || error);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
redirect("/rubgram");
|
||||
return createRubgramCallbackHandler({
|
||||
config,
|
||||
fetch,
|
||||
getState: () => cookieStore.get("discord_oauth_state")?.value,
|
||||
clearState: () => cookieStore.delete("discord_oauth_state"),
|
||||
setSession: (token) => cookieStore.set("discord", token),
|
||||
redirect,
|
||||
logError: (message, error) => console.error(message, error),
|
||||
saveUser: async ({ uid, display, username }) => {
|
||||
const [{ token }] = await db
|
||||
.insert(endgameDiscord)
|
||||
.values({ uid, display, username })
|
||||
.onConflictDoUpdate({
|
||||
target: endgameDiscord.uid,
|
||||
set: { display, username },
|
||||
})
|
||||
.returning({ token: endgameDiscord.token });
|
||||
return token;
|
||||
},
|
||||
})(request);
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ import {
|
||||
type getDiscordSession,
|
||||
type getEndgameConfig,
|
||||
type getUserSubmissions,
|
||||
loginDiscord,
|
||||
} from "./api";
|
||||
import { loginDiscord } from "./discord-login";
|
||||
import { RulesDialog } from "./rules";
|
||||
|
||||
export function WelcomeScreening({
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { discordAuthorizationUrl } from "./oauth";
|
||||
|
||||
export async function loginDiscord() {
|
||||
const state = crypto.randomUUID();
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("discord_oauth_state", state, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 600,
|
||||
path: "/rubgram",
|
||||
});
|
||||
|
||||
redirect(
|
||||
discordAuthorizationUrl({
|
||||
clientId: process.env.DISCORD_CLIENT_ID!,
|
||||
redirectUri: `${process.env.BASE_URL}/rubgram/callback`,
|
||||
state,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { discordAuthorizationUrl } from "./oauth";
|
||||
|
||||
test("Discord authorization URL carries the callback and CSRF state", () => {
|
||||
const url = new URL(
|
||||
discordAuthorizationUrl({
|
||||
clientId: "client id",
|
||||
redirectUri: "https://buzz.test/rubgram/callback",
|
||||
state: "random-state",
|
||||
}),
|
||||
);
|
||||
expect(url.origin + url.pathname).toBe(
|
||||
"https://discord.com/oauth2/authorize",
|
||||
);
|
||||
expect(url.searchParams.get("client_id")).toBe("client id");
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(
|
||||
"https://buzz.test/rubgram/callback",
|
||||
);
|
||||
expect(url.searchParams.get("state")).toBe("random-state");
|
||||
expect(url.searchParams.get("scope")).toBe("identify guilds.join guilds");
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export function discordAuthorizationUrl(config: {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
state: string;
|
||||
}) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: config.redirectUri,
|
||||
scope: "identify guilds.join guilds",
|
||||
state: config.state,
|
||||
});
|
||||
return `https://discord.com/oauth2/authorize?${params}`;
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import {
|
||||
addSubmissionNote,
|
||||
calculatePrice,
|
||||
cancelSubmission,
|
||||
compactExpiredSubmissions,
|
||||
deleteSubmissionNote,
|
||||
isDuplicateSlipError,
|
||||
messages,
|
||||
type PaymentRepository,
|
||||
paySubmission,
|
||||
type RubgramActor,
|
||||
type RubgramConfig,
|
||||
registerSubmission,
|
||||
requireOwner,
|
||||
runAdminOperation,
|
||||
validateRegistration,
|
||||
} from "./service";
|
||||
|
||||
const types = [
|
||||
{ id: "abyss", price: 100 },
|
||||
{ id: "theater", price: 80 },
|
||||
];
|
||||
const config: RubgramConfig = {
|
||||
locked: false,
|
||||
full: false,
|
||||
limit: 10,
|
||||
free: 0,
|
||||
allDiscount: 20,
|
||||
types,
|
||||
};
|
||||
const owner: RubgramActor = {
|
||||
userId: "user-1",
|
||||
isAdmin: mock(async () => false),
|
||||
};
|
||||
const anonymous = (): RubgramActor => ({
|
||||
isAdmin: mock(async () => false),
|
||||
});
|
||||
const admin = (): RubgramActor => ({
|
||||
isAdmin: mock(async () => true),
|
||||
});
|
||||
|
||||
describe("Rubgram pricing", () => {
|
||||
test("sums selected service prices", () => {
|
||||
expect(calculatePrice(["abyss"], config)).toBe(100);
|
||||
});
|
||||
|
||||
test("applies the all-services discount", () => {
|
||||
expect(calculatePrice(["abyss", "theater"], config)).toBe(160);
|
||||
});
|
||||
|
||||
test("does not discount a partial or unknown selection", () => {
|
||||
expect(calculatePrice(["unknown"], config)).toBe(0);
|
||||
});
|
||||
|
||||
test("makes any selection free while a free slot is advertised", () => {
|
||||
expect(calculatePrice(["abyss", "theater"], { ...config, free: 1 })).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registration validation", () => {
|
||||
const valid = {
|
||||
name: "Buzz",
|
||||
server: "as",
|
||||
service: ["abyss"],
|
||||
user: "user-1",
|
||||
};
|
||||
|
||||
test.each([
|
||||
[{ ...valid, name: "" }, messages.incomplete],
|
||||
[{ ...valid, server: "" }, messages.incomplete],
|
||||
[{ ...valid, service: [] }, messages.incomplete],
|
||||
[{ ...valid, user: undefined }, messages.incomplete],
|
||||
[{ ...valid, server: "mars" }, messages.invalidServer],
|
||||
[{ ...valid, service: ["fake"] }, messages.invalidService],
|
||||
[{ ...valid, name: "x".repeat(33) }, messages.nameTooLong],
|
||||
])("rejects invalid registration %#", (input, expected) => {
|
||||
expect(validateRegistration(input, types)).toBe(expected);
|
||||
});
|
||||
|
||||
test("accepts valid input at the name length boundary", () => {
|
||||
expect(
|
||||
validateRegistration({ ...valid, name: "x".repeat(32) }, types),
|
||||
).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registration workflow", () => {
|
||||
const input = {
|
||||
name: "Buzz",
|
||||
server: "as",
|
||||
service: ["abyss"],
|
||||
user: "user-1",
|
||||
};
|
||||
|
||||
function dependencies(
|
||||
overrides: Partial<Parameters<typeof registerSubmission>[1]> = {},
|
||||
) {
|
||||
return {
|
||||
getConfig: mock(async () => config),
|
||||
removeExpired: mock(async () => {}),
|
||||
findDuplicate: mock(async () => undefined),
|
||||
insert: mock(async () => ({ id: "sub-1", queue: 4 })),
|
||||
consumeFreeSlot: mock(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("rejects a forged or absent form user before mutations", async () => {
|
||||
const deps = dependencies();
|
||||
expect(await registerSubmission({ ...input, user: undefined }, deps)).toBe(
|
||||
messages.incomplete,
|
||||
);
|
||||
expect(deps.removeExpired).not.toHaveBeenCalled();
|
||||
expect(deps.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("stops at the registration lock", async () => {
|
||||
const deps = dependencies({
|
||||
getConfig: mock(async () => ({ ...config, locked: true })),
|
||||
});
|
||||
expect(await registerSubmission(input, deps)).toBe(messages.locked);
|
||||
expect(deps.removeExpired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("compacts expirations before enforcing the refreshed limit", async () => {
|
||||
const getConfig = mock()
|
||||
.mockResolvedValueOnce({ ...config, full: true })
|
||||
.mockResolvedValueOnce(config);
|
||||
const deps = dependencies({ getConfig });
|
||||
expect(await registerSubmission(input, deps)).toEqual({
|
||||
id: "sub-1",
|
||||
queue: 4,
|
||||
});
|
||||
expect(deps.removeExpired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("rejects a genuinely full refreshed queue", async () => {
|
||||
const deps = dependencies({
|
||||
getConfig: mock(async () => ({ ...config, full: true, limit: 3 })),
|
||||
});
|
||||
expect(await registerSubmission(input, deps)).toBe(
|
||||
"คิวลงทะเบียนเต็มแล้ว (3 ครั้ง)",
|
||||
);
|
||||
expect(deps.findDuplicate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns an existing unpaid queue instead of inserting", async () => {
|
||||
const duplicate = { id: "old", queue: 2 };
|
||||
const deps = dependencies({
|
||||
findDuplicate: mock(async () => duplicate),
|
||||
});
|
||||
expect(await registerSubmission(input, deps)).toEqual(duplicate);
|
||||
expect(deps.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("inserts the authenticated user with the calculated price", async () => {
|
||||
const deps = dependencies();
|
||||
await registerSubmission(input, deps);
|
||||
expect(deps.insert).toHaveBeenCalledWith({
|
||||
...input,
|
||||
price: 100,
|
||||
});
|
||||
expect(deps.consumeFreeSlot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("consumes one slot only for a free registration", async () => {
|
||||
const deps = dependencies({
|
||||
getConfig: mock(async () => ({ ...config, free: 1 })),
|
||||
});
|
||||
await registerSubmission(input, deps);
|
||||
expect(deps.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ price: 0 }),
|
||||
);
|
||||
expect(deps.consumeFreeSlot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ownership", () => {
|
||||
test("allows the owner without checking admin auth", async () => {
|
||||
await requireOwner(owner, "user-1");
|
||||
expect(owner.isAdmin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows an admin to act on another user's submission", async () => {
|
||||
await expect(requireOwner(admin(), "user-1")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects another user", async () => {
|
||||
await expect(requireOwner(anonymous(), "user-1")).rejects.toThrow(
|
||||
messages.unauthorized,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("payment workflow", () => {
|
||||
const active = {
|
||||
id: "sub-1",
|
||||
user: "user-1",
|
||||
service: ["abyss"],
|
||||
price: 100,
|
||||
};
|
||||
|
||||
function setup(
|
||||
options: {
|
||||
active?: typeof active;
|
||||
expired?: {
|
||||
id: string;
|
||||
user: string;
|
||||
service: string[];
|
||||
name: string;
|
||||
server: "as";
|
||||
};
|
||||
insertSlip?: PaymentRepository["insertSlip"];
|
||||
slipResult?: Awaited<
|
||||
ReturnType<Parameters<typeof paySubmission>[2]["checkSlip"]>
|
||||
>;
|
||||
} = {},
|
||||
) {
|
||||
const repository: PaymentRepository = {
|
||||
findSubmission: mock(async () => options.active),
|
||||
findExpired: mock(async () => options.expired),
|
||||
restoreExpired: mock(async () => {}),
|
||||
insertSlip: options.insertSlip ?? mock(async () => "slip-1"),
|
||||
markPaid: mock(async () => ({ id: "sub-1", queue: 8 })),
|
||||
};
|
||||
const checkSlip = mock(
|
||||
async () =>
|
||||
options.slipResult ?? {
|
||||
success: true as const,
|
||||
data: { transRef: "ref-1", amount: 100 },
|
||||
},
|
||||
);
|
||||
const dependencies = {
|
||||
transaction: async <T>(run: (repo: PaymentRepository) => Promise<T>) =>
|
||||
run(repository),
|
||||
calculatePrice: mock(async () => 160),
|
||||
checkSlip,
|
||||
isDuplicateSlipError,
|
||||
};
|
||||
return { repository, dependencies, checkSlip };
|
||||
}
|
||||
|
||||
const input = {
|
||||
id: "sub-1",
|
||||
slip: Buffer.from(new ArrayBuffer(2)),
|
||||
type: "image/png",
|
||||
};
|
||||
|
||||
test("returns not registered when active and expired records are absent", async () => {
|
||||
const { dependencies, checkSlip } = setup();
|
||||
expect(await paySubmission(input, owner, dependencies)).toBe(
|
||||
messages.notRegistered,
|
||||
);
|
||||
expect(checkSlip).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("requires ownership before checking a slip", async () => {
|
||||
const { dependencies, checkSlip } = setup({ active });
|
||||
await expect(
|
||||
paySubmission(input, anonymous(), dependencies),
|
||||
).rejects.toThrow(messages.unauthorized);
|
||||
expect(checkSlip).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("checks and attaches a valid slip at the stored active price", async () => {
|
||||
const { repository, dependencies, checkSlip } = setup({ active });
|
||||
expect(await paySubmission(input, owner, dependencies)).toEqual({
|
||||
id: "sub-1",
|
||||
queue: 8,
|
||||
});
|
||||
expect(checkSlip).toHaveBeenCalledWith(input.slip, "image/png", 100);
|
||||
expect(repository.markPaid).toHaveBeenCalledWith("sub-1", "slip-1");
|
||||
});
|
||||
|
||||
test("recalculates an expired price and uses it for slip validation", async () => {
|
||||
const expired = {
|
||||
id: "sub-1",
|
||||
user: "user-1",
|
||||
service: ["abyss", "theater"],
|
||||
name: "Buzz",
|
||||
server: "as" as const,
|
||||
};
|
||||
const { repository, dependencies, checkSlip } = setup({ expired });
|
||||
await paySubmission(input, owner, dependencies);
|
||||
expect(dependencies.calculatePrice).toHaveBeenCalledWith(expired.service);
|
||||
expect(repository.restoreExpired).toHaveBeenCalledWith(expired, 160);
|
||||
expect(checkSlip).toHaveBeenCalledWith(input.slip, "image/png", 160);
|
||||
});
|
||||
|
||||
test("returns processor failures without inserting a slip", async () => {
|
||||
const { repository, dependencies } = setup({
|
||||
active,
|
||||
slipResult: { success: false, code: 422, message: "bad slip" },
|
||||
});
|
||||
expect(await paySubmission(input, owner, dependencies)).toBe(
|
||||
"422: bad slip",
|
||||
);
|
||||
expect(repository.insertSlip).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("maps only a duplicate reference violation to duplicate slip", async () => {
|
||||
const error = { code: "23505", constraint: "slips_ref_unique" };
|
||||
const { dependencies } = setup({
|
||||
active,
|
||||
insertSlip: mock(async () => {
|
||||
throw error;
|
||||
}),
|
||||
});
|
||||
expect(await paySubmission(input, owner, dependencies)).toBe(
|
||||
messages.duplicateSlip,
|
||||
);
|
||||
});
|
||||
|
||||
test("propagates unrelated database failures", async () => {
|
||||
const error = Object.assign(new Error("connection lost"), {
|
||||
code: "08006",
|
||||
});
|
||||
const { dependencies } = setup({
|
||||
active,
|
||||
insertSlip: mock(async () => {
|
||||
throw error;
|
||||
}),
|
||||
});
|
||||
await expect(paySubmission(input, owner, dependencies)).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expiration compaction", () => {
|
||||
test("archives, removes, and compacts expired queues in descending order", async () => {
|
||||
const calls: Array<string | number> = [];
|
||||
const expired = [
|
||||
{ id: "one", queue: 2 },
|
||||
{ id: "two", queue: 5 },
|
||||
];
|
||||
const result = await compactExpiredSubmissions(
|
||||
{
|
||||
findExpired: mock(async () => expired),
|
||||
archive: mock(async (rows) => {
|
||||
calls.push(`archive:${rows.length}`);
|
||||
}),
|
||||
remove: mock(async (ids) => {
|
||||
calls.push(`remove:${ids.join(",")}`);
|
||||
}),
|
||||
shiftAfter: mock(async (queue) => {
|
||||
calls.push(queue);
|
||||
}),
|
||||
getMaxQueue: mock(async () => 7),
|
||||
setNextQueue: mock(async (next) => {
|
||||
calls.push(`next:${next}`);
|
||||
}),
|
||||
},
|
||||
new Date("2026-01-01"),
|
||||
);
|
||||
expect(result).toEqual({ removed: 2 });
|
||||
expect(calls).toEqual(["archive:2", "remove:one,two", 5, 2, "next:8"]);
|
||||
});
|
||||
|
||||
test("skips archive/delete/shift and resets an empty sequence to one", async () => {
|
||||
const archive = mock(async () => {});
|
||||
const remove = mock(async () => {});
|
||||
const shiftAfter = mock(async () => {});
|
||||
const setNextQueue = mock(async () => {});
|
||||
expect(
|
||||
await compactExpiredSubmissions({
|
||||
findExpired: mock(async () => []),
|
||||
archive,
|
||||
remove,
|
||||
shiftAfter,
|
||||
getMaxQueue: mock(async () => null),
|
||||
setNextQueue,
|
||||
}),
|
||||
).toEqual({ removed: 0 });
|
||||
expect(archive).not.toHaveBeenCalled();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
expect(shiftAfter).not.toHaveBeenCalled();
|
||||
expect(setNextQueue).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancellation", () => {
|
||||
test("allows the owner to soft-delete", async () => {
|
||||
const cancel = mock(async () => {});
|
||||
expect(
|
||||
await cancelSubmission("sub-1", owner, {
|
||||
findSubmission: mock(async () => ({ user: "user-1" })),
|
||||
cancel,
|
||||
}),
|
||||
).toBeTrue();
|
||||
expect(cancel).toHaveBeenCalledWith("sub-1");
|
||||
});
|
||||
|
||||
test("allows admin cancellation", async () => {
|
||||
const cancel = mock(async () => {});
|
||||
await cancelSubmission("sub-1", admin(), {
|
||||
findSubmission: mock(async () => ({ user: "someone" })),
|
||||
cancel,
|
||||
});
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not mutate missing or unauthorized submissions", async () => {
|
||||
const cancel = mock(async () => {});
|
||||
expect(
|
||||
await cancelSubmission("missing", anonymous(), {
|
||||
findSubmission: mock(async () => undefined),
|
||||
cancel,
|
||||
}),
|
||||
).toBeFalse();
|
||||
await expect(
|
||||
cancelSubmission("sub-1", anonymous(), {
|
||||
findSubmission: mock(async () => ({ user: "someone" })),
|
||||
cancel,
|
||||
}),
|
||||
).rejects.toThrow(messages.unauthorized);
|
||||
expect(cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notes and admin operations", () => {
|
||||
const notes = [
|
||||
{ id: "old", text: "old note", createdAt: "2025-01-01T00:00:00.000Z" },
|
||||
];
|
||||
|
||||
test("admin note creation appends deterministic metadata", async () => {
|
||||
const saveNotes = mock(async () => {});
|
||||
const note = await addSubmissionNote("sub-1", "new note", admin(), {
|
||||
getNotes: mock(async () => notes),
|
||||
saveNotes,
|
||||
createId: () => "new",
|
||||
now: () => new Date("2026-02-03T04:05:06.000Z"),
|
||||
});
|
||||
expect(note).toEqual({
|
||||
id: "new",
|
||||
text: "new note",
|
||||
createdAt: "2026-02-03T04:05:06.000Z",
|
||||
});
|
||||
expect(saveNotes).toHaveBeenCalledWith("sub-1", [...notes, note]);
|
||||
});
|
||||
|
||||
test("admin note deletion preserves all other notes", async () => {
|
||||
const saveNotes = mock(async () => {});
|
||||
await deleteSubmissionNote("sub-1", "old", admin(), {
|
||||
getNotes: mock(async () => [
|
||||
...notes,
|
||||
{ id: "keep", text: "keep", createdAt: "now" },
|
||||
]),
|
||||
saveNotes,
|
||||
});
|
||||
expect(saveNotes).toHaveBeenCalledWith("sub-1", [
|
||||
{ id: "keep", text: "keep", createdAt: "now" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("non-admins cannot change notes", async () => {
|
||||
const saveNotes = mock(async () => {});
|
||||
await expect(
|
||||
addSubmissionNote("sub-1", "no", anonymous(), {
|
||||
getNotes: mock(async () => notes),
|
||||
saveNotes,
|
||||
createId: () => "new",
|
||||
now: () => new Date(),
|
||||
}),
|
||||
).rejects.toThrow(messages.unauthorized);
|
||||
expect(saveNotes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("admin operation runs once and returns its result", async () => {
|
||||
const operation = mock(async () => "updated");
|
||||
expect(await runAdminOperation(admin(), operation)).toBe("updated");
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("unauthorized admin operation never invokes its dependency", async () => {
|
||||
const operation = mock(async () => "updated");
|
||||
await expect(runAdminOperation(anonymous(), operation)).rejects.toThrow(
|
||||
messages.unauthorized,
|
||||
);
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicate slip classification", () => {
|
||||
test("recognizes direct and wrapped slip-reference unique violations", () => {
|
||||
const duplicate = { code: "23505", constraint: "slips_ref_unique" };
|
||||
expect(isDuplicateSlipError(duplicate)).toBeTrue();
|
||||
expect(isDuplicateSlipError({ cause: duplicate })).toBeTrue();
|
||||
});
|
||||
|
||||
test("rejects other constraints and database errors", () => {
|
||||
expect(
|
||||
isDuplicateSlipError({ code: "23505", constraint: "users_email_unique" }),
|
||||
).toBeFalse();
|
||||
expect(isDuplicateSlipError({ code: "23505" })).toBeFalse();
|
||||
expect(isDuplicateSlipError({ code: "08006" })).toBeFalse();
|
||||
expect(isDuplicateSlipError(new Error("boom"))).toBeFalse();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
export type RubgramActor = {
|
||||
userId?: string;
|
||||
isAdmin: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
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<Submission, "price"> & {
|
||||
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<RubgramConfig, "free" | "types" | "allDiscount">,
|
||||
) {
|
||||
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<RubgramConfig>;
|
||||
removeExpired: () => Promise<unknown>;
|
||||
findDuplicate: (user: string) => Promise<QueueResult | undefined>;
|
||||
insert: (input: {
|
||||
user: string;
|
||||
name: string;
|
||||
server: "as" | "eu" | "us" | "tw";
|
||||
service: string[];
|
||||
price: number;
|
||||
}) => Promise<QueueResult>;
|
||||
consumeFreeSlot: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function registerSubmission(
|
||||
input: RegistrationInput,
|
||||
dependencies: RegistrationDependencies,
|
||||
): Promise<QueueResult | string> {
|
||||
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<Submission | undefined>;
|
||||
findExpired: (id: string) => Promise<ExpiredSubmission | undefined>;
|
||||
restoreExpired: (
|
||||
submission: ExpiredSubmission,
|
||||
price: number,
|
||||
) => Promise<void>;
|
||||
insertSlip: (input: {
|
||||
slip: Buffer<ArrayBuffer>;
|
||||
ref: string;
|
||||
amount?: number;
|
||||
data: SlipResult;
|
||||
}) => Promise<string>;
|
||||
markPaid: (submissionId: string, slipId: string) => Promise<QueueResult>;
|
||||
};
|
||||
|
||||
export type PaymentDependencies = {
|
||||
transaction: <T>(
|
||||
run: (repository: PaymentRepository) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
calculatePrice: (service: string[]) => Promise<number>;
|
||||
checkSlip: (
|
||||
buffer: Buffer<ArrayBuffer>,
|
||||
type: string,
|
||||
price: number,
|
||||
) => Promise<SlipResult>;
|
||||
isDuplicateSlipError: (error: unknown) => boolean;
|
||||
};
|
||||
|
||||
export async function paySubmission(
|
||||
input: { id: string; slip: Buffer<ArrayBuffer>; type: string },
|
||||
actor: RubgramActor,
|
||||
dependencies: PaymentDependencies,
|
||||
): Promise<QueueResult | string> {
|
||||
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<T extends { id: string; queue: number }> = {
|
||||
findExpired: (now: Date) => Promise<T[]>;
|
||||
archive: (submissions: T[]) => Promise<void>;
|
||||
remove: (ids: string[]) => Promise<void>;
|
||||
shiftAfter: (queue: number) => Promise<void>;
|
||||
getMaxQueue: () => Promise<number | null>;
|
||||
setNextQueue: (next: number) => Promise<void>;
|
||||
};
|
||||
|
||||
export async function compactExpiredSubmissions<
|
||||
T extends { id: string; queue: number },
|
||||
>(repository: ExpirationRepository<T>, 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<void>;
|
||||
},
|
||||
) {
|
||||
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<Note[]>;
|
||||
saveNotes: (id: string, notes: Note[]) => Promise<void>;
|
||||
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<Note[]>;
|
||||
saveNotes: (id: string, notes: Note[]) => Promise<void>;
|
||||
},
|
||||
) {
|
||||
await requireAdmin(actor);
|
||||
const notes = await dependencies.getNotes(id);
|
||||
await dependencies.saveNotes(
|
||||
id,
|
||||
notes.filter((note) => note.id !== noteId),
|
||||
);
|
||||
}
|
||||
|
||||
export async function runAdminOperation<T>(
|
||||
actor: RubgramActor,
|
||||
operation: () => Promise<T>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user