import { getDb, businessSettings, clients, and, eq, isNull, sql, } from "@groombook/db"; import { resolveGeocodingProvider, type GeocodingProvider, type GeocodeResult, } from "./geocoding.js"; /** * Client geocoding orchestration (GRO-2154, Phase 1.3 of Route Optimization). * * Bridges the provider-agnostic {@link GeocodingProvider} layer (GRO-2153) and * the `clients` table: resolves the configured provider from `businessSettings`, * geocodes a client's address, and persists `latitude`/`longitude`/`geocodedAt`. * * Outcomes are returned as structured {@link ClientGeocodeOutcome} values so that * callers (the geocode endpoints and the auto-geocode create/update hook) can * surface clear, actionable feedback — groomers need to know when an address is * ambiguous or unresolvable, not just that "something failed". */ type Db = ReturnType; type ClientRow = typeof clients.$inferSelect; /** Status of a single client geocode attempt. */ export type ClientGeocodeStatus = /** Coordinates resolved and persisted. */ | "geocoded" /** Client has no (non-blank) address on file — nothing to geocode. */ | "no_address" /** Provider returned no match; the address is ambiguous or unrecognized. */ | "unresolved" /** Provider call failed (transport, quota, bad key). Coordinates unchanged. */ | "error"; /** Structured, UI-surfaceable result of geocoding one client. */ export interface ClientGeocodeOutcome { clientId: string; status: ClientGeocodeStatus; /** Human-readable explanation, safe to show to managers/groomers. */ message: string; latitude: number | null; longitude: number | null; geocodedAt: string | null; /** Provider-normalized address, when a match was found. */ formattedAddress: string | null; /** Provider that produced (or attempted) this result. */ provider: string | null; } /** * Builds the geocoding provider for the current business settings. A single * provider instance should be reused across a batch so its internal rate limiter * throttles the whole run (e.g. Nominatim's 1 req/sec policy). */ export async function resolveClientGeocodingProvider( db: Db ): Promise { const [settings] = await db.select().from(businessSettings).limit(1); return resolveGeocodingProvider(settings ?? null); } /** * Geocodes a single client row through the given provider and persists the * result on success. Never throws on provider failure — transport/quota errors * are captured as an `"error"` outcome so callers (especially the create/update * auto-geocode hook) are not broken by a flaky geocoding backend. */ export async function geocodeClient( db: Db, client: ClientRow, provider: GeocodingProvider ): Promise { const base = { clientId: client.id, latitude: null as number | null, longitude: null as number | null, geocodedAt: null as string | null, formattedAddress: null as string | null, provider: provider.name as string | null, }; const address = client.address?.trim(); if (!address) { return { ...base, status: "no_address", message: "Client has no address on file, so it cannot be geocoded.", }; } let result: GeocodeResult | null; try { result = await provider.geocode(address); } catch (err) { return { ...base, status: "error", message: `Geocoding provider (${provider.name}) failed: ${ err instanceof Error ? err.message : String(err) }`, }; } if (!result) { return { ...base, status: "unresolved", message: `Address could not be resolved to a location: "${address}". Please verify or correct the address.`, }; } const geocodedAt = new Date(); await db .update(clients) .set({ latitude: result.latitude, longitude: result.longitude, geocodedAt, updatedAt: geocodedAt, }) .where(eq(clients.id, client.id)); return { clientId: client.id, status: "geocoded", message: `Geocoded via ${result.provider} to ${result.latitude}, ${result.longitude}.`, latitude: result.latitude, longitude: result.longitude, geocodedAt: geocodedAt.toISOString(), formattedAddress: result.formattedAddress, provider: result.provider, }; } /** Summary returned by {@link geocodeUngeocodedClients}. */ export interface BatchGeocodeSummary { provider: string; /** Number of clients processed in this invocation. */ processed: number; geocoded: number; unresolved: number; errors: number; /** Un-geocoded clients with an address that were NOT processed (over `limit`). */ remaining: number; /** Per-client outcomes for everything processed this invocation. */ outcomes: ClientGeocodeOutcome[]; } /** * Batch-geocodes clients that have an address but no `geocodedAt` yet, throttled * by the active provider's rate limiter. * * Because Nominatim allows only ~1 req/sec, geocoding every un-geocoded client in * a single HTTP request would risk timeouts on large datasets. Each invocation * therefore processes at most `limit` clients (default 50, clamped 1..500) and * reports `remaining`; managers re-run until `remaining` is 0. */ export async function geocodeUngeocodedClients( db: Db, limit = 50, injectedProvider?: GeocodingProvider ): Promise { const effectiveLimit = Math.min(Math.max(Math.trunc(limit) || 0, 1), 500); const provider = injectedProvider ?? (await resolveClientGeocodingProvider(db)); // Un-geocoded = geocodedAt IS NULL with a non-blank address. const candidateFilter = and( isNull(clients.geocodedAt), sql`${clients.address} IS NOT NULL AND length(trim(${clients.address})) > 0` ); const countRows = await db .select({ count: sql`count(*)::int` }) .from(clients) .where(candidateFilter); const totalRemaining = countRows[0]?.count ?? 0; const rows = await db .select() .from(clients) .where(candidateFilter) .orderBy(clients.createdAt) .limit(effectiveLimit); const outcomes: ClientGeocodeOutcome[] = []; for (const row of rows) { outcomes.push(await geocodeClient(db, row, provider)); } const geocoded = outcomes.filter((o) => o.status === "geocoded").length; const unresolved = outcomes.filter((o) => o.status === "unresolved").length; const errors = outcomes.filter((o) => o.status === "error").length; return { provider: provider.name, processed: outcomes.length, geocoded, unresolved, errors, remaining: Math.max(totalRemaining - outcomes.length, 0), outcomes, }; }