import { decryptSecret } from "@groombook/db"; /** * Abstracted geocoding service layer (GRO-2153, Phase 1.2 of Route Optimization). * * Provides a provider-agnostic interface for turning a street address into * latitude/longitude coordinates, with two concrete implementations: * * - {@link NominatimGeocodingProvider} — OpenStreetMap Nominatim (default, free, * self-hostable). Enforces the public Nominatim usage policy of at most one * request per second. * - {@link GoogleGeocodingProvider} — Google Geocoding API (optional fallback, * requires an API key stored encrypted at rest in `businessSettings`). * * Provider selection is driven by `businessSettings.routeOptimizationProvider` * via {@link resolveGeocodingProvider}. A {@link geocodeBatch} helper geocodes a * list of addresses while respecting the active provider's rate limit. */ /** Identifier for a supported geocoding backend. Mirrors `route_optimization_provider`. */ export type GeocodingProviderName = "nominatim" | "google"; /** Successful geocoding result in WGS84 decimal degrees. */ export interface GeocodeResult { latitude: number; longitude: number; /** Provider-normalized display address, when available. */ formattedAddress: string | null; /** Which provider produced this result. */ provider: GeocodingProviderName; } /** Abstract geocoding provider contract. */ export interface GeocodingProvider { /** Stable provider identifier. */ readonly name: GeocodingProviderName; /** * Minimum milliseconds to leave between consecutive requests. Used both by the * provider's internal rate limiter and by {@link geocodeBatch} so callers do * not need to know provider-specific limits. */ readonly minRequestIntervalMs: number; /** * Geocode a single address. * @returns the top match, or `null` when the address is blank or unresolvable. * @throws on transport failures or provider-level errors (e.g. quota, bad key). */ geocode(address: string): Promise; } // --- Constants ------------------------------------------------------------- /** Nominatim usage policy: at most 1 request per second. */ const NOMINATIM_RATE_LIMIT_MS = 1000; const DEFAULT_NOMINATIM_BASE_URL = "https://nominatim.openstreetmap.org"; /** Nominatim requires a descriptive User-Agent identifying the application. */ const DEFAULT_NOMINATIM_USER_AGENT = "GroomBook/1.0 (+https://groombook.app route-optimization)"; const GOOGLE_GEOCODE_BASE_URL = "https://maps.googleapis.com/maps/api/geocode/json"; /** * Google permits a high request rate; a small floor keeps batch traffic polite * without throttling interactive single calls. */ const GOOGLE_RATE_LIMIT_MS = 20; // --- Injectable primitives (for testability) ------------------------------- /** Minimal `fetch` shape this module depends on. Defaults to the global `fetch`. */ export type FetchLike = ( input: string, init?: { headers?: Record } ) => Promise<{ ok: boolean; status: number; statusText: string; json(): Promise; }>; type NowFn = () => number; type SleepFn = (ms: number) => Promise; const defaultSleep: SleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const defaultFetch: FetchLike = (input, init) => (globalThis.fetch as unknown as FetchLike)(input, init); /** * Serializes async tasks and guarantees at least `intervalMs` between the start * of consecutive tasks. A failing task never wedges the queue. */ class RateLimiter { // Negative infinity so the very first task never waits. private last = Number.NEGATIVE_INFINITY; private chain: Promise = Promise.resolve(); constructor( private readonly intervalMs: number, private readonly now: NowFn = Date.now, private readonly sleep: SleepFn = defaultSleep ) {} run(task: () => Promise): Promise { const result = this.chain.then(async () => { if (this.intervalMs > 0) { const elapsed = this.now() - this.last; const wait = this.intervalMs - elapsed; if (wait > 0) await this.sleep(wait); } this.last = this.now(); return task(); }); // Keep the queue alive regardless of whether this task resolves or rejects. this.chain = result.then( () => undefined, () => undefined ); return result; } } function normalizeAddress(address: string): string { return address.trim(); } // --- Nominatim provider ---------------------------------------------------- interface NominatimSearchRow { lat?: string; lon?: string; display_name?: string; } export interface NominatimProviderOptions { /** Override the Nominatim instance base URL (e.g. a self-hosted mirror). */ baseUrl?: string; /** User-Agent header identifying this application, per Nominatim policy. */ userAgent?: string; /** Minimum spacing between requests; defaults to the 1 req/sec policy. */ minRequestIntervalMs?: number; fetchImpl?: FetchLike; now?: NowFn; sleep?: SleepFn; } export class NominatimGeocodingProvider implements GeocodingProvider { readonly name = "nominatim" as const; readonly minRequestIntervalMs: number; private readonly baseUrl: string; private readonly userAgent: string; private readonly fetchImpl: FetchLike; private readonly limiter: RateLimiter; constructor(options: NominatimProviderOptions = {}) { this.baseUrl = (options.baseUrl ?? DEFAULT_NOMINATIM_BASE_URL).replace(/\/+$/, ""); this.userAgent = options.userAgent ?? DEFAULT_NOMINATIM_USER_AGENT; this.minRequestIntervalMs = options.minRequestIntervalMs ?? NOMINATIM_RATE_LIMIT_MS; this.fetchImpl = options.fetchImpl ?? defaultFetch; this.limiter = new RateLimiter(this.minRequestIntervalMs, options.now, options.sleep); } async geocode(address: string): Promise { const query = normalizeAddress(address); if (!query) return null; return this.limiter.run(async () => { const url = new URL(`${this.baseUrl}/search`); url.searchParams.set("q", query); url.searchParams.set("format", "jsonv2"); url.searchParams.set("limit", "1"); const res = await this.fetchImpl(url.toString(), { headers: { "User-Agent": this.userAgent }, }); if (!res.ok) { throw new Error( `Nominatim geocoding failed: ${res.status} ${res.statusText}` ); } const body = (await res.json()) as NominatimSearchRow[]; if (!Array.isArray(body) || body.length === 0) return null; const top = body[0]!; const latitude = Number(top.lat); const longitude = Number(top.lon); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; return { latitude, longitude, formattedAddress: top.display_name ?? null, provider: this.name, }; }); } } // --- Google provider ------------------------------------------------------- interface GoogleGeocodeResponse { status: string; error_message?: string; results?: Array<{ formatted_address?: string; geometry?: { location?: { lat?: number; lng?: number } }; }>; } export interface GoogleProviderOptions { baseUrl?: string; minRequestIntervalMs?: number; fetchImpl?: FetchLike; now?: NowFn; sleep?: SleepFn; } export class GoogleGeocodingProvider implements GeocodingProvider { readonly name = "google" as const; readonly minRequestIntervalMs: number; private readonly apiKey: string; private readonly baseUrl: string; private readonly fetchImpl: FetchLike; private readonly limiter: RateLimiter; constructor(apiKey: string, options: GoogleProviderOptions = {}) { if (!apiKey) { throw new Error("GoogleGeocodingProvider requires a non-empty API key"); } this.apiKey = apiKey; this.baseUrl = options.baseUrl ?? GOOGLE_GEOCODE_BASE_URL; this.minRequestIntervalMs = options.minRequestIntervalMs ?? GOOGLE_RATE_LIMIT_MS; this.fetchImpl = options.fetchImpl ?? defaultFetch; this.limiter = new RateLimiter(this.minRequestIntervalMs, options.now, options.sleep); } async geocode(address: string): Promise { const query = normalizeAddress(address); if (!query) return null; return this.limiter.run(async () => { const url = new URL(this.baseUrl); url.searchParams.set("address", query); url.searchParams.set("key", this.apiKey); const res = await this.fetchImpl(url.toString()); if (!res.ok) { throw new Error( `Google geocoding failed: ${res.status} ${res.statusText}` ); } const body = (await res.json()) as GoogleGeocodeResponse; if (body.status === "ZERO_RESULTS") return null; if (body.status !== "OK") { const detail = body.error_message ? `: ${body.error_message}` : ""; throw new Error(`Google geocoding error: ${body.status}${detail}`); } const top = body.results?.[0]; const location = top?.geometry?.location; const latitude = location?.lat; const longitude = location?.lng; if ( typeof latitude !== "number" || typeof longitude !== "number" || !Number.isFinite(latitude) || !Number.isFinite(longitude) ) { return null; } return { latitude, longitude, formattedAddress: top?.formatted_address ?? null, provider: this.name, }; }); } } // --- Provider selection ---------------------------------------------------- /** Subset of `businessSettings` relevant to geocoding provider selection. */ export interface GeocodingSettings { routeOptimizationProvider?: string | null; /** Google API key, encrypted at rest (AES-256-GCM via `encryptSecret`). */ googleMapsApiKey?: string | null; } export interface ResolveProviderOptions { /** Decryption function for the stored Google key. Defaults to `decryptSecret`. */ decrypt?: (ciphertext: string) => string; /** Options forwarded to the constructed provider (base URL, fetch, timing). */ nominatim?: NominatimProviderOptions; google?: GoogleProviderOptions; /** Sink for non-fatal selection warnings. Defaults to `console.warn`. */ warn?: (message: string) => void; } /** * Resolves the Google API key from settings (decrypting the at-rest value) or, * as a development convenience, from the `GOOGLE_MAPS_API_KEY` env var. * Returns `null` when no usable key is available. */ function resolveGoogleApiKey( settings: GeocodingSettings, decrypt: (ciphertext: string) => string, warn: (message: string) => void ): string | null { const stored = settings.googleMapsApiKey?.trim(); if (stored) { try { const decrypted = decrypt(stored).trim(); if (decrypted) return decrypted; } catch (err) { warn( `Failed to decrypt googleMapsApiKey; falling back to Nominatim: ${ err instanceof Error ? err.message : String(err) }` ); return null; } } const fromEnv = process.env.GOOGLE_MAPS_API_KEY?.trim(); return fromEnv ? fromEnv : null; } /** * Selects a geocoding provider based on `businessSettings.routeOptimizationProvider`. * * - `"google"` returns a {@link GoogleGeocodingProvider} when a usable key exists, * otherwise warns and falls back to Nominatim. * - Any other value (including `null`/`undefined`) returns a * {@link NominatimGeocodingProvider}. */ export function resolveGeocodingProvider( settings: GeocodingSettings | null | undefined, options: ResolveProviderOptions = {} ): GeocodingProvider { const warn = options.warn ?? ((message: string) => console.warn(message)); const decrypt = options.decrypt ?? decryptSecret; const requested = settings?.routeOptimizationProvider ?? "nominatim"; if (requested === "google") { const apiKey = resolveGoogleApiKey(settings ?? {}, decrypt, warn); if (apiKey) { return new GoogleGeocodingProvider(apiKey, options.google); } warn( "routeOptimizationProvider is 'google' but no usable API key was found; falling back to Nominatim" ); } return new NominatimGeocodingProvider(options.nominatim); } // --- Batch geocoding ------------------------------------------------------- /** Input item for {@link geocodeBatch}: a caller-defined key and its address. */ export interface BatchGeocodeItem { key: K; address: string; } /** Per-item outcome from {@link geocodeBatch}. */ export interface BatchGeocodeOutcome { key: K; address: string; /** Resolved coordinates, or `null` when unresolvable. */ result: GeocodeResult | null; /** Present when the geocode call threw; the batch continues past errors. */ error?: string; } export interface GeocodeBatchOptions { /** Invoked after each item completes; useful for progress reporting. */ onProgress?: ( completed: number, total: number, outcome: BatchGeocodeOutcome ) => void; } /** * Geocodes a list of addresses sequentially through the given provider. The * provider's internal rate limiter enforces throttling (e.g. Nominatim's * 1 req/sec), so addresses are processed one at a time and individual failures * are captured per item rather than aborting the whole batch. */ export async function geocodeBatch( items: ReadonlyArray>, provider: GeocodingProvider, options: GeocodeBatchOptions = {} ): Promise>> { const outcomes: Array> = []; for (const item of items) { let outcome: BatchGeocodeOutcome; try { const result = await provider.geocode(item.address); outcome = { key: item.key, address: item.address, result }; } catch (err) { outcome = { key: item.key, address: item.address, result: null, error: err instanceof Error ? err.message : String(err), }; } outcomes.push(outcome); options.onProgress?.(outcomes.length, items.length, outcome); } return outcomes; }