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 0f95961..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 | @@ -261,6 +279,10 @@ This means: | TC-API-8.9 | SSO bridge — no Better Auth session | POST /api/portal/session-from-auth without Better Auth session cookie | 401 Unauthorized | | TC-API-8.10 | SSO bridge — no matching client | POST /api/portal/session-from-auth with valid Better Auth session for a user with no client record | 404 Not Found, error "No client record found for this user" | | TC-API-8.11 | SSO bridge — returned session works on portal routes | After TC-API-8.8, use returned sessionId as `X-Impersonation-Session-Id` header on GET /api/portal/me | 200 OK, client profile returned | +| TC-API-8.12 | Portal GET pets returns extended fields (GRO-2187) | Establish a portal session (TC-API-8.8), then `GET /api/portal/pets` with `X-Impersonation-Session-Id` | 200 OK; each pet includes `coatType`, `petSizeCategory`, `healthAlerts`, `preferredCuts`, `medicalAlerts` (in addition to id/name/breed/weight/birthDate/photoUrl/notes) | +| TC-API-8.13 | Portal pet update — owner success + persistence (GRO-2187, fixes [GRO-1480](/GRO/issues/GRO-1480) §5.23) | With a portal session for the pet's owner, `PATCH /api/portal/pets/{petId}` with body `{ "name": "...", "breed": "...", "weightKg": 18.25, "healthAlerts": "...", "coatType": "double", "petSizeCategory": "xlarge", "preferredCuts": ["teddy bear"], "medicalAlerts": [{"type":"allergy","description":"oatmeal","severity":"medium"}] }` | 200 OK; response reflects the update with `petSizeCategory: "extra_large"` (web `xlarge` → DB `extra_large`). A follow-up `GET /api/portal/pets` shows the persisted values | +| TC-API-8.14 | Portal pet update — non-owner blocked (GRO-2187) | `PATCH /api/portal/pets/{petId}` for a pet owned by a different client, using another client's portal session | 403 Forbidden (or 404 if pet id is unknown); no mutation persisted | +| TC-API-8.15 | Portal pet update — invalid enum rejected (GRO-2187) | `PATCH /api/portal/pets/{petId}` with `coatType: "fluffy"` or `petSizeCategory: "gigantic"` | 422 Unprocessable Entity; pet unchanged | ### 4.9 Waitlist diff --git a/packages/db/migrations/0041_route_optimization.sql b/packages/db/migrations/0041_route_optimization.sql new file mode 100644 index 0000000..634bfa5 --- /dev/null +++ b/packages/db/migrations/0041_route_optimization.sql @@ -0,0 +1,66 @@ +-- Migration: 0041_route_optimization.sql +-- Route optimization schema: geocoding columns on clients, groomerRoutes + +-- routeStops tables, and route settings on business_settings. +-- Written idempotently so it is safe to re-run. + +-- ─── Enums ──────────────────────────────────────────────────────────────────── + +DO $$ BEGIN + CREATE TYPE "route_status" AS ENUM ('draft', 'optimized', 'in_progress', 'completed'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- ─── Clients: geocoding columns ─────────────────────────────────────────────── + +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "latitude" double precision; +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "longitude" double precision; +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "geocoded_at" timestamp; + +-- ─── Business settings: route optimization config ───────────────────────────── + +ALTER TABLE "business_settings" + ADD COLUMN IF NOT EXISTS "default_travel_buffer_mins" integer NOT NULL DEFAULT 15; +ALTER TABLE "business_settings" + ADD COLUMN IF NOT EXISTS "route_optimization_provider" text DEFAULT 'nominatim'; +-- Encrypted at rest at the application layer (AES-256-GCM). +ALTER TABLE "business_settings" + ADD COLUMN IF NOT EXISTS "google_maps_api_key" text; + +-- ─── Groomer routes table ───────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS "groomer_routes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "staff_id" uuid NOT NULL REFERENCES "staff"("id") ON DELETE CASCADE, + "route_date" date NOT NULL, + "status" "route_status" NOT NULL DEFAULT 'draft', + "total_travel_mins" integer, + "total_distance_km" numeric(8, 2), + "optimized_at" timestamp, + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "uq_groomer_routes_staff_date" UNIQUE ("staff_id", "route_date") +); + +CREATE INDEX IF NOT EXISTS "idx_groomer_routes_staff_id" + ON "groomer_routes"("staff_id"); + +-- ─── Route stops table ──────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS "route_stops" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "route_id" uuid NOT NULL REFERENCES "groomer_routes"("id") ON DELETE CASCADE, + "appointment_id" uuid NOT NULL REFERENCES "appointments"("id") ON DELETE CASCADE, + "stop_order" integer NOT NULL, + "latitude" double precision NOT NULL, + "longitude" double precision NOT NULL, + "travel_mins_from_prev" integer, + "travel_distance_km_from_prev" numeric(8, 2), + "buffer_mins" integer NOT NULL DEFAULT 15, + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "uq_route_stops_route_appointment" UNIQUE ("route_id", "appointment_id"), + CONSTRAINT "uq_route_stops_route_order" UNIQUE ("route_id", "stop_order") +); + +CREATE INDEX IF NOT EXISTS "idx_route_stops_route_id" + ON "route_stops"("route_id"); diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 47e54de..1e0c785 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -281,6 +281,13 @@ "when": 1780000000002, "tag": "0040_register_missing_coat_type_values", "breakpoints": true + }, + { + "idx": 41, + "version": "7", + "when": 1780000000003, + "tag": "0041_route_optimization", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index c15d42e..866e9b5 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -78,6 +78,9 @@ export function buildClient(overrides: Partial = {}): ClientRow { stripeCustomerId: null, status: "active", disabledAt: null, + latitude: null, + longitude: null, + geocodedAt: null, createdAt: new Date("2025-01-01T00:00:00Z"), updatedAt: new Date("2025-01-01T00:00:00Z"), ...overrides, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3a12d96..292fe1c 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,5 +1,7 @@ import { boolean, + date, + doublePrecision, index, integer, jsonb, @@ -140,6 +142,10 @@ export const clients = pgTable( stripeCustomerId: text("stripe_customer_id"), status: clientStatusEnum("status").notNull().default("active"), disabledAt: timestamp("disabled_at"), + // Geocoded coordinates for route optimization; null until geocoded. + latitude: doublePrecision("latitude"), + longitude: doublePrecision("longitude"), + geocodedAt: timestamp("geocoded_at"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }, @@ -555,6 +561,16 @@ export const businessSettings = pgTable("business_settings", { accentColor: text("accent_color").notNull().default("#8b7355"), messagingPhoneNumber: text("messaging_phone_number"), telnyxMessagingProfileId: text("telnyx_messaging_profile_id"), + // Route optimization settings. + defaultTravelBufferMins: integer("default_travel_buffer_mins") + .notNull() + .default(15), + routeOptimizationProvider: text("route_optimization_provider").default( + "nominatim" + ), + // Encrypted at rest at the application layer (AES-256-GCM), mirroring + // the handling of authProviderConfigs.clientSecret. + googleMapsApiKey: text("google_maps_api_key"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); @@ -658,3 +674,69 @@ export const bufferRules = pgTable( index("idx_buffer_rules_service_id").on(t.serviceId), ] ); + +// ─── Route Optimization ─────────────────────────────────────────────────────── + +export const routeStatusEnum = pgEnum("route_status", [ + "draft", + "optimized", + "in_progress", + "completed", +]); + +// A groomer's optimized route for a single day. One row per (staff, date). +export const groomerRoutes = pgTable( + "groomer_routes", + { + id: uuid("id").primaryKey().defaultRandom(), + staffId: uuid("staff_id") + .notNull() + .references(() => staff.id, { onDelete: "cascade" }), + routeDate: date("route_date", { mode: "string" }).notNull(), + status: routeStatusEnum("status").notNull().default("draft"), + // Populated once the route is optimized. + totalTravelMins: integer("total_travel_mins"), + totalDistanceKm: numeric("total_distance_km", { precision: 8, scale: 2 }), + optimizedAt: timestamp("optimized_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + // One route per groomer per day. + unique("uq_groomer_routes_staff_date").on(t.staffId, t.routeDate), + index("idx_groomer_routes_staff_id").on(t.staffId), + ] +); + +// An ordered stop within a groomer's route, tied to an appointment. +export const routeStops = pgTable( + "route_stops", + { + id: uuid("id").primaryKey().defaultRandom(), + routeId: uuid("route_id") + .notNull() + .references(() => groomerRoutes.id, { onDelete: "cascade" }), + appointmentId: uuid("appointment_id") + .notNull() + .references(() => appointments.id, { onDelete: "cascade" }), + stopOrder: integer("stop_order").notNull(), + latitude: doublePrecision("latitude").notNull(), + longitude: doublePrecision("longitude").notNull(), + // Null for the first stop in the route. + travelMinsFromPrev: integer("travel_mins_from_prev"), + travelDistanceKmFromPrev: numeric("travel_distance_km_from_prev", { + precision: 8, + scale: 2, + }), + bufferMins: integer("buffer_mins").notNull().default(15), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + // An appointment appears at most once per route. + unique("uq_route_stops_route_appointment").on(t.routeId, t.appointmentId), + // Stop order is unique within a route. + unique("uq_route_stops_route_order").on(t.routeId, t.stopOrder), + index("idx_route_stops_route_id").on(t.routeId), + ] +); 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__/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/__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/__tests__/portalPets.test.ts b/src/__tests__/portalPets.test.ts new file mode 100644 index 0000000..c23bca0 --- /dev/null +++ b/src/__tests__/portalPets.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; +const OTHER_CLIENT_ID = "550e8400-e29b-41d4-a716-446655440099"; +const PET_ID = "880e8400-e29b-41d4-a716-446655440004"; +const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003"; + +const futureDate = () => new Date(Date.now() + 30 * 60 * 1000); + +const ACTIVE_SESSION = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active" as const, + expiresAt: futureDate(), + createdAt: new Date(), +}; + +// A persisted pet owned by CLIENT_ID. weightKg is a string because the column is +// numeric (Drizzle serialises numeric to string). +const PET = { + id: PET_ID, + clientId: CLIENT_ID, + name: "Rex", + species: "dog", + breed: "Labrador", + weightKg: "12.50", + dateOfBirth: null, + healthAlerts: null, + groomingNotes: null, + coatType: null, + petSizeCategory: null, + preferredCuts: [], + medicalAlerts: [], + photoKey: null, +}; + +let selectSessionRow: Record | null = null; +let selectPetRow: Record | null = null; +let updatedValues: Record[] = []; + +function resetMock() { + selectSessionRow = null; + selectPetRow = null; + updatedValues = []; +} + +vi.mock("@groombook/db", () => { + function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => chain; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + return chain; + } + + function tableProxy(name: string) { + return new Proxy( + { _name: name }, + { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) } + ); + } + + const impersonationSessions = tableProxy("impersonationSessions"); + const pets = tableProxy("pets"); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "impersonationSessions") { + return makeChainable(selectSessionRow ? [selectSessionRow] : []); + } + if (table._name === "pets") { + return makeChainable(selectPetRow ? [selectPetRow] : []); + } + return makeChainable([]); + }, + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => ({ + returning: () => { + if (selectPetRow) { + updatedValues.push(vals); + return [{ ...selectPetRow, ...vals }]; + } + return []; + }, + }), + }), + }), + // portalAudit inserts an audit row after the handler; make it a no-op so + // the middleware does not log a swallowed error during tests. + insert: () => ({ values: () => ({ returning: () => [] }) }), + }), + impersonationSessions, + pets, + // Other tables imported by the portal router but unused in these tests. + appointments: tableProxy("appointments"), + waitlistEntries: tableProxy("waitlistEntries"), + clients: tableProxy("clients"), + services: tableProxy("services"), + staff: tableProxy("staff"), + invoices: tableProxy("invoices"), + invoiceLineItems: tableProxy("invoiceLineItems"), + impersonationAuditLogs: tableProxy("impersonationAuditLogs"), + eq: vi.fn(), + and: vi.fn(), + inArray: vi.fn(), + }; +}); + +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/portal", portalRouter); + +function jsonPatch(path: string, body: unknown, headers?: Record) { + return app.request(path, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => resetMock()); + +describe("PATCH /portal/pets/:petId", () => { + it("updates an owned pet and persists the mapped columns (200)", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + // Mirrors the groombook/web PetForm payload: it spreads the GET-shaped pet + // (weight, notes, birthDate, photoUrl) and adds the form's edited keys + // (weightKg, healthAlerts, coatType, …). "xlarge" must map to "extra_large". + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { + id: PET_ID, + name: "Rex Updated", + breed: "Golden Retriever", + weight: "12.50", + weightKg: 18.25, + notes: "old grooming notes", + healthAlerts: "Allergic to oatmeal shampoo", + photoUrl: "pets/rex.jpg", + coatType: "double", + petSizeCategory: "xlarge", + preferredCuts: ["teddy bear", "puppy cut"], + medicalAlerts: [ + { id: "a1", type: "allergy", description: "oatmeal", severity: "medium" }, + ], + }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe("Rex Updated"); + expect(body.petSizeCategory).toBe("extra_large"); + expect(body.coatType).toBe("double"); + + const persisted = updatedValues[0]!; + expect(persisted.name).toBe("Rex Updated"); + expect(persisted.breed).toBe("Golden Retriever"); + // weightKg (form key) wins over weight (GET key) and is stored as a string. + expect(persisted.weightKg).toBe("18.25"); + expect(persisted.groomingNotes).toBe("old grooming notes"); + expect(persisted.healthAlerts).toBe("Allergic to oatmeal shampoo"); + // photoKey is NOT writable via portal PATCH (GRO-2187 S3 key-hijack fix): + // the web form round-trips the GET-shaped photoUrl, but the server must not + // persist it. Photo changes go through the key-validated upload flow. + expect(persisted.photoKey).toBeUndefined(); + expect(persisted.coatType).toBe("double"); + expect(persisted.petSizeCategory).toBe("extra_large"); + expect(persisted.preferredCuts).toEqual(["teddy bear", "puppy cut"]); + expect(persisted.medicalAlerts).toEqual([ + { id: "a1", type: "allergy", description: "oatmeal", severity: "medium" }, + ]); + expect(persisted.updatedAt).toBeInstanceOf(Date); + }); + + // GRO-2187 security regression: a portal customer must not be able to set the + // S3 object key. photoKey is consumed server-side by getPresignedGetUrl / + // deleteObject; the upload path guards keys with a pets/{petId}/ prefix, and the + // portal PATCH must not offer a bypass. A foreign/arbitrary photoUrl is accepted + // (Zod strips the unknown key) but must leave photoKey untouched. + it("does not mutate photoKey when a foreign photoUrl is supplied (200)", async () => { + selectSessionRow = ACTIVE_SESSION; + const ownKey = `pets/${PET_ID}/original.jpg`; + selectPetRow = { ...PET, photoKey: ownKey }; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { + name: "Rex", + // attacker-chosen key pointing at another tenant's object + photoUrl: "pets/00000000-0000-0000-0000-0000000000ff/victim-secret.jpg", + }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(200); + const persisted = updatedValues[0]!; + // The attacker-supplied key never reaches the update payload. + expect(persisted.photoKey).toBeUndefined(); + // And the stored key is unchanged from the pet's own value. + const body = await res.json(); + expect(body.photoUrl).toBe(ownKey); + }); + + // The length/array caps live in the Zod schema, so violations are rejected by + // zValidator with 400 (in-handler enum checks are what return 422). + it("returns 400 when a medicalAlert description exceeds the length cap", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { + medicalAlerts: [ + { type: "allergy", description: "x".repeat(2001), severity: "low" }, + ], + }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(400); + expect(updatedValues).toHaveLength(0); + }); + + it("falls back to the weight key when weightKg is absent", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { weight: "9.75" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(200); + expect(updatedValues[0]!.weightKg).toBe("9.75"); + }); + + it("returns 403 when the pet belongs to a different client", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = { ...PET, clientId: OTHER_CLIENT_ID }; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { name: "Hacker" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(403); + expect(updatedValues).toHaveLength(0); + }); + + it("returns 404 when the pet does not exist", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = null; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { name: "Ghost" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(404); + }); + + it("returns 422 for an invalid coatType", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { coatType: "fluffy" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(422); + expect(updatedValues).toHaveLength(0); + }); + + it("returns 422 for an invalid petSizeCategory", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { petSizeCategory: "gigantic" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(422); + expect(updatedValues).toHaveLength(0); + }); + + it("returns 401 without an impersonation session header", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + const res = await jsonPatch(`/portal/pets/${PET_ID}`, { name: "NoAuth" }); + + expect(res.status).toBe(401); + }); +}); 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/routes/portal.ts b/src/routes/portal.ts index 7b7b160..0b106fb 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -225,9 +225,158 @@ portalRouter.get("/pets", async (c) => { const clientId = c.get("portalClientId"); const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); - return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes }))); + return c.json(clientPets.map(p => ({ + id: p.id, + name: p.name, + breed: p.breed, + weight: p.weightKg, + birthDate: p.dateOfBirth, + photoUrl: p.photoKey, + notes: p.groomingNotes, + coatType: p.coatType, + petSizeCategory: p.petSizeCategory, + healthAlerts: p.healthAlerts, + preferredCuts: p.preferredCuts, + medicalAlerts: p.medicalAlerts, + }))); }); +// ─── Customer-facing pet update ─────────────────────────────────────────────── +// +// The customer portal pet-profile form (groombook/web) saves edits via +// PATCH /api/portal/pets/:petId. The web payload mixes the keys returned by +// GET /portal/pets (weight, birthDate, photoUrl, notes) with the form's own +// edited keys (weightKg, healthAlerts, coatType, …), so we accept both spellings +// and map each to its `pets` column. Ownership is enforced exactly like the +// appointment-notes handler: 404 if the pet does not exist, 403 if it belongs to +// another client. + +// Allowed enum values mirror packages/db/src/schema.ts coatTypeEnum / +// petSizeCategoryEnum. Kept as plain string lists so an invalid value can be +// rejected with 422 in-handler (zValidator failures would surface as 400). +const PORTAL_COAT_TYPES: readonly string[] = ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]; +const PORTAL_PET_SIZES: readonly string[] = ["small", "medium", "large", "extra_large"]; +// The web size dropdown emits "xlarge"; the DB enum value is "extra_large". +const PORTAL_PET_SIZE_ALIASES: Record = { xlarge: "extra_large" }; + +const portalMedicalAlertSchema = z.object({ + id: z.string().optional(), + type: z.string().max(2000), + description: z.string().max(2000), + severity: z.enum(["low", "medium", "high"]), +}); + +const portalPetUpdateSchema = z.object({ + name: z.string().min(1).max(200).optional(), + breed: z.string().max(200).nullable().optional(), + // weightKg is the form's edited key; weight is the GET-shaped key. Accept both. + weightKg: z.union([z.number(), z.string()]).nullable().optional(), + weight: z.union([z.number(), z.string()]).nullable().optional(), + birthDate: z.string().nullable().optional(), + notes: z.string().max(2000).nullable().optional(), + healthAlerts: z.string().max(2000).nullable().optional(), + // photoUrl/photoKey are intentionally NOT writable here: photoKey is a trusted + // S3 object key consumed server-side (getPresignedGetUrl / deleteObject), and the + // upload path (pets.ts) already enforces a pets/{petId}/ prefix guard against key + // hijacking. Photo changes go through the dedicated upload + /photo/confirm flow. + // The web form round-trips the GET-shaped photoUrl; Zod strips it as an unknown key. + // coatType / petSizeCategory validated in-handler so bad values return 422. + coatType: z.string().nullable().optional(), + petSizeCategory: z.string().nullable().optional(), + preferredCuts: z.array(z.string().max(2000)).max(50).nullable().optional(), + medicalAlerts: z.array(portalMedicalAlertSchema).max(50).nullable().optional(), +}); + +portalRouter.patch( + "/pets/:petId", + zValidator("json", portalPetUpdateSchema), + async (c) => { + const db = getDb(); + const petId = c.req.param("petId"); + const body = c.req.valid("json"); + const clientId = c.get("portalClientId"); + + const [pet] = await db + .select() + .from(pets) + .where(eq(pets.id, petId)) + .limit(1); + + if (!pet) { + return c.json({ error: "Not found" }, 404); + } + + if (pet.clientId !== clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + const updateData: Record = { updatedAt: new Date() }; + + if (body.name !== undefined) updateData.name = body.name; + if (body.breed !== undefined) updateData.breed = body.breed; + + if (body.weightKg !== undefined || body.weight !== undefined) { + const w = body.weightKg ?? body.weight; + updateData.weightKg = w === null || w === undefined ? null : String(w); + } + + if (body.birthDate !== undefined) { + updateData.dateOfBirth = body.birthDate ? new Date(body.birthDate) : null; + } + + if (body.notes !== undefined) updateData.groomingNotes = body.notes; + if (body.healthAlerts !== undefined) updateData.healthAlerts = body.healthAlerts; + // photoKey is intentionally not writable here — see portalPetUpdateSchema note. + // Photo changes go through the key-validated upload + /photo/confirm flow. + + if (body.coatType !== undefined) { + if (body.coatType !== null && !PORTAL_COAT_TYPES.includes(body.coatType)) { + return c.json({ error: "Invalid coatType" }, 422); + } + updateData.coatType = body.coatType; + } + + if (body.petSizeCategory !== undefined) { + let size: string | null = body.petSizeCategory; + if (size !== null) { + size = PORTAL_PET_SIZE_ALIASES[size] ?? size; + if (!PORTAL_PET_SIZES.includes(size)) { + return c.json({ error: "Invalid petSizeCategory" }, 422); + } + } + updateData.petSizeCategory = size; + } + + if (body.preferredCuts !== undefined) updateData.preferredCuts = body.preferredCuts ?? []; + if (body.medicalAlerts !== undefined) updateData.medicalAlerts = body.medicalAlerts ?? []; + + const [updated] = await db + .update(pets) + .set(updateData) + .where(eq(pets.id, petId)) + .returning(); + + if (!updated) { + return c.json({ error: "Not found" }, 404); + } + + return c.json({ + id: updated.id, + name: updated.name, + breed: updated.breed, + weight: updated.weightKg, + birthDate: updated.dateOfBirth, + photoUrl: updated.photoKey, + notes: updated.groomingNotes, + coatType: updated.coatType, + petSizeCategory: updated.petSizeCategory, + healthAlerts: updated.healthAlerts, + preferredCuts: updated.preferredCuts, + medicalAlerts: updated.medicalAlerts, + }); + } +); + portalRouter.get("/invoices", async (c) => { const db = getDb(); const clientId = c.get("portalClientId"); 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, + }; +} 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; +}