feat
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function commissionSlipLockKey(checkoutId: string): string {
|
||||
return `${process.env.REDIS_SECURITY_PREFIX || "buzz:security"}:commission-slip-lock:${checkoutId}`;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user