diff --git a/README.md b/README.md index e339b9b..d30b39b 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,6 @@ formula fixtures. Their guide text and media are not imported or published. | `/login` | Public account sign-in | | `/register` | Public account registration | | `/admin/login` | Administrator email/password sign-in | -| `/admin/register` | Create and remove administrator accounts | | `/admin` | Character and page overview | | `/admin/[character]/[page]` | Visual page editor | | `/admin/create` | Create a structured guide from the synced character catalog | @@ -64,8 +63,7 @@ cp .env.example .env `.env.example` contains placeholders only. Configure `.env` yourself; it is ignored by Git and must never be committed. `BETTER_AUTH_URL` must exactly match the application origin. Set `BASE_URL` to the public site origin for SEO -metadata, sitemap, and robots.txt. Existing administrators can create additional -credential accounts from `/admin/register`. Visitors can create public accounts at `/register`. +metadata, sitemap, and robots.txt. Visitors can create public accounts at `/register`. Prepare the database and start the application: @@ -233,8 +231,9 @@ image-verification endpoint from your Slip2Go API Connect account. Set `SLIP2GO_API_SECRET` to the raw secret; the server adds the `Bearer` authorization prefix when calling Slip2Go. Payment slips and ticket images use the configured S3 bucket and are served from `S3_PUBLIC_URL`. Anyone with an attachment URL can -view it. Each checkout accepts payment for one hour after creation; paid tickets -remain available afterward. Apply the commission database migration before enabling checkout. +view it. Each checkout accepts payment for one hour after creation. The outbox +worker removes expired unpaid checkouts; paid tickets remain available. Apply +the commission database migration before enabling checkout. Traefik must overwrite `X-Real-Ip` for every request, and the web pods must be reachable only through Traefik before enabling the configured client IP based diff --git a/app/admin/register/actions.ts b/app/admin/register/actions.ts deleted file mode 100644 index 7b1973b..0000000 --- a/app/admin/register/actions.ts +++ /dev/null @@ -1,114 +0,0 @@ -"use server"; - -import { eq } from "drizzle-orm"; -import { headers } from "next/headers"; -import { revalidatePath } from "next/cache"; -import { z } from "zod"; - -import { getDb } from "@/db"; -import { users } from "@/db/schema"; -import { getAuth, requireAdmin } from "@/lib/auth/server"; -import { - auditActor, - writeAuditLogBestEffort, -} from "@/lib/audit-log"; - -export interface CreateAccountState { - status: "idle" | "error" | "success"; - message: string; -} - -const accountSchema = z - .object({ - name: z.string().trim().min(1).max(100), - email: z.email().transform((value) => value.toLowerCase()), - password: z.string().min(12).max(128), - passwordConfirmation: z.string(), - }) - .refine((data) => data.password === data.passwordConfirmation, { - path: ["passwordConfirmation"], - }); - -export async function createAccount( - _previousState: CreateAccountState, - formData: FormData, -): Promise { - const admin = await requireAdmin(); - const parsed = accountSchema.safeParse(Object.fromEntries(formData)); - if (!parsed.success) { - return { status: "error", message: "โปรดตรวจสอบชื่อ อีเมล และรหัสผ่านให้ถูกต้อง" }; - } - - const existing = await getDb().query.users.findFirst({ - columns: { id: true }, - where: eq(users.email, parsed.data.email), - }); - if (existing) { - return { status: "error", message: "อีเมลนี้มีบัญชีอยู่แล้ว" }; - } - - try { - const created = await getAuth().api.createUser({ - body: { - name: parsed.data.name, - email: parsed.data.email, - password: parsed.data.password, - role: "admin", - data: { emailVerified: true }, - }, - headers: await headers(), - }); - await writeAuditLogBestEffort(auditActor(admin.user), { - action: "admin_account.created", - targetType: "admin_account", - targetId: created.user.id, - metadata: { - accountLabel: created.user.name?.trim() || created.user.email, - }, - }); - } catch { - return { status: "error", message: "สร้างบัญชีไม่สำเร็จ โปรดลองอีกครั้ง" }; - } - - revalidatePath("/admin/register"); - return { status: "success", message: `สร้างบัญชี ${parsed.data.email} แล้ว` }; -} - -export async function removeAccount( - userId: string, -): Promise<{ status: "error" | "success"; message?: string }> { - const session = await requireAdmin(); - if (!z.string().min(1).max(128).safeParse(userId).success) { - return { status: "error", message: "บัญชีไม่ถูกต้อง" }; - } - if (userId === session.user.id) { - return { status: "error", message: "ไม่สามารถลบบัญชีที่กำลังใช้งานอยู่" }; - } - - const [target] = await getDb() - .select({ name: users.name, email: users.email }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - if (!target) return { status: "error", message: "ไม่พบบัญชีนี้" }; - - try { - await getAuth().api.removeUser({ - body: { userId }, - headers: await headers(), - }); - await writeAuditLogBestEffort(auditActor(session.user), { - action: "admin_account.removed", - targetType: "admin_account", - targetId: userId, - metadata: { - accountLabel: target.name.trim() || target.email, - }, - }); - } catch { - return { status: "error", message: "ลบบัญชีไม่สำเร็จ โปรดลองอีกครั้ง" }; - } - - revalidatePath("/admin/register"); - return { status: "success" }; -} diff --git a/app/admin/register/page.tsx b/app/admin/register/page.tsx deleted file mode 100644 index 6966998..0000000 --- a/app/admin/register/page.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { asc } from "drizzle-orm"; -import { redirect } from "next/navigation"; -import { connection } from "next/server"; - -import { AdminHeader } from "@/components/admin/admin-header"; -import { RegisterCard } from "@/components/admin/register-card"; -import { getDb } from "@/db"; -import { users } from "@/db/schema"; -import { getAdminSession } from "@/lib/auth/server"; - -export default async function RegisterPage() { - await connection(); - const session = await getAdminSession(); - if (!session) redirect("/admin/login"); - - const accounts = await getDb() - .select({ - id: users.id, - name: users.name, - email: users.email, - createdAt: users.createdAt, - }) - .from(users) - .orderBy(asc(users.createdAt)); - - return ( -
- -
-
-

Admin access

-

จัดการบัญชี

-

- สร้างบัญชีให้ผู้ดูแลคนอื่น หรือลบบัญชีที่ไม่ต้องใช้งานแล้ว -

-
- -
-
- ); -} diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx index 8550242..2d37a35 100644 --- a/components/admin/admin-header.tsx +++ b/components/admin/admin-header.tsx @@ -12,7 +12,6 @@ import { MessagesSquareIcon, LogOutIcon, PlusIcon, - UserPlusIcon, SwordsIcon, } from "lucide-react"; @@ -65,15 +64,6 @@ export function AdminHeader() { Glossary - - - - - - - - - บัญชีในระบบ - {accounts.length} บัญชี - - - {accounts.map((account) => { - const isCurrent = account.id === currentUserId; - return ( -
-
-

{account.name}

-

{account.email}

-
- {isCurrent ? ( - บัญชีปัจจุบัน - ) : ( - } - title={`ลบบัญชี ${account.name}?`} - description="ผู้ใช้นี้จะออกจากระบบและไม่สามารถเข้าสู่ระบบได้อีก การดำเนินการนี้ย้อนกลับไม่ได้" - confirmLabel="ลบบัญชี" - onConfirm={() => remove(account.id)} - > - - ลบบัญชี {account.name} - - )} -
- ); - })} -
- -

- บัญชีทุกบัญชีมีสิทธิ์ผู้ดูแลระบบ -

-
-
- - ); -} diff --git a/lib/commission/cleanup.test.ts b/lib/commission/cleanup.test.ts new file mode 100644 index 0000000..68ede7a --- /dev/null +++ b/lib/commission/cleanup.test.ts @@ -0,0 +1,43 @@ +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const candidates = vi.fn(); +const returning = vi.fn(); +const deleteWhere = vi.fn((condition: SQL) => { void condition; return { returning }; }); +const deleteRows = vi.fn(() => ({ where: deleteWhere })); +const select = vi.fn(() => ({ + from: () => ({ + leftJoin: () => ({ where: () => ({ orderBy: () => ({ limit: candidates }) }) }), + where: () => ({}), + }), +})); +const set = vi.fn(); +const evalRedis = vi.fn().mockResolvedValue(1); + +vi.mock("server-only", () => ({})); +vi.mock("@/db/client", () => ({ getDb: () => ({ select, delete: deleteRows }) })); +vi.mock("@/lib/redis/client", () => ({ getRedisClient: async () => ({ set, eval: evalRedis }) })); + +const { removeExpiredUnpaidCheckouts } = await import("./cleanup"); + +describe("expired checkout cleanup", () => { + beforeEach(() => { + vi.clearAllMocks(); + candidates.mockResolvedValue([{ id: "checkout-1" }, { id: "checkout-2" }]); + set.mockResolvedValueOnce("OK").mockResolvedValueOnce(null); + returning.mockResolvedValue([{ id: "checkout-1" }]); + }); + + it("deletes only an unlocked expired checkout with no ticket", async () => { + expect(await removeExpiredUnpaidCheckouts()).toBe(1); + expect(deleteRows).toHaveBeenCalledOnce(); + expect(set).toHaveBeenCalledWith(expect.stringContaining("checkout-1"), expect.any(String), "EX", 60, "NX"); + expect(evalRedis).toHaveBeenCalledOnce(); + + const query = new PgDialect().sqlToQuery(deleteWhere.mock.calls[0][0]); + expect(query.sql).toContain("not exists"); + expect(query.sql).toContain('"created_at"'); + expect(query.params).toContain("checkout-1"); + }); +}); diff --git a/lib/commission/cleanup.ts b/lib/commission/cleanup.ts new file mode 100644 index 0000000..fa73066 --- /dev/null +++ b/lib/commission/cleanup.ts @@ -0,0 +1,40 @@ +import { and, asc, eq, isNull, lte, notExists } from "drizzle-orm"; +import { getDb } from "@/db/client"; +import { commissionCheckouts, commissionTickets } from "@/db/schema"; +import { CHECKOUT_DURATION_MS } from "@/lib/commission/checkout-expiration"; +import { commissionSlipLockKey } from "@/lib/commission/slip-lock"; +import { getRedisClient } from "@/lib/redis/client"; + +const BATCH_SIZE = 100; + +export async function removeExpiredUnpaidCheckouts(): Promise { + const db = getDb(); + const redis = await getRedisClient(); + const cutoff = new Date(Date.now() - CHECKOUT_DURATION_MS); + const candidates = await db.select({ id: commissionCheckouts.id }) + .from(commissionCheckouts) + .leftJoin(commissionTickets, eq(commissionTickets.checkoutId, commissionCheckouts.id)) + .where(and(lte(commissionCheckouts.createdAt, cutoff), isNull(commissionTickets.id))) + .orderBy(asc(commissionCheckouts.createdAt)) + .limit(BATCH_SIZE); + + let removed = 0; + for (const { id } of candidates) { + const lockKey = commissionSlipLockKey(id); + const lockId = crypto.randomUUID(); + if (await redis.set(lockKey, lockId, "EX", 60, "NX") !== "OK") continue; + try { + const deleted = await db.delete(commissionCheckouts).where(and( + eq(commissionCheckouts.id, id), + lte(commissionCheckouts.createdAt, cutoff), + notExists(db.select({ id: commissionTickets.id }).from(commissionTickets) + .where(eq(commissionTickets.checkoutId, id))), + )).returning({ id: commissionCheckouts.id }); + removed += deleted.length; + } finally { + await redis.eval("if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0", 1, + lockKey, lockId).catch(() => undefined); + } + } + return removed; +} diff --git a/lib/commission/slip-lock.ts b/lib/commission/slip-lock.ts new file mode 100644 index 0000000..f41f48b --- /dev/null +++ b/lib/commission/slip-lock.ts @@ -0,0 +1,3 @@ +export function commissionSlipLockKey(checkoutId: string): string { + return `${process.env.REDIS_SECURITY_PREFIX || "buzz:security"}:commission-slip-lock:${checkoutId}`; +} diff --git a/lib/commission/slip-upload.ts b/lib/commission/slip-upload.ts index 9532f24..4eb593b 100644 --- a/lib/commission/slip-upload.ts +++ b/lib/commission/slip-upload.ts @@ -7,6 +7,7 @@ import { notifyPaidTicketDiscord } from "@/lib/commission/discord"; import { verifyCommissionSlip } from "@/lib/commission/payment"; import { notifyCommission } from "@/lib/commission/server"; import { checkoutExpired } from "@/lib/commission/checkout-expiration"; +import { commissionSlipLockKey } from "@/lib/commission/slip-lock"; import { getMediaStorage } from "@/lib/media/storage"; import { inspectImage } from "@/lib/media/inspect"; import { getRedisClient } from "@/lib/redis/client"; @@ -21,7 +22,7 @@ export async function verifyAndCreateTicket(checkout: Checkout, file: FormDataEn throw new HttpError(415, "invalid-slip-image"); const redis = await getRedisClient(); - const lockKey = `${process.env.REDIS_SECURITY_PREFIX || "buzz:security"}:commission-slip-lock:${checkout.id}`; + const lockKey = commissionSlipLockKey(checkout.id); const lockId = crypto.randomUUID(); if (await redis.set(lockKey, lockId, "EX", 60, "NX") !== "OK") throw new HttpError(409, "slip-verification-in-progress"); diff --git a/scripts/outbox-worker.ts b/scripts/outbox-worker.ts index f8798a8..2f3cf06 100644 --- a/scripts/outbox-worker.ts +++ b/scripts/outbox-worker.ts @@ -9,6 +9,7 @@ import { processOutboxEvent, } from "@/lib/outbox/processor"; import { closeRedisClient, getRedisClient } from "@/lib/redis/client"; +import { removeExpiredUnpaidCheckouts } from "@/lib/commission/cleanup"; let stopping = false; const stop = () => { @@ -38,8 +39,17 @@ async function run(): Promise { const batchSize = positiveInteger("OUTBOX_BATCH_SIZE", 20, 100); const pollMs = positiveInteger("OUTBOX_POLL_MS", 1_000, 60_000); const leaseMs = positiveInteger("OUTBOX_LEASE_MS", 30_000, 300_000); + let nextCheckoutCleanup = 0; while (!stopping) { + if (Date.now() >= nextCheckoutCleanup) { + nextCheckoutCleanup = Date.now() + 60_000; + try { + await removeExpiredUnpaidCheckouts(); + } catch (cause) { + console.error("Unable to remove expired unpaid checkouts", cause); + } + } const events = await claimOutboxEvents(batchSize, leaseMs); if (events.length === 0) { await wait(pollMs);