From 0bb486223e3a31ea39440d8619252861c2d7c4d8 Mon Sep 17 00:00:00 2001 From: Sewmina Date: Wed, 13 May 2026 12:59:35 +0530 Subject: [PATCH] add supply --- src/app/actions/add-system-supply.ts | 160 ++++++++++++++++++++++++ src/app/page.tsx | 3 + src/components/admin-dashboard.tsx | 4 + src/components/admin-ledger.tsx | 175 +++++++++++++++++++++++++-- src/lib/coins-rc.ts | 22 ++++ src/lib/dashboard-search-url.ts | 3 + 6 files changed, 359 insertions(+), 8 deletions(-) create mode 100644 src/app/actions/add-system-supply.ts diff --git a/src/app/actions/add-system-supply.ts b/src/app/actions/add-system-supply.ts new file mode 100644 index 0000000..bface9c --- /dev/null +++ b/src/app/actions/add-system-supply.ts @@ -0,0 +1,160 @@ +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session"; +import { applyCoinsDeltaToRcBalance } from "@/lib/coins-rc"; +import { + buildDashboardHref, + type AdminDashboardTab, +} from "@/lib/dashboard-search-url"; +import { createAdminSupabase } from "@/lib/supabase/admin"; +import { normalizeLedgerPageSize } from "@/lib/ledger-table-view"; + +const SYSTEM_ACCOUNT_ID = 1; + +function parsePositiveCoins(raw: FormDataEntryValue | null): bigint | null { + if (raw == null) return null; + const s = String(raw).trim(); + if (!/^[1-9]\d*$/.test(s)) return null; + try { + return BigInt(s); + } catch { + return null; + } +} + +export async function addSystemSupply(formData: FormData) { + const cookieStore = await cookies(); + if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") { + redirect("/login"); + } + + const tabRaw = String(formData.get("tab") ?? ""); + const tab: AdminDashboardTab = + tabRaw === "matches" + ? "matches" + : tabRaw === "players" + ? "players" + : tabRaw === "matchmaker" + ? "matchmaker" + : tabRaw === "ledger" + ? "ledger" + : "dashboard"; + + const highlightRaw = String(formData.get("highlightId") ?? "").trim(); + const participantRaw = String(formData.get("participantRaw") ?? "").trim(); + const highlightId = highlightRaw === "" ? null : highlightRaw; + const participant = + participantRaw === "" ? null : participantRaw; + + const ledgerFromRaw = String(formData.get("ledgerFrom") ?? "").trim(); + const ledgerToRaw = String(formData.get("ledgerTo") ?? "").trim(); + const ledgerPageRaw = String(formData.get("ledgerPage") ?? "").trim(); + const ledgerPageSizeRaw = String( + formData.get("ledgerPageSize") ?? "", + ).trim(); + const ledgerSortRaw = String(formData.get("ledgerSort") ?? "").trim(); + const ledgerOrderRaw = String(formData.get("ledgerOrder") ?? "").trim(); + const ledgerPageNum = + ledgerPageRaw === "" ? NaN : Number.parseInt(ledgerPageRaw, 10); + + const base = { + tab, + highlightId, + participantRaw: participant, + ...(tab === "ledger" + ? { + ledgerFrom: ledgerFromRaw || null, + ledgerTo: ledgerToRaw || null, + ledgerPage: + Number.isInteger(ledgerPageNum) && ledgerPageNum > 1 + ? ledgerPageNum + : null, + ledgerSort: ledgerSortRaw || null, + ledgerOrder: + ledgerOrderRaw === "desc" + ? ("desc" as const) + : ledgerOrderRaw === "asc" + ? ("asc" as const) + : null, + ledgerPageSize: normalizeLedgerPageSize( + ledgerPageSizeRaw === "" ? null : ledgerPageSizeRaw, + ), + } + : {}), + }; + + const parsedCoins = parsePositiveCoins(formData.get("supplyAmount")); + if (parsedCoins == null) { + redirect( + buildDashboardHref({ + ...base, + supplyError: true, + }), + ); + } + const coins = parsedCoins; + + const supabase = createAdminSupabase(); + if (!supabase) { + redirect( + buildDashboardHref({ + ...base, + supplyError: true, + }), + ); + } + + const { data: sysRow, error: fetchErr } = await supabase + .from("users") + .select("id, rc") + .eq("id", SYSTEM_ACCOUNT_ID) + .maybeSingle(); + + if (fetchErr || !sysRow) { + redirect( + buildDashboardHref({ + ...base, + supplyError: true, + }), + ); + } + + const currentRc = sysRow.rc as number | null; + + const { error: insertErr } = await supabase.from("transactions").insert({ + from: null, + to: SYSTEM_ACCOUNT_ID, + amount: coins.toString(), + remarks: "supply", + match_id: null, + }); + + if (insertErr) { + redirect( + buildDashboardHref({ + ...base, + supplyError: true, + }), + ); + } + + const newRc = applyCoinsDeltaToRcBalance(currentRc, coins); + + const { error: updateErr } = await supabase + .from("users") + .update({ rc: newRc }) + .eq("id", SYSTEM_ACCOUNT_ID); + + if (updateErr) { + redirect( + buildDashboardHref({ + ...base, + supplyError: true, + }), + ); + } + + redirect(buildDashboardHref(base)); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index b21f979..b496c41 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -55,6 +55,7 @@ export default async function Home({ participant?: string | string[]; edit?: string | string[]; saveError?: string | string[]; + supplyErr?: string | string[]; mklog?: string | string[]; lfrom?: string | string[]; lto?: string | string[]; @@ -80,6 +81,7 @@ export default async function Home({ const participantRaw = firstSearchParam(sp.participant); const editRaw = firstSearchParam(sp.edit); const saveError = firstSearchParam(sp.saveError) === "1"; + const supplyError = firstSearchParam(sp.supplyErr) === "1"; const editIdNum = editRaw != null && editRaw !== "" ? Number(editRaw) : NaN; @@ -272,6 +274,7 @@ export default async function Home({ ledgerOrder={ledgerSortOrderView} ledgerFrom={ledgerFrom} ledgerTo={ledgerTo} + supplyError={supplyError} /> {editUser ? ( ("wins"); @@ -735,6 +738,7 @@ export function AdminDashboard({ participantRaw={participantRaw} ledgerFrom={ledgerFrom} ledgerTo={ledgerTo} + supplyError={supplyError} /> ) : (
diff --git a/src/components/admin-ledger.tsx b/src/components/admin-ledger.tsx index b974044..a59ab27 100644 --- a/src/components/admin-ledger.tsx +++ b/src/components/admin-ledger.tsx @@ -1,10 +1,13 @@ "use client"; import Link from "next/link"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ClickableUserId } from "@/components/clickable-user-id"; import { buildDashboardHref } from "@/lib/dashboard-search-url"; -import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc"; +import { + formatRcBalanceWithCoins, + formatRcLabelFromCoinsBigInt, +} from "@/lib/coins-rc"; import { auditMatchEconomics } from "@/lib/ledger-match-audit"; import { computeLedgerIntegrity, @@ -21,6 +24,7 @@ import { type LedgerSortOrder, } from "@/lib/ledger-table-view"; import { buildLedgerScopedDailyCumulativeSeries } from "@/lib/ledger-scoped-daily-series"; +import { addSystemSupply } from "@/app/actions/add-system-supply"; import { LedgerRangeCumulativeChart } from "@/components/ledger-range-cumulative-chart"; import type { DbTransaction, DbUser } from "@/types/database"; @@ -86,6 +90,7 @@ type Props = { participantRaw: string | null; ledgerFrom: string; ledgerTo: string; + supplyError: boolean; }; export function AdminLedger({ @@ -104,6 +109,7 @@ export function AdminLedger({ participantRaw, ledgerFrom, ledgerTo, + supplyError, }: Props) { const usernameById = useMemo(() => { const m = new Map(); @@ -113,6 +119,13 @@ export function AdminLedger({ return m; }, [users]); + const systemAccount = useMemo(() => { + for (const u of users) { + if (Number(u.id) === 1) return u; + } + return null; + }, [users]); + const { integrityGlobal, userNets } = useMemo(() => { if (ledgerGlobalSummary == null) { return { @@ -240,6 +253,25 @@ export function AdminLedger({ ); const pageNavItems = ledgerPaginationItems(ledgerPage, ledgerTotalPages); + const [supplyModalOpen, setSupplyModalOpen] = useState(false); + + useEffect(() => { + if (supplyError) { + setSupplyModalOpen(true); + } + }, [supplyError]); + + useEffect(() => { + if (!supplyModalOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setSupplyModalOpen(false); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [supplyModalOpen]); + const navLinkClass = "rounded px-2 py-1 font-medium text-sky-700 underline decoration-sky-400/50 underline-offset-2 hover:bg-sky-50 dark:text-sky-300 dark:hover:bg-sky-950/40"; const navMutedClass = "rounded px-2 py-1 text-zinc-400 dark:text-zinc-500"; @@ -275,12 +307,23 @@ export function AdminLedger({ : "border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/40", ].join(" ")} > -

- Σ user nets vs net issued -

-

- All transactions -

+
+
+

+ Σ user nets vs net issued +

+

+ All transactions +

+
+ +

{formatBigAmount(integrityGlobal.sumUserNets)} @@ -956,6 +999,122 @@ export function AdminLedger({

) : null} + {supplyModalOpen ? ( +
setSupplyModalOpen(false)} + > +
e.stopPropagation()} + > +

+ Add system supply +

+

+ Credits user id{" "} + 1 + {systemAccount?.username ? ( + + {" "} + ({systemAccount.username}) + + ) : null} + : mint row (to = 1,{" "} + remarks = supply), RC increases + by the same coin amount. +

+

+ Current RC:{" "} + + {systemAccount + ? formatRcBalanceWithCoins(systemAccount.rc) + : "— (user 1 not in loaded set)"} + +

+ {supplyError ? ( +

+ Could not add supply. Use a positive whole number of coin units + (no decimals), and ensure the database and service key are + available. +

+ ) : null} +
+ + + + + + + + + +
+ + +
+
+ + +
+
+
+
+ ) : null} +
); } diff --git a/src/lib/coins-rc.ts b/src/lib/coins-rc.ts index f91bb58..ddaf727 100644 --- a/src/lib/coins-rc.ts +++ b/src/lib/coins-rc.ts @@ -70,3 +70,25 @@ export function formatRcBalanceWithCoins(rc: number | null): string { } return `${rc.toFixed(1)} RC`; } + +/** Maps total coin units to `users.rc` display (whole + tenths/10). */ +export function coinsBigIntToRcNumber(coins: bigint): number { + let c = coins; + if (c < BigInt(0)) c = BigInt(0); + const whole = c / BigInt(4); + const tenths = c % BigInt(4); + return Number(whole) + Number(tenths) / 10; +} + +/** Adds coin units to an existing RC balance (same encoding as `users.rc`). */ +export function applyCoinsDeltaToRcBalance( + currentRc: number | null, + deltaCoins: bigint, +): number { + const existingCoins = + currentRc != null && Number.isFinite(currentRc) + ? rcToCoins(currentRc) ?? 0 + : 0; + const total = BigInt(existingCoins) + deltaCoins; + return coinsBigIntToRcNumber(total); +} diff --git a/src/lib/dashboard-search-url.ts b/src/lib/dashboard-search-url.ts index ba86019..5e92ce8 100644 --- a/src/lib/dashboard-search-url.ts +++ b/src/lib/dashboard-search-url.ts @@ -14,6 +14,8 @@ export type DashboardUrlQuery = { participantRaw: string | null; editId?: number | null; saveError?: boolean; + /** Ledger: failed system supply action */ + supplyError?: boolean; /** When tab is matchmaker: `raw` selects matchmaker.log; omit or processed → history.log */ matchmakerSource?: MatchmakerLogSource | null; /** Ledger tab: UTC date-only bounds (`lfrom` / `lto` query params). */ @@ -62,6 +64,7 @@ export function buildDashboardHref(q: DashboardUrlQuery): string { p.set("edit", String(q.editId)); } if (q.saveError) p.set("saveError", "1"); + if (q.supplyError) p.set("supplyErr", "1"); const s = p.toString(); return s ? `/?${s}` : "/"; }