Files
kickkingsadmin/src/lib/geolocation-analytics.ts
T
2026-07-12 17:55:44 +00:00

179 lines
4.6 KiB
TypeScript

import type { SupabaseClient } from "@supabase/supabase-js";
import { countryCodeFromIp } from "@/lib/ip-geolocation";
import type { DbUser } from "@/types/database";
export type GeolocationCountryStat = {
countryCode: string;
count: number;
avgPing: number | null;
};
export type GeolocationAnalyticsResult = {
userAccounts: GeolocationCountryStat[];
matches: GeolocationCountryStat[];
error: string | null;
};
const EMPTY_RESULT: GeolocationAnalyticsResult = {
userAccounts: [],
matches: [],
error: null,
};
async function resolveUniqueIps(
ips: Iterable<string | null | undefined>,
): Promise<Map<string, string | null>> {
const unique = new Set<string>();
for (const ip of ips) {
const trimmed = (ip ?? "").trim();
if (trimmed) unique.add(trimmed);
}
const entries = await Promise.all(
[...unique].map(async (ip) => [ip, await countryCodeFromIp(ip)] as const),
);
return new Map(entries);
}
function isValidCountryCode(code: string | null | undefined): code is string {
const cc = (code ?? "").trim().toUpperCase();
return /^[A-Z]{2}$/.test(cc);
}
export async function loadUserGeolocationStats(
users: DbUser[],
playerIds: number[],
): Promise<GeolocationCountryStat[]> {
let filtered = users;
if (playerIds.length > 0) {
const allowed = new Set(playerIds);
filtered = users.filter((u) => allowed.has(u.id));
}
const ips = filtered.map((u) => u.ip_address);
const countryByIp = await resolveUniqueIps(ips);
const byCountry = new Map<string, number>();
for (const user of filtered) {
const ip = user.ip_address?.trim();
if (!ip) continue;
const country = countryByIp.get(ip);
if (!isValidCountryCode(country)) continue;
byCountry.set(country, (byCountry.get(country) ?? 0) + 1);
}
return [...byCountry.entries()]
.map(([countryCode, count]) => ({ countryCode, count, avgPing: null }))
.sort((a, b) => b.count - a.count || a.countryCode.localeCompare(b.countryCode));
}
type PingReportRow = {
match_id: number;
ip_address: string | null;
ping: number;
};
export async function loadMatchGeolocationStats(
supabase: SupabaseClient,
from: string,
to: string,
playerIds: number[],
): Promise<{ stats: GeolocationCountryStat[]; error: string | null }> {
const rangeStartIso = `${from}T00:00:00.000Z`;
const rangeEndIso = `${to}T23:59:59.999Z`;
let query = supabase
.from("ping_reports")
.select("match_id, ip_address, ping")
.gte("created_at", rangeStartIso)
.lte("created_at", rangeEndIso)
.limit(10000);
if (playerIds.length > 0) {
query = query.in("user_id", playerIds);
}
const { data, error } = await query;
if (error) {
return { stats: [], error: error.message };
}
const rows = (data ?? []) as PingReportRow[];
const countryByIp = await resolveUniqueIps(rows.map((r) => r.ip_address));
const byCountry = new Map<
string,
{ matchIds: Set<number>; pings: number[] }
>();
for (const row of rows) {
const ip = row.ip_address?.trim();
if (!ip) continue;
const country = countryByIp.get(ip);
if (!isValidCountryCode(country)) continue;
const matchId = Number(row.match_id);
const ping = Number(row.ping);
if (!Number.isFinite(matchId) || !Number.isFinite(ping)) continue;
const state = byCountry.get(country) ?? {
matchIds: new Set<number>(),
pings: [],
};
state.matchIds.add(matchId);
state.pings.push(ping);
byCountry.set(country, state);
}
const stats = [...byCountry.entries()]
.map(([countryCode, state]) => {
const pings = state.pings;
const avgPing =
pings.length > 0
? pings.reduce((acc, value) => acc + value, 0) / pings.length
: null;
return {
countryCode,
count: state.matchIds.size,
avgPing,
};
})
.sort((a, b) => b.count - a.count || a.countryCode.localeCompare(b.countryCode));
return { stats, error: null };
}
export async function loadGeolocationAnalytics(
supabase: SupabaseClient,
users: DbUser[],
from: string,
to: string,
playerIds: number[],
): Promise<GeolocationAnalyticsResult> {
try {
const [userAccounts, matchResult] = await Promise.all([
loadUserGeolocationStats(users, playerIds),
loadMatchGeolocationStats(supabase, from, to, playerIds),
]);
if (matchResult.error) {
return {
userAccounts,
matches: [],
error: matchResult.error,
};
}
return {
userAccounts,
matches: matchResult.stats,
error: null,
};
} catch (err) {
return {
...EMPTY_RESULT,
error: err instanceof Error ? err.message : "Failed to load geolocation data",
};
}
}