/** * Metro assignment: nearest seeded metro whose radius_km covers the point; otherwise null. * Metros are loaded once per process and refreshed every 10 minutes. */ import { sql } from "@dci/db"; import { haversineKm, normalizeName } from "@dci/core"; import type { Tx } from "./common.js"; export interface MetroRef { id: string; slug: string; name: string; countryIso2: string; lat: number; lng: number; radiusKm: number; aliases: string[]; normalizedAliases: string[]; } let cache: { at: number; metros: MetroRef[] } | null = null; const TTL_MS = 10 * 60 * 1000; export async function loadMetros(tx: Tx, force = false): Promise { if (!force && cache && Date.now() - cache.at < TTL_MS) return cache.metros; const rows = await tx.execute(sql`select id, slug, name, country_iso2, lat, lng, radius_km, aliases from metros`); const metros: MetroRef[] = rows.map((r) => { const aliases = Array.isArray(r.aliases) ? (r.aliases as string[]) : []; return { id: String(r.id), slug: String(r.slug), name: String(r.name), countryIso2: String(r.country_iso2), lat: Number(r.lat), lng: Number(r.lng), radiusKm: Number(r.radius_km ?? 60), aliases, normalizedAliases: [normalizeName(String(r.name)), ...aliases.map((a) => normalizeName(a))].filter(Boolean), }; }); cache = { at: Date.now(), metros }; return metros; } export function invalidateMetroCache(): void { cache = null; } /** Nearest metro covering the coordinates (distance ≤ radius_km). */ export function nearestMetro(metros: MetroRef[], lat: number, lng: number, countryIso2?: string | null): MetroRef | null { let best: MetroRef | null = null; let bestD = Number.POSITIVE_INFINITY; for (const m of metros) { // A metro never spans a border in our seed data, so a known country is a hard filter. if (countryIso2 && m.countryIso2 !== countryIso2) continue; const d = haversineKm(lat, lng, m.lat, m.lng); if (d <= m.radiusKm && d < bestD) { best = m; bestD = d; } } return best; } /** Metro by city / region name (used when no coordinates are available): exact alias match within the same country. */ export function metroByCityName(metros: MetroRef[], city: string | null | undefined, countryIso2: string | null | undefined): MetroRef | null { if (!city || !countryIso2) return null; const n = normalizeName(city); if (!n) return null; for (const m of metros) if (m.countryIso2 === countryIso2 && m.normalizedAliases.includes(n)) return m; return null; } export interface MetroAssignment { metroId: string | null; countryIso2: string | null; method: "geo" | "city" | "none"; } /** Assign a metro (geo first, then city alias) and fall back to the metro's country when the entity has none. */ export async function assignMetro(tx: Tx, input: { lat?: number | null; lng?: number | null; city?: string | null; countryIso2?: string | null }): Promise { const metros = await loadMetros(tx); const country = input.countryIso2?.toUpperCase() ?? null; if (typeof input.lat === "number" && typeof input.lng === "number" && Number.isFinite(input.lat) && Number.isFinite(input.lng)) { const m = nearestMetro(metros, input.lat, input.lng, country); if (m) return { metroId: m.id, countryIso2: country ?? m.countryIso2, method: "geo" }; } const byCity = metroByCityName(metros, input.city, country); if (byCity) return { metroId: byCity.id, countryIso2: country ?? byCity.countryIso2, method: "city" }; return { metroId: null, countryIso2: country, method: "none" }; }