feat : limit commis

This commit is contained in:
2026-10-01 21:32:59 +07:00 Unverified
parent 2517982a27
commit 9fc12c1874
13 changed files with 4129 additions and 6 deletions
+43
View File
@@ -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) });
});
});
+24
View File
@@ -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 ไม่สำเร็จ" };
}
}
+6 -2
View File
@@ -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 <div className="min-h-svh"><AdminHeader /><CommissionLiveRefresh endpoint="/api/commission/events?scope=admin" />
<main className="mx-auto flex max-w-5xl flex-col gap-5 p-4 py-10 sm:p-8">
<h1 className="font-heading text-3xl font-semibold">Commission tickets</h1>
<AdminCommissionAvailabilityControl key={`${availability.isOpen}:${availability.closureMessage}`}
{...availability} />
{tickets.length === 0 && <p>ยังไม่มี Ticket</p>}
{tickets.slice(0, 50).map(({ ticket, amount, customer }) => <Link key={ticket.id} href={`/admin/commission/${ticket.id}`}>
<Card><CardHeader><CardTitle>{ticket.title || `#${ticket.id.slice(0, 8)}`} - {customer}</CardTitle>
@@ -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();
});
});
+12 -4
View File
@@ -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); }
}
+9
View File
@@ -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 <div className="flex flex-col gap-4 rounded-xl border p-6">
<h2 className="font-heading text-xl font-semibold">ปิดรับ Commission ชั่วคราว</h2>
<p className="whitespace-pre-wrap">{availability.closureMessage}</p>
<Button variant="outline" nativeButton={false} render={<Link href="/commission/tickets" />}>
ดู Ticket และคำขอที่มีอยู่
</Button>
</div>;
const session = await getCustomerSession();
if (!session) return <div className="flex flex-col gap-4 rounded-xl border p-6">
<p>เข้าสู่ระบบหรือสมัครสมาชิกก่อนเริ่มคำขอ</p>
@@ -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 <Card><CardHeader><div className="flex items-center gap-3">
<CardTitle>การรับ Commission</CardTitle><Badge variant={isOpen ? "default" : "secondary"}>
{isOpen ? "เปิดรับ" : "ปิดรับ"}</Badge></div>
<CardDescription>การปิดรับจะหยุดคำขอใหม่ ลูกค้าที่มีคำขออยู่แล้วชำระเงินต่อได้</CardDescription>
</CardHeader><CardContent className="flex flex-col gap-3">
<label htmlFor="commission-closure-message" className="text-sm font-medium">ข้อความเมื่อปิดรับ</label>
<Textarea id="commission-closure-message" value={message} maxLength={1000}
onChange={(event) => setMessage(event.target.value)} disabled={pending}
placeholder="แจ้งเหตุผลที่ปิดรับและข้อมูลที่ลูกค้าควรทราบ" />
<div className="flex flex-wrap gap-2">
<Button type="button" disabled={pending || !message.trim()} onClick={() => save(false)}>
{isOpen ? "ปิดรับ Commission" : "บันทึกข้อความ"}
</Button>
{!isOpen && <Button type="button" variant="outline" disabled={pending} onClick={() => save(true)}>
เปิดรับ Commission
</Button>}
</div>
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
</CardContent></Card>;
}
+1
View File
@@ -47,6 +47,7 @@ function checkoutError(code: string | undefined, status: number): string {
code === "COMMISSION_PROMPTPAY_VALUE-not-configured")
return "ระบบยังตั้งค่าบัญชี PromptPay ไม่ถูกต้อง กรุณาแจ้งผู้ดูแลระบบ";
if (code === "invalid-commission-request") return "รายการที่เลือกไม่ตรงกับข้อมูลล่าสุด กรุณาโหลดหน้าใหม่แล้วเลือกอีกครั้ง";
if (code === "commission-closed") return "ขณะนี้ปิดรับ Commission ใหม่ กรุณาโหลดหน้าเพื่อดูประกาศ";
return "สร้างคำขอไม่สำเร็จ กรุณาลองอีกครั้ง";
}
+7
View File
@@ -162,6 +162,13 @@ export const commissionCheckouts = commissionSchema.table("checkout", {
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => [index("commission_checkout_user_idx").on(table.userId, table.createdAt)]);
export const commissionSettings = commissionSchema.table("settings", {
id: integer("id").primaryKey(),
isOpen: boolean("is_open").default(true).notNull(),
closureMessage: text("closure_message").default("").notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const commissionTickets = commissionSchema.table("ticket", {
id: uuid("id").defaultRandom().primaryKey(),
checkoutId: uuid("checkout_id").notNull().references(() => commissionCheckouts.id),
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE "commission"."settings" (
"id" integer PRIMARY KEY NOT NULL,
"is_open" boolean DEFAULT true NOT NULL,
"closure_message" text DEFAULT '' NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
INSERT INTO "commission"."settings" ("id", "is_open", "closure_message") VALUES (1, true, '');
File diff suppressed because it is too large Load Diff
+7
View File
@@ -470,6 +470,13 @@
"when": 1790849456239,
"tag": "0066_burly_william_stryker",
"breakpoints": true
},
{
"idx": 67,
"version": "7",
"when": 1790863872049,
"tag": "0067_nervous_psylocke",
"breakpoints": true
}
]
}
+14
View File
@@ -0,0 +1,14 @@
import "server-only";
import { eq } from "drizzle-orm";
import { getDb } from "@/db";
import { commissionSettings } from "@/db/schema";
export async function getCommissionAvailability() {
const [settings] = await getDb().select({
isOpen: commissionSettings.isOpen,
closureMessage: commissionSettings.closureMessage,
}).from(commissionSettings).where(eq(commissionSettings.id, 1)).limit(1);
if (!settings) throw new Error("Commission settings are missing. Apply the database migration.");
return settings;
}