feat : limit commis
This commit is contained in:
@@ -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) });
|
||||
});
|
||||
});
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user