From 8721f0b63cacb467be1a4dc7c96672faf814aabd Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 12:06:43 +0000 Subject: [PATCH] =?UTF-8?q?dev=20=E2=86=92=20uat:=20GRO-2154=20geocoding?= =?UTF-8?q?=20endpoints=20(Phase=201.3)=20(#171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yml | 6 +- UAT_PLAYBOOK.md | 18 ++ src/__tests__/clientGeocoding.test.ts | 192 +++++++++++++++++++++ src/__tests__/petProfileSummary.test.ts | 16 -- src/index.ts | 9 + src/routes/clients.ts | 124 +++++++++++++- src/services/clientGeocoding.ts | 212 ++++++++++++++++++++++++ 7 files changed, 555 insertions(+), 22 deletions(-) create mode 100644 src/__tests__/clientGeocoding.test.ts create mode 100644 src/services/clientGeocoding.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d848d3b..1529ed5 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -33,11 +33,11 @@ jobs: - name: Typecheck run: | - pnpm --filter @groombook/api typecheck + pnpm run typecheck pnpm --filter @groombook/db typecheck - name: Lint - run: pnpm --filter @groombook/api lint + run: pnpm run lint test: name: Test @@ -58,7 +58,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Run tests - run: pnpm --filter @groombook/api test + run: pnpm run test docker: name: Build & Push Docker Images diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 6653db5..47a84bd 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -120,6 +120,24 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-2.5 | Disable client | PATCH /api/clients/{id} with status: "disabled" | 200 OK, client marked as disabled | | TC-API-2.6 | Delete client | DELETE /api/clients/{id}?confirm=true | 200 OK, client deleted (if no appointments) | +#### Client Geocoding — Route Optimization (GRO-2154, Phase 1.3) + +Geocoding turns a client's street address into `latitude`/`longitude` + `geocodedAt`. Provider is driven by `businessSettings.routeOptimizationProvider` (default Nominatim/OpenStreetMap, 1 req/sec; optional Google fallback). All explicit geocode endpoints are **manager-only**. + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-API-2.7 | Geocode single client (success) | As **manager**, `POST /api/clients/{id}/geocode` for a client with a valid, real address (e.g. a seed client) | 200 OK; body `{ status: "geocoded", latitude, longitude, geocodedAt, formattedAddress, provider }`. Subsequent `GET /api/clients/{id}` shows the same non-null `latitude`/`longitude`/`geocodedAt` persisted | +| TC-API-2.8 | Geocode single client — no address | As manager, `POST /api/clients/{id}/geocode` for a client whose `address` is null/blank | 422; `{ status: "no_address", message: "...no address on file..." }` (clear, actionable) | +| TC-API-2.9 | Geocode single client — unresolvable/ambiguous address | As manager, set a nonsense address (e.g. `"asdkjhqweoui 99999"`) then `POST /api/clients/{id}/geocode` | 422; `{ status: "unresolved", message: "Address could not be resolved..." }` so groomers/managers know to correct it | +| TC-API-2.10 | Geocode single client — not found | As manager, `POST /api/clients/00000000-0000-0000-0000-000000000000/geocode` | 404 `{ error: "Not found" }` | +| TC-API-2.11 | Geocode endpoint is manager-only | As **groomer** or **receptionist**, `POST /api/clients/{id}/geocode` | 403 Forbidden (role not permitted) | +| TC-API-2.12 | Batch geocode un-geocoded clients | As manager, `POST /api/clients/geocode-batch?limit=10` on a DB with un-geocoded clients | 200 OK; body `{ provider, processed, geocoded, unresolved, errors, remaining, outcomes[] }`. `processed` ≤ 10; `remaining` reflects un-geocoded clients beyond this batch. Re-run while `remaining > 0` to finish (throttled to provider rate limit) | +| TC-API-2.13 | Batch geocode — invalid limit | As manager, `POST /api/clients/geocode-batch?limit=0` (or non-numeric) | 400 `{ error: "limit must be a positive integer" }` | +| TC-API-2.14 | Batch geocode — manager-only | As groomer/receptionist, `POST /api/clients/geocode-batch` | 403 Forbidden | +| TC-API-2.15 | Auto-geocode on create | As manager/receptionist, `POST /api/clients` with a valid `address` | 201 Created; response includes a `geocoding` object (`status: "geocoded"` for a resolvable address) and the persisted client carries `latitude`/`longitude`/`geocodedAt`. Creating without an address succeeds with no `geocoding` field | +| TC-API-2.16 | Auto-geocode on address update | As manager/receptionist, `PATCH /api/clients/{id}` changing `address` to a new valid value | 200 OK; response includes a `geocoding` object and refreshed coordinates. Patching unrelated fields (e.g. `name`) does NOT re-geocode (no `geocoding` field) | +| TC-API-2.17 | Clearing address drops coordinates | As manager/receptionist, `PATCH /api/clients/{id}` with `address: ""` | 200 OK; `latitude`/`longitude`/`geocodedAt` reset to null (no stale pin) | + ### 4.3 Pet Management | # | Scenario | Steps | Expected | diff --git a/src/__tests__/clientGeocoding.test.ts b/src/__tests__/clientGeocoding.test.ts new file mode 100644 index 0000000..675bc1e --- /dev/null +++ b/src/__tests__/clientGeocoding.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi } from "vitest"; +import { + geocodeClient, + geocodeUngeocodedClients, + resolveClientGeocodingProvider, +} from "../services/clientGeocoding.js"; +import { + NominatimGeocodingProvider, + type GeocodeResult, + type GeocodingProvider, +} from "../services/geocoding.js"; + +// ─── Fakes ────────────────────────────────────────────────────────────────── + +/** Fake provider with a scripted geocode behaviour and a call log. */ +function fakeProvider( + impl: (address: string) => Promise +): GeocodingProvider & { calls: string[] } { + const calls: string[] = []; + return { + name: "nominatim", + minRequestIntervalMs: 0, + calls, + geocode: (address: string) => { + calls.push(address); + return impl(address); + }, + }; +} + +const okResult = (lat: number, lng: number): GeocodeResult => ({ + latitude: lat, + longitude: lng, + formattedAddress: "1 Main St, Anytown", + provider: "nominatim", +}); + +/** + * Minimal db double recording update() set-values. `select()` chains return the + * preloaded `selectQueue` shift()ed per call so different statements get + * different rows (used by geocodeUngeocodedClients: count, then rows). + */ +function fakeDb(selectQueue: unknown[][]) { + const updates: Record[] = []; + const queue = [...selectQueue]; + const chain = () => { + const rows = queue.shift() ?? []; + const proxy: Record = {}; + for (const k of ["from", "where", "orderBy", "limit"]) { + proxy[k] = () => proxy; + } + // Make the chain awaitable / iterable as the resolved rows. + (proxy as { then: unknown }).then = (resolve: (v: unknown) => void) => + resolve(rows); + (proxy as { [Symbol.iterator]: unknown })[Symbol.iterator] = () => + (rows as unknown[])[Symbol.iterator](); + return proxy; + }; + const db = { + select: () => chain(), + update: () => ({ + set: (vals: Record) => ({ + where: () => { + updates.push(vals); + return { returning: async () => [] }; + }, + }), + }), + updates, + }; + return db as unknown as Parameters[0] & { + updates: Record[]; + }; +} + +const clientRow = (over: Record = {}) => + ({ + id: "client-1", + name: "Alice", + email: "a@example.com", + address: "1 Main St", + latitude: null, + longitude: null, + geocodedAt: null, + ...over, + }) as unknown as Parameters[1]; + +// ─── geocodeClient ──────────────────────────────────────────────────────────── + +describe("geocodeClient", () => { + it("persists coordinates and returns a geocoded outcome", async () => { + const db = fakeDb([]); + const provider = fakeProvider(async () => okResult(40.1, -74.2)); + const outcome = await geocodeClient(db, clientRow(), provider); + + expect(outcome.status).toBe("geocoded"); + expect(outcome.latitude).toBe(40.1); + expect(outcome.longitude).toBe(-74.2); + expect(outcome.geocodedAt).toBeTruthy(); + expect(db.updates).toHaveLength(1); + expect(db.updates[0]!.latitude).toBe(40.1); + expect(db.updates[0]!.longitude).toBe(-74.2); + expect(db.updates[0]!.geocodedAt).toBeInstanceOf(Date); + }); + + it("returns no_address and does not persist when address is blank", async () => { + const db = fakeDb([]); + const provider = fakeProvider(async () => okResult(0, 0)); + const outcome = await geocodeClient(db, clientRow({ address: " " }), provider); + + expect(outcome.status).toBe("no_address"); + expect(provider.calls).toHaveLength(0); + expect(db.updates).toHaveLength(0); + }); + + it("returns unresolved when the provider finds no match", async () => { + const db = fakeDb([]); + const provider = fakeProvider(async () => null); + const outcome = await geocodeClient(db, clientRow(), provider); + + expect(outcome.status).toBe("unresolved"); + expect(outcome.message).toMatch(/could not be resolved/i); + expect(db.updates).toHaveLength(0); + }); + + it("returns error (without throwing) when the provider fails", async () => { + const db = fakeDb([]); + const provider = fakeProvider(async () => { + throw new Error("quota exceeded"); + }); + const outcome = await geocodeClient(db, clientRow(), provider); + + expect(outcome.status).toBe("error"); + expect(outcome.message).toMatch(/quota exceeded/); + expect(db.updates).toHaveLength(0); + }); +}); + +// ─── geocodeUngeocodedClients ───────────────────────────────────────────────── + +describe("geocodeUngeocodedClients", () => { + it("geocodes candidates, tallies outcomes, and reports remaining", async () => { + // First select() = count query, second select() = candidate rows. + const db = fakeDb([ + [{ count: 5 }], + [ + clientRow({ id: "c1", address: "1 Main St" }), + clientRow({ id: "c2", address: "2 Oak Ave" }), + clientRow({ id: "c3", address: "" }), // no_address + ], + ]); + const provider = fakeProvider(async (addr) => + addr === "2 Oak Ave" ? null : okResult(1, 2) + ); + + const summary = await geocodeUngeocodedClients(db, 50, provider); + + expect(summary.processed).toBe(3); + expect(summary.geocoded).toBe(1); + expect(summary.unresolved).toBe(1); // "2 Oak Ave" + expect(summary.remaining).toBe(2); // 5 total - 3 processed + expect(summary.provider).toBe("nominatim"); + expect(db.updates).toHaveLength(1); // only the successful one persisted + }); + + it("clamps the limit to the 1..500 range", async () => { + const db = fakeDb([[{ count: 0 }], []]); + const provider = fakeProvider(async () => okResult(1, 2)); + const summary = await geocodeUngeocodedClients(db, 0, provider); + expect(summary.processed).toBe(0); + expect(summary.remaining).toBe(0); + }); +}); + +// ─── resolveClientGeocodingProvider ─────────────────────────────────────────── + +describe("resolveClientGeocodingProvider", () => { + it("defaults to Nominatim when no settings row exists", async () => { + const db = fakeDb([[]]); // businessSettings select -> empty + const provider = await resolveClientGeocodingProvider(db); + expect(provider).toBeInstanceOf(NominatimGeocodingProvider); + expect(provider.name).toBe("nominatim"); + }); + + it("defaults to Nominatim when provider is unset on settings", async () => { + const db = fakeDb([[{ routeOptimizationProvider: null, googleMapsApiKey: null }]]); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const provider = await resolveClientGeocodingProvider(db); + expect(provider.name).toBe("nominatim"); + warn.mockRestore(); + }); +}); diff --git a/src/__tests__/petProfileSummary.test.ts b/src/__tests__/petProfileSummary.test.ts index d18d2da..0ea0f35 100644 --- a/src/__tests__/petProfileSummary.test.ts +++ b/src/__tests__/petProfileSummary.test.ts @@ -131,20 +131,6 @@ function makeAppointment(overrides: Record = {}) { }; } -function makeService(overrides: Record = {}) { - return { - id: "service-1", - name: "Full Groom", - description: null, - basePriceCents: 6000, - durationMinutes: 120, - active: true, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - function makeSession(overrides: Record = {}) { return { id: "sess-owner", @@ -164,7 +150,6 @@ function makeSession(overrides: Record = {}) { let petsTable: Record[]; let appointmentsTable: Record[]; -let servicesTable: Record[]; let sessionsTable: Record[]; // selectQueue: queries resolve in FIFO order. Each .from(table) result @@ -198,7 +183,6 @@ function enqueueThrow(table: string, message: string) { function resetMock() { petsTable = [makePet()]; appointmentsTable = [makeAppointment()]; - servicesTable = [makeService()]; sessionsTable = [makeSession()]; selectQueue = []; insertCapture = []; diff --git a/src/index.ts b/src/index.ts index 3dd8921..2845b14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -235,6 +235,15 @@ api.on( requireRole("manager", "receptionist", "groomer") ); +// Route-optimization geocoding endpoints are manager-only (GRO-2154), stricter +// than the general client write guard below. Registered FIRST so receptionists +// are rejected here before the manager+receptionist guard can admit them. +api.on( + ["POST"], + ["/clients/geocode-batch", "/clients/:clientId/geocode"], + requireRole("manager") +); + // Clients, appointments: all roles may read; only manager + receptionist may write api.on( ["POST", "PUT", "PATCH", "DELETE"], diff --git a/src/routes/clients.ts b/src/routes/clients.ts index 38104ec..e7ac65c 100644 --- a/src/routes/clients.ts +++ b/src/routes/clients.ts @@ -3,9 +3,61 @@ import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; +import { + geocodeClient, + geocodeUngeocodedClients, + resolveClientGeocodingProvider, + type ClientGeocodeOutcome, +} from "../services/clientGeocoding.js"; export const clientsRouter = new Hono(); +type ClientRow = typeof clients.$inferSelect; + +/** + * Best-effort auto-geocode of a freshly created/updated client (GRO-2154). + * Never throws: a flaky geocoding backend must not break client mutations. + * Returns the (possibly coordinate-enriched) row plus a structured outcome the + * caller surfaces under a `geocoding` field so ambiguous addresses are visible. + */ +async function autoGeocodeClient( + db: ReturnType, + row: ClientRow +): Promise<{ row: ClientRow; outcome: ClientGeocodeOutcome }> { + try { + const provider = await resolveClientGeocodingProvider(db); + const outcome = await geocodeClient(db, row, provider); + const enriched = + outcome.status === "geocoded" + ? { + ...row, + latitude: outcome.latitude, + longitude: outcome.longitude, + geocodedAt: outcome.geocodedAt + ? new Date(outcome.geocodedAt) + : row.geocodedAt, + } + : row; + return { row: enriched, outcome }; + } catch (err) { + return { + row, + outcome: { + clientId: row.id, + status: "error", + message: `Auto-geocode failed: ${ + err instanceof Error ? err.message : String(err) + }`, + latitude: null, + longitude: null, + geocodedAt: null, + formattedAddress: null, + provider: null, + }, + }; + } +} + const createClientSchema = z.object({ name: z.string().min(1).max(200), email: z.string().email(), @@ -91,9 +143,59 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => { const db = getDb(); const body = c.req.valid("json"); const [row] = await db.insert(clients).values(body).returning(); + if (!row) return c.json({ error: "Failed to create client" }, 500); + + // Auto-geocode on create when an address is supplied (GRO-2154). Best-effort: + // the client is created regardless; the `geocoding` field surfaces failures. + if (body.address && body.address.trim()) { + const { row: enriched, outcome } = await autoGeocodeClient(db, row); + return c.json({ ...enriched, geocoding: outcome }, 201); + } return c.json(row, 201); }); +// Geocode a single client's address and persist coordinates (manager-only; +// enforced by the route guard in index.ts). +clientsRouter.post("/:clientId/geocode", async (c) => { + const db = getDb(); + const clientId = c.req.param("clientId"); + const [client] = await db + .select() + .from(clients) + .where(eq(clients.id, clientId)); + if (!client) return c.json({ error: "Not found" }, 404); + + const provider = await resolveClientGeocodingProvider(db); + const outcome = await geocodeClient(db, client, provider); + + // Map outcome to an HTTP status so the result is unambiguous to the caller: + // geocoded -> 200, provider error -> 502, no_address/unresolved -> 422. + const status = + outcome.status === "geocoded" + ? 200 + : outcome.status === "error" + ? 502 + : 422; + return c.json(outcome, status); +}); + +// Batch-geocode un-geocoded clients with provider-rate-limited throttling +// (manager-only). Processes up to ?limit clients (default 50, max 500) per call; +// re-invoke while `remaining` > 0 to finish large datasets. +clientsRouter.post("/geocode-batch", async (c) => { + const db = getDb(); + const limitRaw = c.req.query("limit"); + let limit = 50; + if (limitRaw !== undefined) { + limit = Number(limitRaw); + if (!Number.isFinite(limit) || limit <= 0) { + return c.json({ error: "limit must be a positive integer" }, 400); + } + } + const summary = await geocodeUngeocodedClients(db, limit); + return c.json(summary); +}); + // Update a client (including status changes) const patchClientSchema = createClientSchema.partial().extend({ status: z.enum(["active", "disabled"]).optional(), @@ -123,13 +225,29 @@ clientsRouter.patch( } delete setValues.smsOptOut; - const [row] = await db + // Auto-geocode on address change (GRO-2154). If the address was cleared, + // drop any stale coordinates so a disabled/blank address never keeps a pin. + const addressProvided = Object.prototype.hasOwnProperty.call(body, "address"); + const trimmedAddress = + typeof body.address === "string" ? body.address.trim() : undefined; + if (addressProvided && !trimmedAddress) { + setValues.latitude = null; + setValues.longitude = null; + setValues.geocodedAt = null; + } + + const [updated] = await db .update(clients) .set(setValues) .where(eq(clients.id, c.req.param("id"))) .returning(); - if (!row) return c.json({ error: "Not found" }, 404); - return c.json(row); + if (!updated) return c.json({ error: "Not found" }, 404); + + if (addressProvided && trimmedAddress) { + const { row: enriched, outcome } = await autoGeocodeClient(db, updated); + return c.json({ ...enriched, geocoding: outcome }); + } + return c.json(updated); } ); diff --git a/src/services/clientGeocoding.ts b/src/services/clientGeocoding.ts new file mode 100644 index 0000000..eb16b29 --- /dev/null +++ b/src/services/clientGeocoding.ts @@ -0,0 +1,212 @@ +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, + }; +}