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(); 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(); 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(); 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, }, }; }