diff --git a/src/__tests__/geocoding.test.ts b/src/__tests__/geocoding.test.ts new file mode 100644 index 0000000..3f3e0b8 --- /dev/null +++ b/src/__tests__/geocoding.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + NominatimGeocodingProvider, + GoogleGeocodingProvider, + resolveGeocodingProvider, + geocodeBatch, + type FetchLike, +} from "../services/geocoding.js"; + +/** Builds a fake fetch returning a single JSON body, recording the called URLs. */ +function fakeFetch( + body: unknown, + init: { ok?: boolean; status?: number; statusText?: string } = {} +): { fetchImpl: FetchLike; calls: string[] } { + const calls: string[] = []; + const fetchImpl: FetchLike = async (url) => { + calls.push(url); + return { + ok: init.ok ?? true, + status: init.status ?? 200, + statusText: init.statusText ?? "OK", + json: async () => body, + }; + }; + return { fetchImpl, calls }; +} + +/** Virtual clock whose `sleep` advances `now`, so throttle timing is deterministic. */ +function fakeClock() { + const state = { t: 0 }; + const sleeps: number[] = []; + return { + now: () => state.t, + sleep: async (ms: number) => { + sleeps.push(ms); + state.t += ms; + }, + sleeps, + }; +} + +const NOMINATIM_ROW = { + lat: "40.7128", + lon: "-74.0060", + display_name: "New York, NY, USA", +}; + +describe("NominatimGeocodingProvider", () => { + it("parses the top match into a GeocodeResult", async () => { + const { fetchImpl, calls } = fakeFetch([NOMINATIM_ROW]); + const provider = new NominatimGeocodingProvider({ fetchImpl }); + + const result = await provider.geocode("123 Main St"); + + expect(result).toEqual({ + latitude: 40.7128, + longitude: -74.006, + formattedAddress: "New York, NY, USA", + provider: "nominatim", + }); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain("/search"); + expect(calls[0]).toContain("q=123+Main+St"); + expect(calls[0]).toContain("format=jsonv2"); + expect(calls[0]).toContain("limit=1"); + }); + + it("returns null for an empty result set", async () => { + const { fetchImpl } = fakeFetch([]); + const provider = new NominatimGeocodingProvider({ fetchImpl }); + expect(await provider.geocode("nowhere at all")).toBeNull(); + }); + + it("returns null for a blank address without calling fetch", async () => { + const { fetchImpl, calls } = fakeFetch([NOMINATIM_ROW]); + const provider = new NominatimGeocodingProvider({ fetchImpl }); + expect(await provider.geocode(" ")).toBeNull(); + expect(calls).toHaveLength(0); + }); + + it("throws on a non-OK HTTP response", async () => { + const { fetchImpl } = fakeFetch("rate limited", { + ok: false, + status: 429, + statusText: "Too Many Requests", + }); + const provider = new NominatimGeocodingProvider({ fetchImpl }); + await expect(provider.geocode("123 Main St")).rejects.toThrow( + /Nominatim geocoding failed: 429/ + ); + }); + + it("sends the configured User-Agent and honors a custom base URL", async () => { + const calls: Array<{ url: string; headers?: Record }> = []; + const fetchImpl: FetchLike = async (url, opts) => { + calls.push({ url, headers: opts?.headers }); + return { ok: true, status: 200, statusText: "OK", json: async () => [NOMINATIM_ROW] }; + }; + const provider = new NominatimGeocodingProvider({ + fetchImpl, + baseUrl: "https://nominatim.example.com/", + userAgent: "TestAgent/9.9", + }); + + await provider.geocode("123 Main St"); + + expect(calls[0]!.url).toContain("https://nominatim.example.com/search"); + expect(calls[0]!.headers?.["User-Agent"]).toBe("TestAgent/9.9"); + }); + + it("throttles to ~1 req/sec across consecutive calls (first call not delayed)", async () => { + const clock = fakeClock(); + const { fetchImpl } = fakeFetch([NOMINATIM_ROW]); + const provider = new NominatimGeocodingProvider({ + fetchImpl, + minRequestIntervalMs: 1000, + now: clock.now, + sleep: clock.sleep, + }); + + await provider.geocode("a"); + await provider.geocode("b"); + await provider.geocode("c"); + + // First request immediate; each subsequent waits the full interval. + expect(clock.sleeps).toEqual([1000, 1000]); + }); +}); + +describe("GoogleGeocodingProvider", () => { + const GOOGLE_OK = { + status: "OK", + results: [ + { + formatted_address: "1600 Amphitheatre Pkwy, Mountain View, CA", + geometry: { location: { lat: 37.4224, lng: -122.0842 } }, + }, + ], + }; + + it("parses the first result into a GeocodeResult", async () => { + const { fetchImpl, calls } = fakeFetch(GOOGLE_OK); + const provider = new GoogleGeocodingProvider("test-key", { fetchImpl }); + + const result = await provider.geocode("1600 Amphitheatre Pkwy"); + + expect(result).toEqual({ + latitude: 37.4224, + longitude: -122.0842, + formattedAddress: "1600 Amphitheatre Pkwy, Mountain View, CA", + provider: "google", + }); + expect(calls[0]).toContain("key=test-key"); + expect(calls[0]).toContain("address=1600+Amphitheatre+Pkwy"); + }); + + it("returns null on ZERO_RESULTS", async () => { + const { fetchImpl } = fakeFetch({ status: "ZERO_RESULTS", results: [] }); + const provider = new GoogleGeocodingProvider("test-key", { fetchImpl }); + expect(await provider.geocode("nowhere")).toBeNull(); + }); + + it("throws on an API error status with the error message", async () => { + const { fetchImpl } = fakeFetch({ + status: "REQUEST_DENIED", + error_message: "The provided API key is invalid.", + }); + const provider = new GoogleGeocodingProvider("bad-key", { fetchImpl }); + await expect(provider.geocode("123 Main St")).rejects.toThrow( + /Google geocoding error: REQUEST_DENIED: The provided API key is invalid\./ + ); + }); + + it("returns null for a blank address without calling fetch", async () => { + const { fetchImpl, calls } = fakeFetch(GOOGLE_OK); + const provider = new GoogleGeocodingProvider("test-key", { fetchImpl }); + expect(await provider.geocode("")).toBeNull(); + expect(calls).toHaveLength(0); + }); + + it("rejects construction with an empty API key", () => { + expect(() => new GoogleGeocodingProvider("")).toThrow(/non-empty API key/); + }); +}); + +describe("resolveGeocodingProvider", () => { + const originalEnv = process.env.GOOGLE_MAPS_API_KEY; + afterEach(() => { + if (originalEnv === undefined) delete process.env.GOOGLE_MAPS_API_KEY; + else process.env.GOOGLE_MAPS_API_KEY = originalEnv; + }); + + it("defaults to Nominatim when provider is unset", () => { + const provider = resolveGeocodingProvider(null); + expect(provider.name).toBe("nominatim"); + }); + + it("returns Nominatim for an explicit nominatim setting", () => { + const provider = resolveGeocodingProvider({ + routeOptimizationProvider: "nominatim", + }); + expect(provider.name).toBe("nominatim"); + }); + + it("returns Google and decrypts the stored key when provider is google", () => { + const decrypt = vi.fn().mockReturnValue("decrypted-google-key"); + const provider = resolveGeocodingProvider( + { routeOptimizationProvider: "google", googleMapsApiKey: "enc:abc" }, + { decrypt } + ); + expect(provider.name).toBe("google"); + expect(decrypt).toHaveBeenCalledWith("enc:abc"); + }); + + it("falls back to Nominatim (with a warning) when google has no usable key", () => { + delete process.env.GOOGLE_MAPS_API_KEY; + const warn = vi.fn(); + const provider = resolveGeocodingProvider( + { routeOptimizationProvider: "google", googleMapsApiKey: null }, + { warn } + ); + expect(provider.name).toBe("nominatim"); + expect(warn).toHaveBeenCalledOnce(); + }); + + it("falls back to Nominatim (with a warning) when decryption fails", () => { + const decrypt = vi.fn().mockImplementation(() => { + throw new Error("bad ciphertext"); + }); + const warn = vi.fn(); + delete process.env.GOOGLE_MAPS_API_KEY; + const provider = resolveGeocodingProvider( + { routeOptimizationProvider: "google", googleMapsApiKey: "enc:corrupt" }, + { decrypt, warn } + ); + expect(provider.name).toBe("nominatim"); + expect(warn).toHaveBeenCalled(); + }); + + it("uses GOOGLE_MAPS_API_KEY env var as a fallback key source", () => { + process.env.GOOGLE_MAPS_API_KEY = "env-key"; + const provider = resolveGeocodingProvider({ + routeOptimizationProvider: "google", + }); + expect(provider.name).toBe("google"); + }); +}); + +describe("geocodeBatch", () => { + it("geocodes items in order and preserves keys", async () => { + const { fetchImpl } = fakeFetch([NOMINATIM_ROW]); + const provider = new NominatimGeocodingProvider({ + fetchImpl, + minRequestIntervalMs: 0, + }); + + const outcomes = await geocodeBatch( + [ + { key: "c1", address: "123 Main St" }, + { key: "c2", address: "456 Oak Ave" }, + ], + provider + ); + + expect(outcomes.map((o) => o.key)).toEqual(["c1", "c2"]); + expect(outcomes[0]!.result?.latitude).toBe(40.7128); + expect(outcomes[1]!.error).toBeUndefined(); + }); + + it("captures per-item errors and continues the batch", async () => { + let call = 0; + const fetchImpl: FetchLike = async () => { + call += 1; + if (call === 1) { + return { ok: false, status: 500, statusText: "Server Error", json: async () => "" }; + } + return { ok: true, status: 200, statusText: "OK", json: async () => [NOMINATIM_ROW] }; + }; + const provider = new NominatimGeocodingProvider({ fetchImpl, minRequestIntervalMs: 0 }); + + const outcomes = await geocodeBatch( + [ + { key: 1, address: "bad" }, + { key: 2, address: "good" }, + ], + provider + ); + + expect(outcomes[0]!.result).toBeNull(); + expect(outcomes[0]!.error).toMatch(/500/); + expect(outcomes[1]!.result?.latitude).toBe(40.7128); + }); + + it("reports progress for each completed item", async () => { + const { fetchImpl } = fakeFetch([NOMINATIM_ROW]); + const provider = new NominatimGeocodingProvider({ fetchImpl, minRequestIntervalMs: 0 }); + const progress: Array<[number, number]> = []; + + await geocodeBatch( + [ + { key: "a", address: "1 St" }, + { key: "b", address: "2 St" }, + ], + provider, + { onProgress: (completed, total) => progress.push([completed, total]) } + ); + + expect(progress).toEqual([ + [1, 2], + [2, 2], + ]); + }); +}); diff --git a/src/services/geocoding.ts b/src/services/geocoding.ts new file mode 100644 index 0000000..f88f599 --- /dev/null +++ b/src/services/geocoding.ts @@ -0,0 +1,419 @@ +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; +}