import { GeocodingError, type GeocodeAddress, type GeocodeResult, type GeocodingProvider } from "@/server/services/geo/provider"; import { nominatimLimiter } from "@/server/services/geo/rate-limit"; /** * Nominatim (OpenStreetMap) provider. Usage policy: server side only, max. 1 request/s process-wide, * identifying User-Agent, results are cached in Site.latitude/longitude by the caller, no bulk * geocoding inside requests (only the geocode-site job and scripts/geocode-backfill.ts call this). */ type Config = { url: string; userAgent: string; timeoutMs: number }; type Deps = { fetch: typeof fetch; limiter: { acquire(): Promise } }; /** Structured search URL (no free text → fewer ambiguous hits). */ export function buildNominatimUrl(baseUrl: string, address: GeocodeAddress): URL { const url = new URL(`${baseUrl.replace(/\/+$/, "")}/search`); url.searchParams.set("format", "jsonv2"); url.searchParams.set("limit", "1"); url.searchParams.set("addressdetails", "0"); const street = [address.houseNumber, address.street].map((s) => s?.trim()).filter(Boolean).join(" "); if (street) url.searchParams.set("street", street); if (address.postalCode?.trim()) url.searchParams.set("postalcode", address.postalCode.trim()); if (address.city?.trim()) url.searchParams.set("city", address.city.trim()); const country = (address.country ?? "DE").trim().toLowerCase(); if (/^[a-z]{2}$/.test(country)) url.searchParams.set("countrycodes", country); return url; } export function createNominatimProvider(config: Config, deps: Deps = { fetch: globalThis.fetch, limiter: nominatimLimiter }): GeocodingProvider { return { name: "nominatim", async geocode(address: GeocodeAddress): Promise { await deps.limiter.acquire(); let res: Response; try { res = await deps.fetch(buildNominatimUrl(config.url, address), { headers: { "User-Agent": config.userAgent, "Accept-Language": "de", Accept: "application/json" }, signal: AbortSignal.timeout(config.timeoutMs), }); } catch (err) { throw new GeocodingError(`request failed: ${(err as Error).name}`); } if (!res.ok) throw new GeocodingError(`http ${res.status}`); let body: unknown; try { body = await res.json(); } catch { throw new GeocodingError("invalid response"); } const hit = Array.isArray(body) ? (body[0] as { lat?: unknown; lon?: unknown } | undefined) : undefined; const latitude = Number(hit?.lat); const longitude = Number(hit?.lon); if (!hit || !Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180) { return { status: "not_found" }; } return { status: "ok", latitude, longitude }; }, }; }