sync
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user