From 9fc12c187453481a794c7673836b2a65fa842631 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 1 Oct 2026 21:32:59 +0700 Subject: [PATCH] feat : limit commis --- app/admin/commission/actions.test.ts | 43 + app/admin/commission/actions.ts | 24 + app/admin/commission/page.tsx | 8 +- app/api/commission/checkouts/route.test.ts | 59 + app/api/commission/checkouts/route.ts | 16 +- app/commission/page.tsx | 9 + .../commission/admin-availability-control.tsx | 53 + components/commission/request-form.tsx | 1 + db/schema.ts | 7 + drizzle/0067_nervous_psylocke.sql | 8 + drizzle/meta/0067_snapshot.json | 3886 +++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/commission/availability.ts | 14 + 13 files changed, 4129 insertions(+), 6 deletions(-) create mode 100644 app/admin/commission/actions.test.ts create mode 100644 app/admin/commission/actions.ts create mode 100644 app/api/commission/checkouts/route.test.ts create mode 100644 components/commission/admin-availability-control.tsx create mode 100644 drizzle/0067_nervous_psylocke.sql create mode 100644 drizzle/meta/0067_snapshot.json create mode 100644 lib/commission/availability.ts diff --git a/app/admin/commission/actions.test.ts b/app/admin/commission/actions.test.ts new file mode 100644 index 0000000..9329c0b --- /dev/null +++ b/app/admin/commission/actions.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { HttpError } from "@/lib/security/http"; + +const requireAdmin = vi.fn(); +const update = vi.fn(); +const set = vi.fn(); +const where = vi.fn(); +const returning = vi.fn(); + +vi.mock("@/lib/auth/server", () => ({ requireAdmin })); +vi.mock("@/db", () => ({ getDb: () => ({ update }) })); + +const { setCommissionAvailability } = await import("./actions"); + +describe("admin commission availability", () => { + beforeEach(() => { + vi.clearAllMocks(); + requireAdmin.mockResolvedValue({ user: { id: "admin-1" } }); + update.mockReturnValue({ set }); + set.mockReturnValue({ where }); + where.mockReturnValue({ returning }); + returning.mockResolvedValue([{ isOpen: false }]); + }); + + it("requires admin authorization", async () => { + requireAdmin.mockRejectedValueOnce(new HttpError(401, "unauthorized")); + await expect(setCommissionAvailability(false, "Closed today")).rejects.toMatchObject({ status: 401 }); + expect(update).not.toHaveBeenCalled(); + }); + + it("requires a closure message and saves the trimmed text", async () => { + expect(await setCommissionAvailability(false, " ")).toMatchObject({ status: "error" }); + expect(update).not.toHaveBeenCalled(); + expect(await setCommissionAvailability(false, " Closed today ")).toEqual({ status: "saved" }); + expect(set).toHaveBeenCalledWith({ isOpen: false, closureMessage: "Closed today", + updatedAt: expect.any(Date) }); + }); + + it("reopens without clearing the saved explanation", async () => { + expect(await setCommissionAvailability(true, "Previous reason")).toEqual({ status: "saved" }); + expect(set).toHaveBeenCalledWith({ isOpen: true, updatedAt: expect.any(Date) }); + }); +}); diff --git a/app/admin/commission/actions.ts b/app/admin/commission/actions.ts new file mode 100644 index 0000000..f3f3f4f --- /dev/null +++ b/app/admin/commission/actions.ts @@ -0,0 +1,24 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { getDb } from "@/db"; +import { commissionSettings } from "@/db/schema"; +import { requireAdmin } from "@/lib/auth/server"; + +export async function setCommissionAvailability(isOpen: boolean, message: string) { + await requireAdmin(); + if (typeof isOpen !== "boolean" || typeof message !== "string") + return { status: "error" as const, message: "ข้อมูลไม่ถูกต้อง" }; + const closureMessage = message.trim(); + if (!isOpen && (closureMessage.length < 1 || closureMessage.length > 1000)) + return { status: "error" as const, message: "กรุณาเขียนเหตุผลที่ปิดรับ Commission (ไม่เกิน 1,000 ตัวอักษร)" }; + try { + const [settings] = await getDb().update(commissionSettings).set({ + isOpen, ...(!isOpen ? { closureMessage } : {}), updatedAt: new Date(), + }).where(eq(commissionSettings.id, 1)).returning({ isOpen: commissionSettings.isOpen }); + if (!settings) return { status: "error" as const, message: "ไม่พบการตั้งค่า Commission" }; + return { status: "saved" as const }; + } catch { + return { status: "error" as const, message: "บันทึกสถานะ Commission ไม่สำเร็จ" }; + } +} diff --git a/app/admin/commission/page.tsx b/app/admin/commission/page.tsx index a2f556d..95c21f2 100644 --- a/app/admin/commission/page.tsx +++ b/app/admin/commission/page.tsx @@ -6,9 +6,11 @@ import { getDb } from "@/db"; import { commissionCheckouts, commissionTickets, users } from "@/db/schema"; import { AdminHeader } from "@/components/admin/admin-header"; import { CommissionLiveRefresh } from "@/components/commission/live-refresh"; +import { AdminCommissionAvailabilityControl } from "@/components/commission/admin-availability-control"; import { Badge } from "@/components/ui/badge"; import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { getAdminSession } from "@/lib/auth/server"; +import { getCommissionAvailability } from "@/lib/commission/availability"; export const instant = false; export default async function AdminCommissionPage({ searchParams }: PageProps<"/admin/commission">) { @@ -17,15 +19,17 @@ export default async function AdminCommissionPage({ searchParams }: PageProps<"/ const query = await searchParams; const rawPage = Number(query.page ?? 1); const page = Number.isInteger(rawPage) && rawPage > 0 ? Math.min(rawPage, 10000) : 1; - const tickets = await getDb().select({ ticket: commissionTickets, amount: commissionCheckouts.amountBaht, + const [tickets, availability] = await Promise.all([getDb().select({ ticket: commissionTickets, amount: commissionCheckouts.amountBaht, customer: users.name }).from(commissionTickets) .innerJoin(commissionCheckouts, eq(commissionTickets.checkoutId, commissionCheckouts.id)) .innerJoin(users, eq(commissionTickets.userId, users.id)) .orderBy(desc(commissionTickets.status), desc(commissionTickets.updatedAt), desc(commissionTickets.id)) - .limit(51).offset((page - 1) * 50); + .limit(51).offset((page - 1) * 50), getCommissionAvailability()]); return

Commission tickets

+ {tickets.length === 0 &&

ยังไม่มี Ticket

} {tickets.slice(0, 50).map(({ ticket, amount, customer }) => {ticket.title || `#${ticket.id.slice(0, 8)}`} - {customer} diff --git a/app/api/commission/checkouts/route.test.ts b/app/api/commission/checkouts/route.test.ts new file mode 100644 index 0000000..f14bd0e --- /dev/null +++ b/app/api/commission/checkouts/route.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const requireCommissionUser = vi.fn(); +const limitRequest = vi.fn(); +const validateCommissionCatalog = vi.fn(); +const promptPayConfig = vi.fn(); +const transaction = vi.fn(); +const forUpdate = vi.fn(); +const returning = vi.fn(); +const insert = vi.fn(); + +vi.mock("@/lib/commission/server", () => ({ requireCommissionUser })); +vi.mock("@/lib/security/rate-limit", () => ({ limitRequest })); +vi.mock("@/lib/commission/catalog", () => ({ validateCommissionCatalog })); +vi.mock("@/lib/commission/payment", () => ({ promptPayConfig })); +vi.mock("@/db", () => ({ getDb: () => ({ transaction }) })); + +const { POST } = await import("./route"); + +function request() { + return new Request("https://guide.sudloh.com/api/commission/checkouts", { + method: "POST", headers: { Origin: "https://guide.sudloh.com", "Content-Type": "application/json" }, + body: JSON.stringify({ teams: [], weapons: [{ characterKey: "c", weaponKeys: ["w1", "w2"] }], + constellations: [] }), + }); +} + +describe("commission checkout availability", () => { + beforeEach(() => { + process.env.BETTER_AUTH_URL = "https://guide.sudloh.com"; + vi.clearAllMocks(); + requireCommissionUser.mockResolvedValue({ id: "customer-1" }); + limitRequest.mockResolvedValue(undefined); + validateCommissionCatalog.mockResolvedValue(true); + promptPayConfig.mockReturnValue({ identifier: "test" }); + transaction.mockImplementation((callback) => callback({ + select: () => ({ from: () => ({ where: () => ({ for: forUpdate }) }) }), + insert, + })); + insert.mockReturnValue({ values: () => ({ returning }) }); + returning.mockResolvedValue([{ id: "checkout-1" }]); + }); + + it("rejects a stale form submission after closure", async () => { + forUpdate.mockResolvedValue([{ isOpen: false }]); + const response = await POST(request()); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: "commission-closed" }); + expect(insert).not.toHaveBeenCalled(); + }); + + it("creates a checkout while open", async () => { + forUpdate.mockResolvedValue([{ isOpen: true }]); + const response = await POST(request()); + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ checkoutId: "checkout-1" }); + expect(insert).toHaveBeenCalledOnce(); + }); +}); diff --git a/app/api/commission/checkouts/route.ts b/app/api/commission/checkouts/route.ts index a61e9e1..bdbd419 100644 --- a/app/api/commission/checkouts/route.ts +++ b/app/api/commission/checkouts/route.ts @@ -1,5 +1,6 @@ import { getDb } from "@/db"; -import { commissionCheckouts } from "@/db/schema"; +import { commissionCheckouts, commissionSettings } from "@/db/schema"; +import { eq } from "drizzle-orm"; import { validateCommissionCatalog } from "@/lib/commission/catalog"; import { promptPayConfig } from "@/lib/commission/payment"; import { commissionPrice, commissionRequestSchema } from "@/lib/commission/request"; @@ -16,9 +17,16 @@ export async function POST(request: Request) { if (!parsed.success || !(await validateCommissionCatalog(parsed.data))) throw new HttpError(400, "invalid-commission-request"); promptPayConfig(); - const [checkout] = await getDb().insert(commissionCheckouts).values({ - userId: user.id, request: parsed.data, amountBaht: commissionPrice(parsed.data), - }).returning({ id: commissionCheckouts.id }); + const checkout = await getDb().transaction(async (tx) => { + const [settings] = await tx.select({ isOpen: commissionSettings.isOpen }).from(commissionSettings) + .where(eq(commissionSettings.id, 1)).for("update"); + if (!settings) throw new Error("Commission settings are missing. Apply the database migration."); + if (!settings.isOpen) throw new HttpError(409, "commission-closed"); + const [created] = await tx.insert(commissionCheckouts).values({ + userId: user.id, request: parsed.data, amountBaht: commissionPrice(parsed.data), + }).returning({ id: commissionCheckouts.id }); + return created; + }); return Response.json({ checkoutId: checkout.id }, { status: 201, headers: { "Cache-Control": "no-store" } }); } catch (cause) { return errorResponse(cause); } } diff --git a/app/commission/page.tsx b/app/commission/page.tsx index db391fa..02d1167 100644 --- a/app/commission/page.tsx +++ b/app/commission/page.tsx @@ -7,11 +7,20 @@ import { CommissionSignOut } from "@/components/commission/sign-out"; import { Button } from "@/components/ui/button"; import { getCustomerSession } from "@/lib/auth/server"; import { getCatalogOptions } from "@/lib/guides/queries"; +import { getCommissionAvailability } from "@/lib/commission/availability"; export const instant = false; async function CommissionContent() { await connection(); + const availability = await getCommissionAvailability(); + if (!availability.isOpen) return
+

ปิดรับ Commission ชั่วคราว

+

{availability.closureMessage}

+ +
; const session = await getCustomerSession(); if (!session) return

เข้าสู่ระบบหรือสมัครสมาชิกก่อนเริ่มคำขอ

diff --git a/components/commission/admin-availability-control.tsx b/components/commission/admin-availability-control.tsx new file mode 100644 index 0000000..1c8f427 --- /dev/null +++ b/components/commission/admin-availability-control.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { setCommissionAvailability } from "@/app/admin/commission/actions"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; + +export function AdminCommissionAvailabilityControl({ isOpen, closureMessage }: { + isOpen: boolean; closureMessage: string; +}) { + const router = useRouter(); + const [message, setMessage] = useState(closureMessage); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(""); + + function save(nextOpen: boolean) { + setError(""); + startTransition(async () => { + const result = await setCommissionAvailability(nextOpen, message); + if (result.status === "error") { + setError(result.message); + return; + } + toast.success(nextOpen ? "เปิดรับ Commission แล้ว" : "ปิดรับ Commission แล้ว"); + router.refresh(); + }); + } + + return
+ การรับ Commission + {isOpen ? "เปิดรับ" : "ปิดรับ"}
+ การปิดรับจะหยุดคำขอใหม่ ลูกค้าที่มีคำขออยู่แล้วชำระเงินต่อได้ +
+ +