refactor(rubgram): replace archive/round/slip system with deleted+submit_day columns and month-calendar
Build and Push Image / build-backend (push) Successful in 7m3s
Build and Push Image / build-frontend (push) Successful in 10m12s
Build and Push Image / push (push) Successful in 2m12s

- drop endgameArchive table, remove archive() function
- replace archived boolean with deleted on endgameSubmissions
- add submit_day timestamp column
- replace round-based slip manager with /rubgram/admin/calendar route
- add monthly jsonb column to endgameSettings for month checkmark toggle
- add toggleMonth server action with SimpleTooltip UI
- fix publicQueue SQL bug (ambiguous column reference + precedence)
- wipe() now sets deleted=true instead of round-based archive
- cancel() uses soft-delete (deleted=true) instead of hard-delete
- guard calendar page with adminCheck
- update count route to exclude deleted submissions
This commit is contained in:
2026-06-17 01:38:06 +00:00 Unverified
parent e483360b5b
commit 815698e376
14 changed files with 435 additions and 493 deletions
@@ -1,38 +0,0 @@
"use client";
import { Database, ReceiptText } from "lucide-react";
import { useMemo, useState } from "react";
import { CopyButton } from "@/app/(ui)/artifact/admin/[id]/client";
import { Button } from "@/components/ui/button";
export function DataViewer({
entryYaml,
slipYaml,
}: {
entryYaml: string;
slipYaml: string;
}) {
const [isSlip, setIsSlip] = useState(false);
const yaml = useMemo(
() => (isSlip ? slipYaml : entryYaml),
[entryYaml, isSlip, slipYaml],
);
return (
<div className="relative max-h-[60svh] overflow-auto rounded-xl border bg-black/40 p-4 font-mono text-xs leading-relaxed">
<div className="sticky -top-2 -mt-10 -mr-2 flex justify-end">
<Button
variant="ghost"
size="icon"
onClick={() => setIsSlip((x) => !x)}
>
{isSlip ? <ReceiptText /> : <Database />}
</Button>
<CopyButton text={yaml} />
</div>
<pre className="wrap-break-word whitespace-pre-wrap">{yaml}</pre>
</div>
);
}
@@ -1,102 +0,0 @@
import { calcPrice } from "@/app/(ui)/rubgram/api";
import Image from "@/components/image";
import { DiscordMentionable } from "@/components/mentionable";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { db } from "@/lib/db";
import { endgameArchive, endgameDiscord, endgameSlips } from "@/lib/db/schema";
import { parseSearchNumber } from "@/lib/utils";
import { YAML } from "bun";
import { and, eq, getTableColumns } from "drizzle-orm";
import { ReceiptText } from "lucide-react";
import { DataViewer } from "./client";
export default async function ({
params,
}: PageProps<"/rubgram/admin/slip/[round]/[queue]">) {
const { round: r, queue: q } = await params;
const round = parseSearchNumber(r) || 1;
const queue = parseSearchNumber(q) || 1;
const { slip: _, ...slipColumns } = getTableColumns(endgameSlips);
const [entry] = await db
.select({
...getTableColumns(endgameArchive),
user: getTableColumns(endgameDiscord),
slip: slipColumns,
})
.from(endgameArchive)
.where(
and(eq(endgameArchive.round, round), eq(endgameArchive.queue, queue)),
)
.innerJoin(endgameDiscord, eq(endgameDiscord.uid, endgameArchive.user))
.leftJoin(endgameSlips, eq(endgameSlips.id, endgameArchive.slip))
.limit(1);
const entryYaml = YAML.stringify(
{ ...entry, slip: { ...entry.slip, data: undefined } },
null,
2,
);
const slipYaml = YAML.stringify(entry.slip, null, 2);
if (!entry)
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ReceiptText />
</EmptyMedia>
<EmptyTitle>ยังไม่มีสลิป</EmptyTitle>
</EmptyHeader>
</Empty>
);
return (
<div className="flex h-full flex-col p-2 sm:flex-row">
<div className="flex flex-1 items-center justify-center p-5">
<div className="relative rounded border border-dashed border-white">
<Image
src={`/api/slip/${entry.slip?.id}`}
alt="Slip"
className="object-contain"
width={1000}
height={1000}
/>
<div className="absolute top-0 right-0 bottom-0 left-0 backdrop-blur-lg transition-opacity hover:opacity-0" />
</div>
</div>
<div className="flex flex-1 items-center justify-center">
<Card>
<CardHeader>
<CardTitle>จ่าย {entry.price} บาท</CardTitle>
<CardDescription>
{entry.service.join(", ")} (รวม {calcPrice(entry.service)} บาท)
</CardDescription>
<CardAction>
<DiscordMentionable
id={entry.user.uid}
name={entry.user.display}
type="user"
/>
</CardAction>
</CardHeader>
<CardContent>
<DataViewer {...{ entryYaml, slipYaml }} />
</CardContent>
</Card>
</div>
</div>
);
}
@@ -1,201 +0,0 @@
import { eq, sql } from "drizzle-orm";
import { ChevronLeft, ChevronRight, ReceiptText } from "lucide-react";
import Link from "next/link";
import { cache, Suspense } from "react";
import { SidebarLink } from "@/app/(ui)/admin/client";
import { SimpleTooltip } from "@/components/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { db } from "@/lib/db";
import { endgameArchive } from "@/lib/db/schema";
import { parseSearchNumber } from "@/lib/utils";
import { Watcher } from "../../../../admin/client";
export default async function ({
params,
children,
}: {
params: Promise<{ round: string }>;
children: React.ReactNode;
}) {
const { round: r } = await params;
const round = parseSearchNumber(r);
return (
<SidebarProvider>
<Suspense fallback={<Sidebar />}>
<ThisSidebar round={round} />
</Suspense>
<SidebarInset className="bg-transparent">
{children}
<div className="absolute bottom-1 left-1 block opacity-50 hover:opacity-100 md:hidden">
<SidebarTrigger />
</div>
<Watcher />
</SidebarInset>
</SidebarProvider>
);
}
const getRange = cache(
async () =>
await db
.select({
min: sql<number>`min(${endgameArchive.round})`,
max: sql<number>`max(${endgameArchive.round})`,
})
.from(endgameArchive),
);
const getEntries = cache(
async (round: number) =>
await db
.select()
.from(endgameArchive)
.where(eq(endgameArchive.round, round))
.orderBy(endgameArchive.queue),
);
async function ThisSidebar({ round }: { round: number }) {
const [[{ min, max }], entries] = await Promise.all([
getRange(),
getEntries(round),
]);
return (
<Sidebar variant="floating">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
asChild
>
<span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-foreground">
<ReceiptText className="size-4" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width)"
align="start"
>
<DropdownMenuGroup>
<DropdownMenuLabel>เลือกรอบ</DropdownMenuLabel>
<DropdownMenuRadioGroup value={`${round}`}>
{range(min, max + 1)
.toReversed()
.map((r) => (
<Link href={`/rubgram/admin/slip/${r}`} key={r}>
<DropdownMenuRadioItem value={`${r}`}>
รอบที่ {r}
</DropdownMenuRadioItem>
</Link>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-medium">ประวัติสลิปรายได้</span>
<span>รอบที่ {round}</span>
</div>
<div className="ml-auto flex h-full items-center justify-evenly">
{round <= min ? (
<ChevronLeft className="size-4 text-muted-foreground" />
) : (
<SimpleTooltip text="รอบก่อนหน้า" side="right">
<Link
href={`/rubgram/admin/slip/${round - 1}`}
className="flex h-full items-center"
>
<ChevronLeft className="size-4" />
</Link>
</SimpleTooltip>
)}
{round >= max ? (
<ChevronRight className="size-4 text-muted-foreground" />
) : (
<SimpleTooltip text="รอบถัดไป" side="right">
<Link
href={`/rubgram/admin/slip/${round + 1}`}
className="flex h-full items-center"
>
<ChevronRight className="size-4" />
</Link>
</SimpleTooltip>
)}
</div>
</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
{entries.map((e) => (
<SidebarMenu key={e.id}>
<SidebarMenuItem>
<SidebarLink
prefetch
href={`/rubgram/admin/slip/${round}/${e.queue}`}
>
{e.queue}. {e.name}
</SidebarLink>
</SidebarMenuItem>
</SidebarMenu>
))}
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" className="justify-center">
รายได้รวม {entries.reduce((a, e) => a + e.price, 0)} บาท
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
const range = (start: number, stop?: number, step?: number) => {
// Handle cases where stop/step are optional (e.g., range(5) -> [0, 1, 2, 3, 4])
if (stop === undefined) {
[stop, start] = [start, 0];
}
if (step === undefined) {
step = start < stop ? 1 : -1;
}
// Calculate the length of the array
const length = Math.ceil((stop - start) / step);
// Create the array using Array.from() and a map function
return Array.from({ length }, (_, index) => start + index * step);
};
@@ -1,39 +0,0 @@
import { eq } from "drizzle-orm";
import { ReceiptText } from "lucide-react";
import { redirect } from "next/navigation";
import {
Empty,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { db } from "@/lib/db";
import { endgameArchive } from "@/lib/db/schema";
import { parseSearchNumber } from "@/lib/utils";
export default async function ({
params,
}: {
params: Promise<{ round: string }>;
}) {
const { round: r } = await params;
const round = parseSearchNumber(r) || 1;
const [first] = await db
.select({ queue: endgameArchive.queue })
.from(endgameArchive)
.where(eq(endgameArchive.round, round))
.orderBy(endgameArchive.queue)
.limit(1);
if (first) redirect(`/rubgram/admin/slip/${round}/${first.queue}`);
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ReceiptText />
</EmptyMedia>
<EmptyTitle>ยังไม่มีสลิป</EmptyTitle>
</EmptyHeader>
</Empty>
);
}
@@ -1,13 +0,0 @@
import { sql } from "drizzle-orm";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { endgameArchive } from "@/lib/db/schema";
export async function GET() {
const [{ max }] = await db
.select({
max: sql<number>`max(${endgameArchive.round})`,
})
.from(endgameArchive);
redirect(`/rubgram/admin/slip/${max || "1"}`);
}
+309
View File
@@ -0,0 +1,309 @@
"use client";
import { format, getDaysInMonth, startOfMonth } from "date-fns";
import {
Check,
ChevronLeft,
ChevronRight,
Circle,
ExternalLink,
ImageOff,
ReceiptText,
Trash2,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useMemo, useTransition } from "react";
import { toast } from "sonner";
import { SimpleTooltip } from "@/components/tooltip";
import { toggleMonth } from "../../api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
type Row = {
id: string;
name: string;
queue: number;
checked: boolean;
server: "as" | "eu" | "us" | "tw";
expires: Date | null;
price: number;
service: string[];
slip: {
id: string;
ref: string;
amount: string;
data: unknown;
} | null;
paid: boolean;
deleted: boolean;
submit_day: Date;
user: {
uid: string;
display: string;
username: string;
token: string;
} | null;
};
const DAYS = ["จ.", "อ.", "พ.", "พฤ.", "ศ.", "ส.", "อา."];
export function CalendarClient({
year,
month,
rows,
total,
monthly,
}: {
year: number;
month: number;
rows: Row[];
total: number;
monthly: Record<string, boolean>;
}) {
const current = new Date(year, month);
const rowsByDay = useMemo(() => {
const map: Record<number, Row[]> = {};
for (const r of rows) {
if (!r.submit_day) continue;
const d = new Date(r.submit_day).getDate();
(map[d] ??= []).push(r);
}
return map;
}, [rows]);
const ym = `${year}-${String(month + 1).padStart(2, "0")}`;
const [pending, startTransition] = useTransition();
const monthChecked = monthly[ym] ?? false;
const daysInMonth = getDaysInMonth(current);
const firstDayOfWeek = startOfMonth(current).getDay(); // 0=Sun
const monthLabel = format(current, "MMMM yyyy");
const prevY = month === 0 ? year - 1 : year;
const prevM = month === 0 ? 11 : month - 1;
const nextY = month === 11 ? year + 1 : year;
const nextM = month === 11 ? 0 : month + 1;
const prevHref = `/rubgram/admin/calendar?month=${prevY}-${String(prevM + 1).padStart(2, "0")}`;
const nextHref = `/rubgram/admin/calendar?month=${nextY}-${String(nextM + 1).padStart(2, "0")}`;
return (
<div className="flex h-full flex-col p-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<Link href={prevHref}>
<ChevronLeft />
</Link>
</Button>
<span className="inline-block min-w-35 shrink-0 text-center text-lg font-semibold">
{monthLabel}
</span>
<Button variant="outline" size="icon" asChild>
<Link href={nextHref}>
<ChevronRight />
</Link>
</Button>
</div>
<div className="flex items-center gap-2">
<SimpleTooltip
text={monthChecked ? "ทำเรื่องเงินแล้ว" : "ยังไม่ได้ทำเรื่องเงิน"}
>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(() => {
toggleMonth(ym).catch((e) => toast.error(e as string));
})
}
className="cursor-pointer"
>
{monthChecked ? (
<Check className="size-5 text-green-500" />
) : (
<Circle className="size-5 text-muted-foreground" />
)}
</button>
</SimpleTooltip>
<div className="text-muted-foreground">
รวม{" "}
<span className="font-semibold text-foreground">
{total.toLocaleString()}
</span>{" "}
บาท
</div>
</div>
</div>
<div className="grid flex-1 grid-cols-7 grid-rows-[min-content_repeat(6,minmax(0,1fr))] gap-px rounded-lg bg-black/25">
{DAYS.map((d) => (
<div
key={d}
className="p-2 text-center text-xs font-medium text-muted-foreground"
>
{d}
</div>
))}
{/* some hack for month start */}
{Array.from({
length: firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1,
}).map((_, i) => (
<div key={`pad-${i}`} />
))}
{Array.from({ length: daysInMonth }, (_, i) => i + 1).map((day) => {
const dayRows = rowsByDay[day] || [];
const dayTotal = dayRows.reduce((s, r) => s + r.price, 0);
const hasData = dayRows.length > 0;
return (
<Dialog key={day}>
<DialogTrigger asChild>
<button
type="button"
className={cn(
"flex flex-col items-start gap-0.5 rounded-md border p-2 text-left transition-colors",
hasData
? "cursor-pointer border-primary/30 bg-primary/5 hover:bg-primary/10"
: "border-transparent",
!hasData && "cursor-default",
)}
>
<span className="text-sm">{day}</span>
{hasData && (
<>
<Badge
variant="secondary"
className="h-4 px-1 py-0 text-[10px]"
>
{dayRows.length} คิว
</Badge>
<span className="text-[10px] text-muted-foreground">
{dayTotal.toLocaleString()} ฿
</span>
</>
)}
</button>
</DialogTrigger>
<DialogContent className="max-h-[80svh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>
{format(
new Date(current.getFullYear(), current.getMonth(), day),
"d MMM yyyy",
)}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
{dayRows.length === 0 && (
<p className="text-sm text-muted-foreground">ไม่มีคิวในวันนี้</p>
)}
{dayRows.map((r) => (
<div
key={r.id}
className={cn(
"flex items-start gap-3 rounded-md border p-3",
r.deleted && "opacity-50",
)}
>
{r.slip ? (
<div className="relative size-16 shrink-0 overflow-hidden rounded">
<Image
src={`/api/slip/${r.slip.id}`}
alt="Slip"
fill
className="object-cover blur-sm brightness-50"
/>
</div>
) : r.price <= 0 ? (
<div className="flex size-16 shrink-0 items-center justify-center rounded bg-muted">
<ReceiptText className="size-6 text-muted-foreground" />
</div>
) : (
<div className="flex size-16 shrink-0 items-center justify-center rounded bg-muted">
<ImageOff className="size-6 text-muted-foreground" />
</div>
)}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{r.queue}. {r.name}
</span>
{r.deleted && (
<Tooltip>
<TooltipTrigger>
<Trash2 className="size-3.5 shrink-0 text-destructive" />
</TooltipTrigger>
<TooltipContent>ลบแล้ว</TooltipContent>
</Tooltip>
)}
</div>
<span className="truncate text-xs text-muted-foreground">
{r.service.join(", ")}
</span>
<div className="flex items-center gap-2 text-xs">
<span className="font-medium">{r.price} ฿</span>
{r.user && (
<span className="text-muted-foreground">
{r.user.display}
</span>
)}
{r.slip && (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="ml-auto size-5"
>
<ExternalLink className="size-3" />
</Button>
</DialogTrigger>
<DialogContent className="flex h-full max-w-dvw! flex-col bg-[#2225] backdrop-blur-xs">
<DialogTitle className="h-min">สลิป</DialogTitle>
<DialogClose asChild>
<div className="relative flex h-full w-full grow justify-center">
<Image
src={`/api/slip/${r.slip.id}`}
alt="Slip"
fill
className="object-contain"
/>
</div>
</DialogClose>
</DialogContent>
</Dialog>
)}
</div>
</div>
</div>
))}
</div>
<DialogFooter>
<span className="text-sm text-muted-foreground">
รวม {dayTotal.toLocaleString()} บาท
</span>
</DialogFooter>
</DialogContent>
</Dialog>
);
})}
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { and, between, eq, getTableColumns } from "drizzle-orm";
import { startOfMonth, endOfMonth } from "date-fns";
import { redirect } from "next/navigation";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import {
endgameDiscord,
endgameSettings,
endgameSlips,
endgameSubmissions,
} from "@/lib/db/schema";
import { CalendarClient } from "./client";
export const metadata = { title: "ปฏิทินคิว" };
export default async function CalendarPage({
searchParams,
}: {
searchParams: Promise<{ month?: string }>;
}) {
if (!(await adminCheck()))
redirect(`/login?next=${encodeURIComponent("/rubgram/admin/calendar")}`);
const { month } = await searchParams;
const date = month ? new Date(month + "-01T00:00:00") : new Date();
if (isNaN(date.getTime())) redirect("/rubgram/admin/calendar");
const from = startOfMonth(date);
const to = endOfMonth(date);
const { slip: _, ...slipColumns } = getTableColumns(endgameSlips);
const [rows, [settings]] = await Promise.all([
db
.select({
...getTableColumns(endgameSubmissions),
user: getTableColumns(endgameDiscord),
slip: slipColumns,
})
.from(endgameSubmissions)
.where(and(between(endgameSubmissions.submit_day, from, to)))
.leftJoin(endgameDiscord, eq(endgameDiscord.uid, endgameSubmissions.user))
.leftJoin(endgameSlips, eq(endgameSlips.id, endgameSubmissions.slip))
.orderBy(endgameSubmissions.submit_day, endgameSubmissions.queue),
db
.select({ monthly: endgameSettings.monthly })
.from(endgameSettings)
.limit(1),
]);
const total = rows.reduce((s, r) => s + r.price, 0);
const monthly = settings?.monthly ?? {};
return (
<CalendarClient
year={date.getFullYear()}
month={date.getMonth()}
rows={rows}
total={total}
monthly={monthly}
/>
);
}
+15 -20
View File
@@ -1,7 +1,7 @@
"use client";
import { useProgress } from "@bprogress/next";
import { Lock, Logs, Unlock } from "lucide-react";
import { Calendar, Lock, Unlock } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
@@ -72,7 +72,8 @@ export function SidebarLink({
}
}}
>
{submission.queue}. {submission.name}
{submission.checked ? <div className="px-1" /> : `${submission.queue}. `}
{submission.name}
<SidebarMenuBadge className="pointer-events-auto">
<Checkbox
className="mr-2"
@@ -251,10 +252,9 @@ export function SubmissionList({
id: string;
name: string;
checked: boolean;
queue: number;
publicQueue: number;
paid: boolean;
archived: boolean;
deleted: boolean;
}[];
}) {
const [query, setQuery] = useState("");
@@ -277,18 +277,18 @@ export function SubmissionList({
{subs
.filter(
(s) =>
(debug || (s.paid && !s.archived)) &&
(s.queue + s.name).toLowerCase().includes(query),
(debug || (s.paid && !s.deleted)) &&
(s.publicQueue + s.name).toLowerCase().includes(query),
)
.map((s) => (
<SidebarMenuButton
key={s.id}
asChild
className={cn(((debug && !s.paid) || s.archived) && "opacity-50")}
className={cn(((debug && !s.paid) || s.deleted) && "opacity-50")}
>
<SidebarLink
submission={debug ? s : { ...s, queue: s.publicQueue }}
prefetch={!s.archived}
submission={{ ...s, queue: s.publicQueue }}
prefetch={!s.deleted}
/>
</SidebarMenuButton>
))}
@@ -324,23 +324,18 @@ export function Watcher() {
return <div></div>;
}
export function SlipButton() {
export function CalendarButton() {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => {
window.location.href = "/rubgram/admin/slip";
}}
>
<Logs size={24} className="size-6" />
<Button variant="ghost" size="icon" className="size-8" asChild>
<Link href="/rubgram/admin/calendar">
<Calendar size={24} className="size-6" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>All time คิว</p>
<p>ปฏิทินคิว</p>
</TooltipContent>
</Tooltip>
);
+13 -10
View File
@@ -28,8 +28,13 @@ import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { endgameSubmissions } from "@/lib/db/schema";
import { getEndgameConfig, random, wipe } from "../api";
import { LimitManager, SlipButton, SubmissionList, Watcher } from "./client";
import { desc, sql } from "drizzle-orm";
import {
CalendarButton,
LimitManager,
SubmissionList,
Watcher,
} from "./client";
import { sql } from "drizzle-orm";
import Link from "next/link";
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
import { ErrorModal } from "@/components/error";
@@ -48,21 +53,19 @@ export default async function AdminLayout({
id: endgameSubmissions.id,
name: endgameSubmissions.name,
checked: endgameSubmissions.checked,
queue: endgameSubmissions.queue,
publicQueue: sql<number>`
${endgameSubmissions.queue} - (
select count(*)
select count(*)::int
from ${endgameSubmissions} e2
where e2.checked = true
and e2.queue < ${endgameSubmissions.queue}
or e2.archived
where (e2.checked and e2.queue < endgame.submissions.queue)
or e2.deleted
)
`,
paid: endgameSubmissions.paid,
archived: endgameSubmissions.archived,
deleted: endgameSubmissions.deleted,
})
.from(endgameSubmissions)
.orderBy(desc(endgameSubmissions.archived), endgameSubmissions.id);
.orderBy(endgameSubmissions.deleted, endgameSubmissions.id);
const config = await getEndgameConfig();
return (
<SidebarProvider>
@@ -84,7 +87,7 @@ export default async function AdminLayout({
>
<Dice5 size={24} className="size-6" />
</Button>
<SlipButton />
<CalendarButton />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
+26 -44
View File
@@ -8,7 +8,6 @@ import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import {
endgameArchive,
endgameDiscord,
endgameExpired,
endgameSettings,
@@ -23,52 +22,15 @@ import { checkSlip } from "@/lib/payment";
const { DISCORD_WEBHOOK_URL, DISCORD_CLIENT_ID, BASE_URL } =
process.env as Record<string, string>;
export async function archive({ onlyChecked = true, copy = true }) {
if (!(await adminCheck())) throw "Unauthorized";
await db.transaction(async (tx) => {
const deleted = await (
copy
? tx
.update(endgameSubmissions)
.set({
archived: true,
})
.returning()
: tx.delete(endgameSubmissions).returning()
).where(
and(
onlyChecked ? eq(endgameSubmissions.checked, true) : undefined,
not(endgameSubmissions.archived),
),
);
if (!onlyChecked && !copy)
await tx.execute(
sql`ALTER SEQUENCE endgame.submissions_queue_seq RESTART WITH 1`,
);
const [{ maxRound }] = await tx
.select({ maxRound: sql<number>`MAX(${endgameArchive.round})` })
.from(endgameArchive);
await tx.insert(endgameArchive).values(
deleted
.filter((e) => e.paid)
.map((s) => ({
...s,
round: maxRound + 1,
})),
);
});
}
export async function wipe() {
if (!(await adminCheck())) throw "Unauthorized";
await archive({ onlyChecked: false });
await db.update(endgameSubmissions).set({ deleted: true });
revalidatePath("/rubgram/admin");
revalidatePath("/rubgram");
sse.rubgram.pub("update", { type: "wipe" });
await actionLog(`Deleted rubgram submissions`);
redirect("/rubgram/admin");
}
export async function random() {
@@ -165,6 +127,23 @@ export async function setFree(free: number) {
await actionLog(`Set rubgram free submission amount to ${free}`);
}
export async function toggleMonth(month: string) {
if (!(await adminCheck())) throw "Unauthorized";
const [s] = await db
.select({ monthly: endgameSettings.monthly })
.from(endgameSettings)
.limit(1);
const monthly = {
...(s?.monthly ?? {}),
[month]: !(s?.monthly?.[month] ?? false),
};
await db
.update(endgameSettings)
.set({ monthly })
.where(eq(endgameSettings.id, true));
revalidatePath("/rubgram/admin/calendar");
}
export async function getEndgameConfig() {
const [ngm]: (typeof endgameSettings.$inferSelect | undefined)[] = await db
.select()
@@ -222,7 +201,7 @@ export async function submitEndgame(formData: FormData) {
.where(
and(
eq(endgameSubmissions.user, user),
not(endgameSubmissions.archived),
not(endgameSubmissions.deleted),
not(endgameSubmissions.checked),
not(endgameSubmissions.paid),
),
@@ -265,7 +244,9 @@ export async function submitEndgamePayment(formData: FormData) {
const [s] = await tx
.select()
.from(endgameSubmissions)
.where(eq(endgameSubmissions.id, sid))
.where(
and(eq(endgameSubmissions.id, sid), not(endgameSubmissions.deleted)),
)
.limit(1);
const [a] = await tx
.select()
@@ -410,9 +391,10 @@ export async function removeExpiredSubmissions() {
}
export async function cancel(sid: string) {
await db.delete(endgameSubmissions).where(eq(endgameSubmissions.id, sid));
await removeExpiredSubmissions();
await db
.update(endgameSubmissions)
.set({ deleted: true })
.where(eq(endgameSubmissions.id, sid));
sse.rubgram.pub("update", { type: "cancel" });
+2 -2
View File
@@ -87,7 +87,7 @@ export default async function EndgamePage() {
select count(*)
from ${endgameSubmissions} e2
where e2.checked = true
and e2.queue < ${endgameSubmissions.queue}
and e2.queue < endgame.submissions.queue
)
`,
paid: endgameSubmissions.paid,
@@ -104,7 +104,7 @@ export default async function EndgamePage() {
gt(endgameSubmissions.expires, new Date()),
),
),
not(endgameSubmissions.archived),
not(endgameSubmissions.deleted),
),
)
: [];
+4 -1
View File
@@ -1,6 +1,9 @@
import { not } from "drizzle-orm";
import { db } from "@/lib/db";
import { endgameSubmissions } from "@/lib/db/schema";
export async function GET() {
return Response.json(await db.$count(endgameSubmissions));
return Response.json(
await db.$count(endgameSubmissions, not(endgameSubmissions.deleted)),
);
}
+1 -8
View File
@@ -2,7 +2,7 @@ import { env } from "node:process";
import { cron, redis } from "bun";
import { formatDistanceToNow } from "date-fns";
import { and, asc, eq, gt, isNull, lt, not, sql } from "drizzle-orm";
import { archive, removeExpiredSubmissions } from "@/app/(ui)/rubgram/api";
import { removeExpiredSubmissions } from "@/app/(ui)/rubgram/api";
import { auth, issueInternalToken } from "@/lib/auth";
import { db } from "@/lib/db";
import {
@@ -272,13 +272,6 @@ async function cacheCards() {
} else schedule(120, cacheCards);
}
cron("0 0 1 * *", async function rubgramArchive() {
const { log } = logger("rubgramArchive");
log("Started...");
await archive({});
log("Done");
});
cron("0 0 */14 * *", async function syncAmber() {
const { log, error } = logger("syncAmber");
log("Syncing...");
+3 -15
View File
@@ -159,21 +159,8 @@ export const endgameSubmissions = endgame.table("submissions", {
isNotNull(endgameSubmissions.slip),
)!,
),
archived: boolean().notNull().default(false),
});
export const endgameArchive = endgame.table("sarchive", {
id: text().primaryKey(),
name: text().notNull(),
user: text().notNull(),
queue: integer().notNull(),
price: integer().notNull(),
slip: text().references(() => endgameSlips.id),
round: integer().notNull(),
service: text()
.references(() => endgameTypes.id)
.array()
.notNull(),
deleted: boolean().notNull().default(false),
submit_day: timestamp().notNull().defaultNow(),
});
// expired submission
@@ -207,6 +194,7 @@ export const endgameSettings = endgame.table("settings", {
limit: integer().notNull().default(-1),
free: integer().notNull().default(0),
allDiscount: integer().notNull().default(10),
monthly: jsonb().$type<Record<string, boolean>>().notNull().default({}),
});
export const endgameDiscord = endgame.table("discord", {