add supply

This commit is contained in:
2026-05-13 12:59:35 +05:30
parent fcf7654e78
commit 0bb486223e
6 changed files with 359 additions and 8 deletions
+160
View File
@@ -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));
}
+3
View File
@@ -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 ? (
<EditUserCcRcOverlay
+4
View File
@@ -215,6 +215,8 @@ type Props = {
matchmakerSource: MatchmakerLogSource;
matchmakerContent: string;
matchmakerError: string | null;
/** Ledger: add-system-supply action failed (URL `supplyErr=1`). */
supplyError: boolean;
};
function StatCard({
@@ -264,6 +266,7 @@ export function AdminDashboard({
matchmakerSource,
matchmakerContent,
matchmakerError,
supplyError,
}: Props) {
const [hideNoWinner, setHideNoWinner] = useState(true);
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
@@ -735,6 +738,7 @@ export function AdminDashboard({
participantRaw={participantRaw}
ledgerFrom={ledgerFrom}
ledgerTo={ledgerTo}
supplyError={supplyError}
/>
) : (
<section className="space-y-3">
+167 -8
View File
@@ -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<number, string | null>();
@@ -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(" ")}
>
<p className="text-xs font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-400">
Σ user nets vs net issued
</p>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
All transactions
</p>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-400">
Σ user nets vs net issued
</p>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
All transactions
</p>
</div>
<button
type="button"
onClick={() => setSupplyModalOpen(true)}
className="shrink-0 rounded-md border border-zinc-300 bg-white/90 px-2.5 py-1 text-xs font-medium text-zinc-800 shadow-sm hover:bg-white dark:border-zinc-600 dark:bg-zinc-900/90 dark:text-zinc-100 dark:hover:bg-zinc-900"
>
Add supply
</button>
</div>
<div className="mt-2 text-sm tabular-nums text-zinc-900 dark:text-zinc-100">
<p className="font-mono">
{formatBigAmount(integrityGlobal.sumUserNets)}
@@ -956,6 +999,122 @@ export function AdminLedger({
</div>
) : null}
{supplyModalOpen ? (
<div
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/40 p-4"
role="presentation"
onClick={() => setSupplyModalOpen(false)}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="supply-modal-title"
className="w-full max-w-md rounded-xl border border-zinc-200 bg-white p-6 shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
onClick={(e) => e.stopPropagation()}
>
<h2
id="supply-modal-title"
className="text-lg font-semibold text-zinc-900 dark:text-zinc-50"
>
Add system supply
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Credits user id{" "}
<span className="font-mono">1</span>
{systemAccount?.username ? (
<span>
{" "}
(<span className="font-mono">{systemAccount.username}</span>)
</span>
) : null}
: mint row (<span className="font-mono">to = 1</span>,{" "}
<span className="font-mono">remarks = supply</span>), RC increases
by the same coin amount.
</p>
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-500">
Current RC:{" "}
<span className="font-mono text-zinc-800 dark:text-zinc-200">
{systemAccount
? formatRcBalanceWithCoins(systemAccount.rc)
: "— (user 1 not in loaded set)"}
</span>
</p>
{supplyError ? (
<p
className="mt-3 text-sm text-red-600 dark:text-red-400"
role="alert"
>
Could not add supply. Use a positive whole number of coin units
(no decimals), and ensure the database and service key are
available.
</p>
) : null}
<form
action={addSystemSupply}
className="mt-5 space-y-4"
>
<input type="hidden" name="tab" value="ledger" />
<input
type="hidden"
name="highlightId"
value={highlightId ?? ""}
/>
<input
type="hidden"
name="participantRaw"
value={participantRaw ?? ""}
/>
<input type="hidden" name="ledgerFrom" value={ledgerFrom} />
<input type="hidden" name="ledgerTo" value={ledgerTo} />
<input
type="hidden"
name="ledgerPage"
value={String(ledgerPage)}
/>
<input
type="hidden"
name="ledgerPageSize"
value={String(ledgerPageSize)}
/>
<input type="hidden" name="ledgerSort" value={ledgerSort} />
<input type="hidden" name="ledgerOrder" value={ledgerOrder} />
<div>
<label
htmlFor="supply-amount-modal"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Coin amount
</label>
<input
id="supply-amount-modal"
name="supplyAmount"
type="text"
inputMode="numeric"
autoComplete="off"
placeholder="e.g. 4000"
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm tabular-nums text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setSupplyModalOpen(false)}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Cancel
</button>
<button
type="submit"
className="rounded-md border border-emerald-600 bg-emerald-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 dark:border-emerald-700 dark:bg-emerald-800 dark:hover:bg-emerald-700"
>
Submit
</button>
</div>
</form>
</div>
</div>
) : null}
</section>
);
}
+22
View File
@@ -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);
}
+3
View File
@@ -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}` : "/";
}