179 lines
4.6 KiB
TypeScript
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",
|
|
};
|
|
}
|
|
}
|