Files
kickkingsadmin/src/lib/player-card-server.ts
T
2026-06-02 10:24:58 +00:00

297 lines
8.8 KiB
TypeScript

import type { SupabaseClient } from "@supabase/supabase-js";
import type { DbMatch, DbTransaction } from "@/types/database";
import { txAmountToBigInt } from "@/lib/ledger-integrity";
import { countryCodeFromIp } from "@/lib/ip-geolocation";
const TX_PAGE = 5000;
/** Recent matches listed on the player card (newest first). */
const MATCH_HISTORY_LIMIT = 200;
export type PlayerCardMatchHistoryRow = {
matchId: number;
createdAt: string;
sideLabel: "Red" | "Blue";
opponentId: number | null;
opponentUsername: string | null;
outcomeLabel: "Win" | "Loss" | "Pending";
/** Coins debited: entry_hold + entry_fee for this user on this match. */
entryTotalCoins: string;
/** Coins debited: entry_fee only for this user on this match. */
entryFeeCoins: string;
};
export type PlayerCardData = {
id: number;
username: string | null;
email: string | null;
createdAt: string | null;
lastSeen: string | null;
lastLoggedInIp: string | null;
lastLoggedInCountryCode: string | null;
rcBalance: number | null;
ccBalance: number | null;
matchesPlayed: number;
matchesWon: number;
winRatePercent: number | null;
rcPurchasedCoins: string;
rcRewardCoins: string;
rcSpentHoldCoins: string;
rcSpentFeeCoins: string;
matchHistory: PlayerCardMatchHistoryRow[];
};
function cellUserId(v: number | string | null | undefined): number | null {
if (v == null) return null;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? Math.trunc(n) : null;
}
function sameUser(
cell: number | string | null | undefined,
userId: number,
): boolean {
return cellUserId(cell) === userId;
}
function normRemark(r: string | null | undefined): string {
return (r ?? "").toLowerCase().trim();
}
function winnerRecorded(winnerId: DbMatch["winner_id"]): boolean {
const w = cellUserId(winnerId);
return w != null && w > 0;
}
function outcomeForParticipant(
userId: number,
winnerId: DbMatch["winner_id"],
opponentId: number | null,
): "Win" | "Loss" | "Pending" {
if (!winnerRecorded(winnerId)) return "Pending";
const wid = cellUserId(winnerId);
if (wid === userId) return "Win";
if (opponentId != null && wid === opponentId) return "Loss";
return "Pending";
}
export async function fetchPlayerCardData(
supabase: SupabaseClient,
userId: number,
): Promise<
| { ok: true; data: PlayerCardData }
| { ok: false; error: string }
> {
if (!Number.isInteger(userId) || userId < 1) {
return { ok: false, error: "Invalid player id." };
}
const { data: userRow, error: userErr } = await supabase
.from("users")
.select("id, username, email, created_at, last_logged_at, ip_address, rc, cc")
.eq("id", userId)
.maybeSingle();
if (userErr) {
return { ok: false, error: userErr.message };
}
if (!userRow) {
return { ok: false, error: "Player not found." };
}
const [
{ count: playedCount, error: playedErr },
{ count: wonCount, error: wonErr },
matchesListRes,
] = await Promise.all([
supabase
.from("matches")
.select("id", { count: "exact", head: true })
.or(`user_red.eq.${userId},user_blue.eq.${userId}`),
supabase
.from("matches")
.select("id", { count: "exact", head: true })
.eq("winner_id", userId),
supabase
.from("matches")
.select("id, created_at, user_red, user_blue, winner_id")
.or(`user_red.eq.${userId},user_blue.eq.${userId}`)
.order("id", { ascending: false })
.limit(MATCH_HISTORY_LIMIT),
]);
if (playedErr) {
return { ok: false, error: playedErr.message };
}
if (wonErr) {
return { ok: false, error: wonErr.message };
}
if (matchesListRes.error) {
return { ok: false, error: matchesListRes.error.message };
}
const rawMatches = (matchesListRes.data ?? []) as Pick<
DbMatch,
"id" | "created_at" | "user_red" | "user_blue" | "winner_id"
>[];
const matchIds = rawMatches.map((m) => m.id as number);
const entryByMatch = new Map<number, { hold: bigint; fee: bigint }>();
for (const mid of matchIds) {
entryByMatch.set(mid, { hold: BigInt(0), fee: BigInt(0) });
}
if (matchIds.length > 0) {
const { data: entryTxRows, error: entryTxErr } = await supabase
.from("transactions")
.select("match_id, amount, remarks")
.eq("from", userId)
.in("match_id", matchIds);
if (entryTxErr) {
return { ok: false, error: entryTxErr.message };
}
for (const row of entryTxRows ?? []) {
const midRaw = row.match_id;
if (midRaw == null) continue;
const midNum =
typeof midRaw === "number" ? midRaw : Number(midRaw);
if (!Number.isFinite(midNum)) continue;
const mid = Math.trunc(midNum);
const a = txAmountToBigInt(row.amount);
if (a === BigInt(0)) continue;
const rmk = normRemark(row.remarks);
const cur = entryByMatch.get(mid) ?? {
hold: BigInt(0),
fee: BigInt(0),
};
if (rmk === "entry_hold") {
cur.hold += a;
} else if (rmk === "entry_fee") {
cur.fee += a;
}
entryByMatch.set(mid, cur);
}
}
const opponentIds = new Set<number>();
for (const m of rawMatches) {
const red = cellUserId(m.user_red);
const blue = cellUserId(m.user_blue);
if (red === userId && blue != null) opponentIds.add(blue);
else if (blue === userId && red != null) opponentIds.add(red);
}
const nameById = new Map<number, string | null>();
if (opponentIds.size > 0) {
const { data: nameRows, error: nameErr } = await supabase
.from("users")
.select("id, username")
.in("id", [...opponentIds]);
if (nameErr) {
return { ok: false, error: nameErr.message };
}
for (const row of nameRows ?? []) {
nameById.set(row.id as number, (row.username as string | null) ?? null);
}
}
const matchHistory: PlayerCardMatchHistoryRow[] = rawMatches.map((m) => {
const red = cellUserId(m.user_red);
const blue = cellUserId(m.user_blue);
const onRed = red === userId;
const opponentId = onRed ? blue : red;
const opponentUsername =
opponentId != null ? nameById.get(opponentId) ?? null : null;
const mid = m.id as number;
const e = entryByMatch.get(mid) ?? { hold: BigInt(0), fee: BigInt(0) };
const entryTotal = e.hold + e.fee;
return {
matchId: mid,
createdAt: m.created_at as string,
sideLabel: onRed ? "Red" : "Blue",
opponentId,
opponentUsername,
outcomeLabel: outcomeForParticipant(userId, m.winner_id, opponentId),
entryTotalCoins: entryTotal.toString(),
entryFeeCoins: e.fee.toString(),
};
});
const matchesPlayed = playedCount ?? 0;
const matchesWon = wonCount ?? 0;
const winRatePercent =
matchesPlayed > 0
? Math.round((matchesWon / matchesPlayed) * 1000) / 10
: null;
let purchased = BigInt(0);
let reward = BigInt(0);
let holdSpend = BigInt(0);
let feeSpend = BigInt(0);
let offset = 0;
for (;;) {
const { data: batch, error: txErr } = await supabase
.from("transactions")
.select("amount, remarks, from, to")
.or(`from.eq.${userId},to.eq.${userId}`)
.order("id", { ascending: true })
.range(offset, offset + TX_PAGE - 1);
if (txErr) {
return { ok: false, error: txErr.message };
}
const rows = (batch ?? []) as Pick<
DbTransaction,
"amount" | "remarks" | "from" | "to"
>[];
if (rows.length === 0) break;
for (const row of rows) {
const a = txAmountToBigInt(row.amount);
if (a === BigInt(0)) continue;
const rmk = normRemark(row.remarks);
if (sameUser(row.to, userId) && rmk === "purchase") {
purchased += a;
}
if (sameUser(row.to, userId) && rmk === "reward") {
reward += a;
}
if (sameUser(row.from, userId) && rmk === "entry_hold") {
holdSpend += a;
}
if (sameUser(row.from, userId) && rmk === "entry_fee") {
feeSpend += a;
}
}
offset += rows.length;
if (rows.length < TX_PAGE) break;
}
return {
ok: true,
data: {
id: userRow.id as number,
username: (userRow.username as string | null) ?? null,
email: (userRow.email as string | null) ?? null,
createdAt: (userRow.created_at as string) ?? null,
lastSeen: (userRow.last_logged_at as string | null) ?? null,
lastLoggedInIp: (userRow.ip_address as string | null) ?? null,
lastLoggedInCountryCode: await countryCodeFromIp(
(userRow.ip_address as string | null) ?? null,
),
rcBalance: (userRow.rc as number | null) ?? null,
ccBalance: (userRow.cc as number | null) ?? null,
matchesPlayed,
matchesWon,
winRatePercent,
rcPurchasedCoins: purchased.toString(),
rcRewardCoins: reward.toString(),
rcSpentHoldCoins: holdSpend.toString(),
rcSpentFeeCoins: feeSpend.toString(),
matchHistory,
},
};
}