From 3070286753f7279df865f8b1527fb3f1463ac7db Mon Sep 17 00:00:00 2001 From: dmgnr Date: Mon, 13 Jul 2026 12:47:05 +0700 Subject: [PATCH 01/71] fix(rg): refresh users in manual causes accidental submit --- app/(ui)/rubgram/admin/@modal/manual/client.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/(ui)/rubgram/admin/@modal/manual/client.tsx b/app/(ui)/rubgram/admin/@modal/manual/client.tsx index c6754da..cc47b8c 100644 --- a/app/(ui)/rubgram/admin/@modal/manual/client.tsx +++ b/app/(ui)/rubgram/admin/@modal/manual/client.tsx @@ -209,6 +209,7 @@ export function UserSelect({ variant="outline" onClick={() => setBump(Date.now())} disabled={loading} + type="button" > {loading ? : } From 52beaaa41881b80912ceeb45a6828dbec44ef11b Mon Sep 17 00:00:00 2001 From: gunshiz Date: Mon, 13 Jul 2026 19:50:13 +0700 Subject: [PATCH 02/71] feat : add auto chat redirect --- app/api/chat/route.ts | 45 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 app/api/chat/route.ts diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..916e770 --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; + +type APISearchResponse = { + items?: { + id?: { + videoId?: string; + }; + }[]; +}; + +const channelId = "UCC80OcUEuutVW37MC190JJg"; + +export async function GET() { + const apiKey = process.env.YOUTUBE_API_KEY; + + if (!apiKey) { + return new NextResponse("YOUTUBE_API_KEY is not configured.", { + status: 500, + }); + } + + const response = await fetch( + `https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${channelId}&eventType=live&type=video&maxResults=1&key=${apiKey}`, + { cache: "no-store" }, + ); + + if (!response.ok) { + return new NextResponse("Failed to search for live stream.", { + status: 502, + }); + } + + const data: APISearchResponse = await response.json(); + const videoId = data.items?.[0]?.id?.videoId; + + if (!videoId) { + return new NextResponse("No live stream found for this channel.", { + status: 404, + }); + } + + return NextResponse.redirect( + `https://www.youtube.com/live_chat?is_popout=${videoId}`, + ); +} From cf722c4737b3d0d055e2306a53d91f69a7975ef8 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Mon, 13 Jul 2026 20:03:58 +0700 Subject: [PATCH 03/71] fix(acr) : reuse live API --- app/api/chat/route.ts | 45 ++++++++++++++++--------------------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 916e770..05a3024 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,45 +1,34 @@ import { NextResponse } from "next/server"; +import type { YoutubeLiveInfo } from "../live/route"; -type APISearchResponse = { - items?: { - id?: { - videoId?: string; - }; - }[]; -}; - -const channelId = "UCC80OcUEuutVW37MC190JJg"; - -export async function GET() { - const apiKey = process.env.YOUTUBE_API_KEY; - - if (!apiKey) { - return new NextResponse("YOUTUBE_API_KEY is not configured.", { - status: 500, - }); - } - - const response = await fetch( - `https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${channelId}&eventType=live&type=video&maxResults=1&key=${apiKey}`, - { cache: "no-store" }, - ); +export async function GET(request: Request) { + const response = await fetch(new URL("/api/live", request.url), { + cache: "no-store", + }); if (!response.ok) { - return new NextResponse("Failed to search for live stream.", { + return new NextResponse("Failed to get live stream.", { status: 502, }); } - const data: APISearchResponse = await response.json(); - const videoId = data.items?.[0]?.id?.videoId; + const liveInfo: YoutubeLiveInfo = await response.json(); - if (!videoId) { + if (liveInfo === "none") { return new NextResponse("No live stream found for this channel.", { status: 404, }); } + const videoId = new URL(liveInfo.url).searchParams.get("v"); + + if (!videoId) { + return new NextResponse("Live stream video id was not found.", { + status: 502, + }); + } + return NextResponse.redirect( - `https://www.youtube.com/live_chat?is_popout=${videoId}`, + `https://studio.youtube.com/live_chat?is_popout=1&v=${videoId}`, ); } From 5d088dc3a4040eeceba68bf3e6c752fee216da3e Mon Sep 17 00:00:00 2001 From: gunshiz Date: Mon, 13 Jul 2026 20:36:24 +0700 Subject: [PATCH 04/71] fix(acr) : dreamgineer issue [no ci] --- app/api/chat/route.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 05a3024..c946b0c 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,8 +1,14 @@ import { NextResponse } from "next/server"; -import type { YoutubeLiveInfo } from "../live/route"; + +type LiveResponse = + | { + url: string; + } + | "none"; export async function GET(request: Request) { - const response = await fetch(new URL("/api/live", request.url), { + const liveUrl = new URL("https://buzz.sudloh.com/api/live", request.url).toString(); + const response = await fetch(liveUrl, { cache: "no-store", }); @@ -12,7 +18,7 @@ export async function GET(request: Request) { }); } - const liveInfo: YoutubeLiveInfo = await response.json(); + const liveInfo: LiveResponse = await response.json(); if (liveInfo === "none") { return new NextResponse("No live stream found for this channel.", { From 13b49743d0b7b8c4396188c783aad2f18c79bffe Mon Sep 17 00:00:00 2001 From: dmgnr Date: Mon, 13 Jul 2026 20:43:53 +0700 Subject: [PATCH 05/71] hotfix: disable promptpay donation --- app/(ui)/donate/page.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/(ui)/donate/page.tsx b/app/(ui)/donate/page.tsx index 4ea8606..26fd5ff 100644 --- a/app/(ui)/donate/page.tsx +++ b/app/(ui)/donate/page.tsx @@ -332,15 +332,15 @@ export default async function () { ), value: "tmn", }, - { - label: ( - - - PromptPay - - ), - value: "pp", - }, + // { + // label: ( + // + // + // PromptPay + // + // ), + // value: "pp", + // }, ]} > From bceaf9e7f45997c91dc3b9f01d6b42d40e1e3193 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Tue, 14 Jul 2026 13:39:10 +0700 Subject: [PATCH 06/71] revert: hotfix: disable promptpay donation --- app/(ui)/donate/page.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/(ui)/donate/page.tsx b/app/(ui)/donate/page.tsx index 26fd5ff..4ea8606 100644 --- a/app/(ui)/donate/page.tsx +++ b/app/(ui)/donate/page.tsx @@ -332,15 +332,15 @@ export default async function () { ), value: "tmn", }, - // { - // label: ( - // - // - // PromptPay - // - // ), - // value: "pp", - // }, + { + label: ( + + + PromptPay + + ), + value: "pp", + }, ]} > From 745a9152d9b4e32ef9c981eaca37ada1c21ef9d1 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Tue, 14 Jul 2026 14:12:27 +0700 Subject: [PATCH 07/71] feat(don): add payment method indicator in admin --- app/(ui)/donate/admin/client.tsx | 14 +++++++++++++- app/(ui)/donate/page.tsx | 1 + lib/db/schema.ts | 2 ++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/(ui)/donate/admin/client.tsx b/app/(ui)/donate/admin/client.tsx index 3694d7d..d9a6397 100644 --- a/app/(ui)/donate/admin/client.tsx +++ b/app/(ui)/donate/admin/client.tsx @@ -11,6 +11,7 @@ import { ImageIcon, MessageCircleWarning, WalletIcon, + QrCodeIcon as PromptpayIcon, } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -32,6 +33,8 @@ import { getImage, reloadWidget, resendPopup, testPopup } from "./api"; import { formatDistanceToNow } from "date-fns"; import { lePalette } from "../../rubgram/admin/[id]/client"; import { th } from "date-fns/locale"; +import TruemoneyIcon from "#/assets/tmn.webp"; +import Image from "@/components/image"; // directly adapted for date instead of UUID function colorFor(date: Date) { @@ -135,7 +138,7 @@ const columns: ColumnDef[] = [ cell(row) { return ; }, - meta: { className: "w-27 p-0" }, + meta: { className: "w-36 p-0" }, }, ]; @@ -148,6 +151,15 @@ function ActionRow({ }) { return (
+ {row.method === "pp" ? ( + + ) : ( + truemoney + )} Date: Sun, 19 Jul 2026 02:15:57 +0700 Subject: [PATCH 08/71] feat: add website wide force refresh button --- app/(ui)/admin/settings/api.ts | 10 ++++++ app/(ui)/admin/settings/services.tsx | 27 +++++++------- app/client.tsx | 54 +++++++++++++++------------- lib/db/sse-endpoints.ts | 1 + 4 files changed, 55 insertions(+), 37 deletions(-) diff --git a/app/(ui)/admin/settings/api.ts b/app/(ui)/admin/settings/api.ts index 877b012..03b7d72 100755 --- a/app/(ui)/admin/settings/api.ts +++ b/app/(ui)/admin/settings/api.ts @@ -7,13 +7,17 @@ import { revalidatePath } from "next/cache"; import { actionLog } from "@/lib/api"; import { db } from "@/lib/db"; import { settings } from "@/lib/db/schema"; +import { adminCheck } from "@/lib/auth"; +import { sse } from "@/lib/db/sse-endpoints"; export async function getEnka() { + if (!(await adminCheck())) throw "Unauthorized"; const [{ enka } = { enka: true }] = await db.select().from(settings).limit(1); return enka; } export async function toggleEnka(state: boolean) { + if (!(await adminCheck())) throw "Unauthorized"; const last = await getEnka(); if (last === state) return; await db @@ -27,9 +31,15 @@ export async function toggleEnka(state: boolean) { } export async function syncAmber() { + if (!(await adminCheck())) throw "Unauthorized"; const res = await promisify(exec)("bun util/sync 2>&1", { env: { ...env, NO_AUTH_CHECK: "1" }, }); await actionLog("Triggered an Amber sync", { result: res.stdout }); return res.stdout; } + +export async function forceRefresh() { + if (!(await adminCheck())) throw "Unauthorized"; + sse.active.pub("refresh", null); +} diff --git a/app/(ui)/admin/settings/services.tsx b/app/(ui)/admin/settings/services.tsx index bb80953..70f70b4 100755 --- a/app/(ui)/admin/settings/services.tsx +++ b/app/(ui)/admin/settings/services.tsx @@ -1,21 +1,19 @@ "use client"; -import { FolderSync } from "lucide-react"; +import { Bomb, FolderSync } from "lucide-react"; import { useEffect, useState } from "react"; import { CdnChooser } from "@/components/chooser"; -import { Button } from "@/components/ui/button"; -import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { shared } from "@/lib/comms"; -import { syncAmber, toggleEnka } from "./api"; +import { forceRefresh, syncAmber, toggleEnka } from "./api"; import { Section } from "./page"; +import { ActionButton } from "@/components/action-button"; export function SettingsServicesSection({ enka: enkaInitial, }: { enka: boolean; }) { - const [syncing, setSyncing] = useState(false); const [syncResult, setSyncResult] = useState(""); const [enka, setEnka] = useState(); const [debug] = shared.state("debug"); @@ -26,10 +24,7 @@ export function SettingsServicesSection({ }, [enka, enkaInitial]); async function sync() { - setSyncing(true); - syncAmber() - .then(setSyncResult) - .finally(() => setSyncing(false)); + return syncAmber().then(setSyncResult); } return ( @@ -39,10 +34,16 @@ export function SettingsServicesSection({ เปิดใช้การดึงตัวละครใน Artifact
- +
+ + + Sync Amber + + + + FORCE RELOAD + +
{debug && (
diff --git a/app/client.tsx b/app/client.tsx index 1345ab9..0618c4f 100755 --- a/app/client.tsx +++ b/app/client.tsx @@ -42,36 +42,42 @@ export function VersionCheck({ headless = false }: { headless?: boolean }) { useEffect(() => { if (!readyForUpdate && window.location.hash === "#update") { setUpdated(true); - setTimeout(() => toast.success("คุณอยู่ในเวอร์ชั่นล่าสุดแล้ว"), 1000); + if (!headless) + setTimeout(() => toast.success("คุณอยู่ในเวอร์ชั่นล่าสุดแล้ว"), 1000); const url = new URL(window.location.href); url.hash = ""; window.history.replaceState("", "", url); } - const { clean, es } = sse.active.sub( - "version", - (newVersion) => { - setConnected(true); - emit("sync"); - const version = ver.current; - if (!version) console.log("Client version:", newVersion); - console.log("Server version:", newVersion); - if (version && newVersion !== version) { - setReadyForUpdate(true); + const { clean, es } = sse.active.subMany( + { + version(newVersion) { + setConnected(true); + emit("sync"); + const version = ver.current; + if (!version) console.log("Client version:", newVersion); + console.log("Server version:", newVersion); + if (version && newVersion !== version) { + setReadyForUpdate(true); + window.location.hash = "#update"; + if (headless) window.location.reload(); + else + toast("มีอัปเดตใหม่พร้อมใช้งาน", { + description: "รีโหลดเพื่ออัปเดตเป็นเวอร์ชันล่าสุด", + action: { + label: "รีโหลด", + // Do a full reload + onClick: () => window.location.reload(), + }, + duration: Infinity, + }); + } + if (newVersion !== version) ver.current = newVersion; + }, + refresh() { window.location.hash = "#update"; - if (headless) window.location.reload(); - else - toast("มีอัปเดตใหม่พร้อมใช้งาน", { - description: "รีโหลดเพื่ออัปเดตเป็นเวอร์ชันล่าสุด", - action: { - label: "รีโหลด", - // Do a full reload - onClick: () => window.location.reload(), - }, - duration: Infinity, - }); - } - if (newVersion !== version) ver.current = newVersion; + queueMicrotask(() => window.location.reload()); + }, }, { endpoint: "/api/active", diff --git a/lib/db/sse-endpoints.ts b/lib/db/sse-endpoints.ts index bb3b70d..6386c1b 100644 --- a/lib/db/sse-endpoints.ts +++ b/lib/db/sse-endpoints.ts @@ -48,6 +48,7 @@ export const sse = sseEndpointMap({ active: { version: z.string(), live: z.custom(), + refresh: z.null(), }, // Admin Live Log log: { From 6b6405950121c5b4459610e4beaf7f9ce4e98ce1 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 19 Jul 2026 02:58:44 +0700 Subject: [PATCH 09/71] feat(don): add method toggle --- app/(ui)/admin/@modal/guide/[id]/page.tsx | 2 + app/(ui)/admin/@modal/guide/create/page.tsx | 2 + app/(ui)/admin/guide/api.ts | 3 + app/(ui)/admin/settings/api.ts | 29 +++++++-- app/(ui)/admin/settings/page.tsx | 12 +++- app/(ui)/admin/settings/services.tsx | 71 ++++++++++++++++++--- app/(ui)/donate/page.tsx | 68 ++++++++++++-------- app/client.tsx | 3 +- lib/db/schema.ts | 4 +- lib/db/sse-endpoints.ts | 2 +- next.config.ts | 2 +- 11 files changed, 149 insertions(+), 49 deletions(-) diff --git a/app/(ui)/admin/@modal/guide/[id]/page.tsx b/app/(ui)/admin/@modal/guide/[id]/page.tsx index db58e15..6ff4259 100644 --- a/app/(ui)/admin/@modal/guide/[id]/page.tsx +++ b/app/(ui)/admin/@modal/guide/[id]/page.tsx @@ -18,6 +18,7 @@ import { actionLog } from "@/lib/api"; import { adminCheck } from "@/lib/auth"; import { db } from "@/lib/db"; import { guides } from "@/lib/db/schema"; +import { forceRefresh } from "../../../settings/api"; export default async function GuideEditPage({ params, @@ -66,6 +67,7 @@ export default async function GuideEditPage({ await actionLog(`Updated guide ${data.name}`, data); revalidatePath("/admin/guide"); + forceRefresh("/guide"); return { toast: "Guide updated successfully.", close: true }; } diff --git a/app/(ui)/admin/@modal/guide/create/page.tsx b/app/(ui)/admin/@modal/guide/create/page.tsx index dbc2af1..c28f2c4 100644 --- a/app/(ui)/admin/@modal/guide/create/page.tsx +++ b/app/(ui)/admin/@modal/guide/create/page.tsx @@ -18,6 +18,7 @@ import { actionLog } from "@/lib/api"; import { adminCheck } from "@/lib/auth"; import { db } from "@/lib/db"; import { guides } from "@/lib/db/schema"; +import { forceRefresh } from "../../../settings/api"; export default async function GuideCreatePage() { if (!(await adminCheck())) redirect("/login"); @@ -64,6 +65,7 @@ export default async function GuideCreatePage() { await actionLog(`Added guide ${data.name}`, data); revalidatePath("/admin/guide"); + forceRefresh("/guide"); return { toast: "Guide added successfully.", close: true }; } diff --git a/app/(ui)/admin/guide/api.ts b/app/(ui)/admin/guide/api.ts index 96ee826..9210517 100644 --- a/app/(ui)/admin/guide/api.ts +++ b/app/(ui)/admin/guide/api.ts @@ -6,6 +6,7 @@ import { redirect } from "next/navigation"; import { adminCheck } from "@/lib/auth"; import { db } from "@/lib/db"; import { guides } from "@/lib/db/schema"; +import { forceRefresh } from "../settings/api"; export async function hideGuide(id: string) { if (!(await adminCheck())) redirect("/login"); @@ -15,5 +16,7 @@ export async function hideGuide(id: string) { hidden: not(guides.hidden), }) .where(eq(guides.id, id)); + + forceRefresh("/guide"); revalidatePath("/admin/guide"); } diff --git a/app/(ui)/admin/settings/api.ts b/app/(ui)/admin/settings/api.ts index 03b7d72..d35c414 100755 --- a/app/(ui)/admin/settings/api.ts +++ b/app/(ui)/admin/settings/api.ts @@ -10,16 +10,16 @@ import { settings } from "@/lib/db/schema"; import { adminCheck } from "@/lib/auth"; import { sse } from "@/lib/db/sse-endpoints"; -export async function getEnka() { +export async function getSettongs() { if (!(await adminCheck())) throw "Unauthorized"; - const [{ enka } = { enka: true }] = await db.select().from(settings).limit(1); - return enka; + const [ + settangs = { enka: true, donatePromptpay: true, donateTruemoney: true }, + ] = await db.select().from(settings).limit(1); + return settangs; } export async function toggleEnka(state: boolean) { if (!(await adminCheck())) throw "Unauthorized"; - const last = await getEnka(); - if (last === state) return; await db .insert(settings) .values({ enka: state }) @@ -39,7 +39,22 @@ export async function syncAmber() { return res.stdout; } -export async function forceRefresh() { +export async function forceRefresh(prefix: string | null = null) { if (!(await adminCheck())) throw "Unauthorized"; - sse.active.pub("refresh", null); + sse.active.pub("refresh", prefix); + + await actionLog("Pulled a force refresh"); +} + +export async function toggleDonatePaymentMethod( + method: "donatePromptpay" | "donateTruemoney", + state: boolean, +) { + if (!(await adminCheck())) throw "Unauthorized"; + await db + .insert(settings) + .values({ [method]: state }) + .onConflictDoUpdate({ target: settings.id, set: { [method]: state } }); + + await actionLog("Changed a settings", { [method]: state }); } diff --git a/app/(ui)/admin/settings/page.tsx b/app/(ui)/admin/settings/page.tsx index 562a596..0956a5e 100755 --- a/app/(ui)/admin/settings/page.tsx +++ b/app/(ui)/admin/settings/page.tsx @@ -8,11 +8,11 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import { SettingsAccountSection } from "./account"; -import { getEnka } from "./api"; +import { getSettongs } from "./api"; import { SettingsServicesSection } from "./services"; export default async function () { - const enka = await getEnka(); + const { enka, donatePromptpay, donateTruemoney } = await getSettongs(); return (
@@ -22,7 +22,11 @@ export default async function () { บัญชี - + @@ -49,3 +53,5 @@ export function Section({ ); } + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/settings/services.tsx b/app/(ui)/admin/settings/services.tsx index 70f70b4..42f94f4 100755 --- a/app/(ui)/admin/settings/services.tsx +++ b/app/(ui)/admin/settings/services.tsx @@ -1,28 +1,36 @@ "use client"; import { Bomb, FolderSync } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { CdnChooser } from "@/components/chooser"; import { Switch } from "@/components/ui/switch"; import { shared } from "@/lib/comms"; -import { forceRefresh, syncAmber, toggleEnka } from "./api"; +import { + forceRefresh, + syncAmber, + toggleDonatePaymentMethod, + toggleEnka, +} from "./api"; import { Section } from "./page"; import { ActionButton } from "@/components/action-button"; +import { Input } from "@/components/ui/input"; export function SettingsServicesSection({ enka: enkaInitial, + donPp: initialDonPp, + donTmn: initialDonTmn, }: { enka: boolean; + donPp: boolean; + donTmn: boolean; }) { const [syncResult, setSyncResult] = useState(""); - const [enka, setEnka] = useState(); + const [enka, setEnka] = useState(enkaInitial); + const [donPp, setDonPp] = useState(initialDonPp); + const [donTmn, setDonTmn] = useState(initialDonTmn); + const [pathToReload, setPathToReload] = useState(""); const [debug] = shared.state("debug"); - useEffect(() => { - if (typeof enka !== "boolean") return setEnka(enkaInitial); - toggleEnka(enka); - }, [enka, enkaInitial]); - async function sync() { return syncAmber().then(setSyncResult); } @@ -31,7 +39,13 @@ export function SettingsServicesSection({
- + ( + setEnka(v), + toggleEnka(v).then(forceRefresh.bind(null, "/artifact")) + )} + /> เปิดใช้การดึงตัวละครใน Artifact
@@ -44,6 +58,45 @@ export function SettingsServicesSection({ FORCE RELOAD
+
+ setPathToReload(v.target.value)} + /> + + + Reload + +
+ Donate Payment Method +
+ ( + setDonPp(v), + toggleDonatePaymentMethod("donatePromptpay", v).then( + forceRefresh.bind(null, "/donate"), + ) + )} + /> + Promptpay +
+
+ ( + setDonTmn(v), + toggleDonatePaymentMethod("donateTruemoney", v).then( + forceRefresh.bind(null, "/donate"), + ) + )} + /> + Truemoney +
{debug && (
diff --git a/app/(ui)/donate/page.tsx b/app/(ui)/donate/page.tsx index db3a45f..9cfc446 100644 --- a/app/(ui)/donate/page.tsx +++ b/app/(ui)/donate/page.tsx @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { QrCodeIcon, SendIcon } from "lucide-react"; +import { CircleX, QrCodeIcon, SendIcon } from "lucide-react"; import type { Metadata } from "next"; import z from "zod"; import { th } from "zod/v4/locales"; @@ -345,34 +345,50 @@ export default async function () { ]} > - - - + {artifactConfig.donateTruemoney ? ( + + + + ) : ( +
+ {" "} + ขณะนี้การโอนเงินด้วยทรูมันนี่ใช้ไม่ได้ชั่วคราว +
+ )}
-
- Promptpay QR Code -
- บัญชีรับโดเนท - - ผู้รับ: นาย พัชรพล พลพันธุ์ - - - บัญชี: xxx-x-x8666-x - - - เลขที่อ้างอิง: 004999056945438 - - + {artifactConfig.donatePromptpay ? ( + <> +
+ Promptpay QR Code +
+ บัญชีรับโดเนท + + ผู้รับ: นาย พัชรพล พลพันธุ์ + + + บัญชี: xxx-x-x8666-x + + + เลขที่อ้างอิง: 004999056945438 + + +
+
+ + + + + ) : ( +
+ {" "} + ขณะนี้การโอนเงินด้วยพร้อมเพย์ใช้ไม่ได้ชั่วคราว
-
- - - + )}
diff --git a/app/client.tsx b/app/client.tsx index 0618c4f..a422ff9 100755 --- a/app/client.tsx +++ b/app/client.tsx @@ -74,7 +74,8 @@ export function VersionCheck({ headless = false }: { headless?: boolean }) { } if (newVersion !== version) ver.current = newVersion; }, - refresh() { + refresh(prefix) { + if (prefix && !window.location.pathname.startsWith(prefix)) return; window.location.hash = "#update"; queueMicrotask(() => window.location.reload()); }, diff --git a/lib/db/schema.ts b/lib/db/schema.ts index b33b582..22d06a3 100755 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -74,7 +74,9 @@ export const versions = pgTable("versions", { export const settings = pgTable("settings", { id: boolean().primaryKey().default(true), - enka: boolean().notNull().default(false), // Turn this off when Enka is on maintenance + enka: boolean().notNull().default(true), // Turn this off when Enka is on maintenance + donatePromptpay: boolean().notNull().default(true), + donateTruemoney: boolean().notNull().default(true), donateGoal: numeric({ mode: "number" }), donateGoalStarting: timestamp(), }); diff --git a/lib/db/sse-endpoints.ts b/lib/db/sse-endpoints.ts index 6386c1b..9ca3c96 100644 --- a/lib/db/sse-endpoints.ts +++ b/lib/db/sse-endpoints.ts @@ -48,7 +48,7 @@ export const sse = sseEndpointMap({ active: { version: z.string(), live: z.custom(), - refresh: z.null(), + refresh: z.string().nullable(), }, // Admin Live Log log: { diff --git a/next.config.ts b/next.config.ts index ec50802..6e00d45 100755 --- a/next.config.ts +++ b/next.config.ts @@ -50,7 +50,7 @@ export default { }, deploymentId: process.env.NEXT_DEPLOYMENT_ID, // reactCompiler: true, - allowedDevOrigins: ["astral:3000", "dev3000.astrxl.dev"], + allowedDevOrigins: ["astral:3000", "dev3000.astrxl.dev", "nyx"], // basePath: "/beta" output: "standalone", } satisfies NextConfig; From c29726a8e469c85b1cf2defa03f4ede49b2182c1 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 19 Jul 2026 03:01:31 +0700 Subject: [PATCH 10/71] fix(ci): always pull migration image --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fe54e4a..febafd9 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -114,7 +114,7 @@ jobs: -n buzz \ --dry-run=client -o json \ -- bun dr push | \ - jq '.spec.template.spec.containers[0].envFrom = [{"secretRef": {"name": "buzz-env"}}]' | \ + jq '.spec.template.spec.containers[0].envFrom = [{"secretRef": {"name": "buzz-env"}}] | .spec.template.spec.containers[0].imagePullPolicy = "Always"' | \ kubectl apply -f - # Roll app & backend From 3fda5d212c8ada56b4dfec336cb48e69cdaaf0ba Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 19 Jul 2026 03:12:14 +0700 Subject: [PATCH 11/71] feat(yt): cache based on lookback instead of constant period --- app/api/live/route.ts | 16 ++++---- lib/adaptive-cache.ts | 94 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 lib/adaptive-cache.ts diff --git a/app/api/live/route.ts b/app/api/live/route.ts index 8d396cc..c742173 100755 --- a/app/api/live/route.ts +++ b/app/api/live/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; import { sse } from "@/lib/db/sse-endpoints"; - -export const revalidate = 900; +import { youtubeCache } from "@/lib/adaptive-cache"; export type YoutubeLiveInfo = | { @@ -60,13 +59,10 @@ export async function GET() { if (!apiKey || !channelId) return NextResponse.json("none"); - const response = await fetch( + const response = await youtubeCache.fetch( `https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${channelId}&eventType=live&type=video&maxResults=1&key=${apiKey}`, { - next: { - revalidate: 900, // refresh every 15 minutes - }, - cache: "force-cache", + cache: "no-store", }, ); @@ -74,7 +70,10 @@ export async function GET() { throw new Error(`YouTube API error: ${response.statusText}`); const data: APISearchResponse = await response.json(); - if (data.items.length <= 0) return NextResponse.json("none"); + + if (data.items.length === 0) + return NextResponse.json("none"); + const live = data.items[0]; const res: YoutubeLiveInfo = { @@ -85,5 +84,6 @@ export async function GET() { }; sse.active.pub("live", res); + return NextResponse.json(res); } diff --git a/lib/adaptive-cache.ts b/lib/adaptive-cache.ts new file mode 100644 index 0000000..852ebc5 --- /dev/null +++ b/lib/adaptive-cache.ts @@ -0,0 +1,94 @@ +// gpt'd at 3am, used as-is by another module + +interface AdaptiveCacheOptions { + /** Maximum requests allowed per period. */ + maxRequests: number; + + /** Usually one day. */ + period: number; + + /** Rolling window used to estimate request rate. Defaults to period / 2. */ + lookback?: number; + + /** Enable caching once projected usage exceeds this fraction. Defaults to 0.5. */ + threshold?: number; + + /** How long cached entries live. */ + ttl: number; + + /** Cache key generator. */ + key?(req: Request): string; +} + +export class AdaptiveCache { + private readonly cache = new Map< + string, + { + expires: number; + response: Response; + } + >(); + + private readonly requests: number[] = []; + + constructor(private readonly options: AdaptiveCacheOptions) { + options.lookback ??= options.period / 2; + options.threshold ??= 0.5; + options.key ??= (req) => req.url; + } + + async fetch( + input: RequestInfo | URL, + init?: RequestInit, + fetcher: typeof fetch = fetch, + ): Promise { + const req = input instanceof Request ? input : new Request(input, init); + + const now = Date.now(); + + this.cleanup(now); + + const key = this.options.key!(req); + + if (this.shouldCache()) { + const cached = this.cache.get(key); + if (cached && cached.expires > now) return cached.response.clone(); + } + + const res = await fetcher(req); + + if (res.ok) { + this.requests.push(now); + + this.cache.set(key, { + expires: now + this.options.ttl, + response: res.clone(), + }); + } + + return res; + } + + private shouldCache() { + const projected = + this.requests.length * (this.options.period / this.options.lookback!); + + return projected >= this.options.maxRequests * this.options.threshold!; + } + + private cleanup(now: number) { + const cutoff = now - this.options.lookback!; + + while (this.requests.length && this.requests[0] < cutoff) + this.requests.shift(); + + for (const [key, value] of this.cache) + if (value.expires <= now) this.cache.delete(key); + } +} + +export const youtubeCache = new AdaptiveCache({ + maxRequests: 100, // YouTube search.list requests/day + period: 24 * 60 * 60_000, + ttl: 15 * 60_000, +}); From d327f3ff3ea1162db1006a76a1689669189f430d Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 19 Jul 2026 03:16:25 +0700 Subject: [PATCH 12/71] fix(ci): forgot to copy package.json over --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index e2f08d1..43b1b4a 100755 --- a/Dockerfile +++ b/Dockerfile @@ -45,6 +45,7 @@ WORKDIR /home/container RUN bun i uuidv7 drizzle-orm drizzle-kit +COPY --from=deps /home/container/package*.json ./ COPY --from=builder /home/container/drizzle.config.ts ./drizzle.config.ts COPY --from=builder /home/container/lib ./lib From 6f12599354159596b6a29013d8e18ee2f7c73276 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 19 Jul 2026 03:22:12 +0700 Subject: [PATCH 13/71] fix(ci): missing postgres package --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 43b1b4a..d275a5c 100755 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ RUN mkdir -p .next/cache FROM oven/bun:1.3.14-alpine AS migration WORKDIR /home/container -RUN bun i uuidv7 drizzle-orm drizzle-kit +RUN bun i uuidv7 drizzle-orm drizzle-kit postgres COPY --from=deps /home/container/package*.json ./ COPY --from=builder /home/container/drizzle.config.ts ./drizzle.config.ts From 2e99065edaf0431102fd10fb34d7076ff3749ed5 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Fri, 24 Jul 2026 13:38:22 +0700 Subject: [PATCH 14/71] fix(don): use midnight anchor rather than last 24 hours --- app/(ui)/donate/admin/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(ui)/donate/admin/page.tsx b/app/(ui)/donate/admin/page.tsx index 1c86005..4e2fdcc 100644 --- a/app/(ui)/donate/admin/page.tsx +++ b/app/(ui)/donate/admin/page.tsx @@ -25,7 +25,7 @@ export default async function () { today: sql` COALESCE( SUM(${donations.amount}) FILTER ( - WHERE ${donations.created} >= NOW() - INTERVAL '24 hours' + WHERE ${donations.created} >= date_trunc('day', NOW()) ), 0 ) From ff09642d35bb00e3f19a5e01b881f6373ac74503 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Fri, 24 Jul 2026 14:18:09 +0700 Subject: [PATCH 15/71] feat(don): calendar --- .../donate/admin/@modal/calendar/client.tsx | 228 ++++++++++++++++++ .../donate/admin/@modal/calendar/page.tsx | 49 ++++ app/(ui)/donate/admin/client.tsx | 7 +- 3 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 app/(ui)/donate/admin/@modal/calendar/client.tsx create mode 100644 app/(ui)/donate/admin/@modal/calendar/page.tsx diff --git a/app/(ui)/donate/admin/@modal/calendar/client.tsx b/app/(ui)/donate/admin/@modal/calendar/client.tsx new file mode 100644 index 0000000..00e6678 --- /dev/null +++ b/app/(ui)/donate/admin/@modal/calendar/client.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { format, getDaysInMonth, startOfMonth } from "date-fns"; +import { th } from "date-fns/locale"; +import { + ChevronLeft, + ChevronRight, + QrCodeIcon as PromptpayIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo } from "react"; +import TruemoneyIcon from "#/assets/tmn.webp"; +import Image from "@/components/image"; +import { SimpleTooltip } from "@/components/tooltip"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +type Row = { + id: string; + name: string; + amount: number; + message: string | null; + created: Date; + uid: string | null; + method: "tmn" | "pp"; + image: boolean; +}; + +const DAYS = ["จ.", "อ.", "พ.", "พฤ.", "ศ.", "ส.", "อา."]; + +export function CalendarClient({ + year, + month, + rows, + total, +}: { + year: number; + month: number; + rows: Row[]; + total: number; +}) { + const current = new Date(year, month); + + const rowsByDay = useMemo(() => { + const map: Record = {}; + for (const row of rows) { + const day = new Date(row.created).getDate(); + (map[day] ??= []).push(row); + } + return map; + }, [rows]); + + const daysInMonth = getDaysInMonth(current); + const firstDayOfWeek = startOfMonth(current).getDay(); + const monthLabel = format(current, "MMMM yyyy", { locale: th }); + 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 = `/donate/admin/calendar?month=${prevY}-${String(prevM + 1).padStart(2, "0")}`; + const nextHref = `/donate/admin/calendar?month=${nextY}-${String(nextM + 1).padStart(2, "0")}`; + + return ( +
+
+
+ + + {monthLabel} + + +
+
+ รวม{" "} + + {total.toLocaleString()} + {" "} + บาท +
+
+ +
+ {DAYS.map((day) => ( +
+ {day} +
+ ))} + + {Array.from( + { length: firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1 }, + (_, index) => `pad-${index + 1}`, + ).map((key) => ( +
+ ))} + + {Array.from({ length: daysInMonth }, (_, index) => index + 1).map( + (day) => { + const dayRows = rowsByDay[day] || []; + const dayTotal = dayRows.reduce((sum, row) => sum + row.amount, 0); + const hasData = dayRows.length > 0; + + return ( + + + + + + + + {format( + new Date( + current.getFullYear(), + current.getMonth(), + day, + ), + "d MMM yyyy", + { locale: th }, + )} + + +
+ {dayRows.length === 0 && ( +

+ ไม่มีโดเนทในวันนี้ +

+ )} + {dayRows.map((row) => ( +
+
+ {row.method === "pp" ? ( + + + + ) : ( + + truemoney + + )} +
+
+
+
+ {row.name} +
+
+ {format(row.created, "HH:mm", { locale: th })} + {row.uid ? ` - UID ${row.uid}` : ""} +
+
+ + {row.amount.toLocaleString()} ฿ + +
+
+ ))} +
+ + + รวม {dayTotal.toLocaleString()} บาท + + +
+
+ ); + }, + )} +
+
+ ); +} diff --git a/app/(ui)/donate/admin/@modal/calendar/page.tsx b/app/(ui)/donate/admin/@modal/calendar/page.tsx new file mode 100644 index 0000000..a080e4f --- /dev/null +++ b/app/(ui)/donate/admin/@modal/calendar/page.tsx @@ -0,0 +1,49 @@ +import { endOfMonth, startOfMonth } from "date-fns"; +import { between, getTableColumns, sql } from "drizzle-orm"; +import { redirect } from "next/navigation"; +import { ModalBase } from "@/components/modal"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { donations } from "@/lib/db/schema"; +import { CalendarClient } from "./client"; + +export default async function DonateCalendarModal({ + searchParams, +}: { + searchParams: Promise<{ month?: string }>; +}) { + if (!(await adminCheck())) { + redirect(`/login?next=${encodeURIComponent("/donate/admin/calendar")}`); + } + + const { month } = await searchParams; + const date = month ? new Date(`${month}-01T00:00:00`) : new Date(); + if (Number.isNaN(date.getTime())) redirect("/donate/admin"); + + const from = startOfMonth(date); + const to = endOfMonth(date); + + const rows = await db + .select({ + ...getTableColumns(donations), + image: sql`${donations.image} IS NOT NULL`, + }) + .from(donations) + .where(between(donations.created, from, to)) + .orderBy(donations.created); + + const total = rows.reduce((sum, row) => sum + row.amount, 0); + + return ( + + + + ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/donate/admin/client.tsx b/app/(ui)/donate/admin/client.tsx index d9a6397..7de0d7a 100644 --- a/app/(ui)/donate/admin/client.tsx +++ b/app/(ui)/donate/admin/client.tsx @@ -258,11 +258,14 @@ function TopRow({ รวมวันนี้ {stats.today}฿
-
+ รวมทั้งหมด {stats.total}฿ -
+
); } From cf8ab18c45b3dc681347c1799daa1f1fc1c60638 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sat, 25 Jul 2026 12:02:11 +0700 Subject: [PATCH 16/71] fix(rg): tmp workaround for faulty BASE_URL needs more investigation --- app/(ui)/rubgram/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(ui)/rubgram/api.ts b/app/(ui)/rubgram/api.ts index daab667..3038de3 100755 --- a/app/(ui)/rubgram/api.ts +++ b/app/(ui)/rubgram/api.ts @@ -369,7 +369,7 @@ export async function getDiscordSession() { export async function loginDiscord() { redirect( - `https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}&response_type=code&redirect_uri=${encodeURIComponent(new URL("/rubgram/callback", BASE_URL).href)}&scope=identify+guilds.join+guilds`, + `https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}&response_type=code&redirect_uri=${encodeURIComponent(new URL("/rubgram/callback", BASE_URL.includes("localhost") ? "https://buzz.sudloh.com" : BASE_URL).href)}&scope=identify+guilds.join+guilds`, ); } From e3133d0cd3df3b14c8a17137f8679c403ccac5b3 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sat, 25 Jul 2026 14:17:23 +0700 Subject: [PATCH 17/71] fix(rg): redirect url fix --- app/(ui)/rubgram/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(ui)/rubgram/api.ts b/app/(ui)/rubgram/api.ts index 3038de3..998eb02 100755 --- a/app/(ui)/rubgram/api.ts +++ b/app/(ui)/rubgram/api.ts @@ -369,7 +369,7 @@ export async function getDiscordSession() { export async function loginDiscord() { redirect( - `https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}&response_type=code&redirect_uri=${encodeURIComponent(new URL("/rubgram/callback", BASE_URL.includes("localhost") ? "https://buzz.sudloh.com" : BASE_URL).href)}&scope=identify+guilds.join+guilds`, + `https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}&response_type=code&redirect_uri=${encodeURIComponent(`${BASE_URL}/rubgram/callback`)}&scope=identify+guilds.join+guilds`, ); } From e73c05f7d205dcad53eca0d890ce8173f6825b87 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Mon, 27 Jul 2026 11:40:02 +0700 Subject: [PATCH 18/71] feat: debug endpoint --- app/api/debug/route.ts | 92 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 app/api/debug/route.ts diff --git a/app/api/debug/route.ts b/app/api/debug/route.ts new file mode 100644 index 0000000..682cfaf --- /dev/null +++ b/app/api/debug/route.ts @@ -0,0 +1,92 @@ +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { redis } from "@/lib/db/redis"; +import { env } from "bun"; +import { sql } from "drizzle-orm"; + +async function probe(name: string, fn: () => Promise) { + const started = performance.now(); + + try { + const result = await fn(); + + return { + name, + ok: true, + latencyMs: performance.now() - started, + result, + }; + } catch (error) { + return { + name, + ok: false, + latencyMs: performance.now() - started, + error: + error instanceof Error + ? { + name: error.name, + message: error.message, + stack: error.stack, + cause: error.cause, + } + : error, + }; + } +} + +export async function GET(request: Request) { + if (!adminCheck()) { + return new Response("Unauthorized", { status: 401 }); + } + + const probes = await Promise.all([ + probe("redis", async () => { + const started = performance.now(); + const result = await redis?.ping(); + + return { + result, + latencyMs: performance.now() - started, + }; + }), + + probe("postgres", async () => { + const started = performance.now(); + const result = await db.execute(sql`SELECT 1`); + + return { + result, + latencyMs: performance.now() - started, + }; + }), + ]); + + return Response.json({ + timestamp: new Date().toISOString(), + + runtime: { + bun: Bun.version, + node: process.version, + platform: process.platform, + arch: process.arch, + pid: process.pid, + uptime: process.uptime(), + cwd: process.cwd(), + }, + + memory: process.memoryUsage(), + + eventLoop: { + now: performance.now(), + }, + + request: { + url: request.url, + headers: Object.fromEntries(request.headers), + }, + + env, + + probes, + }); +} From 8533268270cee73780563023a58a7e0444b5cc9a Mon Sep 17 00:00:00 2001 From: dmgnr Date: Mon, 27 Jul 2026 12:08:36 +0700 Subject: [PATCH 19/71] hotfix: authcheck debug route --- app/api/debug/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/debug/route.ts b/app/api/debug/route.ts index 682cfaf..9f2777d 100644 --- a/app/api/debug/route.ts +++ b/app/api/debug/route.ts @@ -35,7 +35,7 @@ async function probe(name: string, fn: () => Promise) { } export async function GET(request: Request) { - if (!adminCheck()) { + if (!await adminCheck()) { return new Response("Unauthorized", { status: 401 }); } From 629b3787b54cfa37901a9b3d9c447bfbbca682cc Mon Sep 17 00:00:00 2001 From: dmgnr Date: Mon, 27 Jul 2026 13:41:53 +0700 Subject: [PATCH 20/71] feat: more healthcheck probes --- app/api/health/route.ts | 62 +++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 8c691c3..a222f05 100755 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,6 +1,9 @@ import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { redis as redisShared } from "@/lib/db/redis"; +import { GoogleGenAI } from "@google/genai"; +import { env } from "bun"; +const { DISCORD_BOT_TOKEN, GEMINI_TTS_API_KEY } = env as Record; const redis = redisShared!; @@ -20,16 +23,22 @@ export async function GET() { red ? (await redis.exists(`health:${k}`)) ? (await redis.get(`health:${k}`)) === "ok" - : await v().then(async (v) => { + : await v().then(async (r: any) => { + const v = r instanceof Response ? r.ok : !!r; if (v) await redis.setex(`health:${k}`, 900, "ok"); return v; }) - : await v(), + : await v().then((r: any) => + r instanceof Response ? r.ok : !!r, + ), ] as const, ), ), ); - return Response.json({ red, ...res }); + return Response.json( + { red, ...res }, + { status: res.database && red ? 200 : 201 }, + ); } const checks = { @@ -40,19 +49,50 @@ const checks = { .catch(() => false); }, async enka() { - return await fetch("https://enka.network/api/uid/888888888/?info", { + return fetch("https://enka.network/api/uid/888888888/?info", { headers: { "User-Agent": "Buzz, https://buzz.sudloh.com/api/health" }, signal: AbortSignal.timeout(5000), - }) - .then((r) => r.ok) - .catch(() => false); + }); }, async amber() { - return await fetch("https://gi.yatta.moe/api/v2/en/avatar", { + return fetch("https://gi.yatta.moe/api/v2/en/avatar", { headers: { "User-Agent": "Buzz, https://buzz.sudloh.com/api/health" }, signal: AbortSignal.timeout(5000), - }) - .then((r) => r.ok) + }); + }, + async yt() { + return fetch("https://buzz.sudloh.com/api/live", { + signal: AbortSignal.timeout(5000), + }); + }, + async card() { + return fetch("https://api.astrxl.dev/v1/card/genshin/stats", { + signal: AbortSignal.timeout(5000), + }); + }, + async slip() { + return fetch("https://api.slipok.com/", { + signal: AbortSignal.timeout(5000), + }); + }, + async tts() { + const client = new GoogleGenAI({ apiKey: GEMINI_TTS_API_KEY }); + return client.models + .list() + .then(() => true) .catch(() => false); }, -} as const; + async tmn() { + return fetch("https://api.sastify.xyz/health", { + signal: AbortSignal.timeout(5000), + }); + }, + async discord() { + return fetch("https://discord.com/api/v10/users/@me", { + signal: AbortSignal.timeout(5000), + headers: { + Authorization: `Bot ${DISCORD_BOT_TOKEN}`, + }, + }); + }, +} satisfies Record Promise>; From 16e88fde016410d0f8dd6deff80dca2ea1e6e68c Mon Sep 17 00:00:00 2001 From: dmgnr Date: Mon, 27 Jul 2026 14:08:23 +0700 Subject: [PATCH 21/71] feat(don): indicate new donates in page title --- app/(ui)/donate/admin/client.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/app/(ui)/donate/admin/client.tsx b/app/(ui)/donate/admin/client.tsx index 7de0d7a..9844a5c 100644 --- a/app/(ui)/donate/admin/client.tsx +++ b/app/(ui)/donate/admin/client.tsx @@ -311,10 +311,30 @@ export function DonateAdminPage({ // component cuz simplicity sake export function DonateWatcher() { const router = useRouter(); + const [newDonates, setNewDonates] = useState(0); + + useEffect(() => { + if (newDonates <= 0) return ((document.title = "โดเนททั้งหมด"), undefined); + var blink = true; + const interval = setInterval(() => { + blink = !blink; + document.title = blink ? "โดเนททั้งหมด" : `${newDonates} โดเนทใหม่`; + }, 1000); + return () => clearInterval(interval); + }, [newDonates]); + + useEffect(() => { + function listener() { + setNewDonates(0); + } + window.addEventListener("focus", listener); + return () => window.removeEventListener("focus", listener); + }, []); + useEffect(() => { if (!router) return; return sse.donate.subMany({ - ping: router.refresh.bind(router), + ping: () => (router.refresh(), setNewDonates((x) => x + 1)), update: router.refresh.bind(router), }).clean; }, [router]); From 6d681b2560547c9e3d374b580cd794ef7c029358 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Mon, 27 Jul 2026 14:09:09 +0700 Subject: [PATCH 22/71] fix(don): unconfuse top row --- app/(ui)/donate/admin/client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(ui)/donate/admin/client.tsx b/app/(ui)/donate/admin/client.tsx index 9844a5c..69fb6fa 100644 --- a/app/(ui)/donate/admin/client.tsx +++ b/app/(ui)/donate/admin/client.tsx @@ -297,7 +297,7 @@ export function DonateAdminPage({ Date: Mon, 27 Jul 2026 14:16:52 +0700 Subject: [PATCH 23/71] fixup: tts healthcheck --- app/api/health/route.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/api/health/route.ts b/app/api/health/route.ts index a222f05..b08a5d1 100755 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -76,7 +76,9 @@ const checks = { }); }, async tts() { - const client = new GoogleGenAI({ apiKey: GEMINI_TTS_API_KEY }); + const client = new GoogleGenAI({ + apiKey: GEMINI_TTS_API_KEY.split(",").pop(), + }); return client.models .list() .then(() => true) From d9f988de9bbe11d88dba6995df5b132700fab3ae Mon Sep 17 00:00:00 2001 From: dmgnr Date: Tue, 28 Jul 2026 09:33:17 +0700 Subject: [PATCH 24/71] chore: force cache /api/live atleast 60 seconds --- app/api/live/route.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/api/live/route.ts b/app/api/live/route.ts index c742173..7d57443 100755 --- a/app/api/live/route.ts +++ b/app/api/live/route.ts @@ -87,3 +87,5 @@ export async function GET() { return NextResponse.json(res); } + +export const revalidate = 60; \ No newline at end of file From 4d0f459bdb07da2b21845635dc21f2c15d2a7d98 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Tue, 28 Jul 2026 11:24:20 +0700 Subject: [PATCH 25/71] feat: chat widget --- app/api/live/route.ts | 10 +++++-- app/widget/chat/page.tsx | 60 ++++++++++++++++++++++++++++++++++++++++ bun.lock | 4 +-- lib/utils.ts | 15 ++++++++++ package.json | 2 +- 5 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 app/widget/chat/page.tsx diff --git a/app/api/live/route.ts b/app/api/live/route.ts index 7d57443..572ef87 100755 --- a/app/api/live/route.ts +++ b/app/api/live/route.ts @@ -66,8 +66,10 @@ export async function GET() { }, ); - if (!response.ok) - throw new Error(`YouTube API error: ${response.statusText}`); + if (!response.ok) { + console.error(`YouTube API error: ${response.statusText}`); + return NextResponse.json("none"); + } const data: APISearchResponse = await response.json(); @@ -88,4 +90,6 @@ export async function GET() { return NextResponse.json(res); } -export const revalidate = 60; \ No newline at end of file +export const POST = GET; + +export const revalidate = 60; diff --git a/app/widget/chat/page.tsx b/app/widget/chat/page.tsx new file mode 100644 index 0000000..7f14010 --- /dev/null +++ b/app/widget/chat/page.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Countdown } from "@/app/(ui)/rubgram/client"; +import type { YoutubeLiveInfo } from "@/app/api/live/route"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { throwNotOk } from "@/lib/utils"; +import { RefreshCw } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { MessageCircleOff } from "lucide-react"; + +export default function ChatWidget() { + const [cooldown, setCooldown] = useState(); + const [loading, setLoading] = useState(false); + const [notFound, setNotFound] = useState(false); + const router = useRouter(); + + const load = useCallback(async () => { + try { + setLoading(true); + // post prevents caching + const data: YoutubeLiveInfo = await fetch("/api/live", { method: "POST" }) + .then(throwNotOk) + .then((r) => r.json()); + if (data === "none") return setNotFound(true); + const vid = new URL(data.url).searchParams.get("v"); + router.push(`https://studio.youtube.com/live_chat?is_popout=1&v=${vid}`); + } catch (e) { + console.error(e); + setNotFound(true); + } finally { + setLoading(false); + } + }, [router]); + + useEffect(() => { + if (!notFound) load(); + }, [load, notFound]); + + return notFound ? ( +
+ + +
+ ) : ( +
+ +
+ ); +} diff --git a/bun.lock b/bun.lock index e891c69..54d036f 100644 --- a/bun.lock +++ b/bun.lock @@ -54,7 +54,7 @@ "drizzle-orm": "^0.44.7", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", - "lucide-react": "^0.562.0", + "lucide-react": "^1.27.0", "motion": "^12.40.0", "next": "16.2.6", "next-themes": "^0.4.6", @@ -1053,7 +1053,7 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], + "lucide-react": ["lucide-react@1.27.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], diff --git a/lib/utils.ts b/lib/utils.ts index aad27d4..b61eddd 100755 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -72,3 +72,18 @@ export function parseSearchString( export function pausePass(time: number): (res: R) => Promise { return (res) => new Promise((r) => setTimeout(() => r(res), time)); } + +// taken from astral/api +export class ResponseNotOkError extends Error { + constructor( + readonly status: number, + readonly response: Response, + ) { + super(`RESPONSE_NOT_OK: Got status ${status}`); + } +} + +export function throwNotOk(res: Response) { + if (!res.ok) throw new ResponseNotOkError(res.status, res); + return res; +} diff --git a/package.json b/package.json index 90b379b..fbddc38 100755 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "drizzle-orm": "^0.44.7", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", - "lucide-react": "^0.562.0", + "lucide-react": "^1.27.0", "motion": "^12.40.0", "next": "16.2.6", "next-themes": "^0.4.6", From 81ae89f21633c6e89413b3bb03bd15f09de4fc2b Mon Sep 17 00:00:00 2001 From: dmgnr Date: Wed, 29 Jul 2026 08:42:26 +0700 Subject: [PATCH 26/71] fix: healthcheck draining youtube api --- app/api/debug/route.ts | 50 ++++++++++++++++++++++++++++++++++------- app/api/health/route.ts | 2 +- app/api/live/route.ts | 10 +++++++-- lib/adaptive-cache.ts | 14 ++++++++++++ 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/app/api/debug/route.ts b/app/api/debug/route.ts index 9f2777d..53a6264 100644 --- a/app/api/debug/route.ts +++ b/app/api/debug/route.ts @@ -1,8 +1,10 @@ +import { youtubeCache } from "@/lib/adaptive-cache"; import { adminCheck } from "@/lib/auth"; import { db } from "@/lib/db"; import { redis } from "@/lib/db/redis"; import { env } from "bun"; import { sql } from "drizzle-orm"; +import type { NextRequest } from "next/server"; async function probe(name: string, fn: () => Promise) { const started = performance.now(); @@ -34,15 +36,28 @@ async function probe(name: string, fn: () => Promise) { } } -export async function GET(request: Request) { - if (!await adminCheck()) { - return new Response("Unauthorized", { status: 401 }); +export async function GET(request: NextRequest) { + try { + if (!(await adminCheck())) { + return new Response("Unauthorized", { status: 401 }); + } + } catch { + // idiomatic fallback + // even though this is extremely risky, we need it + console.error("/api/debug AUTH CHECK ERROR"); + // use REDIS_URL which only I can memorize as the password, + // or IF THAT fails too, use a static string, which shouldn't ever happen + if ( + request.nextUrl.searchParams.get("pass") !== + (env.REDIS_URL || "absolute_solver") + ) + return new Response("Unauthorized (Fallback)", { status: 401 }); } const probes = await Promise.all([ probe("redis", async () => { const started = performance.now(); - const result = await redis?.ping(); + const result = await withTimeout(redis!.ping(), 5000); return { result, @@ -52,7 +67,7 @@ export async function GET(request: Request) { probe("postgres", async () => { const started = performance.now(); - const result = await db.execute(sql`SELECT 1`); + const result = await withTimeout(db.execute(sql`SELECT 1`), 5000); return { result, @@ -64,7 +79,7 @@ export async function GET(request: Request) { return Response.json({ timestamp: new Date().toISOString(), - runtime: { + runtime: safe({ bun: Bun.version, node: process.version, platform: process.platform, @@ -72,7 +87,7 @@ export async function GET(request: Request) { pid: process.pid, uptime: process.uptime(), cwd: process.cwd(), - }, + }), memory: process.memoryUsage(), @@ -82,11 +97,30 @@ export async function GET(request: Request) { request: { url: request.url, - headers: Object.fromEntries(request.headers), + headers: safe(Object.fromEntries(request.headers)), }, + ytCache: await probe("youtubeCache", async () => youtubeCache.debugStats()), + env, probes, }); } + +async function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms), + ), + ]); +} + +function safe(value: unknown) { + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return String(value); + } +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts index b08a5d1..f7e4fb2 100755 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -61,7 +61,7 @@ const checks = { }); }, async yt() { - return fetch("https://buzz.sudloh.com/api/live", { + return fetch("https://buzz.sudloh.com/api/live?last=true", { signal: AbortSignal.timeout(5000), }); }, diff --git a/app/api/live/route.ts b/app/api/live/route.ts index 572ef87..180e2ef 100755 --- a/app/api/live/route.ts +++ b/app/api/live/route.ts @@ -1,3 +1,4 @@ +import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { sse } from "@/lib/db/sse-endpoints"; import { youtubeCache } from "@/lib/adaptive-cache"; @@ -53,7 +54,12 @@ type APISearchResponse = { items: APISearchResource[]; }; -export async function GET() { +var lastResponse: Response; + +export async function GET(req: NextRequest) { + if (req.nextUrl.searchParams.has("last") && lastResponse) + return lastResponse.clone(); + const channelId = process.env.YOUTUBE_CHANNEL_ID; const apiKey = process.env.YOUTUBE_API_KEY; @@ -87,7 +93,7 @@ export async function GET() { sse.active.pub("live", res); - return NextResponse.json(res); + return (lastResponse = NextResponse.json(res)); } export const POST = GET; diff --git a/lib/adaptive-cache.ts b/lib/adaptive-cache.ts index 852ebc5..98c480e 100644 --- a/lib/adaptive-cache.ts +++ b/lib/adaptive-cache.ts @@ -85,6 +85,20 @@ export class AdaptiveCache { for (const [key, value] of this.cache) if (value.expires <= now) this.cache.delete(key); } + + debugStats() { + const projected = + this.requests.length * (this.options.period / this.options.lookback!); + + return [ + `AdaptiveCache`, + this.shouldCache() ? "HOT" : "COLD", + `recent=${this.requests.length}`, + `projected=${projected.toFixed(1)}/${this.options.maxRequests}`, + `usage=${((projected / this.options.maxRequests) * 100).toFixed(0)}%`, + `entries=${this.cache.size}`, + ].join(" | "); + } } export const youtubeCache = new AdaptiveCache({ From 32804b37431cee52d49c36f4e9408660da67f786 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Wed, 29 Jul 2026 08:42:49 +0700 Subject: [PATCH 27/71] chore: fmt [no ci] --- .claude/skills/buzz-feature/SKILL.md | 15 +++------------ app/api/chat/route.ts | 5 ++++- vercel.json | 2 +- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/.claude/skills/buzz-feature/SKILL.md b/.claude/skills/buzz-feature/SKILL.md index 244971a..5067d93 100644 --- a/.claude/skills/buzz-feature/SKILL.md +++ b/.claude/skills/buzz-feature/SKILL.md @@ -173,18 +173,9 @@ import { ImageResponse } from "next/og"; export const size = { width: 1200, height: 630 }; export default function OGImage() { - return new ImageResponse( -
- Buzz Feature -
, - { ...size }, - ); + return new ImageResponse(
Buzz Feature
, { + ...size, + }); } ``` diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index c946b0c..17a55ce 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -7,7 +7,10 @@ type LiveResponse = | "none"; export async function GET(request: Request) { - const liveUrl = new URL("https://buzz.sudloh.com/api/live", request.url).toString(); + const liveUrl = new URL( + "https://buzz.sudloh.com/api/live", + request.url, + ).toString(); const response = await fetch(liveUrl, { cache: "no-store", }); diff --git a/vercel.json b/vercel.json index 49da728..2b6ae97 100644 --- a/vercel.json +++ b/vercel.json @@ -1,3 +1,3 @@ { "bunVersion": "1.x" -} \ No newline at end of file +} From 4f8c37f82e4179b6675acd0900d1db785956f987 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 2 Aug 2026 19:40:12 +0700 Subject: [PATCH 28/71] fix: cdn table checkbox padding --- app/(ui)/admin/cdn/table.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/(ui)/admin/cdn/table.tsx b/app/(ui)/admin/cdn/table.tsx index 88e1063..80bea18 100755 --- a/app/(ui)/admin/cdn/table.tsx +++ b/app/(ui)/admin/cdn/table.tsx @@ -58,6 +58,7 @@ const columns: ColumnDef<{ ), enableSorting: false, enableHiding: false, + meta: { className: "w-6" }, }, { accessorKey: "name", From 6eb4b499f7788d192aca31881410c9cc4192ae88 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 2 Aug 2026 19:40:24 +0700 Subject: [PATCH 29/71] fix: healthcheck undefined --- app/(ui)/admin/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(ui)/admin/layout.tsx b/app/(ui)/admin/layout.tsx index bdc4679..74c41fe 100755 --- a/app/(ui)/admin/layout.tsx +++ b/app/(ui)/admin/layout.tsx @@ -310,7 +310,7 @@ async function HealthStatus() {
{Object.values(health).some((x) => !x) - ? `${{ database: "ฐานข้อมูล", enka: "Enka ", amber: "Amber ", red: "SSE " }[Object.entries(health).find(([, v]) => !v)![0]]}มีปัญหา` + ? `${((h: string) => ({ database: "ฐานข้อมูล", enka: "Enka ", amber: "Amber ", red: "SSE " })[h] ?? `${h} `)(Object.entries(health).find(([, v]) => !v)![0])}มีปัญหา` : "ปกติทุกอย่าง"}
From 8c14999c59959a32d0b508db43af717247bae62f Mon Sep 17 00:00:00 2001 From: dmgnr Date: Thu, 6 Aug 2026 03:00:13 +0700 Subject: [PATCH 30/71] feat(admin): tierlist editor --- .../tl/badge/[type]/[badge]/edit/page.tsx | 160 ++++++++ .../@modal/tl/badge/[type]/create/page.tsx | 130 +++++++ .../tl/layout/column/[id]/edit/page.tsx | 140 +++++++ .../@modal/tl/layout/column/create/page.tsx | 110 ++++++ .../@modal/tl/layout/tier/[id]/edit/page.tsx | 150 ++++++++ .../@modal/tl/layout/tier/create/page.tsx | 121 ++++++ app/(ui)/admin/layout.tsx | 77 +++- app/(ui)/admin/navbar.tsx | 16 +- app/(ui)/admin/tl/[ver]/actions.ts | 82 +++++ app/(ui)/admin/tl/[ver]/badge-section.tsx | 175 +++++++++ app/(ui)/admin/tl/[ver]/page.tsx | 137 +++++++ app/(ui)/admin/tl/components/order.ts | 36 ++ .../admin/tl/components/sortable-grid.tsx | 92 +++++ app/(ui)/admin/tl/layout/actions.ts | 76 ++++ app/(ui)/admin/tl/layout/client.tsx | 344 ++++++++++++++++++ app/(ui)/admin/tl/layout/page.tsx | 24 ++ app/(ui)/admin/tl/ver/page.tsx | 111 ------ app/(ui)/tl/[type]/[ver]/admin/page.tsx | 71 +--- app/(ui)/tl/[type]/[ver]/cell.tsx | 5 +- app/(ui)/tl/[type]/[ver]/page.tsx | 72 +--- lib/db/schema.ts | 1 + lib/tierlist.ts | 182 +++++++++ 22 files changed, 2038 insertions(+), 274 deletions(-) create mode 100644 app/(ui)/admin/@modal/tl/badge/[type]/[badge]/edit/page.tsx create mode 100644 app/(ui)/admin/@modal/tl/badge/[type]/create/page.tsx create mode 100644 app/(ui)/admin/@modal/tl/layout/column/[id]/edit/page.tsx create mode 100644 app/(ui)/admin/@modal/tl/layout/column/create/page.tsx create mode 100644 app/(ui)/admin/@modal/tl/layout/tier/[id]/edit/page.tsx create mode 100644 app/(ui)/admin/@modal/tl/layout/tier/create/page.tsx create mode 100644 app/(ui)/admin/tl/[ver]/actions.ts create mode 100644 app/(ui)/admin/tl/[ver]/badge-section.tsx create mode 100755 app/(ui)/admin/tl/[ver]/page.tsx create mode 100644 app/(ui)/admin/tl/components/order.ts create mode 100644 app/(ui)/admin/tl/components/sortable-grid.tsx create mode 100644 app/(ui)/admin/tl/layout/actions.ts create mode 100644 app/(ui)/admin/tl/layout/client.tsx create mode 100644 app/(ui)/admin/tl/layout/page.tsx delete mode 100755 app/(ui)/admin/tl/ver/page.tsx create mode 100644 lib/tierlist.ts diff --git a/app/(ui)/admin/@modal/tl/badge/[type]/[badge]/edit/page.tsx b/app/(ui)/admin/@modal/tl/badge/[type]/[badge]/edit/page.tsx new file mode 100644 index 0000000..0c543ac --- /dev/null +++ b/app/(ui)/admin/@modal/tl/badge/[type]/[badge]/edit/page.tsx @@ -0,0 +1,160 @@ +import { and, eq, isNull, or } from "drizzle-orm"; +import { SaveIcon, Trash2 } from "lucide-react"; +import { revalidatePath } from "next/cache"; +import { notFound, redirect } from "next/navigation"; +import { CdnChooser } from "@/components/chooser"; +import { FormAction, FormInput, FormProvider } from "@/components/form"; +import { ModalBase } from "@/components/modal"; +import { Button } from "@/components/ui/button"; +import { DialogClose, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistBadges, tierlistTypes } from "@/lib/db/schema"; + +export default async function TlBadgeEditPage({ + params, +}: { + params: Promise<{ type: string; badge: string }>; +}) { + if (!(await adminCheck())) redirect("/login"); + + const { type: typeId, badge: badgeId } = await params; + const [[type], [badge]] = await Promise.all([ + db.select().from(tierlistTypes).where(eq(tierlistTypes.id, typeId)), + db + .select() + .from(tierlistBadges) + .where( + and( + eq(tierlistBadges.id, badgeId), + or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId)), + ), + ), + ]); + if (!type) notFound(); + if (!badge) notFound(); + + async function submit(form: FormData) { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + for (const field of ["name", "order", "scope"] as const) { + if (!form.get(field)) { + return { error: `Field "${field}" is required.` }; + } + } + + let data: Partial; + + try { + data = { + name: form.get("name") as string, + image: (form.get("image") as string) || null, + order: parseInt(form.get("order") as string, 10), + type: form.get("scope") === "global" ? null : typeId, + }; + await db + .update(tierlistBadges) + .set(data) + .where(eq(tierlistBadges.id, badgeId)); + } catch (error) { + console.error(error); + const err = error as Error & { + cause?: { detail?: string; message?: string }; + }; + return { + error: + err?.cause?.detail || + err?.cause?.message || + "Failed to update badge.", + }; + } + + await actionLog(`Updated tierlist badge ${badgeId}`, data); + + revalidatePath(`/admin/tl/${typeId}`); + revalidatePath(`/tl/${typeId}`); + return { toast: "Badge saved successfully.", close: true }; + } + + async function deleteBadge() { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + await db.delete(tierlistBadges).where(eq(tierlistBadges.id, badgeId)); + + await actionLog(`Deleted tierlist badge ${badgeId}`, badge); + + revalidatePath(`/admin/tl/${typeId}`); + revalidatePath(`/tl/${typeId}`); + return { toast: "Badge deleted.", close: true }; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/@modal/tl/badge/[type]/create/page.tsx b/app/(ui)/admin/@modal/tl/badge/[type]/create/page.tsx new file mode 100644 index 0000000..629ae1c --- /dev/null +++ b/app/(ui)/admin/@modal/tl/badge/[type]/create/page.tsx @@ -0,0 +1,130 @@ +import { eq, isNull, or, sql } from "drizzle-orm"; +import { PlusIcon } from "lucide-react"; +import { revalidatePath } from "next/cache"; +import { notFound, redirect } from "next/navigation"; +import { CdnChooser } from "@/components/chooser"; +import { FormAction, FormInput, FormProvider } from "@/components/form"; +import { ModalBase } from "@/components/modal"; +import { Button } from "@/components/ui/button"; +import { DialogClose, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistBadges, tierlistTypes } from "@/lib/db/schema"; + +export default async function TlBadgeCreatePage({ + params, +}: { + params: Promise<{ type: string }>; +}) { + if (!(await adminCheck())) redirect("/login"); + + const { type: typeId } = await params; + const [[type], [{ maxOrder }]] = await Promise.all([ + db.select().from(tierlistTypes).where(eq(tierlistTypes.id, typeId)), + db + .select({ + maxOrder: sql`COALESCE(MAX(${tierlistBadges.order}), 0)`, + }) + .from(tierlistBadges) + .where(or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId))), + ]); + if (!type) notFound(); + + async function submit(form: FormData) { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + for (const field of ["name", "order", "scope"] as const) { + if (!form.get(field)) { + return { error: `Field "${field}" is required.` }; + } + } + + let data: typeof tierlistBadges.$inferInsert; + + try { + data = { + name: form.get("name") as string, + image: (form.get("image") as string) || null, + order: parseInt(form.get("order") as string, 10), + type: form.get("scope") === "global" ? null : typeId, + }; + await db.insert(tierlistBadges).values(data); + } catch (error) { + console.error(error); + const err = error as Error & { + cause?: { detail?: string; message?: string }; + }; + return { + error: + err?.cause?.detail || + err?.cause?.message || + "Failed to create badge.", + }; + } + + await actionLog(`Created tierlist badge for ${typeId}`, data); + + revalidatePath(`/admin/tl/${typeId}`); + revalidatePath(`/tl/${typeId}`); + return { toast: "Badge created successfully.", close: true }; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/@modal/tl/layout/column/[id]/edit/page.tsx b/app/(ui)/admin/@modal/tl/layout/column/[id]/edit/page.tsx new file mode 100644 index 0000000..eeee553 --- /dev/null +++ b/app/(ui)/admin/@modal/tl/layout/column/[id]/edit/page.tsx @@ -0,0 +1,140 @@ +import { eq } from "drizzle-orm"; +import { SaveIcon, Trash2 } from "lucide-react"; +import { revalidatePath } from "next/cache"; +import { notFound, redirect } from "next/navigation"; +import { CdnChooser } from "@/components/chooser"; +import { + FormAction, + FormInput, + FormProvider, + FormRow, +} from "@/components/form"; +import { ModalBase } from "@/components/modal"; +import { Button } from "@/components/ui/button"; +import { DialogClose, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistColumns } from "@/lib/db/schema"; + +export default async function TlLayoutColumnEditPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + if (!(await adminCheck())) redirect("/login"); + + const { id } = await params; + const [column] = await db + .select() + .from(tierlistColumns) + .where(eq(tierlistColumns.id, id)); + if (!column) notFound(); + + async function submit(form: FormData) { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + for (const field of ["id", "name", "order"] as const) { + if (!form.get(field)) return { error: `Field "${field}" is required.` }; + } + + let data: typeof tierlistColumns.$inferInsert; + + try { + data = { + id: form.get("id") as string, + name: form.get("name") as string, + image: (form.get("image") as string) || null, + order: parseInt(form.get("order") as string, 10), + }; + await db + .update(tierlistColumns) + .set(data) + .where(eq(tierlistColumns.id, id)); + } catch (error) { + console.error(error); + const err = error as Error & { + cause?: { detail?: string; message?: string }; + }; + return { + error: + err?.cause?.detail || + err?.cause?.message || + "Failed to update column.", + }; + } + + await actionLog(`Updated tierlist column ${id}`, data); + + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); + return { toast: "Column saved successfully.", close: true }; + } + + async function deleteColumn() { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + await db.delete(tierlistColumns).where(eq(tierlistColumns.id, id)); + await actionLog(`Deleted tierlist column ${id}`, column); + + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); + return { toast: "Column deleted.", close: true }; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/@modal/tl/layout/column/create/page.tsx b/app/(ui)/admin/@modal/tl/layout/column/create/page.tsx new file mode 100644 index 0000000..65510c3 --- /dev/null +++ b/app/(ui)/admin/@modal/tl/layout/column/create/page.tsx @@ -0,0 +1,110 @@ +import { sql } from "drizzle-orm"; +import { PlusIcon } from "lucide-react"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { CdnChooser } from "@/components/chooser"; +import { + FormAction, + FormInput, + FormProvider, + FormRow, +} from "@/components/form"; +import { ModalBase } from "@/components/modal"; +import { Button } from "@/components/ui/button"; +import { DialogClose, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistColumns } from "@/lib/db/schema"; + +export default async function TlLayoutColumnCreatePage() { + if (!(await adminCheck())) redirect("/login"); + + const [{ maxOrder }] = await db + .select({ + maxOrder: sql`COALESCE(MAX(${tierlistColumns.order}), 0)`, + }) + .from(tierlistColumns); + + async function submit(form: FormData) { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + for (const field of ["id", "name", "order"] as const) { + if (!form.get(field)) return { error: `Field "${field}" is required.` }; + } + + let data: typeof tierlistColumns.$inferInsert; + + try { + data = { + id: form.get("id") as string, + name: form.get("name") as string, + image: (form.get("image") as string) || null, + order: parseInt(form.get("order") as string, 10), + }; + await db.insert(tierlistColumns).values(data); + } catch (error) { + console.error(error); + const err = error as Error & { + cause?: { detail?: string; message?: string }; + }; + return { + error: + err?.cause?.detail || + err?.cause?.message || + "Failed to create column.", + }; + } + + await actionLog(`Created tierlist column ${data.id}`, data); + + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); + return { toast: "Column created successfully.", close: true }; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/@modal/tl/layout/tier/[id]/edit/page.tsx b/app/(ui)/admin/@modal/tl/layout/tier/[id]/edit/page.tsx new file mode 100644 index 0000000..024c7be --- /dev/null +++ b/app/(ui)/admin/@modal/tl/layout/tier/[id]/edit/page.tsx @@ -0,0 +1,150 @@ +import { eq } from "drizzle-orm"; +import { SaveIcon, Trash2 } from "lucide-react"; +import { revalidatePath } from "next/cache"; +import { notFound, redirect } from "next/navigation"; +import { CdnChooser } from "@/components/chooser"; +import { + FormAction, + FormInput, + FormProvider, + FormRow, +} from "@/components/form"; +import { ModalBase } from "@/components/modal"; +import { Button } from "@/components/ui/button"; +import { DialogClose, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistTiers } from "@/lib/db/schema"; + +function parseBadges(value: FormDataEntryValue | null) { + return String(value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +export default async function TlLayoutTierEditPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + if (!(await adminCheck())) redirect("/login"); + + const { id } = await params; + const [tier] = await db + .select() + .from(tierlistTiers) + .where(eq(tierlistTiers.id, id)); + if (!tier) notFound(); + + async function submit(form: FormData) { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + for (const field of ["id", "name", "order"] as const) { + if (!form.get(field)) return { error: `Field "${field}" is required.` }; + } + + let data: typeof tierlistTiers.$inferInsert; + + try { + data = { + id: form.get("id") as string, + name: form.get("name") as string, + image: (form.get("image") as string) || null, + order: parseInt(form.get("order") as string, 10), + badges: parseBadges(form.get("badges")), + }; + await db.update(tierlistTiers).set(data).where(eq(tierlistTiers.id, id)); + } catch (error) { + console.error(error); + const err = error as Error & { + cause?: { detail?: string; message?: string }; + }; + return { + error: + err?.cause?.detail || err?.cause?.message || "Failed to update tier.", + }; + } + + await actionLog(`Updated tierlist tier ${id}`, data); + + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); + return { toast: "Tier saved successfully.", close: true }; + } + + async function deleteTier() { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + await db.delete(tierlistTiers).where(eq(tierlistTiers.id, id)); + await actionLog(`Deleted tierlist tier ${id}`, tier); + + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); + return { toast: "Tier deleted.", close: true }; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/@modal/tl/layout/tier/create/page.tsx b/app/(ui)/admin/@modal/tl/layout/tier/create/page.tsx new file mode 100644 index 0000000..92e0625 --- /dev/null +++ b/app/(ui)/admin/@modal/tl/layout/tier/create/page.tsx @@ -0,0 +1,121 @@ +import { sql } from "drizzle-orm"; +import { PlusIcon } from "lucide-react"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { CdnChooser } from "@/components/chooser"; +import { + FormAction, + FormInput, + FormProvider, + FormRow, +} from "@/components/form"; +import { ModalBase } from "@/components/modal"; +import { Button } from "@/components/ui/button"; +import { DialogClose, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistTiers } from "@/lib/db/schema"; + +function parseBadges(value: FormDataEntryValue | null) { + return String(value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +export default async function TlLayoutTierCreatePage() { + if (!(await adminCheck())) redirect("/login"); + + const [{ maxOrder }] = await db + .select({ maxOrder: sql`COALESCE(MAX(${tierlistTiers.order}), 0)` }) + .from(tierlistTiers); + + async function submit(form: FormData) { + "use server"; + if (!(await adminCheck())) redirect("/login"); + + for (const field of ["id", "name", "order"] as const) { + if (!form.get(field)) return { error: `Field "${field}" is required.` }; + } + + let data: typeof tierlistTiers.$inferInsert; + + try { + data = { + id: form.get("id") as string, + name: form.get("name") as string, + image: (form.get("image") as string) || null, + order: parseInt(form.get("order") as string, 10), + badges: parseBadges(form.get("badges")), + }; + await db.insert(tierlistTiers).values(data); + } catch (error) { + console.error(error); + const err = error as Error & { + cause?: { detail?: string; message?: string }; + }; + return { + error: + err?.cause?.detail || err?.cause?.message || "Failed to create tier.", + }; + } + + await actionLog(`Created tierlist tier ${data.id}`, data); + + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); + return { toast: "Tier created successfully.", close: true }; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/layout.tsx b/app/(ui)/admin/layout.tsx index 74c41fe..e1a9058 100755 --- a/app/(ui)/admin/layout.tsx +++ b/app/(ui)/admin/layout.tsx @@ -1,22 +1,22 @@ -import { desc, eq, sql } from "drizzle-orm"; +import { count, desc, eq, sql } from "drizzle-orm"; import { ArrowLeftRight, - Badge, BadgeDollarSign, BitcoinIcon, BookUser, - Columns3Cog, + Columns3CogIcon, Compass, Computer, Database, ExternalLink, - GitGraph, Grid3X3, IdCard, + Layout, ListTree, MoreHorizontal, Package, PlusCircle, + PlusIcon, ScrollText, Settings, SquareUserRound, @@ -54,6 +54,7 @@ import { SidebarHeader, SidebarInset, SidebarMenu, + SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarProvider, @@ -92,6 +93,18 @@ export default async function AdminLayout({ .innerJoin(tierlistTypes, eq(tierlistTypes.id, tierlistVersions.type)) .catch(() => []); + const tlTypes = db + .select({ + id: tierlistTypes.id, + name: tierlistTypes.name, + count: count(tierlistVersions.id), + }) + .from(tierlistTypes) + .orderBy(tierlistTypes.id) + .leftJoin(tierlistVersions, eq(tierlistTypes.id, tierlistVersions.type)) + .groupBy(tierlistTypes.id) + .catch(() => []); + return ( @@ -129,27 +142,24 @@ export default async function AdminLayout({ - Tierlist [WIP] + Tierlist + + + + + + + - - - Versions - - - - - - Badges - - - - - - Layout + + Layout Editor + + + @@ -257,6 +267,7 @@ export default async function AdminLayout({ @@ -322,6 +333,7 @@ async function HealthStatus() { async function AdminNavbarLoader({ adminShortcuts, + tierlistTypes, tierlistVersions, }: { adminShortcuts: Promise< @@ -330,6 +342,13 @@ async function AdminNavbarLoader({ url: string; }[] >; + tierlistTypes: Promise< + { + id: string; + name: string; + count: number; + }[] + >; tierlistVersions: Promise< { id: string; @@ -340,11 +359,29 @@ async function AdminNavbarLoader({ return ( ); } +async function TierlistList({ + types: prom, +}: { + types: Promise<{ id: string; name: string; count: number }[]>; +}) { + const types = await prom; + return types.map((t) => ( + + + + {t.name} + {t.count} + + + )); +} + async function VersionsList({ versions: prom, }: { diff --git a/app/(ui)/admin/navbar.tsx b/app/(ui)/admin/navbar.tsx index 81a69e6..f50ffd6 100644 --- a/app/(ui)/admin/navbar.tsx +++ b/app/(ui)/admin/navbar.tsx @@ -2,7 +2,6 @@ import { AlignJustify, - Badge, BadgeDollarSign, BitcoinIcon, ChevronRight, @@ -10,10 +9,10 @@ import { Compass, ExternalLink, Form, - GitGraph, Grid3x3, Home, IdCard, + Layout, List, Package, Plus, @@ -158,12 +157,18 @@ function DropdownMenu({ export function AdminNavbar({ adminShortcuts, + tierlistTypes, tierlistVersions, }: { adminShortcuts: { name: string; url: string; }[]; + tierlistTypes: { + id: string; + name: string; + count: number; + }[]; tierlistVersions: { id: string; name: string; @@ -181,9 +186,12 @@ export function AdminNavbar({ name: "Tierlist", icon: Table, sub: [ - { name: "Versions", icon: GitGraph, href: "/admin/tl/ver" }, - { name: "Badges", icon: Badge, href: "/admin/tl/badges" }, { name: "Layout", icon: Columns3Cog, href: "/admin/tl/layout" }, + ...tierlistTypes.map((type) => ({ + name: type.name, + icon: Layout, + href: `/admin/tl/${type.id}`, + })), ], }, { name: "Guide", icon: Compass, href: "/admin/guide" }, diff --git a/app/(ui)/admin/tl/[ver]/actions.ts b/app/(ui)/admin/tl/[ver]/actions.ts new file mode 100644 index 0000000..c0355ff --- /dev/null +++ b/app/(ui)/admin/tl/[ver]/actions.ts @@ -0,0 +1,82 @@ +"use server"; + +import { and, eq, isNull, or } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistBadges } from "@/lib/db/schema"; +import { midpointOrder, normalizedOrders } from "../components/order"; + +function sameIds(left: string[], right: string[]) { + if (left.length !== right.length) return false; + + const a = [...left].sort(); + const b = [...right].sort(); + return a.every((id, index) => id === b[index]); +} + +export async function reorderBadges(args: { + typeId: string; + activeId: string; + ids: string[]; +}) { + if (!(await adminCheck())) redirect("/login"); + + const { typeId, activeId, ids } = args; + + await db.transaction(async (tx) => { + const badges = await tx + .select() + .from(tierlistBadges) + .where(or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId))) + .orderBy(tierlistBadges.order, tierlistBadges.id); + + const visibleIds = badges.map((badge) => badge.id); + if (!sameIds(visibleIds, ids)) { + throw new Error("Badge order is out of date. Refresh and try again."); + } + + const index = ids.indexOf(activeId); + if (index === -1) throw new Error("Moved badge is missing."); + + const prev = + index > 0 + ? badges.find((badge) => badge.id === ids[index - 1]) + : undefined; + const next = + index < ids.length - 1 + ? badges.find((badge) => badge.id === ids[index + 1]) + : undefined; + const nextOrder = midpointOrder(prev?.order, next?.order); + + if (nextOrder == null) { + for (const item of normalizedOrders(ids)) { + await tx + .update(tierlistBadges) + .set({ order: item.order }) + .where(eq(tierlistBadges.id, item.id)); + } + } else { + await tx + .update(tierlistBadges) + .set({ order: nextOrder }) + .where( + and( + eq(tierlistBadges.id, activeId), + or(isNull(tierlistBadges.type), eq(tierlistBadges.type, typeId)), + ), + ); + } + }); + + await actionLog(`Reordered tierlist badges for ${typeId}`, { + typeId, + activeId, + ids, + }); + + revalidatePath(`/admin/tl/${typeId}`); + revalidatePath(`/tl/${typeId}`); +} diff --git a/app/(ui)/admin/tl/[ver]/badge-section.tsx b/app/(ui)/admin/tl/[ver]/badge-section.tsx new file mode 100644 index 0000000..978f649 --- /dev/null +++ b/app/(ui)/admin/tl/[ver]/badge-section.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { GripVertical, PencilLine, PlusIcon } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import type { ComponentProps } from "react"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { SimpleTooltip } from "@/components/tooltip"; +import { Button } from "@/components/ui/button"; +import type { tierlistBadges } from "@/lib/db/schema"; +import { cn } from "@/lib/utils"; +import { reorderFlatItems } from "../components/order"; +import { SortableGrid, SortableGridItem } from "../components/sortable-grid"; +import { reorderBadges } from "./actions"; + +export function BadgeSection({ + typeId, + typeName, + badges: initialBadges, +}: { + typeId: string; + typeName: string; + badges: (typeof tierlistBadges.$inferSelect)[]; +}) { + const [badges, setBadges] = useState(initialBadges); + const [pending, startTransition] = useTransition(); + const badgeSize = 48; + + function commitReorder(activeId: string, overId: string) { + const next = reorderFlatItems(badges, activeId, overId); + if (next === badges) return; + + const previous = badges; + setBadges(next); + + startTransition(() => { + reorderBadges({ + typeId, + activeId, + ids: next.map((badge) => badge.id), + }).catch((error) => { + console.error(error); + setBadges(previous); + toast.error( + error instanceof Error ? error.message : "Failed to reorder badges.", + ); + }); + }); + } + + return ( +
+
+ + Badges + + (blue = global, green = {typeName} local) + + + +
+ badge.id)} + onReorder={commitReorder} + disabled={pending} + overlay={(id) => { + const badge = badges.find((entry) => entry.id === id); + return badge ? ( + + ) : null; + }} + > +
+ {badges.map((badge) => ( + + {({ attributes, listeners, setNodeRef, style, isDragging }) => ( +
+ +
+ )} +
+ ))} +
+
+
+ ); +} + +function BadgeTile({ + badge, + typeId, + badgeSize, + dragHandle, + overlay = false, +}: { + badge: typeof tierlistBadges.$inferSelect; + typeId: string; + badgeSize: number; + dragHandle?: ComponentProps<"button">; + overlay?: boolean; +}) { + const borderClass = + badge.type === null ? "border-blue-500" : "border-emerald-400"; + + return ( +
+ + {!overlay && ( +
+ + + +
+ )} + {badge.image ? ( + {badge.name} + ) : ( +
+ {badge.name} +
+ )} +
+ ); +} diff --git a/app/(ui)/admin/tl/[ver]/page.tsx b/app/(ui)/admin/tl/[ver]/page.tsx new file mode 100755 index 0000000..5ff4dda --- /dev/null +++ b/app/(ui)/admin/tl/[ver]/page.tsx @@ -0,0 +1,137 @@ +import { desc, eq } from "drizzle-orm"; +import { ExternalLink, Lock, PencilLine, PlusIcon } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { SimpleTooltip } from "@/components/tooltip"; +import { Button } from "@/components/ui/button"; +import { Kbd } from "@/components/ui/kbd"; +import { db } from "@/lib/db"; +import { + tierlistBadges, + tierlistTypes, + tierlistVersions, +} from "@/lib/db/schema"; +import { syncTierlistSnapshots } from "@/lib/tierlist"; +import { cn } from "@/lib/utils"; +import { BadgeSection } from "./badge-section"; + +export default async function TierlistManagerPage({ + params, +}: PageProps<"/admin/tl/[ver]">) { + const { ver } = await params; + await syncTierlistSnapshots(ver); + const [[type], versionsList, badges] = await Promise.all([ + db.select().from(tierlistTypes).where(eq(tierlistTypes.id, ver)), + db + .select() + .from(tierlistVersions) + .orderBy(desc(tierlistVersions.order)) + .where(eq(tierlistVersions.type, ver)), + db + .select() + .from(tierlistBadges) + .orderBy(tierlistBadges.order, tierlistBadges.id), + ]); + const t = { + ...type, + versions: versionsList, + }; + return ( +
+
+
+
+ {t.name} + + {t.id} + {t.mode} + +
+
+ + + + + + +
+
+
+ {t.versions.map((e) => ( +
+ {e.image ? ( + <> + {e.name} +
+ {e.name} {e.id} +
+ + ) : ( + e.name + )} +
+ + + + + + +
+ {e.snapshot && ( +
+ + + +
+ )} +
+ ))} +
+
+
+
+
+ (badge.type ?? ver) === ver)} + /> +
+
+
+
+ ); +} diff --git a/app/(ui)/admin/tl/components/order.ts b/app/(ui)/admin/tl/components/order.ts new file mode 100644 index 0000000..fcf3dea --- /dev/null +++ b/app/(ui)/admin/tl/components/order.ts @@ -0,0 +1,36 @@ +export const ORDER_STEP = 10; + +export function reorderFlatItems( + items: T[], + activeId: string, + overId: string, +) { + if (activeId === overId) return items; + + const oldIndex = items.findIndex((item) => item.id === activeId); + const newIndex = items.findIndex((item) => item.id === overId); + if (oldIndex === -1 || newIndex === -1) return items; + + return arrayMove(items, oldIndex, newIndex); +} + +export function midpointOrder(prev?: number, next?: number) { + if (prev == null && next == null) return ORDER_STEP; + if (prev == null) return next! - ORDER_STEP; + if (next == null) return prev + ORDER_STEP; + + const middle = Math.floor((prev + next) / 2); + if (middle === prev || middle === next) return null; + return middle; +} + +export function normalizedOrders(ids: string[]) { + return ids.map((id, index) => ({ + id, + order: (index + 1) * ORDER_STEP, + })); +} + +function arrayMove(arr: T[], oldIndex: number, newIndex: number) { + return arr.toSpliced(oldIndex, 1).toSpliced(newIndex, 0, arr[oldIndex]); +} diff --git a/app/(ui)/admin/tl/components/sortable-grid.tsx b/app/(ui)/admin/tl/components/sortable-grid.tsx new file mode 100644 index 0000000..f430b06 --- /dev/null +++ b/app/(ui)/admin/tl/components/sortable-grid.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { + closestCenter, + DndContext, + type DragEndEvent, + type DraggableAttributes, + DragOverlay, +} from "@dnd-kit/core"; +import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"; +import { + rectSortingStrategy, + SortableContext, + useSortable, +} from "@dnd-kit/sortable"; +import type { ReactNode } from "react"; +import { useState } from "react"; + +export function SortableGrid({ + items, + onReorder, + children, + overlay, + disabled = false, +}: { + items: string[]; + onReorder: (activeId: string, overId: string) => void; + children?: ReactNode; + overlay?: (id: string) => ReactNode; + disabled?: boolean; +}) { + const [dragging, setDragging] = useState(null); + + function handleDragEnd(event: DragEndEvent) { + setDragging(null); + if (disabled || !event.over) return; + onReorder(event.active.id as string, event.over.id as string); + } + + return ( + setDragging(event.active.id as string)} + onDragCancel={() => setDragging(null)} + onDragEnd={handleDragEnd} + > + + {children} + + {dragging && overlay?.(dragging)} + + ); +} + +export function SortableGridItem({ + id, + disabled = false, + children, +}: { + id: string; + disabled?: boolean; + children: (props: { + listeners: SyntheticListenerMap | undefined; + attributes: DraggableAttributes | undefined; + setNodeRef: (node: HTMLElement | null) => void; + style: React.CSSProperties | undefined; + isDragging: boolean; + }) => ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id, disabled }); + + return children({ + listeners, + attributes, + setNodeRef, + isDragging, + style: { + transform: transform + ? `translate3d(${transform.x}px, ${transform.y}px, 0) scaleX(${transform.scaleX}) scaleY(${transform.scaleY})` + : undefined, + transition, + opacity: isDragging ? 0.5 : 1, + }, + }); +} diff --git a/app/(ui)/admin/tl/layout/actions.ts b/app/(ui)/admin/tl/layout/actions.ts new file mode 100644 index 0000000..315e378 --- /dev/null +++ b/app/(ui)/admin/tl/layout/actions.ts @@ -0,0 +1,76 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { actionLog } from "@/lib/api"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistColumns, tierlistTiers } from "@/lib/db/schema"; +import { midpointOrder, normalizedOrders } from "../components/order"; + +function sameIds(left: string[], right: string[]) { + if (left.length !== right.length) return false; + + const a = [...left].sort(); + const b = [...right].sort(); + return a.every((id, index) => id === b[index]); +} + +async function reorderTable( + ids: string[], + activeId: string, + type: "tiers" | "columns", +) { + const table = type === "tiers" ? tierlistTiers : tierlistColumns; + + await db.transaction(async (tx) => { + const items = await tx.select().from(table).orderBy(table.order, table.id); + const visibleIds = items.map((item) => item.id); + if (!sameIds(visibleIds, ids)) { + throw new Error("Layout order is out of date. Refresh and try again."); + } + + const index = ids.indexOf(activeId); + if (index === -1) throw new Error("Moved item is missing."); + + const prev = + index > 0 ? items.find((item) => item.id === ids[index - 1]) : undefined; + const next = + index < ids.length - 1 + ? items.find((item) => item.id === ids[index + 1]) + : undefined; + const nextOrder = midpointOrder(prev?.order, next?.order); + + if (nextOrder == null) { + for (const item of normalizedOrders(ids)) { + await tx + .update(table) + .set({ order: item.order }) + .where(eq(table.id, item.id)); + } + } else { + await tx + .update(table) + .set({ order: nextOrder }) + .where(eq(table.id, activeId)); + } + }); + + await actionLog(`Reordered tierlist ${type}`, { activeId, ids }); + revalidatePath("/admin/tl/layout"); + revalidatePath("/tl"); +} + +export async function reorderTiers(args: { activeId: string; ids: string[] }) { + if (!(await adminCheck())) redirect("/login"); + return reorderTable(args.ids, args.activeId, "tiers"); +} + +export async function reorderColumns(args: { + activeId: string; + ids: string[]; +}) { + if (!(await adminCheck())) redirect("/login"); + return reorderTable(args.ids, args.activeId, "columns"); +} diff --git a/app/(ui)/admin/tl/layout/client.tsx b/app/(ui)/admin/tl/layout/client.tsx new file mode 100644 index 0000000..b63b8ba --- /dev/null +++ b/app/(ui)/admin/tl/layout/client.tsx @@ -0,0 +1,344 @@ +"use client"; + +import { GripVertical, PencilLine, PlusIcon } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import type { ComponentProps } from "react"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { SimpleTooltip } from "@/components/tooltip"; +import { Button } from "@/components/ui/button"; +import { Kbd, KbdGroup } from "@/components/ui/kbd"; +import type { tierlistColumns, tierlistTiers } from "@/lib/db/schema"; +import { cn } from "@/lib/utils"; +import { reorderFlatItems } from "../components/order"; +import { SortableGrid, SortableGridItem } from "../components/sortable-grid"; +import { reorderColumns, reorderTiers } from "./actions"; + +type Tier = typeof tierlistTiers.$inferSelect; +type Column = typeof tierlistColumns.$inferSelect; + +export function LayoutEditor({ + tiers: initialTiers, + columns: initialColumns, +}: { + tiers: Tier[]; + columns: Column[]; +}) { + const [tiers, setTiers] = useState(initialTiers); + const [columns, setColumns] = useState(initialColumns); + const [pending, startTransition] = useTransition(); + + function commitReorder( + items: T[], + setItems: (items: T[]) => void, + activeId: string, + overId: string, + persist: (args: { activeId: string; ids: string[] }) => Promise, + label: string, + ) { + const next = reorderFlatItems(items, activeId, overId); + if (next === items) return; + + const previous = items; + setItems(next); + + startTransition(() => { + persist({ activeId, ids: next.map((item) => item.id) }).catch((error) => { + console.error(error); + setItems(previous); + toast.error( + error instanceof Error + ? error.message + : `Failed to reorder ${label}.`, + ); + }); + }); + } + + return ( +
+
+
+
+ + column.id)} + onReorder={(activeId, overId) => + commitReorder( + columns, + setColumns, + activeId, + overId, + reorderColumns, + "columns", + ) + } + disabled={pending} + overlay={(id) => { + const column = columns.find((entry) => entry.id === id); + return column ? ( + + ) : null; + }} + > + {columns.length ? ( + columns.map((column) => ( + + {({ + attributes, + listeners, + setNodeRef, + style, + isDragging, + }) => ( +
+ +
+ )} +
+ )) + ) : ( +
+ Add your first column +
+ )} +
+ + + + {tiers.length ? ( + tier.id)} + onReorder={(activeId, overId) => + commitReorder( + tiers, + setTiers, + activeId, + overId, + reorderTiers, + "tiers", + ) + } + disabled={pending} + overlay={(id) => { + const tier = tiers.find((entry) => entry.id === id); + return tier ? ( + + ) : null; + }} + > + {tiers.map((tier) => ( + column.id) + : ["empty"] + } + pending={pending} + /> + ))} + + ) : ( + <> +
+ Add your first tier +
+ {(columns.length + ? columns.map((column) => column.id) + : ["empty"] + ).map((id) => ( + + ))} + + )} + + + {(columns.length + ? columns.map((column) => column.id) + : ["empty"] + ).map((id) => ( + + ))} + +
+
+
+ ); +} + +function TierRow({ + tier, + columnIds, + pending, +}: { + tier: Tier; + columnIds: string[]; + pending: boolean; +}) { + return ( + <> + + {({ attributes, listeners, setNodeRef, style, isDragging }) => ( +
+ +
+ )} +
+ {columnIds.map((columnId) => ( + + ))} + + + ); +} + +function BlankCell() { + return
; +} + +function ThinCell() { + return
; +} + +function AddSlot({ href, label }: { href: string; label: string }) { + return ( + + + + + + ); +} + +function LayoutNode({ + item, + href, + dragHandle, + orientation, + badges, + overlay = false, +}: { + item: { id: string; name: string; image: string | null }; + href?: string; + dragHandle?: ComponentProps<"button">; + orientation: "tier" | "column"; + badges?: string[] | null; + overlay?: boolean; +}) { + return ( +
+ + {href && !overlay && ( +
+ + + +
+ )} +
+ {item.image ? ( + {item.name} + ) : ( + {item.name[0] ?? "?"} + )} +
+
+
+
{item.name}
+ {item.id} +
+ {orientation === "tier" && ( +
+ {badges?.length ? ( + + {badges.map((badge) => ( + {badge} + ))} + + ) : ( + No badges + )} +
+ )} +
+
+ ); +} diff --git a/app/(ui)/admin/tl/layout/page.tsx b/app/(ui)/admin/tl/layout/page.tsx new file mode 100644 index 0000000..a058f28 --- /dev/null +++ b/app/(ui)/admin/tl/layout/page.tsx @@ -0,0 +1,24 @@ +import { redirect } from "next/navigation"; +import { adminCheck } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { tierlistColumns, tierlistTiers } from "@/lib/db/schema"; +import { LayoutEditor } from "./client"; + +export default async function TierlistLayoutPage() { + if (!(await adminCheck())) redirect("/login"); + + const [tiers, columns] = await Promise.all([ + db + .select() + .from(tierlistTiers) + .orderBy(tierlistTiers.order, tierlistTiers.id), + db + .select() + .from(tierlistColumns) + .orderBy(tierlistColumns.order, tierlistColumns.id), + ]); + + return ; +} + +export const dynamic = "force-dynamic"; diff --git a/app/(ui)/admin/tl/ver/page.tsx b/app/(ui)/admin/tl/ver/page.tsx deleted file mode 100755 index db21788..0000000 --- a/app/(ui)/admin/tl/ver/page.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { desc } from "drizzle-orm"; -import { ExternalLink, PencilLine, PlusIcon } from "lucide-react"; -import Image from "next/image"; -import Link from "next/link"; -import { SimpleTooltip } from "@/components/tooltip"; -import { Button } from "@/components/ui/button"; -import { Kbd } from "@/components/ui/kbd"; -import { db } from "@/lib/db"; -import { tierlistTypes, tierlistVersions } from "@/lib/db/schema"; -import { cn } from "@/lib/utils"; - -export default async function TierlistManagerPage() { - const types = await db.select().from(tierlistTypes).orderBy(tierlistTypes.id); - const versionsList = await db - .select() - .from(tierlistVersions) - .orderBy(desc(tierlistVersions.order)); - const vers = types.map((t) => ({ - ...t, - versions: versionsList.filter((v) => v.type === t.id), - })); - - return ( -
- - {vers.map((t) => ( -
-
-
- {t.name} - - {t.id} - {t.mode} - -
-
- - - - - - -
-
-
- {t.versions.map((e) => ( -
- {e.image ? ( - <> - {e.name} -
- {e.name} {e.id} -
- - ) : ( - e.name - )} -
- - - - - - -
-
- ))} -
-
- ))} -
- ); -} diff --git a/app/(ui)/tl/[type]/[ver]/admin/page.tsx b/app/(ui)/tl/[type]/[ver]/admin/page.tsx index bdb7267..8d35aaa 100755 --- a/app/(ui)/tl/[type]/[ver]/admin/page.tsx +++ b/app/(ui)/tl/[type]/[ver]/admin/page.tsx @@ -1,17 +1,7 @@ -import { and, eq, inArray, isNull, or } from "drizzle-orm"; import { notFound, redirect } from "next/navigation"; import { ViewTransition } from "react"; import { adminCheck } from "@/lib/auth"; -import { db } from "@/lib/db"; -import { - characters, - tierlistBadges, - tierlistColumns, - tierlistTiers, - tierlistTypes, - tierlistVersions, - versions, -} from "@/lib/db/schema"; +import { getTierlistConfig } from "@/lib/tierlist"; import { TierList } from "../tierlist"; export default async function TierlistPage({ @@ -21,64 +11,7 @@ export default async function TierlistPage({ }) { if (!(await adminCheck())) redirect("/login"); const { type, ver } = await params; - const config = await db.transaction(async (tx) => { - // resolve version info - const [version] = await tx - .select() - .from(tierlistVersions) - .where( - and(eq(tierlistVersions.type, type), eq(tierlistVersions.id, ver)), - ); - if (!version) return; - const [typeInfo] = await tx - .select() - .from(tierlistTypes) - .where(eq(tierlistTypes.id, type)); - - // resolve `from` fields - const vers = await tx - .select({ id: versions.id, from: versions.from }) - .from(versions); - const ids: string[] = []; - let cur: string | null = version.from; - while (cur) { - ids.push(cur); - const r = vers.find((v) => v.id === cur); - cur = r?.from ?? null; - } - - const chars = - ids.length > 0 - ? await tx - .select() - .from(characters) - .where(inArray(characters.version, ids)) - .orderBy(characters.order) - : ([] as (typeof characters.$inferSelect)[]); - - // fetch rows, columns, badges data... - const tiers = await tx - .select() - .from(tierlistTiers) - .orderBy(tierlistTiers.order); - const columns = await tx - .select() - .from(tierlistColumns) - .orderBy(tierlistColumns.order); - const badgesList = await tx - .select() - .from(tierlistBadges) - .orderBy(tierlistBadges.order) - .where( - or(isNull(tierlistBadges.type), eq(tierlistBadges.type, version.type)), - ); - const badges = badgesList.map((b) => ({ - ...b, - tier: tiers.filter((t) => t.badges?.includes(b.id)).map((t) => t.id), - })); - - return { type: typeInfo, version, tiers, columns, badges, chars }; - }); + const config = await getTierlistConfig(type, ver); if (!config) // console.log(config); notFound(); diff --git a/app/(ui)/tl/[type]/[ver]/cell.tsx b/app/(ui)/tl/[type]/[ver]/cell.tsx index 331103c..ab92f81 100755 --- a/app/(ui)/tl/[type]/[ver]/cell.tsx +++ b/app/(ui)/tl/[type]/[ver]/cell.tsx @@ -7,7 +7,6 @@ import { SortableContext, useSortable, } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; import { type ReactNode, useContext } from "react"; import type { tierlistColumns, tierlistTiers } from "@/lib/db/schema"; import { TierListContext } from "./context"; @@ -69,7 +68,9 @@ export function SortableDraggable({ } = useSortable({ id, disabled: !editable }); const style = { - transform: CSS.Transform.toString(transform), + transform: transform + ? `translate3d(${transform.x}px, ${transform.y}px, 0) scaleX(${transform.scaleX}) scaleY(${transform.scaleY})` + : undefined, transition, opacity: isDragging ? 0.5 : 1, }; diff --git a/app/(ui)/tl/[type]/[ver]/page.tsx b/app/(ui)/tl/[type]/[ver]/page.tsx index e8a8ac0..619b9a4 100755 --- a/app/(ui)/tl/[type]/[ver]/page.tsx +++ b/app/(ui)/tl/[type]/[ver]/page.tsx @@ -1,16 +1,9 @@ -import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { db } from "@/lib/db"; -import { - characters, - tierlistBadges, - tierlistColumns, - tierlistTiers, - tierlistTypes, - tierlistVersions, - versions, -} from "@/lib/db/schema"; +import { tierlistTypes, tierlistVersions } from "@/lib/db/schema"; +import { getTierlistConfig } from "@/lib/tierlist"; import { TierList } from "./tierlist"; export async function generateMetadata({ @@ -48,64 +41,7 @@ export default async function TierlistPage({ params: Promise<{ type: string; ver: string }>; }) { const { type, ver } = await params; - const config = await db.transaction(async (tx) => { - // resolve version info - const [version] = await tx - .select() - .from(tierlistVersions) - .where( - and(eq(tierlistVersions.type, type), eq(tierlistVersions.id, ver)), - ); - if (!version) return; - const [typeInfo] = await tx - .select() - .from(tierlistTypes) - .where(eq(tierlistTypes.id, type)); - - // resolve `from` fields - const vers = await tx - .select({ id: versions.id, from: versions.from }) - .from(versions); - const ids: string[] = []; - let cur: string | null = version.from; - while (cur) { - ids.push(cur); - const r = vers.find((v) => v.id === cur); - cur = r?.from ?? null; - } - - const chars = - ids.length > 0 - ? await tx - .select() - .from(characters) - .where(inArray(characters.version, ids)) - .orderBy(characters.order) - : ([] as (typeof characters.$inferSelect)[]); - - // fetch rows, columns, badges data... - const [tiers, columns, badgesList] = await Promise.all([ - tx.select().from(tierlistTiers).orderBy(tierlistTiers.order), - tx.select().from(tierlistColumns).orderBy(tierlistColumns.order), - tx - .select() - .from(tierlistBadges) - .orderBy(tierlistBadges.order) - .where( - or( - isNull(tierlistBadges.type), - eq(tierlistBadges.type, version.type), - ), - ), - ]); - const badges = badgesList.map((b) => ({ - ...b, - tier: tiers.filter((t) => t.badges?.includes(b.id)).map((t) => t.id), - })); - - return { type: typeInfo, version, tiers, columns, badges, chars }; - }); - // console.log(config); + const config = await getTierlistConfig(type, ver); if (!config) notFound(); return ; // return
{JSON.stringify(config, null, 2)}
; diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 22d06a3..2cdb09d 100755 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -303,6 +303,7 @@ export const tierlistVersions = tierlist.table("versions", { }), order: integer().notNull(), placements: jsonb().notNull().$type<{ [x: string]: string[] }>().default({}), + snapshot: jsonb().$type | null>().default(null), }); export const tierlistStates = tierlist.table("states", { diff --git a/lib/tierlist.ts b/lib/tierlist.ts new file mode 100644 index 0000000..83e250c --- /dev/null +++ b/lib/tierlist.ts @@ -0,0 +1,182 @@ +import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { + characters, + tierlistBadges, + tierlistColumns, + tierlistTiers, + tierlistTypes, + tierlistVersions, + versions, +} from "@/lib/db/schema"; + +export type TierlistBadgeResolved = typeof tierlistBadges.$inferSelect & { + tier: string[]; +}; + +export type TierlistResolvedConfig = { + type: typeof tierlistTypes.$inferSelect; + version: typeof tierlistVersions.$inferSelect; + tiers: (typeof tierlistTiers.$inferSelect)[]; + columns: (typeof tierlistColumns.$inferSelect)[]; + badges: TierlistBadgeResolved[]; + chars: (typeof characters.$inferSelect)[]; +}; + +type DbLike = Pick; + +function normalizeSnapshot( + config: TierlistResolvedConfig, +): TierlistResolvedConfig { + return { + ...config, + version: { + ...config.version, + snapshot: null, + }, + }; +} + +async function resolveCharacterVersions(tx: DbLike, rootVersionId: string) { + const vers = await tx + .select({ id: versions.id, from: versions.from }) + .from(versions); + const ids: string[] = []; + let cur: string | null = rootVersionId; + + while (cur) { + ids.push(cur); + const found = vers.find((version) => version.id === cur); + cur = found?.from ?? null; + } + + return ids; +} + +async function buildLiveTierlistConfig( + tx: DbLike, + version: typeof tierlistVersions.$inferSelect, +) { + const [typeInfo] = await tx + .select() + .from(tierlistTypes) + .where(eq(tierlistTypes.id, version.type)); + if (!typeInfo) return null; + + const ids = await resolveCharacterVersions(tx, version.from); + const chars = + ids.length > 0 + ? await tx + .select() + .from(characters) + .where(inArray(characters.version, ids)) + .orderBy(characters.order) + : ([] as (typeof characters.$inferSelect)[]); + + const [tiers, columns, badgesList] = await Promise.all([ + tx.select().from(tierlistTiers).orderBy(tierlistTiers.order), + tx.select().from(tierlistColumns).orderBy(tierlistColumns.order), + tx + .select() + .from(tierlistBadges) + .orderBy(tierlistBadges.order, tierlistBadges.id) + .where( + or(isNull(tierlistBadges.type), eq(tierlistBadges.type, version.type)), + ), + ]); + + const badges = badgesList.map((badge) => ({ + ...badge, + tier: tiers + .filter((tier) => tier.badges?.includes(badge.id)) + .map((tier) => tier.id), + })); + + return normalizeSnapshot({ + type: typeInfo, + version, + tiers, + columns, + badges, + chars, + }); +} + +function shouldSnapshot(config: TierlistResolvedConfig) { + const deprecatedAt = Date.parse(config.version.deprecates); + const olderThanMonth = + Number.isFinite(deprecatedAt) && + deprecatedAt <= Date.now() - 30 * 24 * 60 * 60 * 1000; + + const placed = new Set( + Object.values(config.version.placements) + .flat() + .map((entry) => entry.split("#")[0]), + ); + const allTiered = + config.chars.length > 0 && + config.chars.every((char) => placed.has(char.id)); + + return olderThanMonth || allTiered; +} + +async function maybeSnapshotVersion( + tx: DbLike, + version: typeof tierlistVersions.$inferSelect, + config: TierlistResolvedConfig, +) { + if (version.snapshot || !shouldSnapshot(config)) return config; + + await tx + .update(tierlistVersions) + .set({ snapshot: config }) + .where(eq(tierlistVersions.id, version.id)); + + return config; +} + +export async function getTierlistConfig(typeId: string, versionId: string) { + return db.transaction(async (tx) => { + const [version] = await tx + .select() + .from(tierlistVersions) + .where( + and( + eq(tierlistVersions.type, typeId), + eq(tierlistVersions.id, versionId), + ), + ); + if (!version) return null; + + if (version.snapshot) { + return normalizeSnapshot(version.snapshot as TierlistResolvedConfig); + } + + const config = await buildLiveTierlistConfig(tx, version); + if (!config) return null; + + return maybeSnapshotVersion(tx, version, config); + }); +} + +export async function syncTierlistSnapshots(typeId: string) { + return db.transaction(async (tx) => { + const versionList = await tx + .select() + .from(tierlistVersions) + .where(eq(tierlistVersions.type, typeId)) + .orderBy(tierlistVersions.order, tierlistVersions.id); + + for (const version of versionList) { + if (version.snapshot) continue; + + const config = await buildLiveTierlistConfig(tx, version); + if (!config || !shouldSnapshot(config)) continue; + + await tx + .update(tierlistVersions) + .set({ snapshot: config }) + .where(eq(tierlistVersions.id, version.id)); + } + }); +} From 7be3db93b4eb9d748f9efed0d9e5e7f807c9e34d Mon Sep 17 00:00:00 2001 From: dmgnr Date: Thu, 6 Aug 2026 12:08:17 +0700 Subject: [PATCH 31/71] fix(tl): deprecate date parsing --- lib/tierlist.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/tierlist.ts b/lib/tierlist.ts index 83e250c..3ee43ca 100644 --- a/lib/tierlist.ts +++ b/lib/tierlist.ts @@ -103,7 +103,7 @@ async function buildLiveTierlistConfig( } function shouldSnapshot(config: TierlistResolvedConfig) { - const deprecatedAt = Date.parse(config.version.deprecates); + const deprecatedAt = parseDate(config.version.deprecates); const olderThanMonth = Number.isFinite(deprecatedAt) && deprecatedAt <= Date.now() - 30 * 24 * 60 * 60 * 1000; @@ -120,6 +120,24 @@ function shouldSnapshot(config: TierlistResolvedConfig) { return olderThanMonth || allTiered; } +function parseDate(value: string) { + const match = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (!match) return Number.NaN; + + const [, day, month, year] = match; + const date = new Date(Number(year), Number(month) - 1, Number(day)); + + if ( + date.getFullYear() !== Number(year) || + date.getMonth() !== Number(month) - 1 || + date.getDate() !== Number(day) + ) { + return Number.NaN; + } + + return date.getTime(); +} + async function maybeSnapshotVersion( tx: DbLike, version: typeof tierlistVersions.$inferSelect, From c0d016f08dccaa3ec564bfb5b5aaaf255d824a73 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Mon, 17 Aug 2026 19:38:28 +0700 Subject: [PATCH 32/71] fix: remove gemini 3.1 tts model --- app/api/tts/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/tts/route.ts b/app/api/tts/route.ts index 6f99f70..45f77b6 100644 --- a/app/api/tts/route.ts +++ b/app/api/tts/route.ts @@ -12,7 +12,7 @@ const { DONATE_WIDGET_KEY, GEMINI_TTS_API_KEY } = process.env as Record< const MODELS = [ "gemini-2.5-flash-preview-tts", - "gemini-3.1-flash-preview-tts", + // "gemini-3.1-flash-preview-tts", ] as const; const API_KEYS = GEMINI_TTS_API_KEY.split(",") .map((k) => k.trim()) From 5ad6e97f25a4eceb0c550d64c1a2b4b649d0cc50 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Mon, 17 Aug 2026 20:57:40 +0700 Subject: [PATCH 33/71] feat : add tralver cyro --- util/img/traveler_cyro.png | Bin 0 -> 78749 bytes util/sync.ts | 14 ++++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 util/img/traveler_cyro.png diff --git a/util/img/traveler_cyro.png b/util/img/traveler_cyro.png new file mode 100644 index 0000000000000000000000000000000000000000..f020c94392e50811513b390a449eda24a46d1f4c GIT binary patch literal 78749 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
|^e&;?;e$nJJXsTttBQdbV8>SYEV8+Kvs^W= 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 Date: Mon, 17 Aug 2026 20:58:22 +0700 Subject: [PATCH 34/71] fix : correct icon --- util/sync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/sync.ts b/util/sync.ts index b43833a..925b382 100755 --- a/util/sync.ts +++ b/util/sync.ts @@ -100,7 +100,7 @@ const extra: Avatar[] = [ region: "MAINACTOR", specialProp: "FIGHT_PROP_ATTACK_PERCENT", bodyType: "BOY", - icon: "custom/traveler_dendro.webp:image/webp", + icon: "custom/traveler_cyro.webp:image/webp", birthday: [0, 0], route: "Cyro Traveler Boy", release: 1657659700, From 64f3df8d8595fa5d422018713c9c300e5ebb698f Mon Sep 17 00:00:00 2001 From: gunshiz Date: Mon, 17 Aug 2026 21:04:23 +0700 Subject: [PATCH 35/71] fix : correct file extension --- util/sync.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/util/sync.ts b/util/sync.ts index 925b382..cbd4d15 100755 --- a/util/sync.ts +++ b/util/sync.ts @@ -100,10 +100,10 @@ const extra: Avatar[] = [ region: "MAINACTOR", specialProp: "FIGHT_PROP_ATTACK_PERCENT", bodyType: "BOY", - icon: "custom/traveler_cyro.webp:image/webp", + icon: "custom/traveler_cyro.png:image/png", birthday: [0, 0], route: "Cyro Traveler Boy", - release: 1657659700, + release: 1786496400, }, ]; From a1631adf562ac077c7b12c0f4d328c79105e9df1 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Mon, 17 Aug 2026 21:07:27 +0700 Subject: [PATCH 36/71] fix: god of change file extension [no ci] --- util/img/traveler_cyro.png | Bin 78749 -> 0 bytes util/img/traveler_cyro.webp | Bin 0 -> 25636 bytes util/sync.ts | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 util/img/traveler_cyro.png create mode 100644 util/img/traveler_cyro.webp diff --git a/util/img/traveler_cyro.png b/util/img/traveler_cyro.png deleted file mode 100644 index f020c94392e50811513b390a449eda24a46d1f4c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 78749 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

|^e&;?;e$nJJXsTttBQdbV8>SYEV8+Kvs^W= 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

7w6g04 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 Date: Mon, 17 Aug 2026 21:28:42 +0700 Subject: [PATCH 37/71] fix(sync): start from last order instead of 0 [no ci] --- util/sync.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/util/sync.ts b/util/sync.ts index b7ea638..3997b0d 100755 --- a/util/sync.ts +++ b/util/sync.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { inArray } from "drizzle-orm"; +import { inArray, sql } from "drizzle-orm"; import { cdnify, checkCdnRefs } from "@/lib/api"; import { AmberElementMap } from "@/lib/const"; import { db } from "@/lib/db"; @@ -223,7 +223,9 @@ async function main() { }) .onConflictDoNothing(); } - let i = 0; + let [{ maxOrder: i }] = await db + .select({ maxOrder: sql`MAX(${characters.order})` }) + .from(characters); for (const char of newChars) { i += 10; process.stdout.write( From 6245927c47c81a75dd9f5bd0cf973c14e2182b58 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Tue, 25 Aug 2026 11:01:55 +0000 Subject: [PATCH 38/71] feat : new random message --- app/(ui)/admin/client.tsx | 66 ++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/app/(ui)/admin/client.tsx b/app/(ui)/admin/client.tsx index 2edcd9d..a5c3c08 100755 --- a/app/(ui)/admin/client.tsx +++ b/app/(ui)/admin/client.tsx @@ -14,78 +14,74 @@ export function VersionCreateDialogForm() { export function RandomWelcomeMessage() { const items = [ { - header: "ความลับแห่งแรงจันทราผัน", + header: "ไป Snezhnaya ได้ตั้งแต่ AR 18", message: - "แรงจันทราผัน (Kuuvahki) ใน Nod-Krai มีคุณสมบัติเหมือนแรงแม่เหล็ก ซึ่งเป็นกุญแจสำคัญในการแก้ปริศนาโบราณ", + "เมื่อจบ Archon Quest Prologue: Act III แล้ว จุด Teleport Waypoint ที่นำไปยัง Snezhnaya จะปลดล็อกให้อัตโนมัติ โดยไม่ต้องจบเควสต์ของภูมิภาคอื่นก่อน", }, { - header: "การล่าอันบ้าคลั่ง: Wild Hunt", + header: "ห้าพื้นที่ใหม่ในเวอร์ชัน 7.0", message: - "เมื่อเผชิญหน้ากับกองทัพ Wild Hunt การลด HP ให้เหลือ 0 เป็นเพียงจุดเริ่มต้นเท่านั้น คุณต้องลดค่า Max HP ของพวกมันให้เหลือ 0 ในขณะที่พวกมันติดสถานะ Grief-Stricken เพื่อกำจัดพวกมันอย่างถาวร ", + "Snezhnaya เปิดให้สำรวจ Volkodlak Tundra, White Birch Snowgrave, Everfrozen Earth, Flamefeather Valley และ Fellfrost Peak ในเวอร์ชัน 7.0", }, { - header: "ประกายจันทร์: Ascendant Gleam", + header: "อย่าลืมเก็บ Cryo Sigil", message: - "การจัดทีมที่มีตัวละครจาก Nod-Krai อย่างน้อย 2 ตัว จะเปิดใช้งาน Ascendant Gleam เพื่อให้ใช้ความสามรถตัวละครได้อย่างเต็มที่", + "Cryo Sigil ที่พบระหว่างสำรวจ Snezhnaya ใช้สร้างและเพิ่มเลเวล Meeting Points เพื่อรับเสบียงและรางวัลเพิ่มเติม", }, { - header: "มรดกแห่งดวงจันทร์ทั้งสาม", + header: "Meeting Points สามแห่ง", message: - "Nod-Krai เคยถูกเรียกว่า Nephilheim และถูกเชื่อว่าเป็นเศษเสี้ยวของดวงจันทร์ดวงที่สี่ซึ่งแตกสลายก่อนจะก่อตัวเสร็จสิ้น ทำให้ดินแดนแห่งนี้มีความเชื่อมโยงกับพลังจันทราอย่างลึกซึ้ง", + "Meeting Points ในเวอร์ชัน 7.0 ได้แก่ Tidesong Cavern, Huntsman's Cabin และ The Korolevskiy Theater ปลดล็อกได้จากเนื้อเรื่อง Act I", }, { - header: "คำสาบานของผู้เฝ้าประภาคาร", + header: "ปลดล็อก Daily Commission", message: - "กลุ่ม Lightkeepers ยึดถือคติ 'ใช้เลือดและกระดูกเป็นเชื้อเพลิง' พวกเขาเฝ้าประภาคารที่ Piramida มานานกว่า 500 ปีเพื่อไม่ให้ความมืดจาก Abyss ล่มสลายดินแดนของมนุษย์", + "ต้องเล่น Archon Quest Chapter VII: Act I จนถึงช่วง Great Deeds on the Tundra ก่อน จึงจะเปิด Daily Commission ของ Snezhnaya ได้", }, { - header: "อาวุธชำระล้าง Terpikeraunas", + header: "Traveler ใช้ธาตุ Cryo ได้แล้ว", message: - "Arrow of Terpikeraunas เป็นโบราณวัตถุจาก Hyperborea ที่สามารถเปลี่ยนพลังงานจาก Light Realm ให้เป็นเปลวไฟที่ใช้บริสุทธิ์พลังงานจาก Void Realm (Abyss) ได้", + "นำ Traveler ไปสั่นพ้องกับ Statue of The Seven ใน Snezhnaya เพื่อปลดล็อกธาตุ Cryo ซึ่งเล่นได้ทั้งแบบลงสนามและสลับมาทำดาเมจนอกสนาม", }, { - header: "วงล้อจันทรา: สิ่งมาก่อนวิชั่น", + header: "Stellar Conduct", message: - "ใน Nod-Krai ผู้คนอาจได้รับ 'Moon Wheel' (กงล้อจันทรา) ซึ่งเป็นอุปกรณ์ที่ช่วยให้สามารถควบคุมแรงจันทราผันเพื่อปกป้องตนเองในถิ่นทุรกันดาร", + "เมื่อมีตัวละครที่เปิดใช้ Stellar Glimmer อยู่ในทีม ปฏิกิริยา Electro + Cryo จะเปลี่ยนจาก Superconduct เป็น Stellar Conduct และสร้าง Polestar Field", }, { - header: "การรุกรานของ Rerir", + header: "Stellar Swirl", message: - "Rerir หนึ่งในห้ามหาบาปแห่ง Khaenri'ah เป็นผู้อยู่เบื้องหลังการโจมตีของ Wild Hunt เขามีเป้าหมายในการรวบรวมชิ้นส่วนของดวงจันทร์เพื่อฟื้นฟูร่างกายที่แตกสลายของตนเอง ", + "Stellar Swirl เกิดจาก Anemo + Cryo เมื่อมีตัวเปิดใช้ปฏิกิริยาในทีม โดยจะสร้าง Stellar Vortex ที่ระเบิดเป็นความเสียหาย Cryo หลังเวลาหนึ่ง", }, { - header: "เมืองสีทองที่ล่มสลาย Hyperborea", + header: "Odette ตัวช่วยปฏิกิริยาใหม่", message: - "Hyperborea ตั้งอยู่ทางตอนเหนือสุดของ Nod-Krai เมืองนี้ล่มสลายลงหลังโดน Celestial Nail ปักใส่ ปัจจุบันกลายเป็นซากปรักหักพังที่เต็มไปด้วยความลับของภาพสะท้อนแห่งสวรรค์ ", + "Odette เป็นตัวละคร Cryo ดาบ 5 ดาวที่เปิดใช้ Stellar Conduct และ Stellar Swirl พร้อมสร้างความเสียหาย Stellar Glimmer จากนอกสนาม", }, { - header: "นวัตกรรมจาก Clink-Clank Krumkake", + header: "รับ Alyosha ฟรีจากเนื้อเรื่อง", message: - "ร้านงานฝีมือ Krumkake เป็นศูนย์กลางของการประดิษฐ์ใน Nod-Krai มีการผสมผสานเทคโนโลยีเฟืองจาก Fontaine และพลังงานคูวากิเพื่อสร้างอุปกรณ์สำรวจที่ไม่มีใครเหมือน ", + "Alyosha เป็นตัวละคร Electro หอก 4 ดาวที่รับได้จาก Archon Quest Chapter VII: Act I เขาช่วยฮีลและสร้างความเสียหาย Electro ต่อเนื่องได้", }, { - header: "ความเชื่อของลูกหลานจันทราน้ำค้างแข็ง", + header: "ดาบ 5 ดาวฟรีสำหรับ Traveler", message: - "ลูกหลานจันทราน้ำค้างแข็งได้เคารพศรัทธาต่อ 'เทพจันทรา' และได้ละทิ้งอารยธรรมทองคำยุคเก่า", - }, - // { - // "header": "สายใยครอบครัวในดินแดน Elysium", - // "message": "Nod-Krai ได้รับฉายาว่า Elysium เนื่องจากเป็นที่รวมตัวของคนนอกกฎหมายและนักผจญภัยจากทั่ว Teyvat ธีมหลักของตัวละครที่นี่จึงมักเป็นเรื่องของ 'ครอบครัวที่เลือกเอง' (Found Family) " - // }, - { - header: "นกไนติงเกลของ Solovei", - message: - "ตราสัญลักษณ์ของผู้เฝ้าประภาคารคือ นกไนติงเกลคาบไส้ตะเกียงที่ลุกไหม้ ซึ่งมาจากสัญลักษณ์ประจำตระกูลของ Solovei ผู้ก่อตั้งองค์กรและ Starshyna คนแรก ", + "Exaiphanes Blade รับได้จาก Archon Quest Everwinter Without Mercy และในเวอร์ชัน 7.0 สามารถขัดเกลาถึง R3 ผ่าน Statue of The Seven ใน Snezhnaya", }, { - header: "จันทราทั้ง 3", + header: "อาร์ติแฟกต์จาก Inverted Glacier", message: - "ในอดีตของ Teyvat เคยมีดวงจันทร์ทั้ง 3 ได้แก่ จันทราน้ำค้างแข็ง จันทราสีรุ้ง และจันทรานิรันดร์", + "Domain Inverted Glacier ใน Everfrozen Earth มีเซ็ต Scarlet Proof และ Heart of the Furnace ซึ่งออกแบบมาสำหรับทีม Stellar Glimmer", }, { - header: "Genshin impact", + header: "อาวุธคราฟต์ใหม่ครบห้าประเภท", message: - "Action RPG แบบ Open World สไตล์อนิเมะ ที่โดดเด่นด้วยระบบการต่อสู้โดยใช้พลังธาตุและการสำรวจโลกกว้างที่อิสระ", + "เวอร์ชัน 7.0 เพิ่มสูตรอาวุธคราฟต์ 4 ดาวของ Snezhnaya สำหรับ Sword, Claymore, Polearm, Catalyst และ Bow โดยเน้นทีมปฏิกิริยา Stellar Glimmer", + }, + { + header: "แวะสมาคมตกปลา Snezhnaya", + message: + "Snezhnaya มี Fishing Association และจุดตกปลาใหม่ อย่าลืมสำรวจแหล่งน้ำเพื่อจับปลาและนำไปแลกรางวัลประจำภูมิภาค", }, ]; From d3dccdf89cddfda5082e7d06f56a294d2e079623 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Thu, 27 Aug 2026 19:59:39 +0700 Subject: [PATCH 39/71] lets break commit name rule for no reason fix: mutation forms are cached with same form key fix: change amber from commad --- app/(ui)/admin/@modal/char/[id]/page.tsx | 4 ++-- app/(ui)/admin/@modal/guide/[id]/page.tsx | 4 ++-- .../admin/@modal/tl/ver/[type]/[ver]/edit/page.tsx | 4 ++-- app/(ui)/admin/@modal/tl/ver/[type]/edit/page.tsx | 4 ++-- app/(ui)/admin/layout.tsx | 7 +++++++ app/api/amber/sync/route.ts | 14 ++++++-------- util/sync.ts | 6 ++++-- 7 files changed, 25 insertions(+), 18 deletions(-) diff --git a/app/(ui)/admin/@modal/char/[id]/page.tsx b/app/(ui)/admin/@modal/char/[id]/page.tsx index 5625380..ef812cb 100755 --- a/app/(ui)/admin/@modal/char/[id]/page.tsx +++ b/app/(ui)/admin/@modal/char/[id]/page.tsx @@ -100,9 +100,9 @@ export default async function CharacterEditPage({ } return ( - + diff --git a/app/(ui)/admin/@modal/guide/[id]/page.tsx b/app/(ui)/admin/@modal/guide/[id]/page.tsx index 6ff4259..b20215d 100644 --- a/app/(ui)/admin/@modal/guide/[id]/page.tsx +++ b/app/(ui)/admin/@modal/guide/[id]/page.tsx @@ -84,8 +84,8 @@ export default async function GuideEditPage({ } return ( - - + + diff --git a/app/(ui)/admin/@modal/tl/ver/[type]/[ver]/edit/page.tsx b/app/(ui)/admin/@modal/tl/ver/[type]/[ver]/edit/page.tsx index 36a43eb..c704d76 100755 --- a/app/(ui)/admin/@modal/tl/ver/[type]/[ver]/edit/page.tsx +++ b/app/(ui)/admin/@modal/tl/ver/[type]/[ver]/edit/page.tsx @@ -105,8 +105,8 @@ export default async function TlVersionEditPage({ } return ( - - + + diff --git a/app/(ui)/admin/@modal/tl/ver/[type]/edit/page.tsx b/app/(ui)/admin/@modal/tl/ver/[type]/edit/page.tsx index bea0dd8..06c25bb 100755 --- a/app/(ui)/admin/@modal/tl/ver/[type]/edit/page.tsx +++ b/app/(ui)/admin/@modal/tl/ver/[type]/edit/page.tsx @@ -84,8 +84,8 @@ export default async function TlTypeEditPage({ } return ( - - + + diff --git a/app/(ui)/admin/layout.tsx b/app/(ui)/admin/layout.tsx index e1a9058..ab167a7 100755 --- a/app/(ui)/admin/layout.tsx +++ b/app/(ui)/admin/layout.tsx @@ -3,6 +3,7 @@ import { ArrowLeftRight, BadgeDollarSign, BitcoinIcon, + Blocks, BookUser, Columns3CogIcon, Compass, @@ -160,6 +161,12 @@ export default async function AdminLayout({ + + + + Custom TL + + diff --git a/app/api/amber/sync/route.ts b/app/api/amber/sync/route.ts index 7dc7dc9..100daf1 100644 --- a/app/api/amber/sync/route.ts +++ b/app/api/amber/sync/route.ts @@ -1,15 +1,13 @@ -import { exec } from "node:child_process"; -import { env } from "node:process"; -import { promisify } from "node:util"; import { actionLog } from "@/lib/api"; import { adminCheck } from "@/lib/auth"; +import { syncAmber } from "@/util/sync"; export async function GET() { if (!(await adminCheck())) return new Response("Unauthorized", { status: 401 }); - const res = await promisify(exec)("bun util/sync 2>&1", { - env: { ...env, NO_AUTH_CHECK: "1" }, - }); - await actionLog("Triggered an Amber sync from API", { result: res.stdout }); - return new Response(res.stdout); + await syncAmber(); + await actionLog("Triggered an Amber sync from API"); + return new Response( + "OK(log is wip, check `kubectl logs -fn buzz deployments/app`)", + ); } diff --git a/util/sync.ts b/util/sync.ts index 3997b0d..28e98fd 100755 --- a/util/sync.ts +++ b/util/sync.ts @@ -118,7 +118,7 @@ async function main() { e.json(), ), ]); - await db.transaction(async (tx) => { + return await db.transaction(async (tx) => { // console.log("Deleting existing versions..."); // await tx.delete(versions); console.log("Creating new versions..."); @@ -264,7 +264,7 @@ async function toFile(res: Response) { const customImageRegex = /^custom\/([a-z0-9_.-]+)(?::([a-z]+\/[a-z]+))/im; -main(); +if (import.meta.main) main(); export interface AvatarApiResponse { response: number; @@ -303,3 +303,5 @@ export interface ChangelogVersion { }; version: string; } + +export const syncAmber = main; From 44d156814bab1505f08fdbe50b01cf561e415428 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Thu, 27 Aug 2026 20:08:07 +0700 Subject: [PATCH 40/71] feat(don): admin widget now displays this month's donate instead of all-time sum --- app/(ui)/donate/admin/client.tsx | 4 ++-- app/(ui)/donate/admin/page.tsx | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/(ui)/donate/admin/client.tsx b/app/(ui)/donate/admin/client.tsx index 69fb6fa..7c847ce 100644 --- a/app/(ui)/donate/admin/client.tsx +++ b/app/(ui)/donate/admin/client.tsx @@ -263,7 +263,7 @@ function TopRow({ href="/donate/admin/calendar" > - รวมทั้งหมด + รวมเดือนนี้ {stats.total}฿

@@ -291,7 +291,7 @@ export function DonateAdminPage({
- โดเนททั้งหมด + โดเนทเดือนนี้ ; From 799d52a7253d7dd8a7b133b76424634d88d20420 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Fri, 28 Aug 2026 10:31:05 +0700 Subject: [PATCH 41/71] feat: initial promptpay lib [no ci] bank of thailand please dont sue me >_< --- lib/promptpay.test.ts | 719 ++++++++++++++++++++++++++++++++++++++++++ lib/promptpay.ts | 277 ++++++++++++++++ 2 files changed, 996 insertions(+) create mode 100644 lib/promptpay.test.ts create mode 100644 lib/promptpay.ts diff --git a/lib/promptpay.test.ts b/lib/promptpay.test.ts new file mode 100644 index 0000000..f6c300a --- /dev/null +++ b/lib/promptpay.test.ts @@ -0,0 +1,719 @@ +import { describe, expect, test } from "bun:test"; +import { + crc16, + type DataObject, + decodeEMVCo, + decodePromptPay, + encodeEMVCo, + formatCRC, + parseEMVCo, + parseTLV, + serializeTLV, + verifyCRC, +} from "./promptpay"; + +const encoder = new TextEncoder(); +const example = + "00020101021153037645802TH29370016A000000677010111021317299008080556304DC1A"; + +function withCRC(payload: string): string { + const body = `${payload}6304`; + return `${body}${formatCRC(crc16(encoder.encode(body)))}`; +} + +describe("serializeTLV", () => { + test("serializes primitive data objects", () => { + expect( + serializeTLV([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 58, value: "TH" }, + ]), + ).toBe("0002015802TH"); + }); + + test("pads single-digit tags and lengths", () => { + expect(serializeTLV([{ type: "primitive", tag: 7, value: "x" }])).toBe( + "0701x", + ); + }); + + test("serializes nested templates", () => { + const objects: DataObject[] = [ + { + type: "template", + tag: 29, + value: [ + { type: "primitive", tag: 0, value: "A000000677010111" }, + { type: "primitive", tag: 2, value: "1729900808055" }, + ], + }, + ]; + + expect(serializeTLV(objects)).toBe( + "29370016A00000067701011102131729900808055", + ); + }); + + test("serializes empty input and empty values", () => { + expect(serializeTLV([])).toBe(""); + expect(serializeTLV([{ type: "primitive", tag: 99, value: "" }])).toBe( + "9900", + ); + }); + + test("accepts the maximum tag and value length", () => { + const value = "x".repeat(99); + expect(serializeTLV([{ type: "primitive", tag: 99, value }])).toBe( + `9999${value}`, + ); + }); + + test.each([-1, 100])("rejects out-of-range tag %d", (tag) => { + expect(() => + serializeTLV([{ type: "primitive", tag, value: "x" }]), + ).toThrow("Tag 0 must be 00-99"); + }); + + test("reports the index of an invalid tag", () => { + expect(() => + serializeTLV([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 101, value: "x" }, + ]), + ).toThrow("Tag 1 must be 00-99"); + }); + + test("rejects values longer than the two-digit length field", () => { + expect(() => + serializeTLV([{ type: "primitive", tag: 0, value: "x".repeat(100) }]), + ).toThrow("Value 0 exceeds TLV length limit"); + }); + + test("rejects templates whose serialized value is too long", () => { + expect(() => + serializeTLV([ + { + type: "template", + tag: 29, + value: [{ type: "primitive", tag: 0, value: "x".repeat(96) }], + }, + ]), + ).toThrow("Value 0 exceeds TLV length limit"); + }); +}); + +describe("parseTLV", () => { + test("parses multiple primitive data objects", () => { + expect(parseTLV("00020153037645802TH")).toEqual([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 53, value: "764" }, + { type: "primitive", tag: 58, value: "TH" }, + ]); + }); + + test("parses empty payloads and zero-length values", () => { + expect(parseTLV("")).toEqual([]); + expect(parseTLV("9900")).toEqual([ + { type: "primitive", tag: 99, value: "" }, + ]); + }); + + test("preserves values verbatim", () => { + expect(parseTLV("1006aB 1!?")).toEqual([ + { type: "primitive", tag: 10, value: "aB 1!?" }, + ]); + }); + + test.each([ + "0", + "000", + "0001x00", + ])("rejects an incomplete header in %p", (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: incomplete headers at index/, + ); + }); + + test.each([ + "AA00", + "0A00", + " 100", + ])("rejects invalid tags in %p", (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: invalid tag/, + ); + }); + + test.each([ + "00AA", + "000A", + "00 1", + ])("rejects invalid lengths in %p", (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: invalid length/, + ); + }); + + test("rejects a value shorter than its declared length", () => { + expect(() => parseTLV("0005abc")).toThrow( + "Malformed TLV payload: expected value length 5 at index 4, but reached end of string", + ); + }); + + test("reports malformed trailing data after valid objects", () => { + expect(() => parseTLV("000201x")).toThrow( + "Malformed TLV payload: incomplete headers at index 6", + ); + }); + + test("round-trips serialized primitive objects", () => { + const objects: DataObject[] = [ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 1, value: "12" }, + { type: "primitive", tag: 54, value: "125.50" }, + ]; + + expect(parseTLV(serializeTLV(objects))).toEqual(objects); + }); +}); + +describe("parseEMVCo", () => { + test("parses merchant account information as a template", () => { + expect(parseEMVCo(example.slice(0, -8))).toEqual([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 1, value: "11" }, + { type: "primitive", tag: 53, value: "764" }, + { type: "primitive", tag: 58, value: "TH" }, + { + type: "template", + tag: 29, + value: [ + { type: "primitive", tag: 0, value: "A000000677010111" }, + { type: "primitive", tag: 2, value: "1729900808055" }, + ], + }, + ]); + }); + + test("leaves non-template tags primitive", () => { + expect(parseEMVCo("0002015303764")).toEqual([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 53, value: "764" }, + ]); + }); + + test("rejects malformed TLV inside a template", () => { + expect(() => parseEMVCo("29050005x")).toThrow(/Malformed TLV payload/); + }); +}); + +describe("decodeEMVCo", () => { + test("decodes the example payload", () => { + expect(decodeEMVCo(parseEMVCo(example.slice(0, -8)))).toEqual({ + payloadFormatIndicator: "01", + pointOfInitiationMethod: "11", + paymentNetworkSpecific: [ + { + tag: 29, + value: [ + { type: "primitive", tag: 0, value: "A000000677010111" }, + { type: "primitive", tag: 2, value: "1729900808055" }, + ], + }, + ], + transactionCurrency: "764", + transactionAmount: undefined, + countryCode: "TH", + }); + }); + + test("decodes optional amount and dynamic initiation method", () => { + const payload = "00020101021253037645406125.505802TH"; + expect(decodeEMVCo(parseEMVCo(payload))).toEqual({ + payloadFormatIndicator: "01", + pointOfInitiationMethod: "12", + paymentNetworkSpecific: [], + transactionCurrency: "764", + transactionAmount: "125.50", + countryCode: "TH", + }); + }); + + test("allows all optional fields to be absent", () => { + expect( + decodeEMVCo([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 58, value: "TH" }, + ]), + ).toEqual({ + payloadFormatIndicator: "01", + countryCode: "TH", + paymentNetworkSpecific: [], + pointOfInitiationMethod: undefined, + transactionAmount: undefined, + transactionCurrency: undefined, + }); + }); + + test("ignores unknown tags", () => { + expect( + decodeEMVCo([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 58, value: "TH" }, + { type: "primitive", tag: 99, value: "ignored" }, + ]), + ).toEqual({ + payloadFormatIndicator: "01", + countryCode: "TH", + paymentNetworkSpecific: [], + pointOfInitiationMethod: undefined, + transactionAmount: undefined, + transactionCurrency: undefined, + }); + }); + + test.each([ + { + name: "missing payload format indicator", + objects: [{ type: "primitive", tag: 58, value: "TH" }], + }, + { + name: "unsupported payload format indicator", + objects: [ + { type: "primitive", tag: 0, value: "02" }, + { type: "primitive", tag: 58, value: "TH" }, + ], + }, + { + name: "unsupported initiation method", + objects: [ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 1, value: "13" }, + { type: "primitive", tag: 58, value: "TH" }, + ], + }, + { + name: "invalid currency length", + objects: [ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 53, value: "76" }, + { type: "primitive", tag: 58, value: "TH" }, + ], + }, + { + name: "missing country code", + objects: [{ type: "primitive", tag: 0, value: "01" }], + }, + { + name: "invalid country code length", + objects: [ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 58, value: "THA" }, + ], + }, + ] satisfies { name: string; objects: DataObject[] }[])("rejects $name", ({ + objects, + }) => { + expect(() => decodeEMVCo(objects)).toThrow(); + }); +}); + +describe("encodeEMVCo", () => { + test("encodes a complete payload in EMVCo tag order", () => { + expect( + encodeEMVCo({ + payloadFormatIndicator: "01", + pointOfInitiationMethod: "12", + paymentNetworkSpecific: [ + { + tag: 29, + value: [ + { type: "primitive", tag: 0, value: "A000000677010111" }, + { type: "primitive", tag: 2, value: "0812345678" }, + ], + }, + ], + merchantCategoryCode: "0000", + transactionCurrency: "764", + transactionAmount: "125.50", + countryCode: "TH", + }), + ).toEqual([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 1, value: "12" }, + { + type: "template", + tag: 29, + value: [ + { type: "primitive", tag: 0, value: "A000000677010111" }, + { type: "primitive", tag: 2, value: "0812345678" }, + ], + }, + { type: "primitive", tag: 52, value: "0000" }, + { type: "primitive", tag: 53, value: "764" }, + { type: "primitive", tag: 54, value: "125.50" }, + { type: "primitive", tag: 58, value: "TH" }, + ]); + }); + + test("encodes the minimal valid payload", () => { + expect( + encodeEMVCo({ + payloadFormatIndicator: "01", + paymentNetworkSpecific: [], + countryCode: "TH", + }), + ).toEqual([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 58, value: "TH" }, + ]); + }); + + test("preserves the order and contents of payment network templates", () => { + const first: DataObject[] = [ + { type: "primitive", tag: 0, value: "network-one" }, + ]; + const second: DataObject[] = [ + { type: "primitive", tag: 0, value: "network-two" }, + { + type: "template", + tag: 80, + value: [{ type: "primitive", tag: 1, value: "nested" }], + }, + ]; + + expect( + encodeEMVCo({ + payloadFormatIndicator: "01", + paymentNetworkSpecific: [ + { tag: 26, value: first }, + { tag: 51, value: second }, + ], + countryCode: "TH", + }), + ).toEqual([ + { type: "primitive", tag: 0, value: "01" }, + { type: "template", tag: 26, value: first }, + { type: "template", tag: 51, value: second }, + { type: "primitive", tag: 58, value: "TH" }, + ]); + }); + + test("produces data objects that serialize and parse losslessly", () => { + const encoded = encodeEMVCo({ + payloadFormatIndicator: "01", + pointOfInitiationMethod: "11", + paymentNetworkSpecific: [ + { + tag: 29, + value: [ + { type: "primitive", tag: 0, value: "A000000677010111" }, + { type: "primitive", tag: 2, value: "1729900808055" }, + ], + }, + ], + transactionCurrency: "764", + countryCode: "TH", + }); + + expect(parseEMVCo(serializeTLV(encoded))).toEqual(encoded); + }); + + test("round-trips through decodeEMVCo", () => { + const data = { + payloadFormatIndicator: "01" as const, + pointOfInitiationMethod: "12" as const, + paymentNetworkSpecific: [ + { + tag: 29, + value: [ + { type: "primitive" as const, tag: 0, value: "A000000677010111" }, + { type: "primitive" as const, tag: 2, value: "0812345678" }, + ], + }, + ], + transactionCurrency: "764", + transactionAmount: "99.95", + countryCode: "TH", + }; + + expect(decodeEMVCo(encodeEMVCo(data))).toEqual(data); + }); + + test("does not mutate its input", () => { + const data = { + payloadFormatIndicator: "01" as const, + paymentNetworkSpecific: [ + { + tag: 29, + value: [{ type: "primitive" as const, tag: 0, value: "aid" }], + }, + ], + countryCode: "TH", + }; + const snapshot = structuredClone(data); + + encodeEMVCo(data); + + expect(data).toEqual(snapshot); + }); + + test.each([ + { + name: "missing payload format indicator", + data: { paymentNetworkSpecific: [], countryCode: "TH" }, + }, + { + name: "unsupported payload format indicator", + data: { + payloadFormatIndicator: "02", + paymentNetworkSpecific: [], + countryCode: "TH", + }, + }, + { + name: "unsupported initiation method", + data: { + payloadFormatIndicator: "01", + pointOfInitiationMethod: "13", + paymentNetworkSpecific: [], + countryCode: "TH", + }, + }, + { + name: "missing payment network list", + data: { payloadFormatIndicator: "01", countryCode: "TH" }, + }, + { + name: "invalid merchant category code length", + data: { + payloadFormatIndicator: "01", + paymentNetworkSpecific: [], + merchantCategoryCode: "000", + countryCode: "TH", + }, + }, + { + name: "invalid currency length", + data: { + payloadFormatIndicator: "01", + paymentNetworkSpecific: [], + transactionCurrency: "76", + countryCode: "TH", + }, + }, + { + name: "missing country code", + data: { payloadFormatIndicator: "01", paymentNetworkSpecific: [] }, + }, + { + name: "invalid country code length", + data: { + payloadFormatIndicator: "01", + paymentNetworkSpecific: [], + countryCode: "THA", + }, + }, + ])("rejects $name", ({ data }) => { + expect(() => encodeEMVCo(data as never)).toThrow(); + }); +}); + +describe("decodePromptPay", () => { + const aid: DataObject = { + type: "primitive", + tag: 0, + value: "A000000677010111", + }; + + test.each([ + { tag: 1, type: "mobile", value: "0066812345678" }, + { tag: 2, type: "nationalId", value: "1234567890123" }, + { tag: 3, type: "ewallet", value: "123456789012345" }, + { tag: 4, type: "bankAccount", value: "123-4-56789-0" }, + ] as const)("decodes a $type identifier", ({ tag, type, value }) => { + expect(decodePromptPay([aid, { type: "primitive", tag, value }])).toEqual({ + aid: "A000000677010111", + identifier: { type, value }, + }); + }); + + test("decodes merchant account information parsed from an EMVCo payload", () => { + const objects = parseEMVCo("29370016A00000067701011102131729900808055"); + const merchantAccount = objects[0]; + + expect(merchantAccount?.type).toBe("template"); + if (merchantAccount?.type !== "template") return; + + expect(decodePromptPay(merchantAccount.value)).toEqual({ + aid: "A000000677010111", + identifier: { type: "nationalId", value: "1729900808055" }, + }); + }); + + test("uses the first recognized identifier", () => { + expect( + decodePromptPay([ + aid, + { type: "primitive", tag: 4, value: "first-account" }, + { type: "primitive", tag: 2, value: "1234567890123" }, + ]), + ).toEqual({ + aid: "A000000677010111", + identifier: { type: "bankAccount", value: "first-account" }, + }); + }); + + test("ignores unknown tags before a recognized identifier", () => { + expect( + decodePromptPay([ + { type: "primitive", tag: 99, value: "ignored" }, + aid, + { type: "primitive", tag: 2, value: "1234567890123" }, + ]), + ).toEqual({ + aid: "A000000677010111", + identifier: { type: "nationalId", value: "1234567890123" }, + }); + }); + + test("ignores template objects using identifier tags", () => { + expect( + decodePromptPay([ + aid, + { + type: "template", + tag: 1, + value: [{ type: "primitive", tag: 0, value: "ignored" }], + }, + { type: "primitive", tag: 3, value: "123456789012345" }, + ]), + ).toEqual({ + aid: "A000000677010111", + identifier: { type: "ewallet", value: "123456789012345" }, + }); + }); + + test("accepts a bank account at its maximum length", () => { + const value = "1".repeat(43); + expect( + decodePromptPay([aid, { type: "primitive", tag: 4, value }]).identifier, + ).toEqual({ type: "bankAccount", value }); + }); + + test.each([ + { tag: 1, value: "1".repeat(12), name: "short mobile" }, + { tag: 1, value: "1".repeat(14), name: "long mobile" }, + { tag: 2, value: "1".repeat(12), name: "short national ID" }, + { tag: 2, value: "1".repeat(14), name: "long national ID" }, + { tag: 3, value: "1".repeat(14), name: "short e-wallet ID" }, + { tag: 3, value: "1".repeat(16), name: "long e-wallet ID" }, + { tag: 4, value: "1".repeat(44), name: "long bank account" }, + ])("rejects a $name", ({ tag, value }) => { + expect(() => + decodePromptPay([aid, { type: "primitive", tag, value }]), + ).toThrow(); + }); + + test("rejects input without a recognized identifier", () => { + expect(() => decodePromptPay([aid])).toThrow( + "No valid PromptPay identifier found.", + ); + expect(() => + decodePromptPay([aid, { type: "primitive", tag: 5, value: "unknown" }]), + ).toThrow("No valid PromptPay identifier found."); + }); + + test("rejects a missing or incorrect AID", () => { + const identifier: DataObject = { + type: "primitive", + tag: 2, + value: "1234567890123", + }; + + expect(() => decodePromptPay([identifier])).toThrow(); + expect(() => + decodePromptPay([ + { type: "primitive", tag: 0, value: "A000000000000000" }, + identifier, + ]), + ).toThrow(); + }); + + test("rejects a template AID", () => { + expect(() => + decodePromptPay([ + { type: "template", tag: 0, value: [] }, + { type: "primitive", tag: 1, value: "0066812345678" }, + ]), + ).toThrow(); + }); +}); + +describe("crc16", () => { + test("matches the CRC-16/CCITT-FALSE check value", () => { + expect(crc16(encoder.encode("123456789"))).toBe(0x29b1); + }); + + test("returns the initial value for empty input", () => { + expect(crc16(new Uint8Array())).toBe(0xffff); + }); + + test("operates on raw bytes", () => { + expect(crc16(new Uint8Array([0x00, 0xff, 0x10, 0x80]))).toBe(0xd958); + }); + + test("generates the CRC from the PromptPay example", () => { + const withoutCrc = example.slice(0, -4); + expect(formatCRC(crc16(encoder.encode(withoutCrc)))).toBe( + example.slice(-4), + ); + }); +}); + +describe("formatCRC", () => { + test.each([ + [0x0000, "0000"], + [0x000a, "000A"], + [0x0123, "0123"], + [0xabcd, "ABCD"], + [0xffff, "FFFF"], + ])("formats %d as %s", (crc, expected) => { + expect(formatCRC(crc)).toBe(expected); + }); +}); + +describe("verifyCRC", () => { + test("verifies the PromptPay example", () => { + expect(verifyCRC(example)).toBeTrue(); + }); + + test("verifies generated payloads", () => { + expect(verifyCRC(withCRC("0002010102125802TH"))).toBeTrue(); + }); + + test("rejects payloads shorter than a CRC field", () => { + expect(verifyCRC("")).toBeFalse(); + expect(verifyCRC("6304ABC")).toBeFalse(); + }); + + test("rejects a missing or misplaced CRC tag", () => { + expect(verifyCRC("0002019904FFFF")).toBeFalse(); + expect(verifyCRC(`${example}00`)).toBeFalse(); + }); + + test("rejects a modified payload", () => { + const modified = `${example.slice(0, 4)}02${example.slice(6)}`; + expect(verifyCRC(modified)).toBeFalse(); + }); + + test("rejects a modified checksum", () => { + expect(verifyCRC(`${example.slice(0, -1)}B`)).toBeFalse(); + }); + + test("requires the canonical uppercase checksum", () => { + expect( + verifyCRC(example.slice(0, -4) + example.slice(-4).toLowerCase()), + ).toBe(false); + }); +}); diff --git a/lib/promptpay.ts b/lib/promptpay.ts new file mode 100644 index 0000000..3e335d7 --- /dev/null +++ b/lib/promptpay.ts @@ -0,0 +1,277 @@ +import z from "zod"; + +// #region TLV +export type DataObject = + | { + type: "primitive"; + tag: number; + value: string; + } + | { + type: "template"; + tag: number; + value: DataObject[]; + }; + +function serializeTLV(objects: DataObject[]): string { + return objects + .map((e, i) => { + if (e.tag < 0 || e.tag > 99) throw new Error(`Tag ${i} must be 00-99`); + + const value = e.type === "primitive" ? e.value : serializeTLV(e.value); + + if (value.length > 99) + throw new Error(`Value ${i} exceeds TLV length limit`); + + const tag = e.tag.toString().padStart(2, "0"); + const length = value.length.toString().padStart(2, "0"); + + return `${tag}${length}${value}`; + }) + .join(""); +} + +function parseTLV(payload: string): DataObject[] { + const objects: DataObject[] = []; + + let i = 0; + while (i < payload.length) { + if (i + 4 > payload.length) + throw new Error( + `Malformed TLV payload: incomplete headers at index ${i}`, + ); + + const tagStr = payload.slice(i, i + 2); + if (!/^\d{2}$/.test(tagStr)) + throw new Error( + `Malformed TLV payload: invalid tag "${tagStr}" at index ${i}`, + ); + const tag = parseInt(tagStr, 10); + i += 2; + + const lengthStr = payload.slice(i, i + 2); + if (!/^\d{2}$/.test(lengthStr)) + throw new Error( + `Malformed TLV payload: invalid length "${lengthStr}" at index ${i}`, + ); + const length = parseInt(lengthStr, 10); + i += 2; + + if (i + length > payload.length) + throw new Error( + `Malformed TLV payload: expected value length ${length} at index ${i}, but reached end of string`, + ); + + const value = payload.slice(i, i + length); + i += length; + + objects.push({ type: "primitive", tag, value }); + } + return objects; +} + +// #region Utility + +function primitive( + data: DataObject[] | undefined, + tag: number, + transform?: (value: string) => T, +): T | string | undefined { + const object = data?.find( + (o): o is Extract => + o.tag === tag && o.type === "primitive", + ); + + if (!object) return undefined; + + return transform ? transform(object.value) : object.value; +} + +function template( + data: DataObject[] | undefined, + tag: number, + transform?: (value: DataObject[]) => T, +): T | DataObject[] | undefined { + const object = data?.find( + (o): o is Extract => + o.tag === tag && o.type === "template", + ); + + if (!object) return undefined; + + return transform ? transform(object.value) : object.value; +} + +// #region EMVCo +function range(start: number, end: number) { + return new Array(end - start).fill(start).map((s, i) => s + i); +} +const TEMPLATE_TAGS = new Set([...range(26, 52), 62, 64, ...range(80, 100)]); +function parseEMVCo(payload: string): DataObject[] { + const tlv = parseTLV(payload); + return tlv.map((o) => + TEMPLATE_TAGS.has(o.tag) && o.type === "primitive" + ? ({ + type: "template", + tag: o.tag, + value: parseEMVCo(o.value), + } satisfies DataObject) + : o, + ); +} + +const EMVCoPayload = z.object({ + payloadFormatIndicator: z.literal("01"), + pointOfInitiationMethod: z.enum(["11", "12"]).optional(), + + paymentNetworkSpecific: z.array( + z.object({ + tag: z.number(), + value: z.custom(), + }), + ), + + merchantCategoryCode: z.string().length(4).optional(), + + transactionCurrency: z.string().length(3).optional(), + + transactionAmount: z.string().optional(), + + countryCode: z.string().length(2), + + // merchantName: z.string().optional(), + // merchantCity: z.string().optional(), +}); +function decodeEMVCo(objects: DataObject[]) { + return EMVCoPayload.parse({ + payloadFormatIndicator: primitive(objects, 0), + pointOfInitiationMethod: primitive(objects, 1), + paymentNetworkSpecific: objects.filter((o) => o.tag >= 2 && o.tag <= 51), + transactionCurrency: primitive(objects, 53), + transactionAmount: primitive(objects, 54), + countryCode: primitive(objects, 58), + }); +} + +function encodeEMVCo(data: z.infer): DataObject[] { + const d = EMVCoPayload.parse(data); + return ( + [ + [0, d.payloadFormatIndicator], + [1, d.pointOfInitiationMethod], + ...d.paymentNetworkSpecific.map((e) => [e.tag, e.value] as const), + [52, d.merchantCategoryCode], + [53, d.transactionCurrency], + [54, d.transactionAmount], + [58, d.countryCode], + ] as const + ) + .filter(([, value]) => value !== undefined) + .map((t) => + typeof t[1] === "object" + ? { + type: "template", + tag: t[0], + value: t[1]!, + } + : { + type: "primitive", + tag: t[0], + value: t[1]!, + }, + ); +} + +// #region CRC +function crc16(data: Uint8Array): number { + let crc = 0xffff; // Initial value + const polynomial = 0x1021; // Polynomial: x^16 + x^12 + x^5 + 1 + + for (let i = 0; i < data.length; i++) { + crc ^= data[i] << 8; + for (let j = 0; j < 8; j++) { + if ((crc & 0x8000) !== 0) { + crc = ((crc << 1) ^ polynomial) & 0xffff; + } else { + crc = (crc << 1) & 0xffff; + } + } + } + return crc; +} +function formatCRC(crc: number): string { + return crc.toString(16).toUpperCase().padStart(4, "0"); +} +function verifyCRC(payload: string): boolean { + if (payload.length < 8) return false; + + const crcTag = payload.slice(-8, -4); + const expected = payload.slice(-4); + + if (crcTag !== "6304") return false; + + const calculated = crc16(new TextEncoder().encode(payload.slice(0, -4))) + .toString(16) + .toUpperCase() + .padStart(4, "0"); + + return calculated === expected; +} + +// #region PromptPay +const PromptPay = z.object({ + aid: z.literal("A000000677010111"), + + identifier: z.discriminatedUnion("type", [ + z.object({ + type: z.literal("mobile"), + value: z.string().length(13), + }), + z.object({ + type: z.literal("nationalId"), + value: z.string().length(13), + }), + z.object({ + type: z.literal("ewallet"), + value: z.string().length(15), + }), + z.object({ + type: z.literal("bankAccount"), + value: z.string().max(43), + }), + ]), +}); +function decodePromptPay(merchantAccount: DataObject[]) { + const identifierTypes = { + 1: "mobile", + 2: "nationalId", + 3: "ewallet", + 4: "bankAccount", + } as const; + const object = merchantAccount.find( + (o) => o.type === "primitive" && o.tag in identifierTypes, + ); + if (!object || object.type !== "primitive") + throw new Error("No valid PromptPay identifier found."); + + return PromptPay.parse({ + aid: primitive(merchantAccount, 0), + identifier: { + type: identifierTypes[object.tag as keyof typeof identifierTypes], + value: object.value, + }, + }); +} + +export { + serializeTLV, + parseTLV, + EMVCoPayload, + parseEMVCo, + decodeEMVCo, + encodeEMVCo, + crc16, + formatCRC, + verifyCRC, + decodePromptPay, +}; From 3d609a2e8ee6949fc0a80821ee731a06aad37341 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Fri, 28 Aug 2026 10:50:03 +0700 Subject: [PATCH 42/71] feat: finish implementing rest of promptpay transcoder --- lib/promptpay.test.ts | 364 ++++++++++++++++++++++++++--- lib/promptpay.ts | 521 +++++++++++++++++++++++++++++------------- 2 files changed, 696 insertions(+), 189 deletions(-) diff --git a/lib/promptpay.test.ts b/lib/promptpay.test.ts index f6c300a..6f920f5 100644 --- a/lib/promptpay.test.ts +++ b/lib/promptpay.test.ts @@ -5,8 +5,12 @@ import { decodeEMVCo, decodePromptPay, encodeEMVCo, + encodePromptPay, formatCRC, + generatePromptPayPayload, + normalizePromptPayIdentifier, parseEMVCo, + parsePromptPayPayload, parseTLV, serializeTLV, verifyCRC, @@ -14,7 +18,7 @@ import { const encoder = new TextEncoder(); const example = - "00020101021153037645802TH29370016A000000677010111021317299008080556304DC1A"; + "00020101021129370016A000000677010111021311111111111115802TH530376463047B5A"; function withCRC(payload: string): string { const body = `${payload}6304`; @@ -44,13 +48,13 @@ describe("serializeTLV", () => { tag: 29, value: [ { type: "primitive", tag: 0, value: "A000000677010111" }, - { type: "primitive", tag: 2, value: "1729900808055" }, + { type: "primitive", tag: 2, value: "1111111111111" }, ], }, ]; expect(serializeTLV(objects)).toBe( - "29370016A00000067701011102131729900808055", + "29370016A00000067701011102131111111111111", ); }); @@ -182,16 +186,16 @@ describe("parseEMVCo", () => { expect(parseEMVCo(example.slice(0, -8))).toEqual([ { type: "primitive", tag: 0, value: "01" }, { type: "primitive", tag: 1, value: "11" }, - { type: "primitive", tag: 53, value: "764" }, - { type: "primitive", tag: 58, value: "TH" }, { type: "template", tag: 29, value: [ { type: "primitive", tag: 0, value: "A000000677010111" }, - { type: "primitive", tag: 2, value: "1729900808055" }, + { type: "primitive", tag: 2, value: "1111111111111" }, ], }, + { type: "primitive", tag: 58, value: "TH" }, + { type: "primitive", tag: 53, value: "764" }, ]); }); @@ -217,12 +221,11 @@ describe("decodeEMVCo", () => { tag: 29, value: [ { type: "primitive", tag: 0, value: "A000000677010111" }, - { type: "primitive", tag: 2, value: "1729900808055" }, + { type: "primitive", tag: 2, value: "1111111111111" }, ], }, ], transactionCurrency: "764", - transactionAmount: undefined, countryCode: "TH", }); }); @@ -239,6 +242,21 @@ describe("decodeEMVCo", () => { }); }); + test("decodes an optional merchant category code", () => { + expect( + decodeEMVCo([ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 52, value: "0000" }, + { type: "primitive", tag: 58, value: "TH" }, + ]), + ).toEqual({ + payloadFormatIndicator: "01", + paymentNetworkSpecific: [], + merchantCategoryCode: "0000", + countryCode: "TH", + }); + }); + test("allows all optional fields to be absent", () => { expect( decodeEMVCo([ @@ -249,26 +267,21 @@ describe("decodeEMVCo", () => { payloadFormatIndicator: "01", countryCode: "TH", paymentNetworkSpecific: [], - pointOfInitiationMethod: undefined, - transactionAmount: undefined, - transactionCurrency: undefined, }); }); - test("ignores unknown tags", () => { + test("preserves unknown tags", () => { expect( decodeEMVCo([ { type: "primitive", tag: 0, value: "01" }, { type: "primitive", tag: 58, value: "TH" }, - { type: "primitive", tag: 99, value: "ignored" }, + { type: "primitive", tag: 59, value: "ignored" }, ]), ).toEqual({ payloadFormatIndicator: "01", countryCode: "TH", paymentNetworkSpecific: [], - pointOfInitiationMethod: undefined, - transactionAmount: undefined, - transactionCurrency: undefined, + additionalData: [{ type: "primitive", tag: 59, value: "ignored" }], }); }); @@ -408,7 +421,7 @@ describe("encodeEMVCo", () => { tag: 29, value: [ { type: "primitive", tag: 0, value: "A000000677010111" }, - { type: "primitive", tag: 2, value: "1729900808055" }, + { type: "primitive", tag: 2, value: "1111111111111" }, ], }, ], @@ -458,6 +471,17 @@ describe("encodeEMVCo", () => { expect(data).toEqual(snapshot); }); + test("retains additional data through decoding and encoding", () => { + const objects: DataObject[] = [ + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 58, value: "TH" }, + { type: "primitive", tag: 59, value: "MERCHANT" }, + { type: "primitive", tag: 60, value: "BANGKOK" }, + ]; + + expect(encodeEMVCo(decodeEMVCo(objects))).toEqual(objects); + }); + test.each([ { name: "missing payload format indicator", @@ -530,7 +554,7 @@ describe("decodePromptPay", () => { { tag: 1, type: "mobile", value: "0066812345678" }, { tag: 2, type: "nationalId", value: "1234567890123" }, { tag: 3, type: "ewallet", value: "123456789012345" }, - { tag: 4, type: "bankAccount", value: "123-4-56789-0" }, + { tag: 4, type: "bankAccount", value: "1234567890" }, ] as const)("decodes a $type identifier", ({ tag, type, value }) => { expect(decodePromptPay([aid, { type: "primitive", tag, value }])).toEqual({ aid: "A000000677010111", @@ -539,7 +563,7 @@ describe("decodePromptPay", () => { }); test("decodes merchant account information parsed from an EMVCo payload", () => { - const objects = parseEMVCo("29370016A00000067701011102131729900808055"); + const objects = parseEMVCo("29370016A00000067701011102131111111111111"); const merchantAccount = objects[0]; expect(merchantAccount?.type).toBe("template"); @@ -547,21 +571,18 @@ describe("decodePromptPay", () => { expect(decodePromptPay(merchantAccount.value)).toEqual({ aid: "A000000677010111", - identifier: { type: "nationalId", value: "1729900808055" }, + identifier: { type: "nationalId", value: "1111111111111" }, }); }); - test("uses the first recognized identifier", () => { - expect( + test("rejects multiple recognized identifiers", () => { + expect(() => decodePromptPay([ aid, - { type: "primitive", tag: 4, value: "first-account" }, + { type: "primitive", tag: 4, value: "1234567890" }, { type: "primitive", tag: 2, value: "1234567890123" }, ]), - ).toEqual({ - aid: "A000000677010111", - identifier: { type: "bankAccount", value: "first-account" }, - }); + ).toThrow("Expected exactly one PromptPay identifier."); }); test("ignores unknown tags before a recognized identifier", () => { @@ -617,11 +638,11 @@ describe("decodePromptPay", () => { test("rejects input without a recognized identifier", () => { expect(() => decodePromptPay([aid])).toThrow( - "No valid PromptPay identifier found.", + "Expected exactly one PromptPay identifier.", ); expect(() => decodePromptPay([aid, { type: "primitive", tag: 5, value: "unknown" }]), - ).toThrow("No valid PromptPay identifier found."); + ).toThrow("Expected exactly one PromptPay identifier."); }); test("rejects a missing or incorrect AID", () => { @@ -650,6 +671,291 @@ describe("decodePromptPay", () => { }); }); +describe("encodePromptPay", () => { + test.each([ + { type: "mobile", tag: 1, value: "0066812345678" }, + { type: "nationalId", tag: 2, value: "1234567890123" }, + { type: "ewallet", tag: 3, value: "123456789012345" }, + ] as const)("encodes a canonical $type identifier", ({ + type, + tag, + value, + }) => { + expect(encodePromptPay({ type, value })).toEqual([ + { + type: "primitive", + tag: 0, + value: "A000000677010111", + }, + { type: "primitive", tag, value }, + ]); + }); + + test("rejects noncanonical identifier values", () => { + expect(() => + encodePromptPay({ type: "mobile", value: "0812345678" }), + ).toThrow(); + expect(() => + encodePromptPay({ type: "nationalId", value: "123-456" }), + ).toThrow(); + expect(() => + encodePromptPay({ type: "ewallet", value: "A".repeat(15) }), + ).toThrow(); + }); + + test("does not encode the reserved bank account identifier", () => { + expect(() => + encodePromptPay({ type: "bankAccount", value: "1234567890" } as never), + ).toThrow("reserved for future use"); + }); +}); + +describe("normalizePromptPayIdentifier", () => { + test.each([ + ["0801234567", "0066801234567"], + ["080-123-4567", "0066801234567"], + ["+66-89-123-4567", "0066891234567"], + ["66 89 123 4567", "0066891234567"], + ["0066812345678", "0066812345678"], + ])("normalizes mobile %s", (value, expected) => { + expect(normalizePromptPayIdentifier({ type: "mobile", value })).toEqual({ + type: "mobile", + value: expected, + }); + }); + + test("normalizes formatted national IDs", () => { + expect( + normalizePromptPayIdentifier({ + type: "nationalId", + value: "1-2345-67890-12-3", + }), + ).toEqual({ type: "nationalId", value: "1234567890123" }); + }); + + test("normalizes formatted e-wallet IDs", () => { + expect( + normalizePromptPayIdentifier({ + type: "ewallet", + value: "012 345 678 901 234", + }), + ).toEqual({ type: "ewallet", value: "012345678901234" }); + }); + + test.each([ + { type: "mobile", value: "08123abc", name: "mobile with letters" }, + { type: "mobile", value: "081234567", name: "short mobile" }, + { + type: "nationalId", + value: "123456789012A", + name: "national ID with letters", + }, + { + type: "nationalId", + value: "123456789012", + name: "short national ID", + }, + { + type: "ewallet", + value: "12345678901234", + name: "short e-wallet ID", + }, + ] as const)("rejects a $name", ({ type, value }) => { + expect(() => normalizePromptPayIdentifier({ type, value })).toThrow(); + }); +}); + +describe("generatePromptPayPayload", () => { + test.each([ + { + name: "local mobile number", + options: { + identifier: { type: "mobile" as const, value: "0801234567" }, + }, + expected: + "00020101021129370016A000000677010111011300668012345675802TH530376463046197", + }, + { + name: "formatted international mobile number", + options: { + identifier: { type: "mobile" as const, value: "+66-89-123-4567" }, + }, + expected: + "00020101021129370016A000000677010111011300668912345675802TH5303764630429C1", + }, + { + name: "national ID", + options: { + identifier: { + type: "nationalId" as const, + value: "1-1111-11111-11-1", + }, + }, + expected: + "00020101021129370016A000000677010111021311111111111115802TH530376463047B5A", + }, + { + name: "tax ID", + options: { + identifier: { + type: "nationalId" as const, + value: "0123456789012", + }, + }, + expected: + "00020101021129370016A000000677010111021301234567890125802TH530376463040CBD", + }, + { + name: "e-wallet ID", + options: { + identifier: { + type: "ewallet" as const, + value: "012345678901234", + }, + }, + expected: + "00020101021129390016A00000067701011103150123456789012345802TH530376463049781", + }, + { + name: "dynamic amount", + options: { + identifier: { type: "mobile" as const, value: "000-000-0000" }, + amount: 4.22, + }, + expected: + "00020101021229370016A000000677010111011300660000000005802TH530376454044.226304E469", + }, + ])("matches the published $name vector", ({ options, expected }) => { + expect(generatePromptPayPayload(options)).toBe(expected); + }); + + test.each([ + ["4", "4.00"], + ["4.2", "4.20"], + ["4.22", "4.22"], + [4, "4.00"], + [4.2, "4.20"], + ] as const)("formats amount %p as %s", (amount, expected) => { + const parsed = parsePromptPayPayload( + generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + amount, + }), + ); + expect(parsed.emvco.transactionAmount).toBe(expected); + expect(parsed.emvco.pointOfInitiationMethod).toBe("12"); + }); + + test("emits an optional merchant category code", () => { + const parsed = parsePromptPayPayload( + generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + merchantCategoryCode: "0000", + }), + ); + expect(parsed.emvco.merchantCategoryCode).toBe("0000"); + }); + + test.each([ + 0, + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + "", + "1.234", + ])("rejects invalid amount %p", (amount) => { + expect(() => + generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + amount, + }), + ).toThrow(); + }); + + test("rejects invalid merchant category codes", () => { + expect(() => + generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + merchantCategoryCode: "123", + }), + ).toThrow("must be 4 digits"); + }); +}); + +describe("parsePromptPayPayload", () => { + test("returns the raw, EMVCo, and PromptPay representations", () => { + const payload = generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + amount: "19.90", + }); + const parsed = parsePromptPayPayload(payload); + + expect(parsed.payload).toBe(payload); + expect(parsed.dataObjects.at(-1)).toEqual({ + type: "primitive", + tag: 63, + value: payload.slice(-4), + }); + expect(parsed.emvco).toMatchObject({ + payloadFormatIndicator: "01", + pointOfInitiationMethod: "12", + transactionCurrency: "764", + transactionAmount: "19.90", + countryCode: "TH", + }); + expect(parsed.promptPay).toEqual({ + aid: "A000000677010111", + identifier: { type: "mobile", value: "0066801234567" }, + }); + }); + + test("preserves unmodeled EMVCo data", () => { + const generated = generatePromptPayPayload({ + identifier: { type: "nationalId", value: "1234567890123" }, + }); + const payload = withCRC(`${generated.slice(0, -8)}5904TEST`); + + expect(parsePromptPayPayload(payload).emvco.additionalData).toEqual([ + { type: "primitive", tag: 59, value: "TEST" }, + ]); + }); + + test("rejects a corrupted checksum", () => { + const payload = generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + }); + expect(() => parsePromptPayPayload(`${payload.slice(0, -1)}0`)).toThrow( + "Invalid PromptPay payload CRC", + ); + }); + + test("rejects a payload without a PromptPay template", () => { + expect(() => + parsePromptPayPayload(withCRC("0002010102115802TH5303764")), + ).toThrow("exactly one PromptPay merchant account template"); + }); + + test("rejects a non-THB currency", () => { + const generated = generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + }); + const body = generated.slice(0, -8).replace("5303764", "5303840"); + expect(() => parsePromptPayPayload(withCRC(body))).toThrow( + "currency must be THB", + ); + }); + + test("rejects a non-Thai domestic country code", () => { + const generated = generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + }); + const body = generated.slice(0, -8).replace("5802TH", "5802US"); + expect(() => parsePromptPayPayload(withCRC(body))).toThrow( + "country code must be TH", + ); + }); +}); + describe("crc16", () => { test("matches the CRC-16/CCITT-FALSE check value", () => { expect(crc16(encoder.encode("123456789"))).toBe(0x29b1); diff --git a/lib/promptpay.ts b/lib/promptpay.ts index 3e335d7..e424982 100644 --- a/lib/promptpay.ts +++ b/lib/promptpay.ts @@ -1,6 +1,6 @@ -import z from "zod"; +import { z } from "zod"; -// #region TLV +// #region Public types export type DataObject = | { type: "primitive"; @@ -13,19 +13,66 @@ export type DataObject = value: DataObject[]; }; +export type PaymentNetworkSpecific = { + tag: number; + value: DataObject[]; +}; + +export type EMVCoData = { + payloadFormatIndicator: "01"; + pointOfInitiationMethod?: "11" | "12"; + paymentNetworkSpecific: PaymentNetworkSpecific[]; + merchantCategoryCode?: string; + transactionCurrency?: string; + transactionAmount?: string; + countryCode: string; + additionalData?: DataObject[]; +}; + +export type PromptPayIdentifier = + | { type: "mobile"; value: string } + | { type: "nationalId"; value: string } + | { type: "ewallet"; value: string } + | { type: "bankAccount"; value: string }; + +export type EncodablePromptPayIdentifier = Exclude< + PromptPayIdentifier, + { type: "bankAccount" } +>; + +export type PromptPayData = { + aid: "A000000677010111"; + identifier: PromptPayIdentifier; +}; + +export type GeneratePromptPayOptions = { + identifier: EncodablePromptPayIdentifier; + amount?: number | string; + merchantCategoryCode?: string; +}; + +export type ParsedPromptPayPayload = { + payload: string; + dataObjects: DataObject[]; + emvco: EMVCoData; + promptPay: PromptPayData; +}; + +// #region TLV function serializeTLV(objects: DataObject[]): string { return objects - .map((e, i) => { - if (e.tag < 0 || e.tag > 99) throw new Error(`Tag ${i} must be 00-99`); + .map((object, index) => { + if (!Number.isInteger(object.tag) || object.tag < 0 || object.tag > 99) + throw new Error(`Tag ${index} must be 00-99`); - const value = e.type === "primitive" ? e.value : serializeTLV(e.value); + const value = + object.type === "primitive" ? object.value : serializeTLV(object.value); if (value.length > 99) - throw new Error(`Value ${i} exceeds TLV length limit`); + throw new Error(`Value ${index} exceeds TLV length limit`); - const tag = e.tag.toString().padStart(2, "0"); + const tag = object.tag.toString().padStart(2, "0"); const length = value.length.toString().padStart(2, "0"); - return `${tag}${length}${value}`; }) .join(""); @@ -33,245 +80,399 @@ function serializeTLV(objects: DataObject[]): string { function parseTLV(payload: string): DataObject[] { const objects: DataObject[] = []; + let index = 0; - let i = 0; - while (i < payload.length) { - if (i + 4 > payload.length) + while (index < payload.length) { + if (index + 4 > payload.length) throw new Error( - `Malformed TLV payload: incomplete headers at index ${i}`, + `Malformed TLV payload: incomplete headers at index ${index}`, ); - const tagStr = payload.slice(i, i + 2); - if (!/^\d{2}$/.test(tagStr)) + const tagString = payload.slice(index, index + 2); + if (!/^\d{2}$/.test(tagString)) throw new Error( - `Malformed TLV payload: invalid tag "${tagStr}" at index ${i}`, + `Malformed TLV payload: invalid tag "${tagString}" at index ${index}`, ); - const tag = parseInt(tagStr, 10); - i += 2; + const tag = Number.parseInt(tagString, 10); + index += 2; - const lengthStr = payload.slice(i, i + 2); - if (!/^\d{2}$/.test(lengthStr)) + const lengthString = payload.slice(index, index + 2); + if (!/^\d{2}$/.test(lengthString)) throw new Error( - `Malformed TLV payload: invalid length "${lengthStr}" at index ${i}`, + `Malformed TLV payload: invalid length "${lengthString}" at index ${index}`, ); - const length = parseInt(lengthStr, 10); - i += 2; + const length = Number.parseInt(lengthString, 10); + index += 2; - if (i + length > payload.length) + if (index + length > payload.length) throw new Error( - `Malformed TLV payload: expected value length ${length} at index ${i}, but reached end of string`, + `Malformed TLV payload: expected value length ${length} at index ${index}, but reached end of string`, ); - const value = payload.slice(i, i + length); - i += length; - + const value = payload.slice(index, index + length); + index += length; objects.push({ type: "primitive", tag, value }); } + return objects; } -// #region Utility - -function primitive( +// #region Utilities +function primitive( data: DataObject[] | undefined, tag: number, - transform?: (value: string) => T, -): T | string | undefined { - const object = data?.find( - (o): o is Extract => - o.tag === tag && o.type === "primitive", - ); - - if (!object) return undefined; - - return transform ? transform(object.value) : object.value; +): string | undefined { + return data?.find( + (object): object is Extract => + object.tag === tag && object.type === "primitive", + )?.value; } -function template( - data: DataObject[] | undefined, - tag: number, - transform?: (value: DataObject[]) => T, -): T | DataObject[] | undefined { - const object = data?.find( - (o): o is Extract => - o.tag === tag && o.type === "template", - ); - - if (!object) return undefined; - - return transform ? transform(object.value) : object.value; +function optionalProperty(key: string, value: T | undefined) { + return value === undefined ? {} : { [key]: value }; } // #region EMVCo -function range(start: number, end: number) { - return new Array(end - start).fill(start).map((s, i) => s + i); -} -const TEMPLATE_TAGS = new Set([...range(26, 52), 62, 64, ...range(80, 100)]); +const TEMPLATE_TAGS = new Set([ + ...Array.from({ length: 50 }, (_, index) => index + 2), + 62, + 64, + ...Array.from({ length: 20 }, (_, index) => index + 80), +]); + function parseEMVCo(payload: string): DataObject[] { - const tlv = parseTLV(payload); - return tlv.map((o) => - TEMPLATE_TAGS.has(o.tag) && o.type === "primitive" - ? ({ + return parseTLV(payload).map((object) => + TEMPLATE_TAGS.has(object.tag) && object.type === "primitive" + ? { type: "template", - tag: o.tag, - value: parseEMVCo(o.value), - } satisfies DataObject) - : o, + tag: object.tag, + value: parseTLV(object.value), + } + : object, ); } -const EMVCoPayload = z.object({ +const emvCoSchema = z.object({ payloadFormatIndicator: z.literal("01"), pointOfInitiationMethod: z.enum(["11", "12"]).optional(), - paymentNetworkSpecific: z.array( z.object({ - tag: z.number(), - value: z.custom(), + tag: z.number().int().min(2).max(51), + value: z.custom((value) => Array.isArray(value)), }), ), - merchantCategoryCode: z.string().length(4).optional(), - transactionCurrency: z.string().length(3).optional(), - transactionAmount: z.string().optional(), - countryCode: z.string().length(2), - - // merchantName: z.string().optional(), - // merchantCity: z.string().optional(), + additionalData: z.array(z.custom()).optional(), }); -function decodeEMVCo(objects: DataObject[]) { - return EMVCoPayload.parse({ + +const MODELED_EMVCO_TAGS = new Set([0, 1, 52, 53, 54, 58]); + +function decodeEMVCo(objects: DataObject[]): EMVCoData { + const additionalData = objects.filter( + (object) => + !MODELED_EMVCO_TAGS.has(object.tag) && + !(object.tag >= 2 && object.tag <= 51), + ); + + return emvCoSchema.parse({ payloadFormatIndicator: primitive(objects, 0), - pointOfInitiationMethod: primitive(objects, 1), - paymentNetworkSpecific: objects.filter((o) => o.tag >= 2 && o.tag <= 51), - transactionCurrency: primitive(objects, 53), - transactionAmount: primitive(objects, 54), + ...optionalProperty("pointOfInitiationMethod", primitive(objects, 1)), + paymentNetworkSpecific: objects + .filter( + (object): object is Extract => + object.type === "template" && object.tag >= 2 && object.tag <= 51, + ) + .map(({ tag, value }) => ({ tag, value })), + ...optionalProperty("merchantCategoryCode", primitive(objects, 52)), + ...optionalProperty("transactionCurrency", primitive(objects, 53)), + ...optionalProperty("transactionAmount", primitive(objects, 54)), countryCode: primitive(objects, 58), + ...(additionalData.length === 0 ? {} : { additionalData }), }); } -function encodeEMVCo(data: z.infer): DataObject[] { - const d = EMVCoPayload.parse(data); - return ( - [ - [0, d.payloadFormatIndicator], - [1, d.pointOfInitiationMethod], - ...d.paymentNetworkSpecific.map((e) => [e.tag, e.value] as const), - [52, d.merchantCategoryCode], - [53, d.transactionCurrency], - [54, d.transactionAmount], - [58, d.countryCode], - ] as const - ) - .filter(([, value]) => value !== undefined) - .map((t) => - typeof t[1] === "object" - ? { - type: "template", - tag: t[0], - value: t[1]!, - } - : { - type: "primitive", - tag: t[0], - value: t[1]!, - }, - ); +function encodeEMVCo(data: EMVCoData): DataObject[] { + const parsed = emvCoSchema.parse(data); + const objects: DataObject[] = [ + { type: "primitive", tag: 0, value: parsed.payloadFormatIndicator }, + ]; + + if (parsed.pointOfInitiationMethod !== undefined) + objects.push({ + type: "primitive", + tag: 1, + value: parsed.pointOfInitiationMethod, + }); + + objects.push( + ...parsed.paymentNetworkSpecific.map( + ({ tag, value }): DataObject => ({ type: "template", tag, value }), + ), + ); + + for (const [tag, value] of [ + [52, parsed.merchantCategoryCode], + [53, parsed.transactionCurrency], + [54, parsed.transactionAmount], + [58, parsed.countryCode], + ] as const) { + if (value !== undefined) objects.push({ type: "primitive", tag, value }); + } + + objects.push(...(parsed.additionalData ?? [])); + return objects; } // #region CRC function crc16(data: Uint8Array): number { - let crc = 0xffff; // Initial value - const polynomial = 0x1021; // Polynomial: x^16 + x^12 + x^5 + 1 + let crc = 0xffff; + const polynomial = 0x1021; - for (let i = 0; i < data.length; i++) { - crc ^= data[i] << 8; - for (let j = 0; j < 8; j++) { - if ((crc & 0x8000) !== 0) { - crc = ((crc << 1) ^ polynomial) & 0xffff; - } else { - crc = (crc << 1) & 0xffff; - } - } + for (const byte of data) { + crc ^= byte << 8; + for (let bit = 0; bit < 8; bit++) + crc = + (crc & 0x8000) !== 0 + ? ((crc << 1) ^ polynomial) & 0xffff + : (crc << 1) & 0xffff; } + return crc; } + function formatCRC(crc: number): string { return crc.toString(16).toUpperCase().padStart(4, "0"); } + function verifyCRC(payload: string): boolean { - if (payload.length < 8) return false; + if (payload.length < 8 || payload.slice(-8, -4) !== "6304") return false; - const crcTag = payload.slice(-8, -4); const expected = payload.slice(-4); - - if (crcTag !== "6304") return false; - - const calculated = crc16(new TextEncoder().encode(payload.slice(0, -4))) - .toString(16) - .toUpperCase() - .padStart(4, "0"); - + const calculated = formatCRC( + crc16(new TextEncoder().encode(payload.slice(0, -4))), + ); return calculated === expected; } -// #region PromptPay -const PromptPay = z.object({ - aid: z.literal("A000000677010111"), +function appendCRC(payload: string): string { + const body = `${payload}6304`; + return `${body}${formatCRC(crc16(new TextEncoder().encode(body)))}`; +} +// #region PromptPay +const PROMPTPAY_AID = "A000000677010111" as const; + +const promptPaySchema = z.object({ + aid: z.literal(PROMPTPAY_AID), identifier: z.discriminatedUnion("type", [ z.object({ type: z.literal("mobile"), - value: z.string().length(13), + value: z.string().regex(/^\d{13}$/), }), z.object({ type: z.literal("nationalId"), - value: z.string().length(13), + value: z.string().regex(/^\d{13}$/), }), z.object({ type: z.literal("ewallet"), - value: z.string().length(15), + value: z.string().regex(/^\d{15}$/), }), z.object({ type: z.literal("bankAccount"), - value: z.string().max(43), + value: z.string().regex(/^\d{1,43}$/), }), ]), }); -function decodePromptPay(merchantAccount: DataObject[]) { - const identifierTypes = { - 1: "mobile", - 2: "nationalId", - 3: "ewallet", - 4: "bankAccount", - } as const; - const object = merchantAccount.find( - (o) => o.type === "primitive" && o.tag in identifierTypes, - ); - if (!object || object.type !== "primitive") - throw new Error("No valid PromptPay identifier found."); - return PromptPay.parse({ +const IDENTIFIER_TYPES = { + 1: "mobile", + 2: "nationalId", + 3: "ewallet", + 4: "bankAccount", +} as const; + +const IDENTIFIER_TAGS = { + mobile: 1, + nationalId: 2, + ewallet: 3, +} as const; + +function decodePromptPay(merchantAccount: DataObject[]): PromptPayData { + const identifiers = merchantAccount.filter( + (object): object is Extract => + object.type === "primitive" && object.tag in IDENTIFIER_TYPES, + ); + + if (identifiers.length !== 1) + throw new Error("Expected exactly one PromptPay identifier."); + + const identifier = identifiers[0]; + return promptPaySchema.parse({ aid: primitive(merchantAccount, 0), identifier: { - type: identifierTypes[object.tag as keyof typeof identifierTypes], - value: object.value, + type: IDENTIFIER_TYPES[identifier.tag as keyof typeof IDENTIFIER_TYPES], + value: identifier.value, }, }); } +function encodePromptPay( + identifier: EncodablePromptPayIdentifier, +): DataObject[] { + const parsed = promptPaySchema.shape.identifier.parse(identifier); + if (parsed.type === "bankAccount") + throw new Error( + "PromptPay bank account encoding is reserved for future use.", + ); + + return [ + { type: "primitive", tag: 0, value: PROMPTPAY_AID }, + { + type: "primitive", + tag: IDENTIFIER_TAGS[parsed.type], + value: parsed.value, + }, + ]; +} + +function normalizePromptPayIdentifier( + identifier: EncodablePromptPayIdentifier, +): EncodablePromptPayIdentifier { + const value = identifier.value.trim(); + + if (identifier.type === "mobile") { + if (!/^\+?[\d\s()-]+$/.test(value)) + throw new Error("PromptPay mobile number contains invalid characters."); + + const digits = value.replace(/\D/g, ""); + const canonical = /^0\d{9}$/.test(digits) + ? `0066${digits.slice(1)}` + : /^66\d{9}$/.test(digits) + ? `00${digits}` + : digits; + + return promptPaySchema.shape.identifier.parse({ + type: "mobile", + value: canonical, + }) as EncodablePromptPayIdentifier; + } + + if (!/^[\d\s-]+$/.test(value)) + throw new Error( + `PromptPay ${identifier.type} contains invalid characters.`, + ); + + return promptPaySchema.shape.identifier.parse({ + type: identifier.type, + value: value.replace(/[\s-]/g, ""), + }) as EncodablePromptPayIdentifier; +} + +function formatAmount(amount: number | string): string { + const formatted = + typeof amount === "number" + ? Number.isFinite(amount) + ? amount.toFixed(2) + : "" + : /^\d+(?:\.\d{1,2})?$/.test(amount) + ? `${amount.split(".")[0]}.${(amount.split(".")[1] ?? "").padEnd(2, "0")}` + : ""; + + if (!/^\d+\.\d{2}$/.test(formatted) || Number(formatted) <= 0) + throw new Error( + "PromptPay amount must be a positive number with at most 2 decimals.", + ); + if (formatted.length > 13) + throw new Error("PromptPay amount exceeds the EMVCo length limit."); + + return formatted; +} + +function generatePromptPayPayload(options: GeneratePromptPayOptions): string { + const identifier = normalizePromptPayIdentifier(options.identifier); + const amount = + options.amount === undefined ? undefined : formatAmount(options.amount); + + if ( + options.merchantCategoryCode !== undefined && + !/^\d{4}$/.test(options.merchantCategoryCode) + ) + throw new Error("PromptPay merchant category code must be 4 digits."); + + const objects: DataObject[] = [ + { type: "primitive", tag: 0, value: "01" }, + { + type: "primitive", + tag: 1, + value: amount === undefined ? "11" : "12", + }, + { type: "template", tag: 29, value: encodePromptPay(identifier) }, + ]; + + if (options.merchantCategoryCode !== undefined) + objects.push({ + type: "primitive", + tag: 52, + value: options.merchantCategoryCode, + }); + + // This ordering matches established Thai banking implementations and fixtures. + objects.push( + { type: "primitive", tag: 58, value: "TH" }, + { type: "primitive", tag: 53, value: "764" }, + ); + if (amount !== undefined) + objects.push({ type: "primitive", tag: 54, value: amount }); + + return appendCRC(serializeTLV(objects)); +} + +function parsePromptPayPayload(payload: string): ParsedPromptPayPayload { + if (!verifyCRC(payload)) throw new Error("Invalid PromptPay payload CRC."); + + const dataObjects = parseEMVCo(payload); + const crc = dataObjects.at(-1); + if (crc?.type !== "primitive" || crc.tag !== 63) + throw new Error("PromptPay payload is missing its CRC data object."); + + const emvco = decodeEMVCo(dataObjects.slice(0, -1)); + if (emvco.transactionCurrency !== "764") + throw new Error("PromptPay transaction currency must be THB (764)."); + if (emvco.countryCode !== "TH") + throw new Error("Domestic PromptPay country code must be TH."); + + const promptPayTemplates = emvco.paymentNetworkSpecific.filter( + ({ tag }) => tag === 29, + ); + if (promptPayTemplates.length !== 1) + throw new Error( + "Expected exactly one PromptPay merchant account template.", + ); + + return { + payload, + dataObjects, + emvco, + promptPay: decodePromptPay(promptPayTemplates[0].value), + }; +} + export { - serializeTLV, - parseTLV, - EMVCoPayload, - parseEMVCo, - decodeEMVCo, - encodeEMVCo, + appendCRC, crc16, - formatCRC, - verifyCRC, + decodeEMVCo, decodePromptPay, + encodeEMVCo, + encodePromptPay, + formatCRC, + generatePromptPayPayload, + normalizePromptPayIdentifier, + parseEMVCo, + parsePromptPayPayload, + parseTLV, + serializeTLV, + verifyCRC, }; From 66755f33dcd61157da4b727c9353e610ed4ed929 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Fri, 28 Aug 2026 13:03:19 +0700 Subject: [PATCH 43/71] feat(don): embed transaction amount into PromptPay QR --- app/(ui)/donate/api.ts | 10 ++++++ app/(ui)/donate/page.tsx | 26 +++++++--------- app/(ui)/donate/ppqr.tsx | 67 ++++++++++++++++++++++++++++++++++++++++ bun.lock | 5 +++ lib/promptpay.test.ts | 2 ++ lib/promptpay.ts | 18 ++++++----- package.json | 1 + 7 files changed, 106 insertions(+), 23 deletions(-) create mode 100644 app/(ui)/donate/api.ts create mode 100644 app/(ui)/donate/ppqr.tsx diff --git a/app/(ui)/donate/api.ts b/app/(ui)/donate/api.ts new file mode 100644 index 0000000..8508b4d --- /dev/null +++ b/app/(ui)/donate/api.ts @@ -0,0 +1,10 @@ +"use server"; + +import { generatePromptPayPayload } from "@/lib/promptpay"; + +export async function generateQrcode(amount: number | string) { + return generatePromptPayPayload({ + identifier: { type: "nationalId", value: "1729900808055" }, + amount, + }); +} diff --git a/app/(ui)/donate/page.tsx b/app/(ui)/donate/page.tsx index 9cfc446..d6560b0 100644 --- a/app/(ui)/donate/page.tsx +++ b/app/(ui)/donate/page.tsx @@ -3,7 +3,6 @@ import { CircleX, QrCodeIcon, SendIcon } from "lucide-react"; import type { Metadata } from "next"; import z from "zod"; import { th } from "zod/v4/locales"; -import PromptpayImage from "#/assets/promptpay.jpg"; import TruemoneyIcon from "#/assets/tmn.webp"; import DonateLogo from "#/logos/donate.webp"; import Cropper from "@/components/cropper"; @@ -37,6 +36,7 @@ import { } from "../rubgram/admin/@modal/manual/client"; import { DownloadButton } from "../rubgram/client"; import Link from "next/link"; +import { DynamicPPQR } from "./ppqr"; const { TMN_DEST_PHONE_NUM, SASTIFY_API_PRIVKEY } = process.env as Record< string, @@ -320,6 +320,15 @@ export default async function () { label="วิธีการโอนเงิน" name="type" tabs={[ + { + label: ( + + + PromptPay + + ), + value: "pp", + }, { label: ( @@ -333,15 +342,6 @@ export default async function () { ), value: "tmn", }, - { - label: ( - - - PromptPay - - ), - value: "pp", - }, ]} > @@ -360,11 +360,7 @@ export default async function () { {artifactConfig.donatePromptpay ? ( <>
- Promptpay QR Code +
บัญชีรับโดเนท diff --git a/app/(ui)/donate/ppqr.tsx b/app/(ui)/donate/ppqr.tsx new file mode 100644 index 0000000..841516e --- /dev/null +++ b/app/(ui)/donate/ppqr.tsx @@ -0,0 +1,67 @@ +"use client"; +import PromptpayImage from "#/assets/promptpay.jpg"; +import { useFormContext } from "@/components/form"; +import Image from "next/image"; +import { useEffect, useState } from "react"; +import QRCode from "react-qr-code"; +import { generateQrcode } from "./api"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +export function DynamicPPQR() { + const form = useFormContext(); + const [current, setCurrent] = useState(form.values.amount as string); + const [qrcode, setQrcode] = useState(""); + const [changed, setChanged] = useState(false); + + useEffect(() => { + const amount = form.values.amount as string; + if (!amount) { + setCurrent(""); + setQrcode(""); + setChanged(false); + return; + } + let timeout: any; + generateQrcode(amount).then( + (v) => ( + setQrcode(v), + setCurrent(amount), + setChanged(true), + (timeout = setTimeout(() => setChanged(false), 2000)) + ), + ); + return () => clearTimeout(timeout); + }, [form.values.amount]); + return ( + + + {form.values.amount ? ( + current === form.values.amount ? ( +
+ +
+ ) : ( +
+ Promptpay QR Code +
+ ) + ) : ( + Promptpay QR Code + )} +
+ จำนวน {current} บาท +
+ ); +} diff --git a/bun.lock b/bun.lock index 54d036f..58cab68 100644 --- a/bun.lock +++ b/bun.lock @@ -67,6 +67,7 @@ "react-dom": "19.2.6", "react-hook-form": "^7.68.0", "react-hook-text-overflow": "^1.0.1", + "react-qr-code": "^2.2.0", "react-resizable-panels": "^3.0.6", "react-use": "^17.6.0", "recharts": "2.15.4", @@ -1161,6 +1162,8 @@ "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "qrcode-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="], + "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], @@ -1179,6 +1182,8 @@ "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-qr-code": ["react-qr-code@2.2.0", "", { "dependencies": { "prop-types": "^15.8.1", "qrcode-generator": "^2.0.4" }, "peerDependencies": { "react": "*" } }, "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw=="], + "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], diff --git a/lib/promptpay.test.ts b/lib/promptpay.test.ts index 6f920f5..f175c18 100644 --- a/lib/promptpay.test.ts +++ b/lib/promptpay.test.ts @@ -831,6 +831,8 @@ describe("generatePromptPayPayload", () => { test.each([ ["4", "4.00"], + ["10", "10.00"], + [" 10 ", "10.00"], ["4.2", "4.20"], ["4.22", "4.22"], [4, "4.00"], diff --git a/lib/promptpay.ts b/lib/promptpay.ts index e424982..82c4abb 100644 --- a/lib/promptpay.ts +++ b/lib/promptpay.ts @@ -372,14 +372,16 @@ function normalizePromptPayIdentifier( } function formatAmount(amount: number | string): string { - const formatted = - typeof amount === "number" - ? Number.isFinite(amount) - ? amount.toFixed(2) - : "" - : /^\d+(?:\.\d{1,2})?$/.test(amount) - ? `${amount.split(".")[0]}.${(amount.split(".")[1] ?? "").padEnd(2, "0")}` - : ""; + let formatted = ""; + if (typeof amount === "number") { + if (Number.isFinite(amount)) formatted = amount.toFixed(2); + } else { + const value = amount.trim(); + if (/^\d+(?:\.\d{1,2})?$/.test(value)) { + const [integer, fraction = ""] = value.split("."); + formatted = `${integer}.${fraction.padEnd(2, "0")}`; + } + } if (!/^\d+\.\d{2}$/.test(formatted) || Number(formatted) <= 0) throw new Error( diff --git a/package.json b/package.json index fbddc38..36a6cd7 100755 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "react-dom": "19.2.6", "react-hook-form": "^7.68.0", "react-hook-text-overflow": "^1.0.1", + "react-qr-code": "^2.2.0", "react-resizable-panels": "^3.0.6", "react-use": "^17.6.0", "recharts": "2.15.4", From 507e715642d59bab01cb3aaf8d5590ca2a3a72a3 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Fri, 28 Aug 2026 13:19:11 +0700 Subject: [PATCH 44/71] chore: change to secret-based real promptpay identifier --- app/(ui)/donate/api.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/(ui)/donate/api.ts b/app/(ui)/donate/api.ts index 8508b4d..7544645 100644 --- a/app/(ui)/donate/api.ts +++ b/app/(ui)/donate/api.ts @@ -1,10 +1,18 @@ "use server"; import { generatePromptPayPayload } from "@/lib/promptpay"; +import { env } from "bun"; + +const { PROMPTPAY_IDENTIFIER, PROMPTPAY_TYPE } = env; +if (!PROMPTPAY_IDENTIFIER || !PROMPTPAY_TYPE) + console.error("Warning: Missing PromptPay configuration!"); export async function generateQrcode(amount: number | string) { return generatePromptPayPayload({ - identifier: { type: "nationalId", value: "1729900808055" }, + identifier: { + type: PROMPTPAY_TYPE! as "mobile" | "nationalId" | "ewallet", + value: PROMPTPAY_IDENTIFIER!, + }, amount, }); } From 709074ea97b76d6323566ac811b23a2a57ef15f4 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Fri, 28 Aug 2026 13:19:50 +0700 Subject: [PATCH 45/71] chore: fmt [no ci] --- .claude/skills/buzz-feature/SKILL.md | 17 +++- lib/promptpay.test.ts | 115 +++++++++++++-------------- 2 files changed, 68 insertions(+), 64 deletions(-) diff --git a/.claude/skills/buzz-feature/SKILL.md b/.claude/skills/buzz-feature/SKILL.md index 5067d93..cf2dbdc 100644 --- a/.claude/skills/buzz-feature/SKILL.md +++ b/.claude/skills/buzz-feature/SKILL.md @@ -173,9 +173,20 @@ import { ImageResponse } from "next/og"; export const size = { width: 1200, height: 630 }; export default function OGImage() { - return new ImageResponse(
Buzz Feature
, { - ...size, - }); + return new ImageResponse( +
+ Buzz Feature +
, + { + ...size, + }, + ); } ``` diff --git a/lib/promptpay.test.ts b/lib/promptpay.test.ts index f175c18..978757e 100644 --- a/lib/promptpay.test.ts +++ b/lib/promptpay.test.ts @@ -128,35 +128,32 @@ describe("parseTLV", () => { ]); }); - test.each([ - "0", - "000", - "0001x00", - ])("rejects an incomplete header in %p", (payload) => { - expect(() => parseTLV(payload)).toThrow( - /Malformed TLV payload: incomplete headers at index/, - ); - }); + test.each(["0", "000", "0001x00"])( + "rejects an incomplete header in %p", + (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: incomplete headers at index/, + ); + }, + ); - test.each([ - "AA00", - "0A00", - " 100", - ])("rejects invalid tags in %p", (payload) => { - expect(() => parseTLV(payload)).toThrow( - /Malformed TLV payload: invalid tag/, - ); - }); + test.each(["AA00", "0A00", " 100"])( + "rejects invalid tags in %p", + (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: invalid tag/, + ); + }, + ); - test.each([ - "00AA", - "000A", - "00 1", - ])("rejects invalid lengths in %p", (payload) => { - expect(() => parseTLV(payload)).toThrow( - /Malformed TLV payload: invalid length/, - ); - }); + test.each(["00AA", "000A", "00 1"])( + "rejects invalid lengths in %p", + (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: invalid length/, + ); + }, + ); test("rejects a value shorter than its declared length", () => { expect(() => parseTLV("0005abc")).toThrow( @@ -324,11 +321,12 @@ describe("decodeEMVCo", () => { { type: "primitive", tag: 58, value: "THA" }, ], }, - ] satisfies { name: string; objects: DataObject[] }[])("rejects $name", ({ - objects, - }) => { - expect(() => decodeEMVCo(objects)).toThrow(); - }); + ] satisfies { name: string; objects: DataObject[] }[])( + "rejects $name", + ({ objects }) => { + expect(() => decodeEMVCo(objects)).toThrow(); + }, + ); }); describe("encodeEMVCo", () => { @@ -676,20 +674,19 @@ describe("encodePromptPay", () => { { type: "mobile", tag: 1, value: "0066812345678" }, { type: "nationalId", tag: 2, value: "1234567890123" }, { type: "ewallet", tag: 3, value: "123456789012345" }, - ] as const)("encodes a canonical $type identifier", ({ - type, - tag, - value, - }) => { - expect(encodePromptPay({ type, value })).toEqual([ - { - type: "primitive", - tag: 0, - value: "A000000677010111", - }, - { type: "primitive", tag, value }, - ]); - }); + ] as const)( + "encodes a canonical $type identifier", + ({ type, tag, value }) => { + expect(encodePromptPay({ type, value })).toEqual([ + { + type: "primitive", + tag: 0, + value: "A000000677010111", + }, + { type: "primitive", tag, value }, + ]); + }, + ); test("rejects noncanonical identifier values", () => { expect(() => @@ -858,21 +855,17 @@ describe("generatePromptPayPayload", () => { expect(parsed.emvco.merchantCategoryCode).toBe("0000"); }); - test.each([ - 0, - -1, - Number.NaN, - Number.POSITIVE_INFINITY, - "", - "1.234", - ])("rejects invalid amount %p", (amount) => { - expect(() => - generatePromptPayPayload({ - identifier: { type: "mobile", value: "0801234567" }, - amount, - }), - ).toThrow(); - }); + test.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, "", "1.234"])( + "rejects invalid amount %p", + (amount) => { + expect(() => + generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + amount, + }), + ).toThrow(); + }, + ); test("rejects invalid merchant category codes", () => { expect(() => From 825d88e72589d050f4188f6f4bb80182cbc5703e Mon Sep 17 00:00:00 2001 From: dmgnr Date: Fri, 28 Aug 2026 13:22:30 +0700 Subject: [PATCH 46/71] fix: properly check before generating qr --- app/(ui)/donate/ppqr.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(ui)/donate/ppqr.tsx b/app/(ui)/donate/ppqr.tsx index 841516e..0f0f844 100644 --- a/app/(ui)/donate/ppqr.tsx +++ b/app/(ui)/donate/ppqr.tsx @@ -19,7 +19,7 @@ export function DynamicPPQR() { useEffect(() => { const amount = form.values.amount as string; - if (!amount) { + if (!amount || Number.isNaN(Number(amount))) { setCurrent(""); setQrcode(""); setChanged(false); From 01da4c9d5764691c735eb2477797a9c97fa23f4c Mon Sep 17 00:00:00 2001 From: dmgnr Date: Fri, 28 Aug 2026 15:13:44 +0700 Subject: [PATCH 47/71] fix(pp): sort TLV tags before serialization --- lib/promptpay.test.ts | 156 +++++++++++++++++++++++++----------------- lib/promptpay.ts | 6 +- 2 files changed, 98 insertions(+), 64 deletions(-) diff --git a/lib/promptpay.test.ts b/lib/promptpay.test.ts index 978757e..40e46f7 100644 --- a/lib/promptpay.test.ts +++ b/lib/promptpay.test.ts @@ -26,6 +26,33 @@ function withCRC(payload: string): string { } describe("serializeTLV", () => { + test("sorts tags numerically without mutating the input", () => { + const objects: DataObject[] = [ + { type: "primitive", tag: 58, value: "TH" }, + { type: "primitive", tag: 0, value: "01" }, + { type: "primitive", tag: 53, value: "764" }, + ]; + const snapshot = structuredClone(objects); + + expect(serializeTLV(objects)).toBe("00020153037645802TH"); + expect(objects).toEqual(snapshot); + }); + + test("sorts tags inside templates", () => { + expect( + serializeTLV([ + { + type: "template", + tag: 29, + value: [ + { type: "primitive", tag: 3, value: "wallet" }, + { type: "primitive", tag: 0, value: "aid" }, + ], + }, + ]), + ).toBe("29170003aid0306wallet"); + }); + test("serializes primitive data objects", () => { expect( serializeTLV([ @@ -128,32 +155,35 @@ describe("parseTLV", () => { ]); }); - test.each(["0", "000", "0001x00"])( - "rejects an incomplete header in %p", - (payload) => { - expect(() => parseTLV(payload)).toThrow( - /Malformed TLV payload: incomplete headers at index/, - ); - }, - ); + test.each([ + "0", + "000", + "0001x00", + ])("rejects an incomplete header in %p", (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: incomplete headers at index/, + ); + }); - test.each(["AA00", "0A00", " 100"])( - "rejects invalid tags in %p", - (payload) => { - expect(() => parseTLV(payload)).toThrow( - /Malformed TLV payload: invalid tag/, - ); - }, - ); + test.each([ + "AA00", + "0A00", + " 100", + ])("rejects invalid tags in %p", (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: invalid tag/, + ); + }); - test.each(["00AA", "000A", "00 1"])( - "rejects invalid lengths in %p", - (payload) => { - expect(() => parseTLV(payload)).toThrow( - /Malformed TLV payload: invalid length/, - ); - }, - ); + test.each([ + "00AA", + "000A", + "00 1", + ])("rejects invalid lengths in %p", (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: invalid length/, + ); + }); test("rejects a value shorter than its declared length", () => { expect(() => parseTLV("0005abc")).toThrow( @@ -321,12 +351,11 @@ describe("decodeEMVCo", () => { { type: "primitive", tag: 58, value: "THA" }, ], }, - ] satisfies { name: string; objects: DataObject[] }[])( - "rejects $name", - ({ objects }) => { - expect(() => decodeEMVCo(objects)).toThrow(); - }, - ); + ] satisfies { name: string; objects: DataObject[] }[])("rejects $name", ({ + objects, + }) => { + expect(() => decodeEMVCo(objects)).toThrow(); + }); }); describe("encodeEMVCo", () => { @@ -674,19 +703,20 @@ describe("encodePromptPay", () => { { type: "mobile", tag: 1, value: "0066812345678" }, { type: "nationalId", tag: 2, value: "1234567890123" }, { type: "ewallet", tag: 3, value: "123456789012345" }, - ] as const)( - "encodes a canonical $type identifier", - ({ type, tag, value }) => { - expect(encodePromptPay({ type, value })).toEqual([ - { - type: "primitive", - tag: 0, - value: "A000000677010111", - }, - { type: "primitive", tag, value }, - ]); - }, - ); + ] as const)("encodes a canonical $type identifier", ({ + type, + tag, + value, + }) => { + expect(encodePromptPay({ type, value })).toEqual([ + { + type: "primitive", + tag: 0, + value: "A000000677010111", + }, + { type: "primitive", tag, value }, + ]); + }); test("rejects noncanonical identifier values", () => { expect(() => @@ -770,7 +800,7 @@ describe("generatePromptPayPayload", () => { identifier: { type: "mobile" as const, value: "0801234567" }, }, expected: - "00020101021129370016A000000677010111011300668012345675802TH530376463046197", + "00020101021129370016A0000006770101110113006680123456753037645802TH6304BE2B", }, { name: "formatted international mobile number", @@ -778,7 +808,7 @@ describe("generatePromptPayPayload", () => { identifier: { type: "mobile" as const, value: "+66-89-123-4567" }, }, expected: - "00020101021129370016A000000677010111011300668912345675802TH5303764630429C1", + "00020101021129370016A0000006770101110113006689123456753037645802TH6304F67D", }, { name: "national ID", @@ -789,7 +819,7 @@ describe("generatePromptPayPayload", () => { }, }, expected: - "00020101021129370016A000000677010111021311111111111115802TH530376463047B5A", + "00020101021129370016A0000006770101110213111111111111153037645802TH6304A4E6", }, { name: "tax ID", @@ -800,7 +830,7 @@ describe("generatePromptPayPayload", () => { }, }, expected: - "00020101021129370016A000000677010111021301234567890125802TH530376463040CBD", + "00020101021129370016A0000006770101110213012345678901253037645802TH6304D301", }, { name: "e-wallet ID", @@ -811,7 +841,7 @@ describe("generatePromptPayPayload", () => { }, }, expected: - "00020101021129390016A00000067701011103150123456789012345802TH530376463049781", + "00020101021129390016A000000677010111031501234567890123453037645802TH6304483D", }, { name: "dynamic amount", @@ -820,9 +850,9 @@ describe("generatePromptPayPayload", () => { amount: 4.22, }, expected: - "00020101021229370016A000000677010111011300660000000005802TH530376454044.226304E469", + "00020101021229370016A00000067701011101130066000000000530376454044.225802TH63049A72", }, - ])("matches the published $name vector", ({ options, expected }) => { + ])("matches the sorted $name vector", ({ options, expected }) => { expect(generatePromptPayPayload(options)).toBe(expected); }); @@ -855,17 +885,21 @@ describe("generatePromptPayPayload", () => { expect(parsed.emvco.merchantCategoryCode).toBe("0000"); }); - test.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, "", "1.234"])( - "rejects invalid amount %p", - (amount) => { - expect(() => - generatePromptPayPayload({ - identifier: { type: "mobile", value: "0801234567" }, - amount, - }), - ).toThrow(); - }, - ); + test.each([ + 0, + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + "", + "1.234", + ])("rejects invalid amount %p", (amount) => { + expect(() => + generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + amount, + }), + ).toThrow(); + }); test("rejects invalid merchant category codes", () => { expect(() => diff --git a/lib/promptpay.ts b/lib/promptpay.ts index 82c4abb..53a4c2f 100644 --- a/lib/promptpay.ts +++ b/lib/promptpay.ts @@ -60,7 +60,8 @@ export type ParsedPromptPayPayload = { // #region TLV function serializeTLV(objects: DataObject[]): string { - return objects + return [...objects] + .sort((left, right) => left.tag - right.tag) .map((object, index) => { if (!Number.isInteger(object.tag) || object.tag < 0 || object.tag > 99) throw new Error(`Tag ${index} must be 00-99`); @@ -421,10 +422,9 @@ function generatePromptPayPayload(options: GeneratePromptPayOptions): string { value: options.merchantCategoryCode, }); - // This ordering matches established Thai banking implementations and fixtures. objects.push( - { type: "primitive", tag: 58, value: "TH" }, { type: "primitive", tag: 53, value: "764" }, + { type: "primitive", tag: 58, value: "TH" }, ); if (amount !== undefined) objects.push({ type: "primitive", tag: 54, value: amount }); From f7bd43087d7f4287179cbb02f38172de7dd14196 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sat, 29 Aug 2026 15:32:47 +0700 Subject: [PATCH 48/71] feat: add mobile upload --- app/(ui)/donate/slip/[id]/client.tsx | 75 ++++++++++ app/(ui)/donate/slip/[id]/page.tsx | 56 ++++++++ app/(ui)/donate/slip/complete/page.tsx | 14 ++ app/(ui)/rubgram/admin/@modal/manual/api.ts | 31 ++++ .../rubgram/admin/@modal/manual/client.tsx | 132 +++++++++++++++++- components/action-button.tsx | 8 +- .../customized/progress/circular-progress.tsx | 88 ++++++++++++ components/form.tsx | 1 + components/ui/status.tsx | 81 +++++++++++ lib/db/schema.ts | 15 ++ lib/db/sse-endpoints.ts | 6 +- 11 files changed, 501 insertions(+), 6 deletions(-) create mode 100644 app/(ui)/donate/slip/[id]/client.tsx create mode 100644 app/(ui)/donate/slip/[id]/page.tsx create mode 100644 app/(ui)/donate/slip/complete/page.tsx create mode 100644 app/(ui)/rubgram/admin/@modal/manual/api.ts create mode 100644 components/customized/progress/circular-progress.tsx create mode 100644 components/ui/status.tsx diff --git a/app/(ui)/donate/slip/[id]/client.tsx b/app/(ui)/donate/slip/[id]/client.tsx new file mode 100644 index 0000000..361255a --- /dev/null +++ b/app/(ui)/donate/slip/[id]/client.tsx @@ -0,0 +1,75 @@ +// oxlint-disable next/no-img-element +"use client"; + +import { Upload } from "lucide-react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { CircularProgress } from "@/components/customized/progress/circular-progress"; + +export function PageFileInput({ + onValueChange, +}: { + onValueChange?: (value: File) => void; +}) { + const fileInput = useRef(null); + const submitButton = useRef(null); + const [value, setValue] = useState(); + const [progress, setProgress] = useState(0); + const updateFormValue = useEffectEvent((file: File) => { + onValueChange?.(file); + }); + useEffect(() => { + if (!value) return; + updateFormValue(value); + setTimeout(() => submitButton.current?.click(), 100); + setProgress(0); + const start = Date.now(); + + const interval = setInterval(() => { + const t = Math.min((Date.now() - start) / 10000, 1); + const eased = 1 - Math.pow(1 - t, 2); + + setProgress(eased * 100); + + if (t >= 1) clearInterval(interval); + }, 16); + return () => clearInterval(interval); + }, [value]); + return ( +
fileInput.current?.click()} + > +
+ {value ? ( + <> + `${progress}%`} + showLabel + size={120} + value={Math.round(progress)} + /> +
กำลังอัพโหลด กรุณาอย่าปิดหน้านี้
+ + ) : ( + <> +
+ +
+
แตะเพื่อเลือกไฟล์สลิป
+ + )} +
+ setValue(fileInput.current?.files?.[0])} + /> +
+ ); +} diff --git a/app/(ui)/donate/slip/[id]/page.tsx b/app/(ui)/donate/slip/[id]/page.tsx new file mode 100644 index 0000000..b5d6b76 --- /dev/null +++ b/app/(ui)/donate/slip/[id]/page.tsx @@ -0,0 +1,56 @@ +import { db } from "@/lib/db"; +import type { TUploadKey } from "@/lib/db/schema"; +import { slipSync } from "@/lib/db/schema"; +import { sse } from "@/lib/db/sse-endpoints"; +import { eq } from "drizzle-orm"; +import { redirect } from "next/navigation"; +import z from "zod"; +import { PageFileInput } from "./client"; +import { FormInput, FormProvider } from "@/components/form"; +import { formParse } from "@/components/form-submit"; + +const Schema = z.object({ + slip: z.file(), +}); + +export default async function DonateMobileSlipUploadPage({ + params, +}: PageProps<"/donate/slip/[id]">) { + const { id } = (await params) as { id: TUploadKey }; + const [{ trackingKey } = {}] = await db + .select({ + trackingKey: slipSync.trackingKey, + }) + .from(slipSync) + .where(eq(slipSync.uploadKey, id)); + if (!trackingKey) redirect("/donate"); + sse.slip_sync.pub("connected", trackingKey); + async function submit(data: FormData) { + "use server"; + + const { $, error } = formParse(Schema, data); + if (error) return { error }; + const { slip } = $; + + await db + .update(slipSync) + .set({ + name: slip.name, + type: slip.type, + size: `${slip.size}`, + data: Buffer.from(await slip.arrayBuffer()), + }) + .where(eq(slipSync.uploadKey, id)); + + sse.slip_sync.pub("complete", trackingKey!); + redirect("/donate/slip/complete"); + } + + return ( + + + + + + ); +} diff --git a/app/(ui)/donate/slip/complete/page.tsx b/app/(ui)/donate/slip/complete/page.tsx new file mode 100644 index 0000000..b043ae4 --- /dev/null +++ b/app/(ui)/donate/slip/complete/page.tsx @@ -0,0 +1,14 @@ +import { Check } from "lucide-react"; + +export default function PageFileInput() { + return ( +
+
+
+ +
+
อัพโหลดเสร็จเรียบร้อย ปิดหน้านี้ได้
+
+
+ ); +} diff --git a/app/(ui)/rubgram/admin/@modal/manual/api.ts b/app/(ui)/rubgram/admin/@modal/manual/api.ts new file mode 100644 index 0000000..e44624a --- /dev/null +++ b/app/(ui)/rubgram/admin/@modal/manual/api.ts @@ -0,0 +1,31 @@ +"use server"; + +import { db } from "@/lib/db"; +import type { TAccessKey } from "@/lib/db/schema"; +import { slipSync } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; + +export async function startMobileUpload() { + return await db + .insert(slipSync) + .values({}) + .returning() + .then((r) => r.pop()!); +} + +export async function retrieveMobileUpload(accessKey: TAccessKey) { + const object = await db + .delete(slipSync) + .where(eq(slipSync.accessKey, accessKey)) + .returning() + .then((r) => r.pop()!); + const fd = new FormData(); + fd.set( + "file", + new File([Buffer.from(object.data!)], object.name!, { + type: object.type!, + }), + ); + fd.set("name", object.name!) + return fd; +} diff --git a/app/(ui)/rubgram/admin/@modal/manual/client.tsx b/app/(ui)/rubgram/admin/@modal/manual/client.tsx index cc47b8c..30f2090 100644 --- a/app/(ui)/rubgram/admin/@modal/manual/client.tsx +++ b/app/(ui)/rubgram/admin/@modal/manual/client.tsx @@ -1,7 +1,7 @@ /** biome-ignore-all lint/a11y/noStaticElementInteractions: yes maam */ "use client"; -import { FileSearch2, RefreshCw, X } from "lucide-react"; +import { FileSearch2, RefreshCw, TabletSmartphone, X } from "lucide-react"; import { type ComponentProps, useEffect, useRef, useState } from "react"; import { b2sClient } from "@/app/(ui)/admin/cdn/table"; import { VirtualizedComboBox } from "@/components/combobox"; @@ -26,6 +26,12 @@ import { } from "@/components/ui/multi-select"; import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; +import { ActionButton } from "@/components/action-button"; +import { retrieveMobileUpload, startMobileUpload } from "./api"; +import { sse } from "@/lib/db/sse-endpoints"; +import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog"; +import QRCode from "react-qr-code"; +import { Status, StatusIndicator } from "@/components/ui/status"; export function CurrencyInput(props: ComponentProps) { return ( @@ -98,6 +104,11 @@ export function SlipUpload({ }) { const ref = useRef(null); const [value, setValue] = useState(externalValue); + const [muUploadKey, setMuUploadKey] = useState(); + const [muState, setMuState] = useState<"waiting" | "connected" | "completed">( + "waiting", + ); + const [muReject, setMuReject] = useState<(reason?: any) => void>(() => {}); const isSelected = !!value?.name; useEffect(() => { @@ -114,8 +125,66 @@ export function SlipUpload({ function choose() { ref.current?.click(); } + + async function mobileUpload() { + // 1. create sync entry + const { trackingKey, accessKey, uploadKey } = await startMobileUpload(); + + // 2. watch for slip_sync.complete for that tracking ID + let completedResolve: () => void; + const completedPromise = new Promise( + (r, j) => ((completedResolve = r), setMuReject(() => j)), + ); + const ev = sse.slip_sync.subMany({ + complete(tid) { + if (trackingKey !== tid) return; + setMuState("completed"); + completedResolve(); + }, + connected(tid) { + console.log(tid, trackingKey); + if (trackingKey !== tid) return; + setMuState("connected"); + }, + }); + + // 3. show qr/share dialog + setMuUploadKey(uploadKey); + // window.open(`http://localhost:3000/donate/slip/${uploadKey}`); + + try { + // 4. user open link on mobile + // 5. user upload image + // 6. image updates the sync entry and trigger slip_sync.complete + await completedPromise; + + // 7. pc client fetch image and delete the sync entry + const fd = await retrieveMobileUpload(accessKey); + const f = fd.get("file") as File; + const file = new File([f], fd.get("name") as string, { + type: f.type, + lastModified: f.lastModified, + }); + + // 8. pc client stop watch + ev.clean(); + + // 9. close dialog and set slip input? + setMuUploadKey(undefined); + console.log(file); + setValue(file); + onValueChange?.(file); + } catch (e) { + console.error(e); + setMuUploadKey(undefined); + ev.clean(); + + throw e; + } + } + return ( - <> +
+
+ + + +
- + state || muReject?.("user abort")} + > + + + อัพโหลดสลิปจากมือถือ + +
+
+ +
+ + สแกนด้วยโทรศัพท์เพื่อเลือกสลิปจาก Gallery และอัพโหลดโดยตรงมาที่นี่ + + + {muState === "waiting" ? ( + <> + + + + กำลังรอการเชื่อมต่อ + + ) : muState === "connected" ? ( + <> + + + + เชื่อมต่อแล้ว กำลังรออัพโหลดรูป + + ) : ( + <> + + + + กำลังดาวน์โหลดเข้าเครื่องนี + + )} + +
+
+
+
); } diff --git a/components/action-button.tsx b/components/action-button.tsx index 036e796..f054f96 100644 --- a/components/action-button.tsx +++ b/components/action-button.tsx @@ -12,8 +12,12 @@ export function ActionButton({ action, children, onClick, + text, ...props -}: ComponentProps & { action: () => MaybePromise }) { +}: ComponentProps & { + action: () => MaybePromise; + text?: string; +}) { const [state, setState] = useState<0 | 1 | 2>(0); useEffect(() => { @@ -38,7 +42,7 @@ export function ActionButton({ } }} > - {state === 0 ? children : state === 1 ? : } + {state === 0 ? children : state === 1 ? : } {text} ); } diff --git a/components/customized/progress/circular-progress.tsx b/components/customized/progress/circular-progress.tsx new file mode 100644 index 0000000..ad1a65e --- /dev/null +++ b/components/customized/progress/circular-progress.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +interface CircularProgressProps { + value: number; + renderLabel?: (progress: number) => number | string; + size?: number; + strokeWidth?: number; + circleStrokeWidth?: number; + progressStrokeWidth?: number; + shape?: "square" | "round"; + className?: string; + progressClassName?: string; + labelClassName?: string; + showLabel?: boolean; +} + +export const CircularProgress = ({ + value, + renderLabel, + className, + progressClassName, + labelClassName, + showLabel, + shape = "round", + size = 100, + strokeWidth, + circleStrokeWidth = 10, + progressStrokeWidth = 10, +}: CircularProgressProps) => { + const radius = size / 2 - 10; + const circumference = Math.ceil(3.14 * radius * 2); + const percentage = Math.ceil(circumference * ((100 - value) / 100)); + + const viewBox = `-${size * 0.125} -${size * 0.125} ${size * 1.25} ${ + size * 1.25 + }`; + + return ( +
+ + {/* Base Circle */} + + + {/* Progress */} + + + {showLabel && ( +
+ {renderLabel ? renderLabel(value) : value} +
+ )} +
+ ); +}; diff --git a/components/form.tsx b/components/form.tsx index 64144c8..8ba71b9 100755 --- a/components/form.tsx +++ b/components/form.tsx @@ -234,6 +234,7 @@ export function FormProvider({ : typeof error === "string" ? error : "Something went wrong"; + if (message.includes("NEXT_REDIRECT")) return; toast.error(message); } finally { setLoading(false); diff --git a/components/ui/status.tsx b/components/ui/status.tsx new file mode 100644 index 0000000..71ab763 --- /dev/null +++ b/components/ui/status.tsx @@ -0,0 +1,81 @@ +"use client" + +import type { ComponentProps, HTMLAttributes } from "react" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +export type StatusProps = ComponentProps & { + status: "online" | "offline" | "maintenance" | "degraded" +} + +export const Status = ({ className, status, ...props }: StatusProps) => ( + +) + +export type StatusIndicatorProps = HTMLAttributes + +export const StatusIndicator = ({ className, ...props }: StatusIndicatorProps) => ( + + + + +) + +export type StatusLabelProps = HTMLAttributes + +export const StatusLabel = ({ className, children, ...props }: StatusLabelProps) => ( + + {children ?? ( + <> + Online + Offline + Maintenance + Degraded + + )} + +) + +// Demo +export function Demo() { + return ( +
+ + + + + + + + + + + + + + + + +
+ ) +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 2cdb09d..d83a27e 100755 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -366,6 +366,21 @@ export const donations = schDonate.table("donations", { method: paymentMethod().notNull().default("pp"), }); +export type TTrackingKey = string & { __brand: "tracking_key" }; +export type TAccessKey = string & { __brand: "access_key" }; +export type TUploadKey = string & { __brand: "upload_key" }; + +export const slipSync = schDonate.table("sync", { + trackingKey: text().primaryKey().$defaultFn(uuidv7).$type(), + accessKey: text().notNull().$defaultFn(uuidv7).$type(), + uploadKey: text().notNull().$defaultFn(uuidv7).$type(), + + data: bytea(), + name: text(), + type: text(), + size: text(), +}); + //#endregion //#region Better Auth diff --git a/lib/db/sse-endpoints.ts b/lib/db/sse-endpoints.ts index 9ca3c96..9e2807d 100644 --- a/lib/db/sse-endpoints.ts +++ b/lib/db/sse-endpoints.ts @@ -1,7 +1,7 @@ import z from "zod/v4"; import type { YoutubeLiveInfo } from "@/app/api/live/route"; import { sseEndpoint, sseEndpointMap } from "./redis"; -import type { auditLog, tierlistStates } from "./schema"; +import type { auditLog, tierlistStates, TTrackingKey } from "./schema"; export const sse = sseEndpointMap({ // Artifact Admin Listener @@ -54,6 +54,10 @@ export const sse = sseEndpointMap({ log: { update: z.custom(), }, + slip_sync: { + complete: z.custom(), + connected: z.custom() + }, }); export const adminSseList: (keyof typeof sse)[] = ["log"]; export function tlSse(list: T) { From f23cb03a8d46d71d8042e1aeb98d895fb8d93ff2 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sat, 29 Aug 2026 21:36:56 +0700 Subject: [PATCH 49/71] fix(rg): missing queue cancel button --- app/(ui)/rubgram/page.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/(ui)/rubgram/page.tsx b/app/(ui)/rubgram/page.tsx index f7e8d16..ccfc37c 100755 --- a/app/(ui)/rubgram/page.tsx +++ b/app/(ui)/rubgram/page.tsx @@ -299,13 +299,11 @@ export default async function EndgamePage({ {q && ( - - - - - + + + แน่ใจหรอ From cba9bf4e8d7050de76ebec699e64b6a536be24bb Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sat, 29 Aug 2026 21:37:15 +0700 Subject: [PATCH 50/71] fix(don): reset mobile sync state on init --- app/(ui)/rubgram/admin/@modal/manual/client.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/(ui)/rubgram/admin/@modal/manual/client.tsx b/app/(ui)/rubgram/admin/@modal/manual/client.tsx index 30f2090..224f011 100644 --- a/app/(ui)/rubgram/admin/@modal/manual/client.tsx +++ b/app/(ui)/rubgram/admin/@modal/manual/client.tsx @@ -127,6 +127,8 @@ export function SlipUpload({ } async function mobileUpload() { + setMuState("waiting"); + // 1. create sync entry const { trackingKey, accessKey, uploadKey } = await startMobileUpload(); From b2c0849a54cfac90be186ec83b3bd38e5cd755e5 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sat, 29 Aug 2026 21:45:45 +0700 Subject: [PATCH 51/71] feat(don): purge expired mobile upload sessions --- backend/index.ts | 16 ++++++++++++++++ lib/db/schema.ts | 1 + 2 files changed, 17 insertions(+) diff --git a/backend/index.ts b/backend/index.ts index 2e35f2c..175745f 100755 --- a/backend/index.ts +++ b/backend/index.ts @@ -12,6 +12,7 @@ import { endgameSubmissions, endgameTypes, settings, + slipSync, submissions, tierlistColumns, tierlistTiers, @@ -60,6 +61,20 @@ function logger(group: string) { return t; } +async function checkMobileSyncExpiration() { + const { log, schedule } = logger("mobileSync"); + + const count = await db + .delete(slipSync) + .where(lt(slipSync.created, sql`NOW() - INTERVAL '1 hour'`)) + .returning({ id: slipSync.trackingKey }); + const removed = count.length; + + if (removed) log("Purged", removed, "sessions"); + + schedule([], checkMobileSyncExpiration); +} + async function checkRubgramExpiration() { const { log, schedule } = logger("rubgramExpiration"); const { removed } = await removeExpiredSubmissions(); @@ -326,6 +341,7 @@ const redisSubscribers: Record< }; checkRubgramExpiration(); +checkMobileSyncExpiration(); seedDatabase(); cacheCards(); console.log("Tasks assigned"); diff --git a/lib/db/schema.ts b/lib/db/schema.ts index d83a27e..76a16cd 100755 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -374,6 +374,7 @@ export const slipSync = schDonate.table("sync", { trackingKey: text().primaryKey().$defaultFn(uuidv7).$type(), accessKey: text().notNull().$defaultFn(uuidv7).$type(), uploadKey: text().notNull().$defaultFn(uuidv7).$type(), + created: timestamp("created_at").defaultNow().notNull(), data: bytea(), name: text(), From fdd8a165be18276c2a6986e68abece4a14f2b597 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 30 Aug 2026 00:57:36 +0700 Subject: [PATCH 52/71] fix(api/card): separately check for null --- app/api/card/[sub]/route.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/api/card/[sub]/route.ts b/app/api/card/[sub]/route.ts index a1f0eb2..d169710 100755 --- a/app/api/card/[sub]/route.ts +++ b/app/api/card/[sub]/route.ts @@ -13,10 +13,15 @@ export async function GET( db.select().from(cards).where(eq(cards.submission, subId)), ]); if (image?.image) return new Response(new Uint8Array(image.image)); + if (!sub) return new Response(`Unknown submission: ${sub}`, { status: 404 }); + if (!sub.char) + return new Response(`This submission isn't character-specific.`, { + status: 422, + }); const [char] = await db .select() .from(characters) - .where(eq(characters.name, sub.char ?? "THISISNULLANDNOTSUPPOSEDTOMATCH")); + .where(eq(characters.name, sub.char)); if (!char) return new Response(`Unknown character: ${sub.char}`, { status: 500 }); const card = await fetch( From 5b2975267f1a3a244660aa45cbbeac34589c7e40 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 30 Aug 2026 02:10:13 +0700 Subject: [PATCH 53/71] fix(admin): amber sync button fix --- app/(ui)/admin/settings/api.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/(ui)/admin/settings/api.ts b/app/(ui)/admin/settings/api.ts index d35c414..65c1194 100755 --- a/app/(ui)/admin/settings/api.ts +++ b/app/(ui)/admin/settings/api.ts @@ -1,14 +1,12 @@ "use server"; -import { exec } from "node:child_process"; -import { env } from "node:process"; -import { promisify } from "node:util"; import { revalidatePath } from "next/cache"; import { actionLog } from "@/lib/api"; import { db } from "@/lib/db"; import { settings } from "@/lib/db/schema"; import { adminCheck } from "@/lib/auth"; import { sse } from "@/lib/db/sse-endpoints"; +import { syncAmber as syncAmberRaw } from "@/util/sync"; export async function getSettongs() { if (!(await adminCheck())) throw "Unauthorized"; @@ -32,11 +30,11 @@ export async function toggleEnka(state: boolean) { export async function syncAmber() { if (!(await adminCheck())) throw "Unauthorized"; - const res = await promisify(exec)("bun util/sync 2>&1", { - env: { ...env, NO_AUTH_CHECK: "1" }, + await syncAmberRaw(); + await actionLog("Triggered an Amber sync", { + result: "OK(log is wip, check `kubectl logs -fn buzz deployments/app`)", }); - await actionLog("Triggered an Amber sync", { result: res.stdout }); - return res.stdout; + return "OK(log is wip, check `kubectl logs -fn buzz deployments/app`)"; } export async function forceRefresh(prefix: string | null = null) { From 4339b7f02e0955508a0df948e96a5fc288913fdf Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 30 Aug 2026 02:12:32 +0700 Subject: [PATCH 54/71] fix(backend): schedule amber sync everyday --- backend/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/index.ts b/backend/index.ts index 175745f..6e23a49 100755 --- a/backend/index.ts +++ b/backend/index.ts @@ -287,7 +287,7 @@ async function cacheCards() { } else schedule(120, cacheCards); } -cron("0 0 */14 * *", async function syncAmber() { +cron("0 1 * * *", async function syncAmber() { const { log, error } = logger("syncAmber"); log("Syncing..."); const token = await issueInternalToken(); From 061cce00dc1322231db56e19e1b9b971ad9a8aed Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 30 Aug 2026 02:27:00 +0700 Subject: [PATCH 55/71] feat(yt): integrate AdaptiveCache into redis --- lib/adaptive-cache.test.ts | 166 +++++++++++++++++++++++++++++ lib/adaptive-cache.ts | 210 ++++++++++++++++++++++++++++--------- 2 files changed, 328 insertions(+), 48 deletions(-) create mode 100644 lib/adaptive-cache.test.ts diff --git a/lib/adaptive-cache.test.ts b/lib/adaptive-cache.test.ts new file mode 100644 index 0000000..4f7f322 --- /dev/null +++ b/lib/adaptive-cache.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, mock, test } from "bun:test"; +import { AdaptiveCache } from "./adaptive-cache"; + +class FakeRedis { + readonly strings = new Map(); + readonly sortedSets = new Map>(); + readonly commands: { command: string; args: string[] }[] = []; + + async get(key: string) { + const entry = this.strings.get(key); + if (!entry || entry.expires <= Date.now()) { + this.strings.delete(key); + return null; + } + return entry.value; + } + + async send(command: string, args: string[]): Promise { + this.commands.push({ command, args }); + + if (command === "DEL") return this.strings.delete(args[0]) ? 1 : 0; + if (command !== "EVAL") throw new Error(`Unsupported command: ${command}`); + + if (args[0].includes("return {")) { + const requests = this.sortedSet(args[2]); + const entries = this.sortedSet(args[3]); + const cutoff = Number(args[4]); + const now = Number(args[5]); + + for (const [member, score] of requests) + if (score < cutoff) requests.delete(member); + for (const [member, score] of entries) + if (score <= now) entries.delete(member); + + return [requests.size, entries.size]; + } + + const responseKey = args[2]; + const requests = this.sortedSet(args[3]); + const entries = this.sortedSet(args[4]); + const startedAt = Number(args[5]); + const requestMember = args[6]; + const cached = args[8]; + const remainingTtl = Number(args[9]); + const expiresAt = Number(args[10]); + const hash = args[11]; + + requests.set(requestMember, startedAt); + if (remainingTtl > 0) { + this.strings.set(responseKey, { + value: cached, + expires: Date.now() + remainingTtl, + }); + entries.set(hash, expiresAt); + } + return null; + } + + private sortedSet(key: string) { + let set = this.sortedSets.get(key); + if (!set) { + set = new Map(); + this.sortedSets.set(key, set); + } + return set; + } +} + +const options = { + name: "test", + maxRequests: 4, + period: 1_000, + lookback: 500, + ttl: 1_000, +}; + +describe("AdaptiveCache", () => { + test("shares quota and cached responses between instances", async () => { + const redis = new FakeRedis(); + const firstReplica = new AdaptiveCache(options, redis); + const restartedReplica = new AdaptiveCache(options, redis); + const upstream = mock( + async () => + new Response("first", { + status: 201, + statusText: "Created", + headers: { "x-cache-test": "yes" }, + }), + ); + const url = "https://example.com/data?secret=do-not-leak"; + + await firstReplica.fetch(url, undefined, upstream); + const response = await restartedReplica.fetch(url, undefined, upstream); + + expect(upstream).toHaveBeenCalledTimes(1); + expect(response.status).toBe(201); + expect(response.statusText).toBe("Created"); + expect(response.headers.get("x-cache-test")).toBe("yes"); + expect(await response.text()).toBe("first"); + expect(redis.commands.flatMap(({ args }) => args).join(" ")).not.toContain( + "do-not-leak", + ); + expect(await restartedReplica.debugStats()).toContain( + "HOT | recent=1 | projected=2.0/4", + ); + }); + + test("does not count or cache unsuccessful responses", async () => { + const redis = new FakeRedis(); + const cache = new AdaptiveCache(options, redis); + + await cache.fetch( + "https://example.com/failure", + undefined, + async () => new Response("failure", { status: 500 }), + ); + + expect(await cache.debugStats()).toContain("recent=0"); + expect(redis.strings.size).toBe(0); + }); + + test("fetches upstream without writing when Redis is unavailable", async () => { + const unavailable = { + get: async () => { + throw new Error("offline"); + }, + send: mock(async () => { + throw new Error("offline"); + }), + }; + const cache = new AdaptiveCache(options, unavailable); + const error = console.error; + console.error = mock(() => {}); + + try { + const response = await cache.fetch( + "https://example.com/data", + undefined, + async () => new Response("upstream"), + ); + expect(await response.text()).toBe("upstream"); + expect(unavailable.send).toHaveBeenCalledTimes(1); + } finally { + console.error = error; + } + }); + + test("expires response entries without losing request history", async () => { + const redis = new FakeRedis(); + const cache = new AdaptiveCache({ ...options, ttl: 5 }, redis); + let calls = 0; + const upstream = async () => new Response(`${++calls}`); + + await cache.fetch("https://example.com/data", undefined, upstream); + await Bun.sleep(10); + const response = await cache.fetch( + "https://example.com/data", + undefined, + upstream, + ); + + expect(await response.text()).toBe("2"); + expect(calls).toBe(2); + expect(await cache.debugStats()).toContain("recent=2"); + }); +}); diff --git a/lib/adaptive-cache.ts b/lib/adaptive-cache.ts index 98c480e..1197c7c 100644 --- a/lib/adaptive-cache.ts +++ b/lib/adaptive-cache.ts @@ -1,6 +1,9 @@ -// gpt'd at 3am, used as-is by another module +import { redis } from "@/lib/db/redis"; interface AdaptiveCacheOptions { + /** Stable namespace shared by every replica. */ + name: string; + /** Maximum requests allowed per period. */ maxRequests: number; @@ -20,88 +23,199 @@ interface AdaptiveCacheOptions { key?(req: Request): string; } +type RedisClient = { + get(key: string): Promise; + send(command: string, args: string[]): Promise; +}; + +type Fetcher = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +type CachedResponse = { + version: 1; + status: number; + statusText: string; + headers: [string, string][]; + body: string; +}; + +const STATS_SCRIPT = ` +redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", "(" .. ARGV[1]) +redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", ARGV[2]) +return { redis.call("ZCARD", KEYS[1]), redis.call("ZCARD", KEYS[2]) } +`; + +const STORE_SCRIPT = ` +redis.call("ZADD", KEYS[2], ARGV[1], ARGV[2]) +redis.call("PEXPIRE", KEYS[2], ARGV[3]) + +if tonumber(ARGV[5]) > 0 then + redis.call("SET", KEYS[1], ARGV[4], "PX", ARGV[5]) + redis.call("ZADD", KEYS[3], ARGV[6], ARGV[7]) + redis.call("PEXPIRE", KEYS[3], ARGV[8]) +end +`; + export class AdaptiveCache { - private readonly cache = new Map< - string, - { - expires: number; - response: Response; - } - >(); + private readonly lookback: number; + private readonly threshold: number; + private readonly key: (req: Request) => string; + private readonly prefix: string; - private readonly requests: number[] = []; - - constructor(private readonly options: AdaptiveCacheOptions) { - options.lookback ??= options.period / 2; - options.threshold ??= 0.5; - options.key ??= (req) => req.url; + constructor( + private readonly options: AdaptiveCacheOptions, + private readonly client: RedisClient | null = redis, + ) { + this.lookback = options.lookback ?? options.period / 2; + this.threshold = options.threshold ?? 0.5; + this.key = options.key ?? ((req) => req.url); + this.prefix = `adaptive-cache:v1:${options.name}`; } async fetch( input: RequestInfo | URL, init?: RequestInit, - fetcher: typeof fetch = fetch, + fetcher: Fetcher = fetch, ): Promise { const req = input instanceof Request ? input : new Request(input, init); + const startedAt = Date.now(); - const now = Date.now(); + if (!this.client) return fetcher(req); - this.cleanup(now); + const hash = await this.hash(this.key(req)); + const responseKey = `${this.prefix}:response:${hash}`; - const key = this.options.key!(req); + try { + const { requests } = await this.stats(startedAt); - if (this.shouldCache()) { - const cached = this.cache.get(key); - if (cached && cached.expires > now) return cached.response.clone(); + if (this.shouldCache(requests)) { + const cached = await this.client.get(responseKey); + if (cached) { + try { + return this.deserialize(cached); + } catch (error) { + console.error("Invalid AdaptiveCache response in Redis", error); + await this.client.send("DEL", [responseKey]); + } + } + } + } catch (error) { + console.error("AdaptiveCache Redis read failed", error); + return fetcher(req); } const res = await fetcher(req); if (res.ok) { - this.requests.push(now); + const expiresAt = startedAt + this.options.ttl; + const remainingTtl = Math.max(0, expiresAt - Date.now()); + let cached = ""; - this.cache.set(key, { - expires: now + this.options.ttl, - response: res.clone(), - }); + try { + cached = await this.serialize(res.clone()); + } catch (error) { + console.error("AdaptiveCache response serialization failed", error); + } + + try { + await this.client.send("EVAL", [ + STORE_SCRIPT, + "3", + responseKey, + `${this.prefix}:requests`, + `${this.prefix}:entries`, + `${startedAt}`, + `${startedAt}:${crypto.randomUUID()}`, + `${this.lookback}`, + cached, + `${cached ? remainingTtl : 0}`, + `${expiresAt}`, + hash, + `${this.options.ttl}`, + ]); + } catch (error) { + console.error("AdaptiveCache Redis write failed", error); + } } return res; } - private shouldCache() { - const projected = - this.requests.length * (this.options.period / this.options.lookback!); + async debugStats() { + if (!this.client) + throw new Error("Redis is not available in this environment."); - return projected >= this.options.maxRequests * this.options.threshold!; - } - - private cleanup(now: number) { - const cutoff = now - this.options.lookback!; - - while (this.requests.length && this.requests[0] < cutoff) - this.requests.shift(); - - for (const [key, value] of this.cache) - if (value.expires <= now) this.cache.delete(key); - } - - debugStats() { - const projected = - this.requests.length * (this.options.period / this.options.lookback!); + const { requests, entries } = await this.stats(Date.now()); + const projected = requests * (this.options.period / this.lookback); return [ `AdaptiveCache`, - this.shouldCache() ? "HOT" : "COLD", - `recent=${this.requests.length}`, + this.shouldCache(requests) ? "HOT" : "COLD", + `recent=${requests}`, `projected=${projected.toFixed(1)}/${this.options.maxRequests}`, `usage=${((projected / this.options.maxRequests) * 100).toFixed(0)}%`, - `entries=${this.cache.size}`, + `entries=${entries}`, ].join(" | "); } + + private shouldCache(requests: number) { + const projected = requests * (this.options.period / this.lookback); + return projected >= this.options.maxRequests * this.threshold; + } + + private async stats(now: number) { + const result = await this.client!.send("EVAL", [ + STATS_SCRIPT, + "2", + `${this.prefix}:requests`, + `${this.prefix}:entries`, + `${now - this.lookback}`, + `${now}`, + ]); + + if (!Array.isArray(result) || result.length !== 2) + throw new Error("Unexpected AdaptiveCache stats response from Redis."); + + return { requests: Number(result[0]), entries: Number(result[1]) }; + } + + private async hash(value: string) { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(value), + ); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + } + + private async serialize(response: Response) { + const cached: CachedResponse = { + version: 1, + status: response.status, + statusText: response.statusText, + headers: Array.from(response.headers.entries()), + body: Buffer.from(await response.arrayBuffer()).toString("base64"), + }; + return JSON.stringify(cached); + } + + private deserialize(value: string) { + const cached = JSON.parse(value) as CachedResponse; + if (cached.version !== 1) throw new Error("Unsupported cache version."); + + return new Response(Buffer.from(cached.body, "base64"), { + status: cached.status, + statusText: cached.statusText, + headers: cached.headers, + }); + } } export const youtubeCache = new AdaptiveCache({ + name: "youtube", maxRequests: 100, // YouTube search.list requests/day period: 24 * 60 * 60_000, ttl: 15 * 60_000, From 988b0e2d353865c8f232969c75daea0942c322f2 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 30 Aug 2026 07:07:53 +0000 Subject: [PATCH 56/71] fix(health): handle failed external probes --- app/api/health/route.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/app/api/health/route.ts b/app/api/health/route.ts index f7e4fb2..41423c2 100755 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,8 +1,9 @@ +import { GoogleGenAI } from "@google/genai"; +import { env } from "bun"; import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { redis as redisShared } from "@/lib/db/redis"; -import { GoogleGenAI } from "@google/genai"; -import { env } from "bun"; + const { DISCORD_BOT_TOKEN, GEMINI_TTS_API_KEY } = env as Record; const redis = redisShared!; @@ -23,14 +24,16 @@ export async function GET() { red ? (await redis.exists(`health:${k}`)) ? (await redis.get(`health:${k}`)) === "ok" - : await v().then(async (r: any) => { - const v = r instanceof Response ? r.ok : !!r; - if (v) await redis.setex(`health:${k}`, 900, "ok"); - return v; - }) - : await v().then((r: any) => - r instanceof Response ? r.ok : !!r, - ), + : await v() + .then(async (r) => { + const v = r instanceof Response ? r.ok : !!r; + if (v) await redis.setex(`health:${k}`, 900, "ok"); + return v; + }) + .catch(() => false) + : await v() + .then((r) => (r instanceof Response ? r.ok : !!r)) + .catch(() => false), ] as const, ), ), From 41b24bd7c268149c35db5a06b494f4ac2ed9e97b Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 30 Aug 2026 14:09:17 +0700 Subject: [PATCH 57/71] fix(ci): update k3s api server --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index febafd9..c663bc3 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: - name: Configure kubectl run: | - kubectl config set-cluster buzz --server=https://100.75.220.33:6443 --insecure-skip-tls-verify=true + kubectl config set-cluster buzz --server=https://astrxl-la3.neko-piranha.ts.net:6443 --insecure-skip-tls-verify=true kubectl config set-credentials ci-deployer --token=${{ secrets.KUBE_TOKEN }} kubectl config set-context buzz --cluster=buzz --user=ci-deployer --namespace=buzz kubectl config use-context buzz @@ -57,7 +57,7 @@ jobs: - name: Configure kubectl run: | - kubectl config set-cluster buzz --server=https://100.75.220.33:6443 --insecure-skip-tls-verify=true + kubectl config set-cluster buzz --server=https://astrxl-la3.neko-piranha.ts.net:6443 --insecure-skip-tls-verify=true kubectl config set-credentials ci-deployer --token=${{ secrets.KUBE_TOKEN }} kubectl config set-context buzz --cluster=buzz --user=ci-deployer --namespace=buzz kubectl config use-context buzz @@ -98,7 +98,7 @@ jobs: - name: Configure kubectl run: | - kubectl config set-cluster buzz --server=https://100.75.220.33:6443 --insecure-skip-tls-verify=true + kubectl config set-cluster buzz --server=https://astrxl-la3.neko-piranha.ts.net:6443 --insecure-skip-tls-verify=true kubectl config set-credentials ci-deployer --token=${{ secrets.KUBE_TOKEN }} kubectl config set-context buzz --cluster=buzz --user=ci-deployer --namespace=buzz kubectl config use-context buzz From a83015384c2fe756e69ecc804ac60c68293e814b Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 30 Aug 2026 16:19:13 +0000 Subject: [PATCH 58/71] feat(db): add read replicas --- Dockerfile | 2 +- app/(ui)/rubgram/admin/@modal/manual/api.ts | 2 +- lib/db/index.ts | 8 +- lib/db/sse-endpoints.ts | 2 +- lib/promptpay.test.ts | 115 +++++++++----------- 5 files changed, 64 insertions(+), 65 deletions(-) diff --git a/Dockerfile b/Dockerfile index d275a5c..52da55e 100755 --- a/Dockerfile +++ b/Dockerfile @@ -68,4 +68,4 @@ COPY --from=builder /home/container/.next/static ./.next/static COPY --from=builder /home/container/.version ./.version ENV HOSTNAME=0.0.0.0 -CMD ["bun", "server.js"] \ No newline at end of file +CMD ["bun", "server.js"] diff --git a/app/(ui)/rubgram/admin/@modal/manual/api.ts b/app/(ui)/rubgram/admin/@modal/manual/api.ts index e44624a..6cf492c 100644 --- a/app/(ui)/rubgram/admin/@modal/manual/api.ts +++ b/app/(ui)/rubgram/admin/@modal/manual/api.ts @@ -26,6 +26,6 @@ export async function retrieveMobileUpload(accessKey: TAccessKey) { type: object.type!, }), ); - fd.set("name", object.name!) + fd.set("name", object.name!); return fd; } diff --git a/lib/db/index.ts b/lib/db/index.ts index 3aee936..2f251b2 100755 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -1,4 +1,10 @@ import { drizzle } from "drizzle-orm/bun-sql"; import * as schema from "./schema"; +import { withReplicas } from "drizzle-orm/pg-core"; -export const db = drizzle(process.env.DATABASE_URL!, { schema }); +export const db = process.env.REPLICA_DATABASE_URL + ? withReplicas(drizzle(process.env.DATABASE_URL!, { schema }), [ + drizzle(process.env.REPLICA_DATABASE_URL!, { schema }), + drizzle(process.env.REPLICA_DATABASE_URL!, { schema }), + ]) + : drizzle(process.env.DATABASE_URL!, { schema }); diff --git a/lib/db/sse-endpoints.ts b/lib/db/sse-endpoints.ts index 9e2807d..1823696 100644 --- a/lib/db/sse-endpoints.ts +++ b/lib/db/sse-endpoints.ts @@ -56,7 +56,7 @@ export const sse = sseEndpointMap({ }, slip_sync: { complete: z.custom(), - connected: z.custom() + connected: z.custom(), }, }); export const adminSseList: (keyof typeof sse)[] = ["log"]; diff --git a/lib/promptpay.test.ts b/lib/promptpay.test.ts index 40e46f7..b727d7b 100644 --- a/lib/promptpay.test.ts +++ b/lib/promptpay.test.ts @@ -155,35 +155,32 @@ describe("parseTLV", () => { ]); }); - test.each([ - "0", - "000", - "0001x00", - ])("rejects an incomplete header in %p", (payload) => { - expect(() => parseTLV(payload)).toThrow( - /Malformed TLV payload: incomplete headers at index/, - ); - }); + test.each(["0", "000", "0001x00"])( + "rejects an incomplete header in %p", + (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: incomplete headers at index/, + ); + }, + ); - test.each([ - "AA00", - "0A00", - " 100", - ])("rejects invalid tags in %p", (payload) => { - expect(() => parseTLV(payload)).toThrow( - /Malformed TLV payload: invalid tag/, - ); - }); + test.each(["AA00", "0A00", " 100"])( + "rejects invalid tags in %p", + (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: invalid tag/, + ); + }, + ); - test.each([ - "00AA", - "000A", - "00 1", - ])("rejects invalid lengths in %p", (payload) => { - expect(() => parseTLV(payload)).toThrow( - /Malformed TLV payload: invalid length/, - ); - }); + test.each(["00AA", "000A", "00 1"])( + "rejects invalid lengths in %p", + (payload) => { + expect(() => parseTLV(payload)).toThrow( + /Malformed TLV payload: invalid length/, + ); + }, + ); test("rejects a value shorter than its declared length", () => { expect(() => parseTLV("0005abc")).toThrow( @@ -351,11 +348,12 @@ describe("decodeEMVCo", () => { { type: "primitive", tag: 58, value: "THA" }, ], }, - ] satisfies { name: string; objects: DataObject[] }[])("rejects $name", ({ - objects, - }) => { - expect(() => decodeEMVCo(objects)).toThrow(); - }); + ] satisfies { name: string; objects: DataObject[] }[])( + "rejects $name", + ({ objects }) => { + expect(() => decodeEMVCo(objects)).toThrow(); + }, + ); }); describe("encodeEMVCo", () => { @@ -703,20 +701,19 @@ describe("encodePromptPay", () => { { type: "mobile", tag: 1, value: "0066812345678" }, { type: "nationalId", tag: 2, value: "1234567890123" }, { type: "ewallet", tag: 3, value: "123456789012345" }, - ] as const)("encodes a canonical $type identifier", ({ - type, - tag, - value, - }) => { - expect(encodePromptPay({ type, value })).toEqual([ - { - type: "primitive", - tag: 0, - value: "A000000677010111", - }, - { type: "primitive", tag, value }, - ]); - }); + ] as const)( + "encodes a canonical $type identifier", + ({ type, tag, value }) => { + expect(encodePromptPay({ type, value })).toEqual([ + { + type: "primitive", + tag: 0, + value: "A000000677010111", + }, + { type: "primitive", tag, value }, + ]); + }, + ); test("rejects noncanonical identifier values", () => { expect(() => @@ -885,21 +882,17 @@ describe("generatePromptPayPayload", () => { expect(parsed.emvco.merchantCategoryCode).toBe("0000"); }); - test.each([ - 0, - -1, - Number.NaN, - Number.POSITIVE_INFINITY, - "", - "1.234", - ])("rejects invalid amount %p", (amount) => { - expect(() => - generatePromptPayPayload({ - identifier: { type: "mobile", value: "0801234567" }, - amount, - }), - ).toThrow(); - }); + test.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, "", "1.234"])( + "rejects invalid amount %p", + (amount) => { + expect(() => + generatePromptPayPayload({ + identifier: { type: "mobile", value: "0801234567" }, + amount, + }), + ).toThrow(); + }, + ); test("rejects invalid merchant category codes", () => { expect(() => From 6d1aa4a29d65a4245fa854144bed05f8d74a8644 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Fri, 4 Sep 2026 06:44:52 +0000 Subject: [PATCH 59/71] fix: make health dependant on redis --- app/api/health/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 41423c2..2983aaa 100755 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -40,7 +40,7 @@ export async function GET() { ); return Response.json( { red, ...res }, - { status: res.database && red ? 200 : 201 }, + { status: red ? (res.database ? 200 : 201) : 503 }, ); } From ac2b4e7f0cd707fecec47b26e81c01ba3ee64a82 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Fri, 4 Sep 2026 14:18:40 +0000 Subject: [PATCH 60/71] feat: redirect guide to guide.sudloh.com [no ci] --- app/(ui)/guide/{page.tsx => _page.tsx} | 0 app/(ui)/guide/route.ts | 3 +++ 2 files changed, 3 insertions(+) rename app/(ui)/guide/{page.tsx => _page.tsx} (100%) create mode 100644 app/(ui)/guide/route.ts diff --git a/app/(ui)/guide/page.tsx b/app/(ui)/guide/_page.tsx similarity index 100% rename from app/(ui)/guide/page.tsx rename to app/(ui)/guide/_page.tsx diff --git a/app/(ui)/guide/route.ts b/app/(ui)/guide/route.ts new file mode 100644 index 0000000..f6d35fd --- /dev/null +++ b/app/(ui)/guide/route.ts @@ -0,0 +1,3 @@ +export default async function () { + return Response.redirect("https://guide.sudloh.com/"); +} From c54da0ae704a0d0922c7f17190ff65b0bbdacce4 Mon Sep 17 00:00:00 2001 From: Dreamgineer Date: Sat, 5 Sep 2026 05:38:21 +0000 Subject: [PATCH 61/71] fixup: sleep deprivation got me --- app/(ui)/guide/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(ui)/guide/route.ts b/app/(ui)/guide/route.ts index f6d35fd..675d7a6 100644 --- a/app/(ui)/guide/route.ts +++ b/app/(ui)/guide/route.ts @@ -1,3 +1,3 @@ -export default async function () { +export async function GET() { return Response.redirect("https://guide.sudloh.com/"); } From d8e7c29c51d8cf5a4e7f75cbfa986b503055a9f3 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sat, 12 Sep 2026 00:00:33 +0700 Subject: [PATCH 62/71] feat: add message broadcaster --- app/(ui)/admin/settings/api.ts | 17 ++++++ app/(ui)/admin/settings/broadcaster.tsx | 75 +++++++++++++++++++++++++ app/(ui)/admin/settings/services.tsx | 6 +- app/client.tsx | 5 ++ app/globals.css | 9 +++ components/fake-toast.tsx | 57 +++++++++++++++++++ k8s/README.md | 2 +- k8s/deployment-app.yaml | 3 +- lib/db/sse-endpoints.ts | 5 ++ 9 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 app/(ui)/admin/settings/broadcaster.tsx create mode 100644 components/fake-toast.tsx diff --git a/app/(ui)/admin/settings/api.ts b/app/(ui)/admin/settings/api.ts index 65c1194..3f7f555 100755 --- a/app/(ui)/admin/settings/api.ts +++ b/app/(ui)/admin/settings/api.ts @@ -7,6 +7,7 @@ import { settings } from "@/lib/db/schema"; import { adminCheck } from "@/lib/auth"; import { sse } from "@/lib/db/sse-endpoints"; import { syncAmber as syncAmberRaw } from "@/util/sync"; +import type { ToastType } from "@/components/fake-toast"; export async function getSettongs() { if (!(await adminCheck())) throw "Unauthorized"; @@ -56,3 +57,19 @@ export async function toggleDonatePaymentMethod( await actionLog("Changed a settings", { [method]: state }); } + +export async function broadcastMessage( + prefix: string, + value: string, + type: ToastType, +) { + "use server"; + + sse.active.pub("announcement", { + severity: type, + message: value, + prefix, + }); + + await actionLog("Broadcasted message", { prefix, value, type }); +} diff --git a/app/(ui)/admin/settings/broadcaster.tsx b/app/(ui)/admin/settings/broadcaster.tsx new file mode 100644 index 0000000..7942f90 --- /dev/null +++ b/app/(ui)/admin/settings/broadcaster.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { Send } from "lucide-react"; +import { useState } from "react"; +import { FakeToast, type ToastType } from "@/components/fake-toast"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { broadcastMessage } from "./api"; +import { ActionButton } from "@/components/action-button"; + +const toastTypes: ToastType[] = ["warning", "error", "success", "info"]; + +export function Broadcaster() { + const [expanded, setExpanded] = useState(false); + const [prefix, setPrefix] = useState("/"); + const [prefixFocused, setPrefixFocused] = useState(false); + const [value, setValue] = useState(""); + const [type, setType] = useState("warning"); + + function cycleType() { + setType((currentType) => { + const currentIndex = toastTypes.indexOf(currentType); + return toastTypes[(currentIndex + 1) % toastTypes.length]; + }); + } + + return ( +
+ + setExpanded(true)} + onBlur={(ev) => setValue(ev.target.textContent)} + contentEditable + data-placeholder="Broadcast message..." + className="w-full flex-1" + > + +
+ setPrefix(event.target.value)} + onFocus={() => setPrefixFocused(true)} + onBlur={() => setPrefixFocused(false)} + className="backdrop-blur-xs transition-[width] focus:w-48" + style={{ + width: prefixFocused + ? undefined + : `calc(24px + ${Math.max(prefix.length, 1)}ch)`, + }} + /> + { + await broadcastMessage(prefix, value, type); + setExpanded(false); + }} + > + + +
+
+ ); +} diff --git a/app/(ui)/admin/settings/services.tsx b/app/(ui)/admin/settings/services.tsx index 42f94f4..02bd51a 100755 --- a/app/(ui)/admin/settings/services.tsx +++ b/app/(ui)/admin/settings/services.tsx @@ -2,7 +2,9 @@ import { Bomb, FolderSync } from "lucide-react"; import { useState } from "react"; +import { ActionButton } from "@/components/action-button"; import { CdnChooser } from "@/components/chooser"; +import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { shared } from "@/lib/comms"; import { @@ -11,9 +13,8 @@ import { toggleDonatePaymentMethod, toggleEnka, } from "./api"; +import { Broadcaster } from "./broadcaster"; import { Section } from "./page"; -import { ActionButton } from "@/components/action-button"; -import { Input } from "@/components/ui/input"; export function SettingsServicesSection({ enka: enkaInitial, @@ -108,6 +109,7 @@ export function SettingsServicesSection({ {syncResult && (
{syncResult}
)} +
); diff --git a/app/client.tsx b/app/client.tsx index a422ff9..a8e656d 100755 --- a/app/client.tsx +++ b/app/client.tsx @@ -79,6 +79,11 @@ export function VersionCheck({ headless = false }: { headless?: boolean }) { window.location.hash = "#update"; queueMicrotask(() => window.location.reload()); }, + announcement({ message, prefix, severity }) { + if (headless) return; + if (prefix && !window.location.pathname.startsWith(prefix)) return; + return toast[severity](message, { duration: Infinity }); + }, }, { endpoint: "/api/active", diff --git a/app/globals.css b/app/globals.css index 1777b7b..fcd2ac8 100755 --- a/app/globals.css +++ b/app/globals.css @@ -236,3 +236,12 @@ img { opacity: 1; } } + +[contenteditable="true"]:empty::before { + content: attr(data-placeholder); + color: #a0a0a0; + pointer-events: none; +} +[contenteditable="true"]:focus { + outline: none; +} diff --git a/components/fake-toast.tsx b/components/fake-toast.tsx new file mode 100644 index 0000000..652f218 --- /dev/null +++ b/components/fake-toast.tsx @@ -0,0 +1,57 @@ +import { Check, CircleAlert, CircleX, Info } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type ToastType = "success" | "info" | "warning" | "error"; + +const toastStyles: Record = { + success: "border-emerald-950 bg-emerald-950/70 text-emerald-300", + info: "border-blue-950 bg-blue-950/70 text-blue-300", + warning: "border-yellow-950 bg-yellow-950/70 text-yellow-200", + error: "border-red-950 bg-red-950/70 text-red-200", +}; + +const toastIcons: Record = { + success: Check, + info: Info, + warning: CircleAlert, + error: CircleX, +}; + +export function FakeToast({ + type = "success", + children, + onIconClick, +}: { + type?: ToastType; + children: React.ReactNode; + onIconClick?: () => void; +}) { + const Icon = toastIcons[type]; + + return ( +
+ {onIconClick ? ( + + ) : ( +
+ ); +} diff --git a/k8s/README.md b/k8s/README.md index 685c5de..8895361 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -69,7 +69,7 @@ ### Resource Limits -- **app**: 2 replicas, limits 1 CPU / 1 Gi memory, requests 500m / 512 Mi. Liveness probe on `/api/health` (60s interval). Readiness probe same. +- **app**: 2 replicas, limits 1 CPU / 1 Gi memory, requests 500m / 512 Mi. Liveness probe on `/api/health` (60s interval, 30s timeout). Readiness probe uses a 15s timeout. - **backend**: 1 replica, limits 1 CPU / 512 Mi memory, requests 250m / 256 Mi. No probes (no HTTP endpoint). - **db-migrate Job**: limits 1 CPU / 512 Mi, requests 250m / 256 Mi. diff --git a/k8s/deployment-app.yaml b/k8s/deployment-app.yaml index 7a0ecfb..9f07699 100644 --- a/k8s/deployment-app.yaml +++ b/k8s/deployment-app.yaml @@ -56,7 +56,7 @@ spec: port: 3000 initialDelaySeconds: 10 periodSeconds: 60 - timeoutSeconds: 5 + timeoutSeconds: 30 failureThreshold: 3 readinessProbe: httpGet: @@ -64,3 +64,4 @@ spec: port: 3000 initialDelaySeconds: 5 periodSeconds: 10 + timeoutSeconds: 15 diff --git a/lib/db/sse-endpoints.ts b/lib/db/sse-endpoints.ts index 1823696..069fc65 100644 --- a/lib/db/sse-endpoints.ts +++ b/lib/db/sse-endpoints.ts @@ -49,6 +49,11 @@ export const sse = sseEndpointMap({ version: z.string(), live: z.custom(), refresh: z.string().nullable(), + announcement: z.object({ + severity: z.enum(["info", "success", "warning", "error"]), + message: z.string(), + prefix: z.string().default("/"), + }), }, // Admin Live Log log: { From 4c6106b99ec3cb6edefed3f7718dab3285355735 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 13 Sep 2026 18:05:36 +0700 Subject: [PATCH 63/71] feat(art): switch submit button to ActionButton --- app/(ui)/artifact/form.tsx | 29 ++++++++++++++++++--------- components/action-button.tsx | 10 ++++++--- components/action-submit-provider.tsx | 13 ++++++++++++ 3 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 components/action-submit-provider.tsx diff --git a/app/(ui)/artifact/form.tsx b/app/(ui)/artifact/form.tsx index e99aae9..50501e0 100755 --- a/app/(ui)/artifact/form.tsx +++ b/app/(ui)/artifact/form.tsx @@ -2,10 +2,12 @@ import { useRouter } from "next/navigation"; import posthog from "posthog-js"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { toast } from "sonner"; import { checkEnkaStatus, submitArtifact } from "@/lib/api"; import { shared } from "@/lib/comms"; +import type { ActionSubmitContextData } from "@/components/action-submit-provider"; +import { ActionSubmitContext } from "@/components/action-submit-provider"; export function ArtifactFormWrapper({ edit, @@ -20,6 +22,12 @@ export function ArtifactFormWrapper({ const [, setWarningUid] = shared.state("warning.uid"); const [warningSrc, setWarningSrc] = shared.state("warning.src"); const [dataHold, setDataHold] = useState(); + const callback = useRef<() => void>(() => {}); + const context: ActionSubmitContextData = { + listenForComplete(complete) { + callback.current = complete; + }, + }; // Disable warning for 60 seconds shared.signal( @@ -53,15 +61,18 @@ export function ArtifactFormWrapper({ } catch {} router.refresh(); }) - .catch((e) => toast.error(`${e.message || e}`)); + .catch((e) => toast.error(`${e.message || e}`)) + .finally(callback.current); } return ( -
{ - e.preventDefault(); - submit(new FormData(e.target as HTMLFormElement)); - }} - /> + + { + e.preventDefault(); + submit(new FormData(e.target as HTMLFormElement)); + }} + /> + ); } diff --git a/components/action-button.tsx b/components/action-button.tsx index f054f96..817bdcf 100644 --- a/components/action-button.tsx +++ b/components/action-button.tsx @@ -3,10 +3,11 @@ import type { MaybePromise } from "bun"; import { Check } from "lucide-react"; import type { ComponentProps, ReactNode } from "react"; -import { useEffect, useState } from "react"; +import { use, useEffect, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; +import { ActionSubmitContext } from "./action-submit-provider"; export function ActionButton({ action, @@ -15,10 +16,11 @@ export function ActionButton({ text, ...props }: ComponentProps & { - action: () => MaybePromise; + action: (() => MaybePromise) | "provider"; text?: string; }) { const [state, setState] = useState<0 | 1 | 2>(0); + const ctx = use(ActionSubmitContext); useEffect(() => { if (state !== 2) return; @@ -33,7 +35,9 @@ export function ActionButton({ onClick?.(ev); setState(1); try { - await action(); + if (action === "provider") + await new Promise(ctx.listenForComplete); + else await action(); setState(2); } catch (e) { toast.error("An error occured!"); diff --git a/components/action-submit-provider.tsx b/components/action-submit-provider.tsx new file mode 100644 index 0000000..d19dcbe --- /dev/null +++ b/components/action-submit-provider.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { createContext } from "react"; + +export type ActionSubmitContextData = { + listenForComplete: (complete: () => void) => void; +}; + +export const ActionSubmitContext = createContext({ + listenForComplete: (r) => { + r(); + }, +}); From 7a5f6a951e349219f991b6b6379d3f8a17042fcd Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 13 Sep 2026 18:07:28 +0700 Subject: [PATCH 64/71] fixup: always schedule callback first --- app/(ui)/artifact/form.tsx | 7 ++++--- app/(ui)/artifact/page.tsx | 6 ++++-- components/action-button.tsx | 8 ++++---- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/(ui)/artifact/form.tsx b/app/(ui)/artifact/form.tsx index 50501e0..71fe9b0 100755 --- a/app/(ui)/artifact/form.tsx +++ b/app/(ui)/artifact/form.tsx @@ -61,8 +61,7 @@ export function ArtifactFormWrapper({ } catch {} router.refresh(); }) - .catch((e) => toast.error(`${e.message || e}`)) - .finally(callback.current); + .catch((e) => toast.error(`${e.message || e}`)); } return ( @@ -70,7 +69,9 @@ export function ArtifactFormWrapper({ {...props} onSubmit={(e) => { e.preventDefault(); - submit(new FormData(e.target as HTMLFormElement)); + submit(new FormData(e.target as HTMLFormElement)).finally( + callback.current, + ); }} /> diff --git a/app/(ui)/artifact/page.tsx b/app/(ui)/artifact/page.tsx index 0d58b87..644c65d 100755 --- a/app/(ui)/artifact/page.tsx +++ b/app/(ui)/artifact/page.tsx @@ -40,6 +40,7 @@ import { import { ArtifactFormWrapper } from "./form"; import { LiveButton } from "./live"; import { RulesDialog } from "./rules"; +import { ActionButton } from "@/components/action-button"; export const metadata: Metadata = { title: "เสือกไอดีชาวบ้าน", @@ -257,9 +258,10 @@ export default async function ArtifactFormPage({ {count} / {config.limit < 0 ? "∞" : config.limit} คิว - +
diff --git a/components/action-button.tsx b/components/action-button.tsx index 817bdcf..d90377b 100644 --- a/components/action-button.tsx +++ b/components/action-button.tsx @@ -32,12 +32,12 @@ export function ActionButton({ disabled={state > 0} {...props} onClick={async (ev) => { - onClick?.(ev); setState(1); + if (action === "provider") + await new Promise(ctx.listenForComplete); + onClick?.(ev); try { - if (action === "provider") - await new Promise(ctx.listenForComplete); - else await action(); + if (action !== "provider") await action(); setState(2); } catch (e) { toast.error("An error occured!"); From 99a3e483708f6b5745582d5dbefc8566e77e2daa Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 13 Sep 2026 18:16:28 +0700 Subject: [PATCH 65/71] fix(art): action button is outside context --- app/(ui)/artifact/page.tsx | 115 +++++++++++++------------- components/action-submit-provider.tsx | 1 + 2 files changed, 59 insertions(+), 57 deletions(-) diff --git a/app/(ui)/artifact/page.tsx b/app/(ui)/artifact/page.tsx index 644c65d..d3870bd 100755 --- a/app/(ui)/artifact/page.tsx +++ b/app/(ui)/artifact/page.tsx @@ -153,12 +153,13 @@ export default async function ArtifactFormPage({
- - + +
@@ -222,58 +223,58 @@ export default async function ArtifactFormPage({ />
-
-
- -
- - - - - - - - - - - - อ่านกฏการลงคิว - - {editing ? ( - - ) : ( - - - - )} -
-
- - {count} / {config.limit < 0 ? "∞" : config.limit} คิว - - - = 0 && count >= config.limit)) && - !editing - } - > - {editing ? : } - - -
-
+ + + + + + + + + อ่านกฏการลงคิว + + {editing ? ( + + ) : ( + + + + )} + +
+ + {count} / {config.limit < 0 ? "∞" : config.limit} คิว + + + = 0 && count >= config.limit)) && + !editing + } + > + {editing ? : } + + +
+ + {sid && !q && } diff --git a/components/action-submit-provider.tsx b/components/action-submit-provider.tsx index d19dcbe..2d2e3a7 100644 --- a/components/action-submit-provider.tsx +++ b/components/action-submit-provider.tsx @@ -8,6 +8,7 @@ export type ActionSubmitContextData = { export const ActionSubmitContext = createContext({ listenForComplete: (r) => { + console.warn("submit context not caught"); r(); }, }); From f932afe00c2eda273c0a6f4dfe34d3512ca3bf76 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 13 Sep 2026 18:21:43 +0700 Subject: [PATCH 66/71] fixup: add intentional race condition delay --- app/(ui)/artifact/form.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/(ui)/artifact/form.tsx b/app/(ui)/artifact/form.tsx index 71fe9b0..94da125 100755 --- a/app/(ui)/artifact/form.tsx +++ b/app/(ui)/artifact/form.tsx @@ -42,7 +42,7 @@ export function ArtifactFormWrapper({ (data.get("character") ?? "").toString(), ); console.log(data.get("uid"), data.get("character"), check); - setWarningUid(data.get("character")?.toString()); + setWarningUid(data.get("uid")?.toString()); setWarningSrc("submit"); setDataHold(data); if (check) return setDialog(check); @@ -69,8 +69,8 @@ export function ArtifactFormWrapper({ {...props} onSubmit={(e) => { e.preventDefault(); - submit(new FormData(e.target as HTMLFormElement)).finally( - callback.current, + submit(new FormData(e.target as HTMLFormElement)).finally(() => + setTimeout(callback.current, 1000), ); }} /> From 621205435a0d28b3f7f76e76dd01cd1870773994 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 13 Sep 2026 20:48:09 +0700 Subject: [PATCH 67/71] feat(don): add synced checkbox to donate admin --- app/(ui)/donate/admin/client.tsx | 29 +++++++++++++++++++++++++++-- app/(ui)/donate/admin/page.tsx | 20 ++++++++++++++++---- lib/api.ts | 2 ++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/app/(ui)/donate/admin/client.tsx b/app/(ui)/donate/admin/client.tsx index 7c847ce..ab3e975 100644 --- a/app/(ui)/donate/admin/client.tsx +++ b/app/(ui)/donate/admin/client.tsx @@ -35,6 +35,8 @@ import { lePalette } from "../../rubgram/admin/[id]/client"; import { th } from "date-fns/locale"; import TruemoneyIcon from "#/assets/tmn.webp"; import Image from "@/components/image"; +import { Checkbox } from "@/components/ui/checkbox"; +import { toggleCheck } from "@/lib/api"; // directly adapted for date instead of UUID function colorFor(date: Date) { @@ -46,7 +48,12 @@ function colorFor(date: Date) { return lePalette[seed % lePalette.length]; } -const columns: ColumnDef[] = [ +const columns: ColumnDef< + typeof donations.$inferSelect & { + checked: boolean | null; + artifactSubmissionId: string | null; + } +>[] = [ { accessorKey: "created", header: "", @@ -63,6 +70,21 @@ const columns: ColumnDef[] = [ meta: { className: "w-1 p-0 relative" }, }, { accessorKey: "name", header: "ชื่อ", meta: { className: "w-50 truncate" } }, + { + accessorKey: "art_check", + header: "", + meta: { className: "w-6 truncate" }, + cell(row) { + const { artifactSubmissionId, checked } = row.row.original; + if (checked === null || !artifactSubmissionId) return; + return ( + toggleCheck(artifactSubmissionId)} + /> + ); + }, + }, { accessorFn: (row) => `${row.amount}฿`, header: "จำนวน", @@ -274,7 +296,10 @@ export function DonateAdminPage({ data, stats, }: { - data: (typeof donations.$inferSelect)[]; + data: (typeof donations.$inferSelect & { + checked: boolean | null; + artifactSubmissionId: string | null; + })[]; stats: { total: number; today: number; diff --git a/app/(ui)/donate/admin/page.tsx b/app/(ui)/donate/admin/page.tsx index 245eb65..9288f2e 100644 --- a/app/(ui)/donate/admin/page.tsx +++ b/app/(ui)/donate/admin/page.tsx @@ -1,5 +1,6 @@ import { endOfMonth, startOfMonth } from "date-fns"; import { between, desc, getTableColumns, sql, sum } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { adminCheck } from "@/lib/auth"; @@ -12,15 +13,26 @@ export default async function () { redirect(`/login?next=${encodeURIComponent("/donate/admin")}`); const now = new Date(); + const donation = alias(donations, "donation"); const [data, [stats]] = await Promise.all([ db .select({ - ...getTableColumns(donations), - image: sql`${donations.image} IS NOT NULL`, + ...getTableColumns(donation), + image: sql`${donation.image} IS NOT NULL`, + checked: sql` + (SELECT "artifact"."submissions"."checked" + FROM "artifact"."submissions" + WHERE "artifact"."submissions"."uid" = "donation"."uid") + `, + artifactSubmissionId: sql` + (SELECT "artifact"."submissions"."id" + FROM "artifact"."submissions" + WHERE "artifact"."submissions"."uid" = "donation"."uid") + `, }) - .from(donations) + .from(donation) .limit(100) - .orderBy(desc(donations.id)), + .orderBy(desc(donation.id)), db .select({ total: sum(donations.amount).mapWith(Number), diff --git a/lib/api.ts b/lib/api.ts index fe4530b..d4701c1 100755 --- a/lib/api.ts +++ b/lib/api.ts @@ -180,8 +180,10 @@ export async function toggleCheck(submissionId: string) { .where(eq(submissions.id, submissionId)); revalidatePath("/artifact/admin"); revalidatePath("/artifact"); + revalidatePath("/donate/admin"); sse.artifact.pub("update", { type: "toggleCheck" }); + sse.donate.pub("update", null); await actionLog(`Toggled an artifact submission check mark`); } From 0c63e7daef2e83549dc387a3fe2841dc9daeb1e9 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 13 Sep 2026 20:53:46 +0700 Subject: [PATCH 68/71] feat(don): optimistic update checkbox --- app/(ui)/artifact/admin/client.tsx | 4 ++-- app/(ui)/donate/admin/client.tsx | 28 ++++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/app/(ui)/artifact/admin/client.tsx b/app/(ui)/artifact/admin/client.tsx index 3a345ef..ccbb919 100755 --- a/app/(ui)/artifact/admin/client.tsx +++ b/app/(ui)/artifact/admin/client.tsx @@ -126,8 +126,8 @@ export function SidebarLink({ { - setChecked((x) => !x); + onCheckedChange={async (checked) => { + setChecked(checked === true); await toggleCheck(submission.id); stop(); }} diff --git a/app/(ui)/donate/admin/client.tsx b/app/(ui)/donate/admin/client.tsx index ab3e975..0b96fb3 100644 --- a/app/(ui)/donate/admin/client.tsx +++ b/app/(ui)/donate/admin/client.tsx @@ -48,6 +48,30 @@ function colorFor(date: Date) { return lePalette[seed % lePalette.length]; } +function ArtifactCheckbox({ + checked, + submissionId, +}: { + checked: boolean; + submissionId: string; +}) { + const [value, setValue] = useState(checked); + + useEffect(() => { + setValue(checked); + }, [checked]); + + return ( + { + setValue(checked === true); + await toggleCheck(submissionId); + }} + /> + ); +} + const columns: ColumnDef< typeof donations.$inferSelect & { checked: boolean | null; @@ -78,9 +102,9 @@ const columns: ColumnDef< const { artifactSubmissionId, checked } = row.row.original; if (checked === null || !artifactSubmissionId) return; return ( - toggleCheck(artifactSubmissionId)} + submissionId={artifactSubmissionId} /> ); }, From 94ed955e4141fe35c0ed830f1ada151cdb377bf0 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 13 Sep 2026 21:42:48 +0700 Subject: [PATCH 69/71] feat(don): automatically match and inject artifact uid --- app/(ui)/donate/page.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/(ui)/donate/page.tsx b/app/(ui)/donate/page.tsx index d6560b0..4f65805 100644 --- a/app/(ui)/donate/page.tsx +++ b/app/(ui)/donate/page.tsx @@ -117,6 +117,14 @@ export default async function () { const ph = getPostHogClient(); const distinctId = crypto.randomUUID(); + if ($.message && $.artifact === "false") { + // @ts-expect-error + $.artifact = "true"; + const match = $.message.match(/\b(?:[0-35-9]|18)\d{8}\b/); + // @ts-expect-error + if (match) $.uid = match[0]; + } + return await db.transaction(async (tx): Promise => { if ($.type === "pp") { const arrayBuffer = await $.slip.arrayBuffer(); From 2e0d7042d70849f84f01becedccfff2758b7bd1a Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 13 Sep 2026 21:46:33 +0700 Subject: [PATCH 70/71] feat(don): right click show hover card instead --- app/(ui)/donate/admin/client.tsx | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/app/(ui)/donate/admin/client.tsx b/app/(ui)/donate/admin/client.tsx index 0b96fb3..f2fd458 100644 --- a/app/(ui)/donate/admin/client.tsx +++ b/app/(ui)/donate/admin/client.tsx @@ -17,7 +17,6 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import posthog from "posthog-js"; import { type ComponentProps, useEffect, useRef, useState } from "react"; -import { useEllipsisVisible } from "react-hook-text-overflow"; import { DataTable } from "@/components/tantable"; import { SimpleTooltip } from "@/components/tooltip"; import { Button } from "@/components/ui/button"; @@ -117,11 +116,22 @@ const columns: ColumnDef< { accessorKey: "message", cell(props) { - const [overflow, ref] = useEllipsisVisible(); - return overflow ? ( - + const [open, setOpen] = useState(false); + return ( + o || setOpen(false)} + > -
+
{ + ev.preventDefault(); + setOpen(true); + }} + > {props.row.original.message}
@@ -133,10 +143,6 @@ const columns: ColumnDef<
- ) : ( -
- {props.row.original.message} -
); }, header: "ข้อความ", From 1f6a482a580592b7cc4580e7f0d031029d7d380e Mon Sep 17 00:00:00 2001 From: dmgnr Date: Wed, 16 Sep 2026 07:59:56 +0000 Subject: [PATCH 71/71] fix: add NEXT_SERVER_ACTIONS_ENCRYPTION_KEY --- .env.example | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index e0ea53d..f12929b 100644 --- a/.env.example +++ b/.env.example @@ -38,4 +38,6 @@ SASTIFY_API_PRIVKEY=YOUR_SASTIFY_API_PRIVKEY DONATE_WIDGET_KEY=RANDOM_GENERATED # Environment -ENVIRONMENT=production \ No newline at end of file +ENVIRONMENT=production + +NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="openssl rand -base64 32" \ No newline at end of file