feat
CI / Verify (push) Successful in 1m47s
CI / Build immutable images and deploy (push) Successful in 2m51s

This commit is contained in:
2026-10-01 22:53:48 +07:00 Unverified
parent d85687c332
commit bdce288951
10 changed files with 102 additions and 371 deletions
+4 -5
View File
@@ -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
-114
View File
@@ -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<CreateAccountState> {
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" };
}
-41
View File
@@ -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 (
<div className="min-h-svh">
<AdminHeader />
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 sm:p-6 lg:p-8">
<header>
<p className="text-sm font-medium text-primary">Admin access</p>
<h1 className="font-heading text-3xl font-semibold">จัดการบัญชี</h1>
<p className="mt-2 text-muted-foreground">
สร้างบัญชีให้ผู้ดูแลคนอื่น หรือลบบัญชีที่ไม่ต้องใช้งานแล้ว
</p>
</header>
<RegisterCard accounts={accounts} currentUserId={session.user.id} />
</main>
</div>
);
}
-10
View File
@@ -12,7 +12,6 @@ import {
MessagesSquareIcon,
LogOutIcon,
PlusIcon,
UserPlusIcon,
SwordsIcon,
} from "lucide-react";
@@ -65,15 +64,6 @@ export function AdminHeader() {
<BookOpenIcon data-icon="inline-start" />
<span className="hidden sm:inline">Glossary</span>
</Button>
<Button
variant={pathname === "/admin/register" ? "secondary" : "ghost"}
size="sm"
nativeButton={false}
render={<Link href="/admin/register" />}
>
<UserPlusIcon data-icon="inline-start" />
<span className="hidden sm:inline">Accounts</span>
</Button>
<Button
variant={pathname === "/admin" ? "secondary" : "ghost"}
size="sm"
-200
View File
@@ -1,200 +0,0 @@
"use client";
import { CircleAlertIcon, Trash2Icon, UserPlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useRef, useTransition } from "react";
import { toast } from "sonner";
import {
createAccount,
removeAccount,
type CreateAccountState,
} from "@/app/admin/register/actions";
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
const initialCreateAccountState: CreateAccountState = {
status: "idle",
message: "",
};
export function RegisterCard({
accounts,
currentUserId,
}: {
accounts: Array<{ id: string; name: string; email: string; createdAt: Date }>;
currentUserId: string;
}) {
const router = useRouter();
const formRef = useRef<HTMLFormElement>(null);
const [state, formAction, pending] = useActionState(
createAccount,
initialCreateAccountState,
);
const [removing, startRemoval] = useTransition();
useEffect(() => {
if (state.status !== "success") return;
formRef.current?.reset();
router.refresh();
}, [router, state]);
function remove(userId: string) {
startRemoval(async () => {
const result = await removeAccount(userId);
if (result.status === "error") toast.error(result.message);
else {
toast.success("ลบบัญชีแล้ว");
router.refresh();
}
});
}
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,28rem)_minmax(0,1fr)]">
<Card>
<CardHeader>
<CardTitle>สร้างบัญชี</CardTitle>
<CardDescription>
เพิ่มบัญชีผู้ดูแลที่สามารถเข้าสู่ระบบและจัดการเนื้อหาได้
</CardDescription>
</CardHeader>
<CardContent>
<form ref={formRef} action={formAction}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="register-name">ชื่อ</FieldLabel>
<Input
id="register-name"
name="name"
autoComplete="name"
disabled={pending}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="register-email">อีเมล</FieldLabel>
<Input
id="register-email"
name="email"
type="email"
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
disabled={pending}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="register-password">รหัสผ่าน</FieldLabel>
<Input
id="register-password"
name="password"
type="password"
autoComplete="new-password"
minLength={12}
maxLength={128}
disabled={pending}
required
/>
<FieldDescription>อย่างน้อย 12 ตัวอักษร</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="register-password-confirmation">
ยืนยันรหัสผ่าน
</FieldLabel>
<Input
id="register-password-confirmation"
name="passwordConfirmation"
type="password"
autoComplete="new-password"
minLength={12}
maxLength={128}
disabled={pending}
required
/>
</Field>
{state.message ? (
<Alert variant={state.status === "error" ? "destructive" : "default"}>
<CircleAlertIcon aria-hidden="true" />
<AlertTitle>
{state.status === "error" ? "สร้างบัญชีไม่สำเร็จ" : "สร้างบัญชีแล้ว"}
</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<Button className="w-full" size="lg" type="submit" disabled={pending}>
{pending ? (
<Spinner data-icon="inline-start" />
) : (
<UserPlusIcon data-icon="inline-start" aria-hidden="true" />
)}
{pending ? "กำลังสร้างบัญชี…" : "สร้างบัญชี"}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>บัญชีในระบบ</CardTitle>
<CardDescription>{accounts.length} บัญชี</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{accounts.map((account) => {
const isCurrent = account.id === currentUserId;
return (
<div key={account.id} className="flex items-center gap-3 rounded-lg border p-3">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{account.name}</p>
<p className="truncate text-sm text-muted-foreground">{account.email}</p>
</div>
{isCurrent ? (
<span className="text-sm text-muted-foreground">บัญชีปัจจุบัน</span>
) : (
<DeleteConfirmationDialog
trigger={<Button variant="ghost" size="icon-sm" disabled={removing} />}
title={`ลบบัญชี ${account.name}?`}
description="ผู้ใช้นี้จะออกจากระบบและไม่สามารถเข้าสู่ระบบได้อีก การดำเนินการนี้ย้อนกลับไม่ได้"
confirmLabel="ลบบัญชี"
onConfirm={() => remove(account.id)}
>
<Trash2Icon />
<span className="sr-only">ลบบัญชี {account.name}</span>
</DeleteConfirmationDialog>
)}
</div>
);
})}
</CardContent>
<CardFooter>
<p className="text-sm text-muted-foreground">
บัญชีทุกบัญชีมีสิทธิ์ผู้ดูแลระบบ
</p>
</CardFooter>
</Card>
</div>
);
}
+43
View File
@@ -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");
});
});
+40
View File
@@ -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<number> {
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;
}
+3
View File
@@ -0,0 +1,3 @@
export function commissionSlipLockKey(checkoutId: string): string {
return `${process.env.REDIS_SECURITY_PREFIX || "buzz:security"}:commission-slip-lock:${checkoutId}`;
}
+2 -1
View File
@@ -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");
+10
View File
@@ -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<void> {
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);