From b9fc6887697e30ad7e1eef6c15d95b62c8ced435 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 13:37:30 +0000 Subject: [PATCH 1/4] fix(db): wait for/retry DB DNS resolution before drizzle-kit migrate (GRO-2163) (#161) --- packages/db/package.json | 7 +- packages/db/scripts/wait-for-db.mjs | 104 ++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 packages/db/scripts/wait-for-db.mjs diff --git a/packages/db/package.json b/packages/db/package.json index 4cdd0d9..7f97370 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -18,9 +18,10 @@ "scripts": { "build": "tsc --project .", "generate": "drizzle-kit generate", - "migrate": "drizzle-kit migrate", - "seed": "tsx src/seed.ts", - "reset": "tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts", + "wait-for-db": "node ./scripts/wait-for-db.mjs", + "migrate": "node ./scripts/wait-for-db.mjs && drizzle-kit migrate", + "seed": "node ./scripts/wait-for-db.mjs && tsx src/seed.ts", + "reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts", "studio": "drizzle-kit studio", "typecheck": "tsc --noEmit" }, diff --git a/packages/db/scripts/wait-for-db.mjs b/packages/db/scripts/wait-for-db.mjs new file mode 100644 index 0000000..04d9d9e --- /dev/null +++ b/packages/db/scripts/wait-for-db.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +// wait-for-db.mjs +// +// GRO-2163: wait for / retry DNS resolution of the database hostname derived +// from DATABASE_URL before invoking `drizzle-kit migrate`. The first attempt +// of a fresh migrate-schema pod occasionally hits a transient CoreDNS miss +// (EAI_AGAIN) on `groombook-postgres-rw..svc`; with backoffLimit: 2 the +// retry pod usually wins, but three unlucky attempts in a row trips +// BackoffLimitExceeded. Resolving once here, with backoff, removes the dice +// roll at the source so the first attempt reliably succeeds. +// +// Mirrors the belt-and-braces pattern used in GRO-1985 (no Corepack +// download fallback): we don't try to outsmart CoreDNS, we just don't ask +// drizzle-kit to do the very first DNS lookup of a freshly-scheduled pod. +// +// Configuration (env): +// WAIT_FOR_DB_MAX_ATTEMPTS default 12 (~30s of total wait at default backoff) +// WAIT_FOR_DB_BASE_DELAY_MS default 500 +// WAIT_FOR_DB_MAX_DELAY_MS default 5000 +// WAIT_FOR_DB_SKIP default unset; set to "1" to skip (debug only) +// +// On success: exit 0. On exhaustion: exit 1 so the Job's backoff is +// preserved (we don't want to silently mask a real outage by giving up +// after 30s and letting drizzle-kit fail with a less-actionable error). + +import { setTimeout as delay } from "node:timers/promises"; +import dns from "node:dns/promises"; + +const MAX_ATTEMPTS = Number(process.env.WAIT_FOR_DB_MAX_ATTEMPTS ?? 12); +const BASE_DELAY_MS = Number(process.env.WAIT_FOR_DB_BASE_DELAY_MS ?? 500); +const MAX_DELAY_MS = Number(process.env.WAIT_FOR_DB_MAX_DELAY_MS ?? 5000); + +function parseHost(databaseUrl) { + try { + return new URL(databaseUrl).hostname || null; + } catch { + return null; + } +} + +async function resolveOnce(host) { + const start = Date.now(); + const result = await dns.lookup(host); + return { address: result.address, ms: Date.now() - start }; +} + +async function main() { + if (process.env.WAIT_FOR_DB_SKIP === "1") { + console.log("[wait-for-db] WAIT_FOR_DB_SKIP=1, skipping"); + return; + } + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + // Don't gate the migrate on a misconfigured env — let drizzle-kit fail + // loudly with its own clear error. + console.warn("[wait-for-db] DATABASE_URL not set; skipping"); + return; + } + const host = parseHost(databaseUrl); + if (!host) { + console.warn(`[wait-for-db] could not parse hostname from DATABASE_URL; skipping`); + return; + } + console.log( + `[wait-for-db] host=${host} max_attempts=${MAX_ATTEMPTS} ` + + `base_delay_ms=${BASE_DELAY_MS} max_delay_ms=${MAX_DELAY_MS}`, + ); + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + const { address, ms } = await resolveOnce(host); + console.log(`[wait-for-db] ok attempt=${attempt} host=${host} -> ${address} (${ms}ms)`); + return; + } catch (err) { + const code = err?.code ?? "UNKNOWN"; + const transient = code === "EAI_AGAIN" || code === "ENOTFOUND" || code === "EAI_NODATA"; + if (!transient) { + // Hard error (e.g. invalid hostname): surface and let drizzle-kit fail + // with a real error rather than spinning. + console.error(`[wait-for-db] non-transient DNS error attempt=${attempt} code=${code}: ${err.message}`); + process.exit(1); + } + if (attempt === MAX_ATTEMPTS) { + console.error( + `[wait-for-db] exhausted attempts=${MAX_ATTEMPTS} host=${host} last_code=${code}; exiting 1`, + ); + process.exit(1); + } + const backoff = Math.min( + MAX_DELAY_MS, + BASE_DELAY_MS * 2 ** (attempt - 1) + Math.floor(Math.random() * BASE_DELAY_MS), + ); + console.log( + `[wait-for-db] transient attempt=${attempt} code=${code} retry_in_ms=${backoff}`, + ); + await delay(backoff); + } + } +} + +main().catch((err) => { + console.error(`[wait-for-db] fatal: ${err?.message ?? err}`); + process.exit(1); +}); -- 2.52.0 From d0c0b1b646246d045aff0f2f85c87906bd2b9d51 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 13:57:07 +0000 Subject: [PATCH 2/4] feat(GRO-2155): route CRUD + optimization endpoint (Phase 2.1) (#175) --- UAT_PLAYBOOK.md | 18 ++ src/__tests__/routeOptimization.test.ts | 184 +++++++++++ src/index.ts | 6 + src/routes/routes.ts | 284 ++++++++++++++++ src/services/routeOptimization.ts | 413 ++++++++++++++++++++++++ 5 files changed, 905 insertions(+) create mode 100644 src/__tests__/routeOptimization.test.ts create mode 100644 src/routes/routes.ts create mode 100644 src/services/routeOptimization.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index c729065..cacd4f4 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -358,6 +358,24 @@ This means: | TC-API-15.6 | Reject missing required fields | POST /api/admin/buffer-rules with service only | 400 Bad Request, species and sizeCategory required | | TC-API-15.7 | Booking uses buffer | Book appointment for pet with sizeCategory; verify duration reflects buffer | 201 Created, appointment duration includes buffer time | +### 4.16 Route Optimization — Route CRUD + Optimize (GRO-2155, Phase 2.1) + +A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes`, with ordered `route_stops`. `POST /api/routes/optimize` pulls the day's non-cancelled appointments whose client is geocoded (GRO-2154), orders them (Google Directions `optimizeWaypoints` when a key is configured in `businessSettings.googleMapsApiKey`, else an offline nearest-neighbor heuristic), and persists `stopOrder`, `travelMinsFromPrev`, `travelDistanceKmFromPrev` plus route `totalTravelMins`/`totalDistanceKm`/`optimizedAt`. **Auth: manager (any groomer's route) or groomer (own route only); receptionists have no access.** Pre-condition: at least one geocoded client with appointments on the target date for the staff member (use §4.2 geocoding + a seed groomer). + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-API-16.1 | Fetch daily route (auto-create draft) | As **manager**, `GET /api/routes/daily?staffId={groomerId}&date=YYYY-MM-DD` for a date with no existing route | 200 OK; body `{ route, stops }`. `route.status` is `"draft"`, `route.staffId`/`routeDate` match, `stops` is `[]`. Re-calling returns the same route row (no duplicate) | +| TC-API-16.2 | Optimize a multi-stop day | As manager, with ≥2 geocoded appointments for the groomer on the date, `POST /api/routes/optimize` body `{ "staffId": "{groomerId}", "date": "YYYY-MM-DD" }` | 200 OK; `route.status: "optimized"`, `optimizedAt` set, `totalTravelMins`/`totalDistanceKm` populated. `stops` ordered by `stopOrder` (1..N); first stop has `travelMinsFromPrev: null`, the rest positive. `provider` is `"nearest_neighbor"` (no Google key in UAT). Each stop carries `bufferMins` (default 15) | +| TC-API-16.3 | Re-optimize replaces prior order | As manager, run TC-API-16.2 twice | Second call returns 200; stops fully replaced (no duplicate `route_stops`, `stopOrder` still contiguous 1..N), `optimizedAt` refreshed | +| TC-API-16.4 | Skips un-geocoded appointments | As manager, optimize a day where one appointment's client has no coordinates | 200 OK; that appointment is absent from `stops` and listed under `skipped[]` with `reason: "client address is not geocoded"`; a corresponding entry appears in `warnings[]` | +| TC-API-16.5 | Empty / single-stop day | As manager, optimize a date with 0 (or 1) geocoded appointments | 200 OK; `route.status: "optimized"`, `totalTravelMins: 0`, `totalDistanceKm: "0.00"`. For 1 stop, `stops` has one entry with `travelMinsFromPrev: null` | +| TC-API-16.6 | >25 stops chunked with warning | As manager, optimize a day with >25 geocoded appointments | 200 OK; `chunked: true`, `subRouteCount ≥ 2`, a `warnings[]` entry mentions sub-routes; all appointments appear exactly once with contiguous `stopOrder` | +| TC-API-16.7 | Groomer reads own route | As **groomer**, `GET /api/routes/daily?date=YYYY-MM-DD` (omit staffId, or pass own id) | 200 OK; route resolves to the groomer's own `staffId` | +| TC-API-16.8 | Groomer cannot access another's route | As groomer, `GET /api/routes/daily?staffId={otherGroomerId}&date=...` or `POST /api/routes/optimize` with another `staffId` | 403 Forbidden (`groomers may only access their own route`) | +| TC-API-16.9 | Receptionist denied | As **receptionist**, `GET /api/routes/daily?...` or `POST /api/routes/optimize` | 403 Forbidden (role not permitted) | +| TC-API-16.10 | Manager must supply staffId | As manager, `POST /api/routes/optimize` body `{ "date": "YYYY-MM-DD" }` (no staffId) | 400 `{ error: "staffId is required" }` | +| TC-API-16.11 | Invalid date rejected | `GET /api/routes/daily?staffId=...&date=06-08-2026` (wrong format) | 400 validation error (`date must be YYYY-MM-DD`) | + ## Pass/Fail Criteria **Pass:** diff --git a/src/__tests__/routeOptimization.test.ts b/src/__tests__/routeOptimization.test.ts new file mode 100644 index 0000000..49877d4 --- /dev/null +++ b/src/__tests__/routeOptimization.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from "vitest"; +import { + haversineKm, + estimateLeg, + nearestNeighborOrder, + optimizeRoute, + MAX_STOPS_PER_ROUTE, + type RouteStopInput, +} from "../services/routeOptimization.js"; +import type { FetchLike } from "../services/geocoding.js"; + +/** Builds a fake fetch returning a single JSON body, recording 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 }; +} + +function stop(appointmentId: string, lat: number, lng: number): RouteStopInput { + return { appointmentId, latitude: lat, longitude: lng }; +} + +describe("haversineKm", () => { + it("is zero for the same point", () => { + expect(haversineKm({ latitude: 40, longitude: -74 }, { latitude: 40, longitude: -74 })).toBe(0); + }); + + it("approximates 1 degree of latitude as ~111km", () => { + const d = haversineKm({ latitude: 0, longitude: 0 }, { latitude: 1, longitude: 0 }); + expect(d).toBeGreaterThan(110); + expect(d).toBeLessThan(112); + }); +}); + +describe("estimateLeg", () => { + it("applies the circuity factor and average speed", () => { + const a = { latitude: 0, longitude: 0 }; + const b = { latitude: 0, longitude: 1 }; + const { distanceKm, mins } = estimateLeg(a, b); + // ~111km straight * 1.3 circuity ≈ 144.6km; at 40km/h ≈ 217 min + expect(distanceKm).toBeGreaterThan(140); + expect(distanceKm).toBeLessThan(150); + expect(mins).toBeGreaterThan(200); + expect(mins).toBeLessThan(230); + expect(Number.isInteger(mins)).toBe(true); + }); +}); + +describe("nearestNeighborOrder", () => { + it("returns trivial order for 0 or 1 points", () => { + expect(nearestNeighborOrder([])).toEqual([]); + expect(nearestNeighborOrder([{ latitude: 1, longitude: 1 }])).toEqual([0]); + }); + + it("greedily visits the nearest unvisited point", () => { + // Points on a line; scrambled input order. + const points = [ + { latitude: 0, longitude: 0 }, // 0 (start) + { latitude: 0, longitude: 5 }, // 1 (far) + { latitude: 0, longitude: 1 }, // 2 + { latitude: 0, longitude: 2 }, // 3 + ]; + expect(nearestNeighborOrder(points, 0)).toEqual([0, 2, 3, 1]); + }); +}); + +describe("optimizeRoute — nearest-neighbor fallback (no API key)", () => { + it("returns an empty route for no stops", async () => { + const r = await optimizeRoute([]); + expect(r.stops).toHaveLength(0); + expect(r.totalTravelMins).toBe(0); + expect(r.totalDistanceKm).toBe(0); + expect(r.provider).toBe("nearest_neighbor"); + expect(r.chunked).toBe(false); + }); + + it("handles a single stop with null travel-from-prev", async () => { + const r = await optimizeRoute([stop("a", 40, -74)]); + expect(r.stops).toHaveLength(1); + expect(r.stops[0]!.travelMinsFromPrev).toBeNull(); + expect(r.stops[0]!.travelDistanceKmFromPrev).toBeNull(); + expect(r.totalTravelMins).toBe(0); + }); + + it("orders multiple stops greedily and sums totals", async () => { + const stops = [ + stop("start", 0, 0), + stop("far", 0, 5), + stop("near1", 0, 1), + stop("near2", 0, 2), + ]; + const r = await optimizeRoute(stops); + expect(r.provider).toBe("nearest_neighbor"); + expect(r.stops.map((s) => s.appointmentId)).toEqual([ + "start", + "near1", + "near2", + "far", + ]); + // First stop has no inbound leg. + expect(r.stops[0]!.travelMinsFromPrev).toBeNull(); + // Remaining stops have positive travel. + for (const s of r.stops.slice(1)) { + expect(s.travelMinsFromPrev!).toBeGreaterThan(0); + expect(s.travelDistanceKmFromPrev!).toBeGreaterThan(0); + } + const summed = r.stops.reduce((acc, s) => acc + (s.travelMinsFromPrev ?? 0), 0); + expect(r.totalTravelMins).toBe(summed); + }); +}); + +describe("optimizeRoute — Google Directions path", () => { + it("uses optimized waypoint order and real leg metrics, dropping the return leg", async () => { + const stops = [stop("A", 0, 0), stop("B", 0, 1), stop("C", 0, 2)]; + // waypoints = [B, C]; optimizer reorders them to [C, B] (waypoint_order [1,0]). + // legs: A->C, C->B, B->A(return). The return leg must be dropped. + const { fetchImpl, calls } = fakeFetch({ + status: "OK", + routes: [ + { + waypoint_order: [1, 0], + legs: [ + { distance: { value: 2000 }, duration: { value: 600 } }, // A->C + { distance: { value: 1000 }, duration: { value: 300 } }, // C->B + { distance: { value: 3000 }, duration: { value: 900 } }, // B->A (return, dropped) + ], + }, + ], + }); + + const r = await optimizeRoute(stops, { googleApiKey: "key", fetchImpl }); + expect(r.provider).toBe("google"); + expect(r.stops.map((s) => s.appointmentId)).toEqual(["A", "C", "B"]); + expect(r.stops[0]!.travelMinsFromPrev).toBeNull(); + expect(r.stops[1]!.travelDistanceKmFromPrev).toBe(2); // 2000m -> 2km + expect(r.stops[1]!.travelMinsFromPrev).toBe(10); // 600s -> 10min + expect(r.stops[2]!.travelDistanceKmFromPrev).toBe(1); // 1000m + expect(r.stops[2]!.travelMinsFromPrev).toBe(5); // 300s + expect(r.totalDistanceKm).toBe(3); + expect(r.totalTravelMins).toBe(15); + expect(decodeURIComponent(calls[0]!)).toContain("optimize:true"); + }); + + it("falls back to the heuristic when Google returns a non-OK status", async () => { + const stops = [stop("A", 0, 0), stop("B", 0, 1), stop("C", 0, 2)]; + const { fetchImpl } = fakeFetch({ status: "REQUEST_DENIED", error_message: "bad key" }); + const r = await optimizeRoute(stops, { googleApiKey: "key", fetchImpl }); + // Provider label reflects the chosen strategy (google requested) but a + // warning records the degradation and stops are still ordered. + expect(r.stops).toHaveLength(3); + expect(r.warnings.some((w) => w.includes("offline heuristic"))).toBe(true); + expect(r.stops[0]!.travelMinsFromPrev).toBeNull(); + }); +}); + +describe("optimizeRoute — >25 stop chunking", () => { + it("splits into sub-routes with a warning and continuous stop ordering", async () => { + const stops: RouteStopInput[] = []; + for (let i = 0; i < MAX_STOPS_PER_ROUTE + 5; i++) { + stops.push(stop(`s${i}`, 0, i * 0.1)); + } + const r = await optimizeRoute(stops); + expect(r.chunked).toBe(true); + expect(r.subRouteCount).toBe(2); + expect(r.warnings.some((w) => w.includes("sub-routes"))).toBe(true); + expect(r.stops).toHaveLength(MAX_STOPS_PER_ROUTE + 5); + // Only the very first stop of the whole route lacks an inbound leg. + expect(r.stops[0]!.travelMinsFromPrev).toBeNull(); + expect(r.stops.slice(1).every((s) => s.travelMinsFromPrev !== null)).toBe(true); + // All appointment ids preserved exactly once. + expect(new Set(r.stops.map((s) => s.appointmentId)).size).toBe(stops.length); + }); +}); diff --git a/src/index.ts b/src/index.ts index 2845b14..681d731 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { settingsRouter } from "./routes/settings.js"; import { authProviderRouter } from "./routes/authProvider.js"; import { searchRouter } from "./routes/search.js"; import { bufferRulesRouter } from "./routes/buffer-rules.js"; +import { routesRouter } from "./routes/routes.js"; import { getObject } from "./lib/s3.js"; import { calendarRouter } from "./routes/calendar.js"; import { setupRouter } from "./routes/setup.js"; @@ -220,6 +221,10 @@ api.use("/reports/*", requireRole("manager")); api.use("/invoices/*", requireRole("manager", "groomer")); api.use("/impersonation/*", requireRole("manager")); +// Route optimization: manager (any groomer's route) or groomer (own route only, +// enforced in-handler). Receptionists have no access. (GRO-2155) +api.use("/routes/*", requireRole("manager", "groomer")); + // Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist api.use("/appointment-groups/*", requireRole("manager", "receptionist")); api.use("/grooming-logs/*", requireRole("manager", "receptionist")); @@ -283,6 +288,7 @@ api.route("/admin/auth-provider", authProviderRouter); api.route("/admin/seed", adminSeedRouter); api.route("/search", searchRouter); api.route("/buffer-rules", bufferRulesRouter); +api.route("/routes", routesRouter); const port = Number(process.env.PORT ?? 3000); await initAuth(); diff --git a/src/routes/routes.ts b/src/routes/routes.ts new file mode 100644 index 0000000..e9a1d41 --- /dev/null +++ b/src/routes/routes.ts @@ -0,0 +1,284 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { + and, + asc, + eq, + gte, + lt, + ne, + getDb, + appointments, + businessSettings, + clients, + groomerRoutes, + routeStops, +} from "@groombook/db"; +import type { AppEnv, StaffRow } from "../middleware/rbac.js"; +import { + optimizeRoute, + resolveRouteGoogleApiKey, + type RouteStopInput, +} from "../services/routeOptimization.js"; + +export const routesRouter = new Hono(); + +const dailyQuerySchema = z.object({ + staffId: z.string().uuid().optional(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"), +}); + +const optimizeBodySchema = z.object({ + staffId: z.string().uuid().optional(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"), +}); + +/** + * Resolves the target staffId for the request and enforces the groomer-own / + * manager authorization rule. Groomers may only act on their own route; if a + * groomer omits staffId it defaults to their own. Returns either the resolved + * id or an error tuple the caller turns into a JSON response. + */ +function resolveTargetStaffId( + staffRow: StaffRow | undefined, + requestedStaffId: string | undefined +): { staffId: string } | { error: string; status: 400 | 403 } { + const isGroomer = staffRow?.role === "groomer"; + + if (isGroomer) { + if (requestedStaffId && requestedStaffId !== staffRow.id) { + return { + error: "Forbidden: groomers may only access their own route", + status: 403, + }; + } + return { staffId: staffRow.id }; + } + + // Manager: staffId is required (no implicit self — managers plan others' days). + if (!requestedStaffId) { + return { error: "staffId is required", status: 400 }; + } + return { staffId: requestedStaffId }; +} + +/** Day window [date 00:00:00Z, nextDay 00:00:00Z) for filtering appointments. */ +function dayBounds(date: string): { start: Date; end: Date } { + const start = new Date(`${date}T00:00:00.000Z`); + const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); + return { start, end }; +} + +/** Loads a route's persisted stops, enriched with appointment + client detail. */ +async function loadRouteStops(db: ReturnType, routeId: string) { + return db + .select({ + id: routeStops.id, + appointmentId: routeStops.appointmentId, + stopOrder: routeStops.stopOrder, + latitude: routeStops.latitude, + longitude: routeStops.longitude, + travelMinsFromPrev: routeStops.travelMinsFromPrev, + travelDistanceKmFromPrev: routeStops.travelDistanceKmFromPrev, + bufferMins: routeStops.bufferMins, + appointmentStartTime: appointments.startTime, + appointmentEndTime: appointments.endTime, + appointmentStatus: appointments.status, + clientId: clients.id, + clientName: clients.name, + clientAddress: clients.address, + }) + .from(routeStops) + .innerJoin(appointments, eq(routeStops.appointmentId, appointments.id)) + .innerJoin(clients, eq(appointments.clientId, clients.id)) + .where(eq(routeStops.routeId, routeId)) + .orderBy(asc(routeStops.stopOrder)); +} + +/** + * GET /api/routes/daily?staffId=&date= + * Fetches (creating a draft if absent) the daily route for a groomer, with all + * persisted stops. Auth: groomer (own) or manager. + */ +routesRouter.get("/daily", zValidator("query", dailyQuerySchema), async (c) => { + const db = getDb(); + const { staffId: requestedStaffId, date } = c.req.valid("query"); + + const resolved = resolveTargetStaffId(c.get("staff"), requestedStaffId); + if ("error" in resolved) { + return c.json({ error: resolved.error }, resolved.status); + } + const staffId = resolved.staffId; + + let [route] = await db + .select() + .from(groomerRoutes) + .where( + and( + eq(groomerRoutes.staffId, staffId), + eq(groomerRoutes.routeDate, date) + ) + ); + + if (!route) { + // Create a draft route so the day is addressable before optimization. + [route] = await db + .insert(groomerRoutes) + .values({ staffId, routeDate: date, status: "draft" }) + .returning(); + } + + const stops = await loadRouteStops(db, route!.id); + return c.json({ route, stops }); +}); + +/** + * POST /api/routes/optimize { staffId, date } + * Generates or re-optimizes the daily route: pulls the day's geocoded + * appointments, optimizes the visiting order (Google Directions when a key is + * configured, else nearest-neighbor), and persists the ordered stops + totals. + * Auth: groomer (own) or manager. + */ +routesRouter.post( + "/optimize", + zValidator("json", optimizeBodySchema), + async (c) => { + const db = getDb(); + const { staffId: requestedStaffId, date } = c.req.valid("json"); + + const resolved = resolveTargetStaffId(c.get("staff"), requestedStaffId); + if ("error" in resolved) { + return c.json({ error: resolved.error }, resolved.status); + } + const staffId = resolved.staffId; + const { start, end } = dayBounds(date); + + // Pull the day's non-cancelled appointments for this groomer, joined to the + // client coordinates. Ordered by start time so the earliest booking anchors + // the route. + const dayAppointments = await db + .select({ + appointmentId: appointments.id, + startTime: appointments.startTime, + clientId: clients.id, + clientName: clients.name, + latitude: clients.latitude, + longitude: clients.longitude, + }) + .from(appointments) + .innerJoin(clients, eq(appointments.clientId, clients.id)) + .where( + and( + eq(appointments.staffId, staffId), + gte(appointments.startTime, start), + lt(appointments.startTime, end), + ne(appointments.status, "cancelled") + ) + ) + .orderBy(asc(appointments.startTime)); + + const stopInputs: RouteStopInput[] = []; + const skipped: Array<{ appointmentId: string; clientName: string; reason: string }> = + []; + for (const appt of dayAppointments) { + if (appt.latitude == null || appt.longitude == null) { + skipped.push({ + appointmentId: appt.appointmentId, + clientName: appt.clientName, + reason: "client address is not geocoded", + }); + continue; + } + stopInputs.push({ + appointmentId: appt.appointmentId, + latitude: appt.latitude, + longitude: appt.longitude, + }); + } + + const [settings] = await db.select().from(businessSettings).limit(1); + const bufferMins = settings?.defaultTravelBufferMins ?? 15; + + const googleApiKey = await resolveRouteGoogleApiKey(db); + const optimized = await optimizeRoute(stopInputs, { googleApiKey }); + + const warnings = [...optimized.warnings]; + if (skipped.length > 0) { + warnings.push( + `${skipped.length} appointment(s) were skipped because the client address is not geocoded.` + ); + } + + const now = new Date(); + const route = await db.transaction(async (tx) => { + // Upsert the route row for (staffId, date). + const [existing] = await tx + .select() + .from(groomerRoutes) + .where( + and( + eq(groomerRoutes.staffId, staffId), + eq(groomerRoutes.routeDate, date) + ) + ); + + const [routeRow] = existing + ? await tx + .update(groomerRoutes) + .set({ + status: "optimized", + totalTravelMins: optimized.totalTravelMins, + totalDistanceKm: optimized.totalDistanceKm.toFixed(2), + optimizedAt: now, + updatedAt: now, + }) + .where(eq(groomerRoutes.id, existing.id)) + .returning() + : await tx + .insert(groomerRoutes) + .values({ + staffId, + routeDate: date, + status: "optimized", + totalTravelMins: optimized.totalTravelMins, + totalDistanceKm: optimized.totalDistanceKm.toFixed(2), + optimizedAt: now, + }) + .returning(); + + // Replace stops: clear prior ordering, insert the freshly optimized one. + await tx.delete(routeStops).where(eq(routeStops.routeId, routeRow!.id)); + if (optimized.stops.length > 0) { + await tx.insert(routeStops).values( + optimized.stops.map((s, i) => ({ + routeId: routeRow!.id, + appointmentId: s.appointmentId, + stopOrder: i + 1, + latitude: s.latitude, + longitude: s.longitude, + travelMinsFromPrev: s.travelMinsFromPrev, + travelDistanceKmFromPrev: + s.travelDistanceKmFromPrev == null + ? null + : s.travelDistanceKmFromPrev.toFixed(2), + bufferMins, + })) + ); + } + + return routeRow!; + }); + + const stops = await loadRouteStops(db, route.id); + return c.json({ + route, + stops, + provider: optimized.provider, + chunked: optimized.chunked, + subRouteCount: optimized.subRouteCount, + skipped, + warnings, + }); + } +); diff --git a/src/services/routeOptimization.ts b/src/services/routeOptimization.ts new file mode 100644 index 0000000..14de121 --- /dev/null +++ b/src/services/routeOptimization.ts @@ -0,0 +1,413 @@ +import { businessSettings, decryptSecret, type Db } from "@groombook/db"; +import type { FetchLike } from "./geocoding.js"; + +/** + * Route optimization service (GRO-2155, Phase 2.1 of Route Optimization). + * + * Given a groomer's geocoded stops for a day, produces an optimized visiting + * order plus per-leg and total travel estimates. Two strategies: + * + * - {@link optimizeWithGoogle}: Google Maps Directions API with + * `optimizeWaypoints: true` (real road durations/distances), used when a + * Google Maps API key is configured. + * - {@link nearestNeighborOrder}: an offline nearest-neighbor TSP heuristic over + * great-circle distance, used as the default free / no-API-key fallback. + * + * Both strategies share the same public {@link optimizeRoute} orchestrator, + * which also handles the >25-stop edge case by chunking into sub-routes (the + * Google Directions waypoint cap) and surfacing a warning. + */ + +/** Google Directions allows origin + destination + up to 23 waypoints = 25 + * points per request. We cap a sub-route at 25 stops and chunk beyond that. */ +export const MAX_STOPS_PER_ROUTE = 25; + +/** Average driving speed (km/h) used to convert distance into travel minutes in + * the offline heuristic. Tuned for mixed urban/suburban mobile-groomer routes. */ +export const AVG_SPEED_KMH = 40; + +/** Multiplier applied to great-circle distance to approximate real road + * distance in the offline heuristic (straight-line underestimates driving). */ +export const ROAD_CIRCUITY_FACTOR = 1.3; + +const EARTH_RADIUS_KM = 6371; + +/** A geocoded stop to be ordered. `appointmentId` ties it back to the schedule. */ +export interface RouteStopInput { + appointmentId: string; + latitude: number; + longitude: number; +} + +/** A single stop in the optimized order, with travel from the previous stop. */ +export interface OptimizedStop { + appointmentId: string; + latitude: number; + longitude: number; + /** Null for the first stop of the whole route. */ + travelMinsFromPrev: number | null; + /** Null for the first stop of the whole route. Kilometres, 2-dp. */ + travelDistanceKmFromPrev: number | null; +} + +export type RouteOptimizationProvider = "google" | "nearest_neighbor"; + +export interface OptimizedRoute { + provider: RouteOptimizationProvider; + stops: OptimizedStop[]; + totalTravelMins: number; + /** Kilometres, rounded to 2 decimal places. */ + totalDistanceKm: number; + /** True when the route was split into multiple sub-routes (>25 stops). */ + chunked: boolean; + subRouteCount: number; + /** Non-fatal advisories for the caller to surface to the user. */ + warnings: string[]; +} + +export interface OptimizeRouteOptions { + /** Google Maps API key. When absent, the nearest-neighbor heuristic is used. */ + googleApiKey?: string | null; + /** Injectable fetch for testing the Google path. Defaults to global fetch. */ + fetchImpl?: FetchLike; +} + +const defaultFetch: FetchLike = (input, init) => + (globalThis.fetch as unknown as FetchLike)(input, init); + +// ─── Geometry helpers ─────────────────────────────────────────────────────── + +function toRadians(deg: number): number { + return (deg * Math.PI) / 180; +} + +/** Great-circle distance between two coordinates, in kilometres. */ +export function haversineKm( + a: { latitude: number; longitude: number }, + b: { latitude: number; longitude: number } +): number { + const dLat = toRadians(b.latitude - a.latitude); + const dLon = toRadians(b.longitude - a.longitude); + const lat1 = toRadians(a.latitude); + const lat2 = toRadians(b.latitude); + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))); +} + +/** Round to 2 decimal places, returning a finite number. */ +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +/** + * Estimate a road travel leg from the great-circle distance between two points. + * Applies a circuity factor for distance and a fixed average speed for time. + */ +export function estimateLeg( + a: { latitude: number; longitude: number }, + b: { latitude: number; longitude: number } +): { distanceKm: number; mins: number } { + const straight = haversineKm(a, b); + const distanceKm = straight * ROAD_CIRCUITY_FACTOR; + const mins = (distanceKm / AVG_SPEED_KMH) * 60; + return { distanceKm: round2(distanceKm), mins: Math.round(mins) }; +} + +// ─── Nearest-neighbor heuristic ───────────────────────────────────────────── + +/** + * Orders points greedily: start at `startIndex`, then repeatedly visit the + * nearest unvisited point (great-circle distance). Returns indices into the + * input array in visiting order. Deterministic ties broken by lowest index. + */ +export function nearestNeighborOrder( + points: Array<{ latitude: number; longitude: number }>, + startIndex = 0 +): number[] { + const n = points.length; + if (n <= 1) return points.map((_, i) => i); + + const visited = new Array(n).fill(false); + const order: number[] = [startIndex]; + visited[startIndex] = true; + let current = startIndex; + + for (let step = 1; step < n; step++) { + let best = -1; + let bestDist = Infinity; + for (let j = 0; j < n; j++) { + if (visited[j]) continue; + const d = haversineKm(points[current]!, points[j]!); + if (d < bestDist) { + bestDist = d; + best = j; + } + } + visited[best] = true; + order.push(best); + current = best; + } + return order; +} + +/** Orders one chunk (<= MAX_STOPS_PER_ROUTE) via nearest-neighbor. */ +function optimizeChunkNearestNeighbor( + stops: RouteStopInput[] +): RouteStopInput[] { + const order = nearestNeighborOrder(stops, 0); + return order.map((i) => stops[i]!); +} + +// ─── Google Directions ────────────────────────────────────────────────────── + +const GOOGLE_DIRECTIONS_URL = + "https://maps.googleapis.com/maps/api/directions/json"; + +interface GoogleDirectionsResponse { + status: string; + error_message?: string; + routes?: Array<{ + waypoint_order?: number[]; + legs?: Array<{ + duration?: { value?: number }; + distance?: { value?: number }; + }>; + }>; +} + +/** + * Orders one chunk via the Google Directions API with `optimizeWaypoints=true`. + * + * The first stop is fixed as both origin and destination (a closed tour); the + * remaining stops are passed as optimizable waypoints. We keep the optimized + * forward order and drop the final return-to-origin leg, yielding an open route + * whose per-leg durations/distances come from real road data. + */ +async function optimizeChunkGoogle( + stops: RouteStopInput[], + apiKey: string, + fetchImpl: FetchLike +): Promise<{ stops: RouteStopInput[]; legsMeters: number[]; legsSeconds: number[] }> { + if (stops.length <= 1) { + return { stops: [...stops], legsMeters: [], legsSeconds: [] }; + } + + const origin = stops[0]!; + const waypoints = stops.slice(1); + const url = new URL(GOOGLE_DIRECTIONS_URL); + url.searchParams.set("origin", `${origin.latitude},${origin.longitude}`); + url.searchParams.set("destination", `${origin.latitude},${origin.longitude}`); + url.searchParams.set( + "waypoints", + "optimize:true|" + + waypoints.map((w) => `${w.latitude},${w.longitude}`).join("|") + ); + url.searchParams.set("key", apiKey); + + const res = await fetchImpl(url.toString()); + if (!res.ok) { + throw new Error( + `Google Directions request failed: ${res.status} ${res.statusText}` + ); + } + const body = (await res.json()) as GoogleDirectionsResponse; + if (body.status !== "OK" || !body.routes || body.routes.length === 0) { + throw new Error( + `Google Directions returned status ${body.status}${ + body.error_message ? `: ${body.error_message}` : "" + }` + ); + } + + const route = body.routes[0]!; + const waypointOrder = route.waypoint_order ?? waypoints.map((_, i) => i); + const legs = route.legs ?? []; + + // Ordered stops: origin first, then waypoints in the optimized order. + const orderedStops: RouteStopInput[] = [ + origin, + ...waypointOrder.map((i) => waypoints[i]!), + ]; + + // legs[k] is the travel into orderedStops[k+1]. Drop the trailing return leg + // (orderedStops.length-1 legs describe the open route). + const legsMeters: number[] = []; + const legsSeconds: number[] = []; + for (let k = 0; k < orderedStops.length - 1; k++) { + const leg = legs[k]; + legsMeters.push(leg?.distance?.value ?? 0); + legsSeconds.push(leg?.duration?.value ?? 0); + } + + return { stops: orderedStops, legsMeters, legsSeconds }; +} + +// ─── Orchestration ────────────────────────────────────────────────────────── + +function chunk(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) { + out.push(items.slice(i, i + size)); + } + return out; +} + +/** + * Optimizes a full day's stops into a single visiting order with travel + * metrics. Uses Google Directions when `googleApiKey` is provided, otherwise the + * offline nearest-neighbor heuristic. Routes longer than + * {@link MAX_STOPS_PER_ROUTE} stops are split into sub-routes and a warning is + * emitted; sub-routes are stitched end-to-end, with the boundary leg estimated + * from great-circle distance. + */ +export async function optimizeRoute( + inputStops: RouteStopInput[], + options: OptimizeRouteOptions = {} +): Promise { + const fetchImpl = options.fetchImpl ?? defaultFetch; + const useGoogle = Boolean(options.googleApiKey); + const provider: RouteOptimizationProvider = useGoogle + ? "google" + : "nearest_neighbor"; + const warnings: string[] = []; + + if (inputStops.length === 0) { + return { + provider, + stops: [], + totalTravelMins: 0, + totalDistanceKm: 0, + chunked: false, + subRouteCount: 0, + warnings, + }; + } + + const chunks = chunk(inputStops, MAX_STOPS_PER_ROUTE); + const chunked = chunks.length > 1; + if (chunked) { + warnings.push( + `Route has ${inputStops.length} stops, exceeding the ${MAX_STOPS_PER_ROUTE}-stop optimization limit. Split into ${chunks.length} sub-routes; review the order at sub-route boundaries.` + ); + } + + const ordered: OptimizedStop[] = []; + let prev: RouteStopInput | null = null; + + for (const group of chunks) { + let groupStops: RouteStopInput[]; + let legDistanceKm: (i: number) => number; + let legMins: (i: number) => number; + + if (useGoogle) { + try { + const result = await optimizeChunkGoogle( + group, + options.googleApiKey!, + fetchImpl + ); + groupStops = result.stops; + legDistanceKm = (i) => round2(result.legsMeters[i]! / 1000); + legMins = (i) => Math.round(result.legsSeconds[i]! / 60); + } catch (err) { + // Google failed mid-optimization — degrade to the offline heuristic for + // this run rather than failing the whole request. + warnings.push( + `Google Directions unavailable; used offline heuristic: ${ + err instanceof Error ? err.message : String(err) + }` + ); + groupStops = optimizeChunkNearestNeighbor(group); + legDistanceKm = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).distanceKm; + legMins = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).mins; + } + } else { + groupStops = optimizeChunkNearestNeighbor(group); + legDistanceKm = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).distanceKm; + legMins = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).mins; + } + + for (let i = 0; i < groupStops.length; i++) { + const stop = groupStops[i]!; + if (prev === null) { + // Very first stop of the whole route. + ordered.push({ + appointmentId: stop.appointmentId, + latitude: stop.latitude, + longitude: stop.longitude, + travelMinsFromPrev: null, + travelDistanceKmFromPrev: null, + }); + } else if (i === 0) { + // First stop of a non-initial chunk: estimate the boundary leg. + const est = estimateLeg(prev, stop); + ordered.push({ + appointmentId: stop.appointmentId, + latitude: stop.latitude, + longitude: stop.longitude, + travelMinsFromPrev: est.mins, + travelDistanceKmFromPrev: est.distanceKm, + }); + } else { + ordered.push({ + appointmentId: stop.appointmentId, + latitude: stop.latitude, + longitude: stop.longitude, + travelMinsFromPrev: legMins(i - 1), + travelDistanceKmFromPrev: legDistanceKm(i - 1), + }); + } + prev = stop; + } + } + + const totalTravelMins = ordered.reduce( + (sum, s) => sum + (s.travelMinsFromPrev ?? 0), + 0 + ); + const totalDistanceKm = round2( + ordered.reduce((sum, s) => sum + (s.travelDistanceKmFromPrev ?? 0), 0) + ); + + return { + provider, + stops: ordered, + totalTravelMins, + totalDistanceKm, + chunked, + subRouteCount: chunks.length, + warnings, + }; +} + +// ─── Google API key resolution ────────────────────────────────────────────── + +/** + * Resolves the Google Maps API key for route optimization from + * `businessSettings.googleMapsApiKey` (decrypted at rest) or, as a development + * convenience, the `GOOGLE_MAPS_API_KEY` env var. Returns `null` when no usable + * key exists, in which case callers fall back to the offline heuristic. + */ +export async function resolveRouteGoogleApiKey( + db: Db, + decrypt: (ciphertext: string) => string = decryptSecret +): Promise { + const [settings] = await db.select().from(businessSettings).limit(1); + const stored = settings?.googleMapsApiKey?.trim(); + if (stored) { + try { + const decrypted = decrypt(stored).trim(); + if (decrypted) return decrypted; + } catch (err) { + console.warn( + `Failed to decrypt googleMapsApiKey for route optimization; using offline heuristic: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + } + const fromEnv = process.env.GOOGLE_MAPS_API_KEY?.trim(); + return fromEnv ? fromEnv : null; +} -- 2.52.0 From b8422374259113e86a2359aaf9a614e0f723fc29 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 17:03:44 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(portal):=20GRO-2203=20validate=20petId?= =?UTF-8?q?=20as=20UUID=20before=20PATCH=20lookup=20(500=E2=86=92404)=20(#?= =?UTF-8?q?177)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UAT_PLAYBOOK.md | 1 + src/__tests__/portalPets.test.ts | 17 +++++++++++++++++ src/routes/portal.ts | 8 ++++++++ 3 files changed, 26 insertions(+) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index cacd4f4..0267135 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -281,6 +281,7 @@ This means: | 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 | +| TC-API-8.16 | Portal pet update — malformed (non-UUID) petId returns 404 (GRO-2203) | With a valid portal session, `PATCH /api/portal/pets/not-a-uuid` with header `X-Impersonation-Session-Id` and body `{"coatType":"short"}` | 404 Not Found with body `{"error":"Not found"}` (was an unhandled 500 from the Postgres uuid cast in GRO-2203; mirrors the GRO-2014 guard). No mutation persisted | ### 4.9 Waitlist diff --git a/src/__tests__/portalPets.test.ts b/src/__tests__/portalPets.test.ts index c23bca0..a9e41c2 100644 --- a/src/__tests__/portalPets.test.ts +++ b/src/__tests__/portalPets.test.ts @@ -280,6 +280,23 @@ describe("PATCH /portal/pets/:petId", () => { expect(res.status).toBe(404); }); + it("returns 404 for a malformed (non-UUID) petId without hitting the db (GRO-2203)", async () => { + selectSessionRow = ACTIVE_SESSION; + // A non-UUID petId previously reached `where(eq(pets.id, ...))` and made + // Postgres throw "invalid input syntax for type uuid" → unhandled 500. + // It must now short-circuit to 404 before any select/update. + selectPetRow = PET; + + const res = await jsonPatch( + `/portal/pets/not-a-uuid`, + { coatType: "short" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(404); + expect(updatedValues).toHaveLength(0); + }); + it("returns 422 for an invalid coatType", async () => { selectSessionRow = ACTIVE_SESSION; selectPetRow = PET; diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 0b106fb..8b15f38 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -296,6 +296,14 @@ portalRouter.patch( const body = c.req.valid("json"); const clientId = c.get("portalClientId"); + // GRO-2203: validate UUID format before hitting Postgres. Passing a non-UUID + // string to a uuid column makes the driver throw ("invalid input syntax for + // type uuid"), which previously surfaced as an unhandled 500. Mirror the + // GRO-2014 fix in pets.ts and treat a malformed id as Not found. + if (!z.string().uuid().safeParse(petId).success) { + return c.json({ error: "Not found" }, 404); + } + const [pet] = await db .select() .from(pets) -- 2.52.0 From 29c42e3130b8d9d933a85ed450d3ad5f94e15881 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 17:19:39 +0000 Subject: [PATCH 4/4] fix(portal): validate waitlist preferredTime/preferredDate, return 400 on bad input (GRO-2211) (#179) --- src/__tests__/waitlist.test.ts | 70 ++++++++++++++++++++++++++++++++++ src/routes/portal.ts | 28 +++++++++++--- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/__tests__/waitlist.test.ts b/src/__tests__/waitlist.test.ts index 383bc80..5c4d209 100644 --- a/src/__tests__/waitlist.test.ts +++ b/src/__tests__/waitlist.test.ts @@ -184,6 +184,66 @@ describe("POST /portal/waitlist", () => { expect(insertedValues).toHaveLength(1); }); + it("normalizes HH:MM:SS preferredTime and returns 201 (GRO-2211)", async () => { + selectSessionRow = ACTIVE_SESSION; + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00:00", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(201); + expect(insertedValues[0]?.preferredTime).toBe("10:00:00"); + }); + + it("normalizes HH:MM preferredTime to HH:MM:SS before insert (GRO-2211)", async () => { + selectSessionRow = ACTIVE_SESSION; + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(201); + expect(insertedValues[0]?.preferredTime).toBe("10:00:00"); + }); + + it("returns 400 (not 500) for a full ISO datetime preferredTime (GRO-2211)", async () => { + selectSessionRow = ACTIVE_SESSION; + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "2026-06-09T10:00:00.000Z", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(400); + expect(insertedValues).toHaveLength(0); + }); + + it("returns 400 for a malformed preferredDate (GRO-2211)", async () => { + selectSessionRow = ACTIVE_SESSION; + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "03/25/2026", + preferredTime: "10:00", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(400); + expect(insertedValues).toHaveLength(0); + }); + + it("returns 400 for an out-of-range preferredTime (GRO-2211)", async () => { + selectSessionRow = ACTIVE_SESSION; + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "25:99", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(400); + expect(insertedValues).toHaveLength(0); + }); + it("returns 401 without session", async () => { const res = await jsonRequest("POST", "/portal/waitlist", { petId: VALID_UUID_3, @@ -258,6 +318,16 @@ describe("PATCH /portal/waitlist/:id", () => { expect(updatedValues[0]?.status).toBe("cancelled"); }); + it("returns 400 (not 500) for a full ISO datetime preferredTime on update (GRO-2211)", async () => { + selectSessionRow = ACTIVE_SESSION; + selectRows = [WAITLIST_ENTRY]; + const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, { + preferredTime: "2026-06-09T10:00:00.000Z", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(400); + expect(updatedValues).toHaveLength(0); + }); + it("returns 401 without session", async () => { const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, { status: "cancelled", diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 8b15f38..aa1593a 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -559,17 +559,33 @@ portalRouter.post("/appointments/:id/cancel", async (c) => { // ─── Client-facing waitlist routes ──────────────────────────────────────────── +// Postgres `date` / `time` columns reject arbitrary strings (e.g. a full ISO +// datetime), throwing a DateTimeParseError that surfaces as an unhandled 500. +// Constrain client input here so malformed values are rejected with a 400 by +// zValidator before they ever reach the DB (GRO-2211 defense-in-depth). +const preferredDateSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "preferredDate must be YYYY-MM-DD"); +const preferredTimeSchema = z + .string() + .regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/, "preferredTime must be HH:MM or HH:MM:SS"); + +// Normalize HH:MM → HH:MM:SS so it matches the Postgres `time` column format. +function normalizeTime(value: string): string { + return value.length === 5 ? `${value}:00` : value; +} + const createWaitlistEntrySchema = z.object({ petId: z.string().uuid(), serviceId: z.string().uuid(), - preferredDate: z.string(), - preferredTime: z.string(), + preferredDate: preferredDateSchema, + preferredTime: preferredTimeSchema, }); const updateWaitlistEntrySchema = z.object({ status: z.literal("cancelled").optional(), - preferredDate: z.string().optional(), - preferredTime: z.string().optional(), + preferredDate: preferredDateSchema.optional(), + preferredTime: preferredTimeSchema.optional(), }); portalRouter.post( @@ -587,7 +603,7 @@ portalRouter.post( petId: body.petId, serviceId: body.serviceId, preferredDate: body.preferredDate, - preferredTime: body.preferredTime, + preferredTime: normalizeTime(body.preferredTime), }) .returning(); @@ -618,7 +634,7 @@ portalRouter.patch( const updateData: Record = { updatedAt: new Date() }; if (body.status !== undefined) updateData.status = body.status; if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate; - if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime; + if (body.preferredTime !== undefined) updateData.preferredTime = normalizeTime(body.preferredTime); const [updated] = await db .update(waitlistEntries) -- 2.52.0