feat : big update
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { AdminHeader } from "@/components/admin/admin-header";
|
||||
import { CommissionTicketDetail } from "@/components/commission/ticket-detail";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
|
||||
export const instant = false;
|
||||
export default async function AdminCommissionTicketPage({ params }: PageProps<"/admin/commission/[id]">) {
|
||||
await connection();
|
||||
if (!(await getAdminSession())) notFound();
|
||||
const { id } = await params;
|
||||
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex max-w-6xl flex-col gap-6 p-4 py-10 sm:p-8">
|
||||
<Link href="/admin/commission" className="text-sm underline">กลับไปหน้า Ticket ทั้งหมด</Link>
|
||||
<CommissionTicketDetail id={id} admin />
|
||||
</main></div>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from "next/link";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
|
||||
export const instant = false;
|
||||
export default async function AdminCommissionPage({ searchParams }: PageProps<"/admin/commission">) {
|
||||
await connection();
|
||||
if (!(await getAdminSession())) redirect("/admin/login");
|
||||
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,
|
||||
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);
|
||||
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>
|
||||
{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>
|
||||
<CardAction><Badge variant={ticket.status === "open" ? "outline" : "destructive"}
|
||||
className={ticket.status === "open" ? "border-success/30 bg-success/10 text-success" : undefined}>
|
||||
{ticket.status === "open" ? "เปิดอยู่" : "ปิดแล้ว"}
|
||||
</Badge></CardAction></CardHeader>
|
||||
<CardContent>฿{amount} - {ticket.updatedAt.toLocaleString("th-TH")}</CardContent></Card>
|
||||
</Link>)}
|
||||
<nav className="flex gap-4 text-sm" aria-label="Ticket pages">
|
||||
{page > 1 && <Link href={`/admin/commission?page=${page - 1}`} className="underline">ก่อนหน้า</Link>}
|
||||
{tickets.length > 50 && <Link href={`/admin/commission?page=${page + 1}`} className="underline">ถัดไป</Link>}
|
||||
</nav>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionCheckouts, commissionPayments, commissionTickets } from "@/db/schema";
|
||||
import { notifyCommission, requireCommissionUser } from "@/lib/commission/server";
|
||||
import { notifyPaidTicketDiscord } from "@/lib/commission/discord";
|
||||
import { verifyCommissionSlip } from "@/lib/commission/payment";
|
||||
import { getMediaStorage } from "@/lib/media/storage";
|
||||
import { inspectImage } from "@/lib/media/inspect";
|
||||
import { boundedBody, errorResponse, HttpError, requireSameOrigin, withUploadSlot } from "@/lib/security/http";
|
||||
import { limitRequest } from "@/lib/security/rate-limit";
|
||||
|
||||
const MAX_SLIP_BYTES = 6 * 1024 * 1024;
|
||||
|
||||
export async function POST(request: Request, context: RouteContext<"/api/commission/checkouts/[id]/verify">) {
|
||||
try {
|
||||
requireSameOrigin(request);
|
||||
const user = await requireCommissionUser();
|
||||
await limitRequest("commission-slip", user.id, 10);
|
||||
const { id } = await context.params;
|
||||
const [checkout] = await getDb().select().from(commissionCheckouts)
|
||||
.where(and(eq(commissionCheckouts.id, id), eq(commissionCheckouts.userId, user.id))).limit(1);
|
||||
if (!checkout) throw new HttpError(404, "checkout-not-found");
|
||||
const [existing] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
|
||||
.where(eq(commissionTickets.checkoutId, id)).limit(1);
|
||||
if (existing) return Response.json({ ticketId: existing.id });
|
||||
if (!request.headers.get("content-type")?.startsWith("multipart/form-data;"))
|
||||
throw new HttpError(415, "expected-multipart");
|
||||
return await withUploadSlot(async () => {
|
||||
const form = await boundedBody(request, MAX_SLIP_BYTES + 64 * 1024).formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File) || file.size === 0 || file.size > MAX_SLIP_BYTES ||
|
||||
!["image/png", "image/jpeg", "image/webp"].includes(file.type))
|
||||
throw new HttpError(415, "invalid-slip-image");
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
await inspectImage(bytes, file.type as "image/png" | "image/jpeg" | "image/webp");
|
||||
const verified = await verifyCommissionSlip(file, checkout.amountBaht, checkout.createdAt);
|
||||
const objectKey = `commission/slips/${crypto.randomUUID()}`;
|
||||
const storage = await getMediaStorage();
|
||||
await storage.write(objectKey, bytes, { type: file.type, acl: "private" });
|
||||
let ticketId: string;
|
||||
try {
|
||||
ticketId = await getDb().transaction(async (tx) => {
|
||||
const [ticket] = await tx.insert(commissionTickets).values({
|
||||
checkoutId: checkout.id, userId: user.id,
|
||||
}).returning({ id: commissionTickets.id });
|
||||
await tx.insert(commissionPayments).values({
|
||||
checkoutId: checkout.id, ticketId: ticket.id, slipObjectKey: objectKey,
|
||||
slipMimeType: file.type, ...verified,
|
||||
});
|
||||
return ticket.id;
|
||||
});
|
||||
} catch (cause) {
|
||||
await storage.delete(objectKey).catch(() => undefined);
|
||||
const [paid] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
|
||||
.where(eq(commissionTickets.checkoutId, checkout.id)).limit(1);
|
||||
if (paid) return Response.json({ ticketId: paid.id });
|
||||
if (cause && typeof cause === "object" && "code" in cause && cause.code === "23505")
|
||||
throw new HttpError(409, "payment-already-used");
|
||||
throw cause;
|
||||
}
|
||||
await notifyCommission(ticketId, user.id);
|
||||
await notifyPaidTicketDiscord(ticketId, checkout.amountBaht);
|
||||
return Response.json({ ticketId }, { status: 201, headers: { "Cache-Control": "no-store" } });
|
||||
});
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getDb } from "@/db";
|
||||
import { commissionCheckouts } from "@/db/schema";
|
||||
import { validateCommissionCatalog } from "@/lib/commission/catalog";
|
||||
import { promptPayConfig } from "@/lib/commission/payment";
|
||||
import { commissionPrice, commissionRequestSchema } from "@/lib/commission/request";
|
||||
import { requireCommissionUser } from "@/lib/commission/server";
|
||||
import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http";
|
||||
import { limitRequest } from "@/lib/security/rate-limit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
requireSameOrigin(request);
|
||||
const user = await requireCommissionUser();
|
||||
await limitRequest("commission-checkout", user.id, 15);
|
||||
const parsed = commissionRequestSchema.safeParse(await readJson(request, 32 * 1024));
|
||||
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 });
|
||||
return Response.json({ checkoutId: checkout.id }, { status: 201, headers: { "Cache-Control": "no-store" } });
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createRedisNamedEventResponse } from "@/lib/events/redis-stream";
|
||||
import { authorizeTicket } from "@/lib/commission/tickets";
|
||||
import { requireCommissionUser } from "@/lib/commission/server";
|
||||
import { errorResponse, HttpError } from "@/lib/security/http";
|
||||
import { limitRequest, trustedClientAddress } from "@/lib/security/rate-limit";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const user = await requireCommissionUser();
|
||||
await limitRequest("stream-open", trustedClientAddress(request.headers), 60);
|
||||
const url = new URL(request.url);
|
||||
const ticketId = url.searchParams.get("ticketId");
|
||||
const scope = url.searchParams.get("scope");
|
||||
let topic: string;
|
||||
if (ticketId) {
|
||||
if (!/^[a-f0-9-]{36}$/i.test(ticketId)) throw new HttpError(400, "invalid-ticket-id");
|
||||
await authorizeTicket(ticketId);
|
||||
topic = `commission:ticket:${ticketId}`;
|
||||
} else if (scope === "admin") {
|
||||
if (user.role !== "admin" || !user.emailVerified) throw new HttpError(403, "forbidden");
|
||||
topic = "commission:admin";
|
||||
} else topic = `commission:user:${user.id}`;
|
||||
return await createRedisNamedEventResponse(topic, "changed", request.signal);
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionMessages } from "@/db/schema";
|
||||
import { authorizeTicket } from "@/lib/commission/tickets";
|
||||
import { getMediaStorage } from "@/lib/media/storage";
|
||||
import { errorResponse, HttpError } from "@/lib/security/http";
|
||||
|
||||
export async function GET(_request: Request,
|
||||
context: RouteContext<"/api/commission/tickets/[id]/images/[messageId]">) {
|
||||
try {
|
||||
const { id, messageId } = await context.params;
|
||||
await authorizeTicket(id);
|
||||
const [message] = await getDb().select({ key: commissionMessages.imageObjectKey,
|
||||
type: commissionMessages.imageMimeType }).from(commissionMessages)
|
||||
.where(and(eq(commissionMessages.id, messageId), eq(commissionMessages.ticketId, id))).limit(1);
|
||||
if (!message?.key || !message.type) throw new HttpError(404, "image-not-found");
|
||||
const file = (await getMediaStorage()).file(message.key);
|
||||
return new Response(await file.bytes(), { headers: { "Content-Type": message.type,
|
||||
"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff" } });
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const authorizeTicket = vi.fn();
|
||||
const notifyCommission = vi.fn();
|
||||
const deleteObject = vi.fn();
|
||||
const deleteReturning = vi.fn();
|
||||
const deleteWhere = vi.fn();
|
||||
const updateWhere = vi.fn();
|
||||
|
||||
const tx = {
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
update: vi.fn(() => ({ set: () => ({ where: updateWhere }) })),
|
||||
};
|
||||
|
||||
vi.mock("@/db", () => ({ getDb: () => ({ transaction: async (run: (value: typeof tx) => Promise<unknown>) => run(tx) }) }));
|
||||
vi.mock("@/lib/commission/tickets", () => ({ authorizeTicket }));
|
||||
vi.mock("@/lib/commission/server", () => ({ notifyCommission }));
|
||||
vi.mock("@/lib/media/storage", () => ({ getMediaStorage: async () => ({ delete: deleteObject }) }));
|
||||
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: async () => undefined }));
|
||||
|
||||
const { DELETE } = await import("./route");
|
||||
const ticketId = "11111111-1111-4111-8111-111111111111";
|
||||
const messageId = "22222222-2222-4222-8222-222222222222";
|
||||
const ownerId = "owner-1";
|
||||
|
||||
function deletionRequest(origin = "https://guide.sudloh.com") {
|
||||
return new Request(`https://guide.sudloh.com/api/commission/tickets/${ticketId}/messages/${messageId}`,
|
||||
{ method: "DELETE", headers: { Origin: origin } });
|
||||
}
|
||||
|
||||
function context() {
|
||||
return { params: Promise.resolve({ id: ticketId, messageId }) } as RouteContext<
|
||||
"/api/commission/tickets/[id]/messages/[messageId]">;
|
||||
}
|
||||
|
||||
describe("commission message deletion", () => {
|
||||
beforeEach(() => {
|
||||
process.env.BETTER_AUTH_URL = "https://guide.sudloh.com";
|
||||
vi.clearAllMocks();
|
||||
authorizeTicket.mockResolvedValue({ ticket: { userId: ownerId }, user: { id: ownerId } });
|
||||
deleteWhere.mockImplementation(() => ({ returning: deleteReturning }));
|
||||
deleteReturning.mockResolvedValue([{ imageObjectKey: "commission/messages/image-1" }]);
|
||||
updateWhere.mockResolvedValue(undefined);
|
||||
deleteObject.mockResolvedValue(undefined);
|
||||
notifyCommission.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("rejects cross-origin requests before ticket authorization", async () => {
|
||||
const response = await DELETE(deletionRequest("https://example.com"), context());
|
||||
expect(response.status).toBe(403);
|
||||
expect(authorizeTicket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes only a message owned by the current user in this ticket", async () => {
|
||||
const response = await DELETE(deletionRequest(), context());
|
||||
expect(response.status).toBe(204);
|
||||
const condition = deleteWhere.mock.calls[0][0];
|
||||
const query = new PgDialect().sqlToQuery(condition);
|
||||
expect(query.sql).toContain('"ticket_id"');
|
||||
expect(query.sql).toContain('"author_id"');
|
||||
expect(query.params).toContain(ticketId);
|
||||
expect(query.params).toContain(messageId);
|
||||
expect(query.params).toContain(ownerId);
|
||||
expect(deleteObject).toHaveBeenCalledWith("commission/messages/image-1");
|
||||
expect(notifyCommission).toHaveBeenCalledWith(ticketId, ownerId);
|
||||
});
|
||||
|
||||
it("leaves storage and notifications untouched when no owned message exists", async () => {
|
||||
deleteReturning.mockResolvedValue([]);
|
||||
const response = await DELETE(deletionRequest(), context());
|
||||
expect(response.status).toBe(404);
|
||||
expect(deleteObject).not.toHaveBeenCalled();
|
||||
expect(notifyCommission).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as z from "zod";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionMessages, commissionTickets } from "@/db/schema";
|
||||
import { notifyCommission } from "@/lib/commission/server";
|
||||
import { authorizeTicket } from "@/lib/commission/tickets";
|
||||
import { getMediaStorage } from "@/lib/media/storage";
|
||||
import { errorResponse, HttpError, requireSameOrigin } from "@/lib/security/http";
|
||||
import { limitRequest } from "@/lib/security/rate-limit";
|
||||
|
||||
export async function DELETE(request: Request,
|
||||
context: RouteContext<"/api/commission/tickets/[id]/messages/[messageId]">) {
|
||||
try {
|
||||
requireSameOrigin(request);
|
||||
const { id, messageId } = await context.params;
|
||||
if (!z.uuid().safeParse(messageId).success) throw new HttpError(404, "message-not-found");
|
||||
const { ticket, user } = await authorizeTicket(id);
|
||||
await limitRequest("commission-message-delete", user.id, 30);
|
||||
const imageObjectKey = await getDb().transaction(async (tx) => {
|
||||
const [message] = await tx.delete(commissionMessages)
|
||||
.where(and(eq(commissionMessages.id, messageId), eq(commissionMessages.ticketId, id),
|
||||
eq(commissionMessages.authorId, user.id)))
|
||||
.returning({ imageObjectKey: commissionMessages.imageObjectKey });
|
||||
if (!message) throw new HttpError(404, "message-not-found");
|
||||
await tx.update(commissionTickets).set({ updatedAt: new Date() }).where(eq(commissionTickets.id, id));
|
||||
return message.imageObjectKey;
|
||||
});
|
||||
if (imageObjectKey) await (await getMediaStorage()).delete(imageObjectKey).catch(() => undefined);
|
||||
await notifyCommission(id, ticket.userId);
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionMessages, commissionTickets } from "@/db/schema";
|
||||
import { notifyCommission } from "@/lib/commission/server";
|
||||
import { authorizeTicket } from "@/lib/commission/tickets";
|
||||
import { getMediaStorage } from "@/lib/media/storage";
|
||||
import { inspectImage } from "@/lib/media/inspect";
|
||||
import { boundedBody, errorResponse, HttpError, requireSameOrigin, withUploadSlot } from "@/lib/security/http";
|
||||
import { limitRequest } from "@/lib/security/rate-limit";
|
||||
|
||||
export async function POST(request: Request, context: RouteContext<"/api/commission/tickets/[id]/messages">) {
|
||||
try {
|
||||
requireSameOrigin(request);
|
||||
const { id } = await context.params;
|
||||
const { ticket, user } = await authorizeTicket(id);
|
||||
if (ticket.status !== "open") throw new HttpError(409, "ticket-closed");
|
||||
await limitRequest("commission-message", user.id, 60);
|
||||
if (!request.headers.get("content-type")?.startsWith("multipart/form-data;"))
|
||||
throw new HttpError(415, "expected-multipart");
|
||||
return await withUploadSlot(async () => {
|
||||
const form = await boundedBody(request, 6 * 1024 * 1024 + 64 * 1024).formData();
|
||||
const value = form.get("text");
|
||||
const body = typeof value === "string" ? value.trim() : "";
|
||||
const image = form.get("image");
|
||||
if (body.length > 4000 || (image && !(image instanceof File)))
|
||||
throw new HttpError(400, "invalid-message");
|
||||
if (!body && !image) throw new HttpError(400, "empty-message");
|
||||
let imageObjectKey: string | null = null;
|
||||
if (image instanceof File) {
|
||||
if (image.size === 0 || image.size > 6 * 1024 * 1024 ||
|
||||
!["image/png", "image/jpeg", "image/webp"].includes(image.type))
|
||||
throw new HttpError(415, "invalid-message-image");
|
||||
const bytes = new Uint8Array(await image.arrayBuffer());
|
||||
await inspectImage(bytes, image.type as "image/png" | "image/jpeg" | "image/webp");
|
||||
imageObjectKey = `commission/messages/${crypto.randomUUID()}`;
|
||||
await (await getMediaStorage()).write(imageObjectKey, bytes, { type: image.type, acl: "private" });
|
||||
}
|
||||
let messageId: string;
|
||||
try {
|
||||
messageId = await getDb().transaction(async (tx) => {
|
||||
const [message] = await tx.insert(commissionMessages).values({ ticketId: id, authorId: user.id,
|
||||
text: body || null, imageObjectKey, imageMimeType: image instanceof File ? image.type : null })
|
||||
.returning({ id: commissionMessages.id });
|
||||
await tx.update(commissionTickets).set({ updatedAt: new Date() }).where(eq(commissionTickets.id, id));
|
||||
return message.id;
|
||||
});
|
||||
} catch (cause) {
|
||||
if (imageObjectKey) await (await getMediaStorage()).delete(imageObjectKey).catch(() => undefined);
|
||||
throw cause;
|
||||
}
|
||||
await notifyCommission(id, ticket.userId);
|
||||
return Response.json({ ok: true, messageId }, { status: 201 });
|
||||
});
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionMessages, commissionReactions } from "@/db/schema";
|
||||
import { notifyCommission } from "@/lib/commission/server";
|
||||
import { authorizeTicket } from "@/lib/commission/tickets";
|
||||
import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http";
|
||||
import { limitRequest } from "@/lib/security/rate-limit";
|
||||
import * as z from "zod";
|
||||
|
||||
const inputSchema = z.object({ messageId: z.uuid(), emoji: z.enum(["👍", "❤️", "😂", "😮", "😢", "🎉"]) });
|
||||
|
||||
export async function POST(request: Request, context: RouteContext<"/api/commission/tickets/[id]/reactions">) {
|
||||
try {
|
||||
requireSameOrigin(request);
|
||||
const { id } = await context.params;
|
||||
const { ticket, user } = await authorizeTicket(id);
|
||||
if (ticket.status !== "open") throw new HttpError(409, "ticket-closed");
|
||||
await limitRequest("commission-reaction", user.id, 60);
|
||||
const parsed = inputSchema.safeParse(await readJson(request));
|
||||
if (!parsed.success) throw new HttpError(400, "invalid-reaction");
|
||||
const [message] = await getDb().select({ id: commissionMessages.id }).from(commissionMessages)
|
||||
.where(and(eq(commissionMessages.id, parsed.data.messageId), eq(commissionMessages.ticketId, id))).limit(1);
|
||||
if (!message) throw new HttpError(404, "message-not-found");
|
||||
const where = and(eq(commissionReactions.messageId, message.id),
|
||||
eq(commissionReactions.userId, user.id), eq(commissionReactions.emoji, parsed.data.emoji));
|
||||
const [existing] = await getDb().select().from(commissionReactions).where(where).limit(1);
|
||||
if (existing) await getDb().delete(commissionReactions).where(where);
|
||||
else await getDb().insert(commissionReactions).values({
|
||||
messageId: message.id, userId: user.id, emoji: parsed.data.emoji,
|
||||
}).onConflictDoNothing();
|
||||
await notifyCommission(id, ticket.userId);
|
||||
return Response.json({ active: !existing });
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionPayments } from "@/db/schema";
|
||||
import { authorizeTicket } from "@/lib/commission/tickets";
|
||||
import { getMediaStorage } from "@/lib/media/storage";
|
||||
import { errorResponse, HttpError } from "@/lib/security/http";
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext<"/api/commission/tickets/[id]/slip">) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
await authorizeTicket(id);
|
||||
const [payment] = await getDb().select({ key: commissionPayments.slipObjectKey,
|
||||
type: commissionPayments.slipMimeType }).from(commissionPayments)
|
||||
.where(eq(commissionPayments.ticketId, id)).limit(1);
|
||||
if (!payment) throw new HttpError(404, "slip-not-found");
|
||||
const file = (await getMediaStorage()).file(payment.key);
|
||||
return new Response(await file.bytes(), { headers: { "Content-Type": payment.type,
|
||||
"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff" } });
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as z from "zod";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionTickets } from "@/db/schema";
|
||||
import { requireAdmin } from "@/lib/auth/server";
|
||||
import { notifyCommission } from "@/lib/commission/server";
|
||||
import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http";
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext<"/api/commission/tickets/[id]/status">) {
|
||||
try {
|
||||
requireSameOrigin(request);
|
||||
await requireAdmin();
|
||||
const { id } = await context.params;
|
||||
const parsed = z.object({ status: z.enum(["open", "closed"]) }).safeParse(await readJson(request));
|
||||
if (!parsed.success) throw new HttpError(400, "invalid-status");
|
||||
const [ticket] = await getDb().update(commissionTickets)
|
||||
.set({ status: parsed.data.status, updatedAt: new Date() })
|
||||
.where(eq(commissionTickets.id, id)).returning({ userId: commissionTickets.userId });
|
||||
if (!ticket) throw new HttpError(404, "ticket-not-found");
|
||||
await notifyCommission(id, ticket.userId);
|
||||
return Response.json({ status: parsed.data.status });
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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();
|
||||
const notifyCommission = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/server", () => ({ requireAdmin }));
|
||||
vi.mock("@/db", () => ({ getDb: () => ({ update }) }));
|
||||
vi.mock("@/lib/commission/server", () => ({ notifyCommission }));
|
||||
|
||||
const { PATCH } = await import("./route");
|
||||
const ticketId = "11111111-1111-4111-8111-111111111111";
|
||||
const userId = "customer-1";
|
||||
|
||||
function request(title: unknown, origin = "https://guide.sudloh.com") {
|
||||
return new Request(`https://guide.sudloh.com/api/commission/tickets/${ticketId}/title`, {
|
||||
method: "PATCH", headers: { Origin: origin, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
}
|
||||
|
||||
function context() {
|
||||
return { params: Promise.resolve({ id: ticketId }) } as RouteContext<"/api/commission/tickets/[id]/title">;
|
||||
}
|
||||
|
||||
describe("commission ticket rename", () => {
|
||||
beforeEach(() => {
|
||||
process.env.BETTER_AUTH_URL = "https://guide.sudloh.com";
|
||||
vi.clearAllMocks();
|
||||
requireAdmin.mockResolvedValue({ user: { id: "admin-1" } });
|
||||
update.mockReturnValue({ set });
|
||||
set.mockReturnValue({ where });
|
||||
where.mockReturnValue({ returning });
|
||||
returning.mockResolvedValue([{ title: "New name", userId }]);
|
||||
notifyCommission.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("rejects cross-origin and non-admin requests", async () => {
|
||||
expect((await PATCH(request("New name", "https://example.com"), context())).status).toBe(403);
|
||||
expect(requireAdmin).not.toHaveBeenCalled();
|
||||
requireAdmin.mockRejectedValueOnce(new HttpError(401, "unauthorized"));
|
||||
expect((await PATCH(request("New name"), context())).status).toBe(401);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes and saves a name, then notifies ticket viewers", async () => {
|
||||
const response = await PATCH(request(" New name "), context());
|
||||
expect(response.status).toBe(200);
|
||||
expect(set).toHaveBeenCalledWith({ title: "New name", updatedAt: expect.any(Date) });
|
||||
expect(notifyCommission).toHaveBeenCalledWith(ticketId, userId);
|
||||
});
|
||||
|
||||
it("allows clearing the name and rejects names over 80 characters", async () => {
|
||||
returning.mockResolvedValueOnce([{ title: null, userId }]);
|
||||
expect((await PATCH(request(" "), context())).status).toBe(200);
|
||||
expect(set).toHaveBeenCalledWith({ title: null, updatedAt: expect.any(Date) });
|
||||
vi.clearAllMocks();
|
||||
expect((await PATCH(request("x".repeat(81)), context())).status).toBe(400);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as z from "zod";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionTickets } from "@/db/schema";
|
||||
import { requireAdmin } from "@/lib/auth/server";
|
||||
import { notifyCommission } from "@/lib/commission/server";
|
||||
import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http";
|
||||
|
||||
const inputSchema = z.object({ title: z.string().trim().max(80) });
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext<"/api/commission/tickets/[id]/title">) {
|
||||
try {
|
||||
requireSameOrigin(request);
|
||||
await requireAdmin();
|
||||
const { id } = await context.params;
|
||||
const parsed = inputSchema.safeParse(await readJson(request, 512));
|
||||
if (!parsed.success) throw new HttpError(400, "invalid-ticket-title");
|
||||
const title = parsed.data.title.replace(/\s+/gu, " ") || null;
|
||||
const [ticket] = await getDb().update(commissionTickets).set({ title, updatedAt: new Date() })
|
||||
.where(eq(commissionTickets.id, id)).returning({ title: commissionTickets.title, userId: commissionTickets.userId });
|
||||
if (!ticket) throw new HttpError(404, "ticket-not-found");
|
||||
await notifyCommission(id, ticket.userId);
|
||||
return Response.json({ title: ticket.title });
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { authorizeTicket } from "@/lib/commission/tickets";
|
||||
import { getRedisClient, redisEventChannel } from "@/lib/redis/client";
|
||||
import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http";
|
||||
import { limitRequest } from "@/lib/security/rate-limit";
|
||||
|
||||
export async function POST(request: Request, context: RouteContext<"/api/commission/tickets/[id]/typing">) {
|
||||
try {
|
||||
requireSameOrigin(request);
|
||||
const { id } = await context.params;
|
||||
const { ticket, user } = await authorizeTicket(id);
|
||||
if (ticket.status !== "open") throw new HttpError(409, "ticket-closed");
|
||||
await limitRequest("commission-typing", user.id, 30);
|
||||
const body = await readJson(request, 128);
|
||||
if (!body || typeof body !== "object" || !("active" in body) || typeof body.active !== "boolean")
|
||||
throw new HttpError(400, "invalid-typing-state");
|
||||
await (await getRedisClient()).publish(redisEventChannel(`commission:ticket:${id}`),
|
||||
`typing:${JSON.stringify({ userId: user.id, name: user.name, active: body.active })}`);
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function LegacyCommissionLoginPage() {
|
||||
redirect("/login?next=%2Fcommission");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { connection } from "next/server";
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
import { CommissionRequestForm } from "@/components/commission/request-form";
|
||||
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";
|
||||
|
||||
export const instant = false;
|
||||
|
||||
async function CommissionContent() {
|
||||
await connection();
|
||||
const session = await getCustomerSession();
|
||||
if (!session) return <div className="flex flex-col gap-4 rounded-xl border p-6">
|
||||
<p>เข้าสู่ระบบหรือสมัครสมาชิกก่อนเริ่มคำขอคอมมิชชัน</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button nativeButton={false} render={<Link href="/login?next=%2Fcommission" />}>เข้าสู่ระบบ</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/register?next=%2Fcommission" />}>สมัครสมาชิก</Button>
|
||||
</div>
|
||||
</div>;
|
||||
const catalog = await getCatalogOptions();
|
||||
if (!catalog) return <p>ยังไม่มีข้อมูลตัวละครสำหรับสร้างคำขอ</p>;
|
||||
return <div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3"><Button variant="outline" nativeButton={false} render={<Link href="/commission/tickets" />}>ดู Ticket ของฉัน</Button><CommissionSignOut /></div>
|
||||
<CommissionRequestForm catalog={catalog} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function CommissionPage() {
|
||||
return <div className="min-h-svh"><SiteHeader />
|
||||
<main className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 py-10 sm:p-8">
|
||||
<header><h1 className="font-heading text-3xl font-semibold">Commission</h1>
|
||||
<p className="text-muted-foreground">ขอทีม เปรียบเทียบอาวุธหรือกลุ่มดาวตามที่ต้องการ</p></header>
|
||||
<Suspense fallback={<p>กำลังโหลด...</p>}><CommissionContent /></Suspense>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import QRCode from "qrcode";
|
||||
import Link from "next/link";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionCheckouts, commissionTickets } from "@/db/schema";
|
||||
import { CommissionPaymentForm } from "@/components/commission/payment-form";
|
||||
import { CommissionRequestSummary } from "@/components/commission/request-summary";
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getCustomerSession } from "@/lib/auth/server";
|
||||
import { promptPayConfig } from "@/lib/commission/payment";
|
||||
import { generatePromptPayPayload } from "@/lib/commission/promptpay";
|
||||
import { getCommissionLabels } from "@/lib/commission/catalog";
|
||||
|
||||
export const instant = false;
|
||||
|
||||
export default async function CommissionPayPage({ params }: PageProps<"/commission/pay/[id]">) {
|
||||
await connection();
|
||||
const { id } = await params;
|
||||
const session = await getCustomerSession();
|
||||
if (!session) redirect(`/login?next=${encodeURIComponent(`/commission/pay/${id}`)}`);
|
||||
const [checkout] = await getDb().select().from(commissionCheckouts)
|
||||
.where(and(eq(commissionCheckouts.id, id), eq(commissionCheckouts.userId, session.user.id))).limit(1);
|
||||
if (!checkout) notFound();
|
||||
const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
|
||||
.where(eq(commissionTickets.checkoutId, id)).limit(1);
|
||||
if (ticket) redirect(`/commission/tickets/${ticket.id}`);
|
||||
const { identifier } = promptPayConfig();
|
||||
const payload = generatePromptPayPayload({ identifier, amount: checkout.amountBaht });
|
||||
const qr = await QRCode.toDataURL(payload, { margin: 2, width: 300 });
|
||||
const catalog = await getCommissionLabels(checkout.request);
|
||||
return <div className="min-h-svh"><SiteHeader /><main className="mx-auto flex max-w-3xl flex-col gap-6 p-4 py-10 sm:p-8">
|
||||
<Link href="/commission" className="text-sm underline">กลับไป Commission</Link>
|
||||
<h1 className="font-heading text-3xl font-semibold">ชำระเงิน</h1>
|
||||
<Card><CardHeader><CardTitle>รายการคำขอ - ฿{checkout.amountBaht}</CardTitle></CardHeader>
|
||||
<CardContent>{catalog && <CommissionRequestSummary request={checkout.request} catalog={catalog} amountBaht={checkout.amountBaht} />}</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle>สแกน PromptPay</CardTitle></CardHeader><CardContent className="flex flex-col items-center gap-4">
|
||||
{/* Generated locally from the fixed checkout amount and configured recipient. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={qr} alt={`PromptPay QR สำหรับชำระเงิน ${checkout.amountBaht} บาท`} width={300} height={300} />
|
||||
<strong>฿{checkout.amountBaht}</strong><p className="text-sm text-muted-foreground">โอนเงินแล้วอัปโหลดรูปสลิปเพื่อยืนยัน</p>
|
||||
</CardContent></Card>
|
||||
<CommissionPaymentForm checkoutId={id} />
|
||||
</main></div>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import { connection } from "next/server";
|
||||
import { CommissionTicketDetail } from "@/components/commission/ticket-detail";
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
|
||||
export const instant = false;
|
||||
export default async function CommissionTicketPage({ params }: PageProps<"/commission/tickets/[id]">) {
|
||||
await connection();
|
||||
const { id } = await params;
|
||||
return <div className="min-h-svh"><SiteHeader /><main className="mx-auto flex max-w-6xl flex-col gap-6 p-4 py-10 sm:p-8">
|
||||
<Link href="/commission/tickets" className="text-sm underline">กลับไปหน้า Ticket ของฉัน</Link>
|
||||
<CommissionTicketDetail id={id} admin={false} />
|
||||
</main></div>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Link from "next/link";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionCheckouts, commissionTickets } from "@/db/schema";
|
||||
import { CommissionLiveRefresh } from "@/components/commission/live-refresh";
|
||||
import { CommissionSignOut } from "@/components/commission/sign-out";
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getCustomerSession } from "@/lib/auth/server";
|
||||
|
||||
export const instant = false;
|
||||
export default async function CommissionTicketsPage() {
|
||||
await connection();
|
||||
const session = await getCustomerSession();
|
||||
if (!session) redirect("/login?next=%2Fcommission%2Ftickets");
|
||||
const [tickets, pending] = await Promise.all([
|
||||
getDb().select({ ticket: commissionTickets, amount: commissionCheckouts.amountBaht })
|
||||
.from(commissionTickets).innerJoin(commissionCheckouts, eq(commissionTickets.checkoutId, commissionCheckouts.id))
|
||||
.where(eq(commissionTickets.userId, session.user.id)).orderBy(desc(commissionTickets.updatedAt)).limit(50),
|
||||
getDb().select({ id: commissionCheckouts.id, amount: commissionCheckouts.amountBaht,
|
||||
createdAt: commissionCheckouts.createdAt }).from(commissionCheckouts)
|
||||
.leftJoin(commissionTickets, eq(commissionTickets.checkoutId, commissionCheckouts.id))
|
||||
.where(and(eq(commissionCheckouts.userId, session.user.id), isNull(commissionTickets.id)))
|
||||
.orderBy(desc(commissionCheckouts.createdAt)).limit(20),
|
||||
]);
|
||||
return <div className="min-h-svh"><SiteHeader /><CommissionLiveRefresh endpoint="/api/commission/events" />
|
||||
<main className="mx-auto flex max-w-4xl flex-col gap-5 p-4 py-10 sm:p-8">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h1 className="font-heading text-3xl font-semibold">Ticket ของฉัน</h1>
|
||||
<CommissionSignOut />
|
||||
</div>
|
||||
<Link href="/commission" className="text-sm underline">สร้างคำขอเพิ่ม</Link>
|
||||
{pending.length > 0 && <h2 className="text-xl font-semibold">รอชำระเงิน</h2>}
|
||||
{pending.map((checkout) => <Link key={checkout.id} href={`/commission/pay/${checkout.id}`}>
|
||||
<Card><CardHeader><CardTitle>รอชำระเงิน - ฿{checkout.amount}</CardTitle></CardHeader>
|
||||
<CardContent>{checkout.createdAt.toLocaleString("th-TH")}</CardContent></Card>
|
||||
</Link>)}
|
||||
<h2 className="text-xl font-semibold">Ticket ที่ชำระเงินแล้ว</h2>
|
||||
{tickets.length === 0 && <p>ยังไม่มี Ticket</p>}
|
||||
{tickets.map(({ ticket, amount }) => <Link key={ticket.id} href={`/commission/tickets/${ticket.id}`}>
|
||||
<Card><CardHeader><CardTitle>{ticket.title || `Ticket #${ticket.id.slice(0, 8)}`}</CardTitle>
|
||||
<CardAction><Badge variant={ticket.status === "open" ? "outline" : "destructive"}
|
||||
className={ticket.status === "open" ? "border-success/30 bg-success/10 text-success" : undefined}>
|
||||
{ticket.status === "open" ? "เปิดอยู่" : "ปิดแล้ว"}
|
||||
</Badge></CardAction></CardHeader>
|
||||
<CardContent>฿{amount} - {ticket.updatedAt.toLocaleString("th-TH")}</CardContent></Card>
|
||||
</Link>)}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { connection } from "next/server";
|
||||
import { AccountPage } from "@/components/auth/account-page";
|
||||
|
||||
export const instant = false;
|
||||
|
||||
export default async function LoginPage({ searchParams }: PageProps<"/login">) {
|
||||
await connection();
|
||||
const { next } = await searchParams;
|
||||
return <AccountPage mode="login" next={next} />;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { AccountPage } from "@/components/auth/account-page";
|
||||
|
||||
export default async function RegisterPage() {
|
||||
redirect("/admin/register");
|
||||
export const instant = false;
|
||||
|
||||
export default async function RegisterPage({ searchParams }: PageProps<"/register">) {
|
||||
await connection();
|
||||
const { next } = await searchParams;
|
||||
return <AccountPage mode="register" next={next} />;
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ export default function robots(): MetadataRoute.Robots {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/admin", "/api", "/register"],
|
||||
disallow: ["/admin", "/api", "/login", "/register"],
|
||||
},
|
||||
sitemap: `${siteUrl}/sitemap.xml`,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user