From 27e6674b9acf3d6f38a8ba207ddcdad99618c55b Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 23:15:51 +0000 Subject: [PATCH 1/4] feat(GRO-2225): UAT seed route cohort + receptionist credential (#187) --- UAT_PLAYBOOK.md | 7 +- packages/db/src/seed.ts | 210 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 3d0445b..3ab23ef 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -363,7 +363,12 @@ This means: ### 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). +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 (GRO-2225 — zero-touch; no manual PATCH/geocoding needed).** A fresh UAT reset+seed now provisions a deterministic route cohort, so §4.16 runs directly against seed data: +- **Groomer:** `uat-groomer@groombook.dev` (staffId `00000000-0000-0000-0000-000000000004`). Resolve its id via `GET /api/staff` or sign in as the groomer and omit `staffId`. +- **Date:** `2026-09-15` (fixed). On this date the groomer has **12** confirmed appointments: **10 pre-geocoded** clients clustered in the Seattle metro (multi-stop route) + **2 intentionally un-geocoded** clients (exercise the skip-and-surface path, TC-API-16.4). Cohort clients are named `Route Demo — …` (emails `route-client-NN@uat.groombook.dev`). +- **Receptionist (TC-API-16.9 403):** sign in as `uat-receptionist@groombook.dev` (password from the `seed-uat-passwords` secret, key `SEED_UAT_RECEPTIONIST_PASSWORD`) — a standing receptionist login; no hand-built session required. | # | Scenario | Steps | Expected | |---|----------|-------|----------| diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 0959be0..55b2ee4 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -456,6 +456,36 @@ async function seedUatStaffAccounts( } } + // ── Staff: UAT Receptionist (GRO-2225) ────────────────────────────────────── + // Standing receptionist staff record so the route-optimization 403 path + // (TC-API-16.9: receptionist GET/POST /api/routes → 403) is reproducible + // without a hand-built session. The matching Better-Auth credential is + // provisioned below from SEED_UAT_RECEPTIONIST_PASSWORD. Created here (gated + // on the password env) so the credential loop's staff-link step finds it. + if (process.env.SEED_UAT_RECEPTIONIST_PASSWORD) { + const UAT_RECEPTIONIST_STAFF_ID = "00000000-0000-0000-0000-000000000099"; + const [existingReceptionist] = await db + .select() + .from(schema.staff) + .where(eq(schema.staff.email, "uat-receptionist@groombook.dev")) + .limit(1); + + if (existingReceptionist) { + console.log(`✓ Staff 'UAT Receptionist' already exists — skipping`); + } else { + await db.insert(schema.staff).values({ + id: UAT_RECEPTIONIST_STAFF_ID, + name: "UAT Receptionist", + email: "uat-receptionist@groombook.dev", + oidcSub: "uat-receptionist@groombook.dev", + role: "receptionist", + isSuperUser: false, + active: true, + }); + console.log(`✓ Created staff 'UAT Receptionist' (uat-receptionist@groombook.dev)`); + } + } + // ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ── const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? []; const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? []; @@ -495,6 +525,8 @@ async function seedUatStaffAccounts( { email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" }, { email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null }, { email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" }, + // GRO-2225: standing receptionist login for the route-optimization 403 path (TC-API-16.9). + { email: "uat-receptionist@groombook.dev", name: "UAT Receptionist", passwordEnv: "SEED_UAT_RECEPTIONIST_PASSWORD", staffEmail: "uat-receptionist@groombook.dev" }, ]; for (const acct of uatPasswordAccounts) { @@ -798,6 +830,179 @@ async function seedUatGroomerLinkage( ); } +// ── GRO-2225: deterministic route-optimization cohort ──────────────────────── + +/** + * GRO-2225: seed a deterministic, pre-geocoded client cohort + a fixed-date set + * of appointments for the UAT groomer so the route-optimization endpoints + * (`GET /api/routes/daily`, `POST /api/routes/optimize`, UAT §4.16 + * TC-API-16.1…16.11) are exercisable with ZERO manual PATCHing. + * + * Design (no live geocoder — UAT has no Google Maps key, provider is + * nearest_neighbor; coordinates are hand-picked fixtures clustered in the + * Seattle metro): + * - All appointments are on a FIXED calendar date (ROUTE_DATE) and assigned to + * the UAT groomer (`uat-groomer@groombook.dev`). The optimize endpoint pulls + * non-cancelled appointments in [date 00:00Z, +24h) joined to client coords. + * - 10 clients carry deterministic lat/lng → a multi-stop optimized route. + * - 2 clients are intentionally left UN-geocoded so the "skipped + surfaced" + * path (TC-API-16.5) stays reproducible. + * + * Idempotent: clients/pets are upserted by fixed UUID (they are NOT truncated on + * reset); appointments are upserted by fixed UUID too (they ARE truncated on + * reset, but the upsert keeps re-runs safe in non-truncating dev/test paths). + * Skips cleanly when the UAT groomer staff record is absent (e.g. prod/demo or a + * dev seed without the UAT personas). + */ +async function seedUatRouteCohort(db: ReturnType): Promise { + // Fixed calendar date the UAT playbook hardcodes for §4.16. Times are UTC so + // they fall inside the optimize endpoint's [date 00:00Z, +24h) day window. + const ROUTE_DATE = "2026-09-15"; + + const [uatGroomer] = await db + .select({ id: schema.staff.id }) + .from(schema.staff) + .where(eq(schema.staff.email, "uat-groomer@groombook.dev")) + .limit(1); + if (!uatGroomer) { + console.log("✓ GRO-2225: uat-groomer not present — skipping route cohort"); + return; + } + + // Resolve a service for the appointments: prefer Bath & Brush, else any active. + const BATH_AND_BRUSH_ID = "b0000001-0000-0000-0000-000000000001"; + const [bathService] = await db + .select({ id: schema.services.id }) + .from(schema.services) + .where(eq(schema.services.id, BATH_AND_BRUSH_ID)) + .limit(1); + let serviceId: string; + if (bathService) { + serviceId = bathService.id; + } else { + const [fallback] = await db + .select({ id: schema.services.id }) + .from(schema.services) + .where(eq(schema.services.active, true)) + .limit(1); + if (!fallback) { + console.warn("⚠ GRO-2225: no active services found — skipping route cohort"); + return; + } + serviceId = fallback.id; + } + + // Hand-picked fixture coordinates clustered in the Seattle metro. `coords:null` + // marks an intentionally un-geocoded client (skip-and-surface path TC-16.5). + const cohort: Array<{ + n: number; + name: string; + coords: { lat: number; lng: number } | null; + }> = [ + { n: 1, name: "Route Demo — Ada Lovelace", coords: { lat: 47.6097, lng: -122.3331 } }, + { n: 2, name: "Route Demo — Grace Hopper", coords: { lat: 47.6205, lng: -122.3493 } }, + { n: 3, name: "Route Demo — Alan Turing", coords: { lat: 47.5990, lng: -122.3300 } }, + { n: 4, name: "Route Demo — Katherine Johnson", coords: { lat: 47.6150, lng: -122.3200 } }, + { n: 5, name: "Route Demo — Edsger Dijkstra", coords: { lat: 47.6280, lng: -122.3550 } }, + { n: 6, name: "Route Demo — Barbara Liskov", coords: { lat: 47.5920, lng: -122.3150 } }, + { n: 7, name: "Route Demo — Donald Knuth", coords: { lat: 47.6350, lng: -122.3400 } }, + { n: 8, name: "Route Demo — Margaret Hamilton", coords: { lat: 47.6050, lng: -122.3600 } }, + { n: 9, name: "Route Demo — Ken Thompson", coords: { lat: 47.6420, lng: -122.3250 } }, + { n: 10, name: "Route Demo — Radia Perlman", coords: { lat: 47.5880, lng: -122.3450 } }, + // Intentionally un-geocoded — exercises the skip-and-surface path. + { n: 11, name: "Route Demo — Ungeocoded One", coords: null }, + { n: 12, name: "Route Demo — Ungeocoded Two", coords: null }, + ]; + + // Stagger appointments 45 min apart starting 15:00Z on ROUTE_DATE. + const dayStartMs = new Date(`${ROUTE_DATE}T15:00:00.000Z`).getTime(); + const SLOT_MS = 45 * 60 * 1000; + + let geocodedCount = 0; + let ungeocodedCount = 0; + for (const c of cohort) { + const pad = String(c.n).padStart(2, "0"); + const clientId = `d0000000-0000-0000-0000-0000000000${pad}`; + const petId = `d0000000-0000-0000-0000-0000000001${pad}`; + const apptId = `d0000000-0000-0000-0000-0000000002${pad}`; + const geocodedAt = c.coords ? new Date(`${ROUTE_DATE}T00:00:00.000Z`) : null; + + await db.insert(schema.clients) + .values({ + id: clientId, + name: c.name, + email: `route-client-${pad}@uat.groombook.dev`, + phone: `(206) 555-01${pad}`, + address: `${100 + c.n} Pike Street, Seattle, WA 98101`, + status: "active", + latitude: c.coords?.lat ?? null, + longitude: c.coords?.lng ?? null, + geocodedAt, + }) + .onConflictDoUpdate({ + target: schema.clients.id, + set: { + name: c.name, + address: `${100 + c.n} Pike Street, Seattle, WA 98101`, + latitude: c.coords?.lat ?? null, + longitude: c.coords?.lng ?? null, + geocodedAt, + }, + }); + + await db.insert(schema.pets) + .values({ + id: petId, + clientId, + name: `Route Pup ${c.n}`, + species: "Dog", + breed: "Mixed", + weightKg: "18.00", + }) + .onConflictDoUpdate({ + target: schema.pets.id, + set: { clientId, name: `Route Pup ${c.n}`, species: "Dog" }, + }); + + const startTime = new Date(dayStartMs + (c.n - 1) * SLOT_MS); + const endTime = new Date(startTime.getTime() + SLOT_MS); + await db.insert(schema.appointments) + .values({ + id: apptId, + clientId, + petId, + serviceId, + staffId: uatGroomer.id, + batherStaffId: null, + status: "confirmed", + startTime, + endTime, + notes: "GRO-2225: deterministic route-optimization cohort appointment.", + priceCents: null, + confirmationStatus: "confirmed", + }) + .onConflictDoUpdate({ + target: schema.appointments.id, + set: { + clientId, + petId, + serviceId, + staffId: uatGroomer.id, + status: "confirmed", + startTime, + endTime, + }, + }); + + if (c.coords) geocodedCount++; + else ungeocodedCount++; + } + + console.log( + `✓ GRO-2225: seeded route cohort for ${ROUTE_DATE} — ${geocodedCount} geocoded + ${ungeocodedCount} un-geocoded appointment(s) for uat-groomer (${uatGroomer.id})`, + ); +} + // ── Known-users-only seed (prod/demo) ─────────────────────────────────────── /** @@ -1169,6 +1374,11 @@ async function runSeedBody( // the time seedUatStaffAccounts() returns). await seedUatGroomerLinkage(db, uatCustomerClientId); + // GRO-2225: deterministic pre-geocoded route cohort + fixed-date appointments + // for the UAT groomer. Must run AFTER services are seeded (it looks up a + // service id for the appointments). Skips cleanly if uat-groomer is absent. + await seedUatRouteCohort(db); + // ── Clients & Pets ── const now = new Date(); const appointmentsBackDate = new Date(now); From 6702086c7bc4337ad8dba3a2a142beae4065085d Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 23:50:21 +0000 Subject: [PATCH 2/4] fix(GRO-2235): return 409 on duplicate portal waitlist submit (#189) --- src/__tests__/portalWaitlistDuplicate.test.ts | 154 ++++++++++++++++++ src/routes/portal.ts | 36 ++-- 2 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/portalWaitlistDuplicate.test.ts diff --git a/src/__tests__/portalWaitlistDuplicate.test.ts b/src/__tests__/portalWaitlistDuplicate.test.ts new file mode 100644 index 0000000..c0edbc6 --- /dev/null +++ b/src/__tests__/portalWaitlistDuplicate.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// GRO-2235: a duplicate active waitlist entry violates the partial unique index +// idx_waitlist_active_unique. postgres-js surfaces it as SQLSTATE 23505 — the +// handler must return a friendly 409, not a generic 500. The first insert still +// returns 201, and unrelated errors still surface as 500. + +const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; +const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003"; +const PET_ID = "880e8400-e29b-41d4-a716-446655440004"; +const SERVICE_ID = "990e8400-e29b-41d4-a716-446655440005"; + +const futureDate = () => new Date(Date.now() + 30 * 60 * 1000); + +const ACTIVE_SESSION = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active" as const, + reason: "manual", + startedAt: new Date(), + expiresAt: futureDate(), + createdAt: new Date(), +}; + +// Behaviour knob for the waitlist insert: "ok" returns a row, "duplicate" throws +// a postgres-js-shaped unique-violation, "other" throws an unrelated error. +let waitlistInsertMode: "ok" | "duplicate" | "other" = "ok"; + +function resetMock() { + waitlistInsertMode = "ok"; +} + +function tableProxy(name: string) { + return new Proxy( + { _name: name }, + { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) } + ); +} + +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; + } + + const impersonationSessions = tableProxy("impersonationSessions"); + const waitlistEntries = tableProxy("waitlistEntries"); + const impersonationAuditLogs = tableProxy("impersonationAuditLogs"); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "impersonationSessions") { + return makeChainable([ACTIVE_SESSION]); + } + return makeChainable([]); + }, + }), + insert: (table: { _name: string }) => ({ + values: (vals: Record) => ({ + returning: () => { + if (table._name === "waitlistEntries") { + if (waitlistInsertMode === "duplicate") { + throw Object.assign(new Error("duplicate key value"), { code: "23505" }); + } + if (waitlistInsertMode === "other") { + throw Object.assign(new Error("not null violation"), { code: "23502" }); + } + return [{ id: "entry-1", ...vals }]; + } + // impersonationAuditLogs and anything else: succeed silently. + return [{ id: "audit-1", ...vals }]; + }, + }), + }), + update: () => ({ + set: () => ({ where: () => Promise.resolve() }), + }), + }), + impersonationSessions, + waitlistEntries, + impersonationAuditLogs, + appointments: tableProxy("appointments"), + clients: tableProxy("clients"), + pets: tableProxy("pets"), + services: tableProxy("services"), + staff: tableProxy("staff"), + invoices: tableProxy("invoices"), + invoiceLineItems: tableProxy("invoiceLineItems"), + 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 postWaitlist(body: unknown) { + return app.request("/portal/waitlist", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Impersonation-Session-Id": SESSION_ID, + }, + body: JSON.stringify(body), + }); +} + +const VALID_BODY = { + petId: PET_ID, + serviceId: SERVICE_ID, + preferredDate: "2026-07-01", + preferredTime: "09:00", +}; + +beforeEach(() => resetMock()); + +describe("POST /portal/waitlist duplicate handling (GRO-2235)", () => { + it("returns 201 for the first insert", async () => { + waitlistInsertMode = "ok"; + const res = await postWaitlist(VALID_BODY); + expect(res.status).toBe(201); + }); + + it("returns 409 with a friendly message for a duplicate (23505)", async () => { + waitlistInsertMode = "duplicate"; + const res = await postWaitlist(VALID_BODY); + expect(res.status).toBe(409); + const json = (await res.json()) as { error: string }; + expect(json.error).toBe( + "You already have a booking for this pet at that date and time." + ); + }); + + it("still surfaces unrelated DB errors as 500", async () => { + waitlistInsertMode = "other"; + const res = await postWaitlist(VALID_BODY); + expect(res.status).toBe(500); + }); +}); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index d614e51..3c7dab9 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -596,16 +596,32 @@ portalRouter.post( const body = c.req.valid("json"); const clientId = c.get("portalClientId"); - const [entry] = await db - .insert(waitlistEntries) - .values({ - clientId, - petId: body.petId, - serviceId: body.serviceId, - preferredDate: body.preferredDate, - preferredTime: normalizeTime(body.preferredTime), - }) - .returning(); + let entry; + try { + [entry] = await db + .insert(waitlistEntries) + .values({ + clientId, + petId: body.petId, + serviceId: body.serviceId, + preferredDate: body.preferredDate, + preferredTime: normalizeTime(body.preferredTime), + }) + .returning(); + } catch (err) { + // An exact duplicate active waitlist entry violates the partial unique + // index idx_waitlist_active_unique (client_id, pet_id, service_id, + // preferred_date, preferred_time WHERE status='active'). postgres-js + // surfaces this as SQLSTATE 23505 — return a friendly 409 rather than a + // generic 500 (GRO-2235). Unrelated errors still surface as 500. + if ((err as { code?: string })?.code === "23505") { + return c.json( + { error: "You already have a booking for this pet at that date and time." }, + 409 + ); + } + throw err; + } return c.json(entry, 201); } From cd2f60e28206df3652fdf4b526e85e807b4aec8c Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 00:16:42 +0000 Subject: [PATCH 3/4] feat(GRO-2157): navigation export endpoints (Phase 2.3) (#190) --- UAT_PLAYBOOK.md | 23 ++++ src/__tests__/navigationExport.test.ts | 140 ++++++++++++++++++++++ src/routes/routes.ts | 69 ++++++++++- src/services/navigationExport.ts | 155 +++++++++++++++++++++++++ 4 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/navigationExport.test.ts create mode 100644 src/services/navigationExport.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 3ab23ef..cf0a541 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -406,6 +406,29 @@ Builds on §4.16. After optimization each consecutive leg carries a travel `buff | TC-API-17.7 | Reorder invalid routeId | `PATCH /api/routes/not-a-uuid/reorder` | 400 `{ error: "routeId must be a UUID" }` | | TC-API-17.8 | Groomer cannot reorder another's route | As groomer, reorder a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) | +### 4.18 Route Optimization — Navigation Export (GRO-2157, Phase 2.3) + +Builds on §4.16/§4.17. Two read-only endpoints turn an optimized route into a native-navigation deep-link URL the frontend opens on the groomer's phone: + +- `GET /api/routes/:routeId/export/google-maps` → Google Maps URLs API link (`https://www.google.com/maps/dir/?api=1&travelmode=driving&origin=…&destination=…&waypoints=…`) +- `GET /api/routes/:routeId/export/apple-maps` → Apple Maps URL scheme (`maps://?saddr=…&daddr=+to:…&dirflg=d`) + +Both use the stops' stored `latitude`/`longitude` in `stopOrder`: **origin = first stop, destination = last stop, the rest are ordered intermediate waypoints**. Each response body is `{ platform, url, stopCount, waypointCount }` where `waypointCount` = stops minus origin and destination. Waypoint limits are validated per platform: **Google Maps ≤ 9**, **Apple Maps ≤ 15** intermediate waypoints; over-limit routes return 400. **Auth: manager (any route) or groomer (own route only); receptionists have no access.** + +| ID | Scenario | Steps | Expected | +|----|----------|-------|----------| +| TC-API-18.1 | Google Maps export of a multi-stop route | As manager, optimize a multi-stop day (§4.16), then `GET /api/routes/{routeId}/export/google-maps` | 200 OK; `platform:"google-maps"`, `url` starts `https://www.google.com/maps/dir/?api=1`, contains `travelmode=driving`, `origin`/`destination` are the first/last stop coords, `waypoints` lists the middle stops in order (pipe-separated). `stopCount` = total stops, `waypointCount` = `stopCount − 2` | +| TC-API-18.2 | Apple Maps export of a multi-stop route | As manager, `GET /api/routes/{routeId}/export/apple-maps` for the same route | 200 OK; `platform:"apple-maps"`, `url` starts `maps://?saddr=`, `daddr` chains the remaining stops with `+to:`, ends `&dirflg=d`; `stopCount`/`waypointCount` as above | +| TC-API-18.3 | Single-stop route | Export a route (google-maps and apple-maps) that has exactly one stop | 200 OK; `waypointCount:0`. Google url has `destination` and no `waypoints=`; Apple url is `maps://?daddr=&dirflg=d` (no `saddr`) | +| TC-API-18.4 | Empty route rejected | Export a route with no stops (a fresh `draft` route) | 400 `{ error: "route has no stops to export" }` | +| TC-API-18.5 | Google waypoint limit | Export (google-maps) a route with >11 stops (>9 intermediate waypoints) | 400 with an `error` mentioning Google Maps' limit of 9 | +| TC-API-18.6 | Apple waypoint limit | Export (apple-maps) a route with >17 stops (>15 intermediate waypoints) | 400 with an `error` mentioning Apple Maps' limit of 15 | +| TC-API-18.7 | Unknown route | `GET /api/routes/{randomUuid}/export/google-maps` | 404 `{ error: "Route not found" }` | +| TC-API-18.8 | Invalid routeId | `GET /api/routes/not-a-uuid/export/apple-maps` | 400 `{ error: "routeId must be a UUID" }` | +| TC-API-18.9 | Groomer exports own route | As **groomer**, export a route owned by self | 200 OK; deep-link returned | +| TC-API-18.10 | Groomer cannot export another's route | As groomer, export a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) | +| TC-API-18.11 | Receptionist denied | As **receptionist**, export any route | 403 Forbidden (role not permitted) | + ## Pass/Fail Criteria **Pass:** diff --git a/src/__tests__/navigationExport.test.ts b/src/__tests__/navigationExport.test.ts new file mode 100644 index 0000000..902cb9d --- /dev/null +++ b/src/__tests__/navigationExport.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; +import { + buildGoogleMapsUrl, + buildAppleMapsUrl, + buildNavigationUrl, + intermediateWaypointCount, + GOOGLE_MAPS_MAX_WAYPOINTS, + APPLE_MAPS_MAX_WAYPOINTS, + type NavigationStop, +} from "../services/navigationExport.js"; + +function stops(n: number): NavigationStop[] { + return Array.from({ length: n }, (_, i) => ({ + latitude: 47 + i / 100, + longitude: -122 - i / 100, + label: `Stop ${i + 1}`, + })); +} + +describe("intermediateWaypointCount", () => { + it("excludes origin and destination", () => { + expect(intermediateWaypointCount(0)).toBe(0); + expect(intermediateWaypointCount(1)).toBe(0); + expect(intermediateWaypointCount(2)).toBe(0); + expect(intermediateWaypointCount(5)).toBe(3); + }); +}); + +describe("buildGoogleMapsUrl", () => { + it("rejects an empty route", () => { + const r = buildGoogleMapsUrl([]); + expect(r).toEqual({ error: "route has no stops to export", status: 400 }); + }); + + it("builds a single-stop link (destination only, no waypoints)", () => { + const r = buildGoogleMapsUrl(stops(1)); + if ("error" in r) throw new Error(r.error); + expect(r.platform).toBe("google-maps"); + expect(r.stopCount).toBe(1); + expect(r.waypointCount).toBe(0); + expect(r.url).toContain("https://www.google.com/maps/dir/?"); + expect(r.url).toContain("api=1"); + expect(r.url).toContain("travelmode=driving"); + expect(r.url).toContain("origin=47%2C-122"); + expect(r.url).toContain("destination=47%2C-122"); + expect(r.url).not.toContain("waypoints="); + }); + + it("builds origin/destination only for two stops", () => { + const r = buildGoogleMapsUrl(stops(2)); + if ("error" in r) throw new Error(r.error); + expect(r.waypointCount).toBe(0); + expect(r.url).not.toContain("waypoints="); + expect(r.url).toContain("origin=47%2C-122"); + expect(r.url).toContain("destination=47.01%2C-122.01"); + }); + + it("includes intermediate waypoints in order, pipe-separated", () => { + const r = buildGoogleMapsUrl(stops(4)); + if ("error" in r) throw new Error(r.error); + expect(r.stopCount).toBe(4); + expect(r.waypointCount).toBe(2); + // waypoints param holds stops[1] and stops[2], pipe-joined (encoded %7C) + const url = new URL(r.url); + expect(url.searchParams.get("origin")).toBe("47,-122"); + expect(url.searchParams.get("destination")).toBe("47.03,-122.03"); + expect(url.searchParams.get("waypoints")).toBe( + "47.01,-122.01|47.02,-122.02" + ); + }); + + it("accepts a route at exactly the waypoint limit", () => { + const r = buildGoogleMapsUrl(stops(GOOGLE_MAPS_MAX_WAYPOINTS + 2)); + if ("error" in r) throw new Error(r.error); + expect(r.waypointCount).toBe(GOOGLE_MAPS_MAX_WAYPOINTS); + }); + + it("rejects a route over the waypoint limit", () => { + const r = buildGoogleMapsUrl(stops(GOOGLE_MAPS_MAX_WAYPOINTS + 3)); + expect("error" in r).toBe(true); + if ("error" in r) { + expect(r.status).toBe(400); + expect(r.error).toContain(`${GOOGLE_MAPS_MAX_WAYPOINTS}`); + } + }); +}); + +describe("buildAppleMapsUrl", () => { + it("rejects an empty route", () => { + const r = buildAppleMapsUrl([]); + expect(r).toEqual({ error: "route has no stops to export", status: 400 }); + }); + + it("builds a destination-only link for one stop", () => { + const r = buildAppleMapsUrl(stops(1)); + if ("error" in r) throw new Error(r.error); + expect(r.platform).toBe("apple-maps"); + expect(r.url).toBe("maps://?daddr=47,-122&dirflg=d"); + expect(r.url).not.toContain("saddr="); + }); + + it("chains destinations with +to: for multiple stops", () => { + const r = buildAppleMapsUrl(stops(3)); + if ("error" in r) throw new Error(r.error); + expect(r.stopCount).toBe(3); + expect(r.waypointCount).toBe(1); + expect(r.url).toBe( + "maps://?saddr=47,-122&daddr=47.01,-122.01+to:47.02,-122.02&dirflg=d" + ); + }); + + it("accepts a route at exactly the waypoint limit", () => { + const r = buildAppleMapsUrl(stops(APPLE_MAPS_MAX_WAYPOINTS + 2)); + if ("error" in r) throw new Error(r.error); + expect(r.waypointCount).toBe(APPLE_MAPS_MAX_WAYPOINTS); + }); + + it("rejects a route over the waypoint limit", () => { + const r = buildAppleMapsUrl(stops(APPLE_MAPS_MAX_WAYPOINTS + 3)); + expect("error" in r).toBe(true); + if ("error" in r) { + expect(r.status).toBe(400); + expect(r.error).toContain(`${APPLE_MAPS_MAX_WAYPOINTS}`); + } + }); +}); + +describe("buildNavigationUrl", () => { + it("dispatches to the google-maps builder", () => { + const r = buildNavigationUrl("google-maps", stops(2)); + if ("error" in r) throw new Error(r.error); + expect(r.platform).toBe("google-maps"); + }); + + it("dispatches to the apple-maps builder", () => { + const r = buildNavigationUrl("apple-maps", stops(2)); + if ("error" in r) throw new Error(r.error); + expect(r.platform).toBe("apple-maps"); + }); +}); diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 3bf905a..898b16a 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -1,4 +1,4 @@ -import { Hono } from "hono"; +import { Hono, type Context } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { @@ -24,6 +24,11 @@ import { type RouteStopInput, type StopConflictFlags, } from "../services/routeOptimization.js"; +import { + buildNavigationUrl, + type NavigationPlatform, + type NavigationStop, +} from "../services/navigationExport.js"; export const routesRouter = new Hono(); @@ -460,3 +465,65 @@ routesRouter.patch( }); } ); + +/** + * GET /:routeId/export/:platform — build a native-navigation deep-link URL for an + * optimized route. Origin = first stop, destination = last stop, the rest carried + * as ordered intermediate waypoints. Waypoint count is validated against the + * platform's limit. Auth: manager (any route) or groomer (own route only). + */ +async function handleNavigationExport( + c: Context, + platform: NavigationPlatform +) { + const db = getDb(); + const routeId = c.req.param("routeId"); + if (!routeId || !z.string().uuid().safeParse(routeId).success) { + return c.json({ error: "routeId must be a UUID" }, 400); + } + + const [route] = await db + .select() + .from(groomerRoutes) + .where(eq(groomerRoutes.id, routeId)); + if (!route) { + return c.json({ error: "Route not found" }, 404); + } + + // Reuse the groomer-own / manager authorization rule against the route owner. + const resolved = resolveTargetStaffId(c.get("staff"), route.staffId); + if ("error" in resolved) { + return c.json({ error: resolved.error }, resolved.status); + } + + const stops = await loadRouteStops(db, routeId); + if (stops.length === 0) { + return c.json({ error: "route has no stops to export" }, 400); + } + + const navStops: NavigationStop[] = stops.map((s) => ({ + latitude: s.latitude, + longitude: s.longitude, + label: s.clientName, + })); + + const result = buildNavigationUrl(platform, navStops); + if ("error" in result) { + return c.json({ error: result.error }, result.status); + } + + return c.json({ + platform: result.platform, + url: result.url, + stopCount: result.stopCount, + waypointCount: result.waypointCount, + }); +} + +routesRouter.get("/:routeId/export/google-maps", (c) => + handleNavigationExport(c, "google-maps") +); + +routesRouter.get("/:routeId/export/apple-maps", (c) => + handleNavigationExport(c, "apple-maps") +); diff --git a/src/services/navigationExport.ts b/src/services/navigationExport.ts new file mode 100644 index 0000000..ecac68a --- /dev/null +++ b/src/services/navigationExport.ts @@ -0,0 +1,155 @@ +// Navigation export — turn an optimized groomer route into a deep-link URL that +// opens the device's native navigation app (Google Maps / Apple Maps). +// +// A route is exported as: origin = first stop, destination = last stop, with the +// in-between stops carried as ordered intermediate waypoints. Each platform caps +// how many intermediate waypoints a deep link may carry, so callers must validate +// the route length before handing the URL to the client. + +/** + * Max intermediate waypoints a Google Maps URLs API deep link supports + * (`https://www.google.com/maps/dir/?api=1&...&waypoints=...`). Google documents + * a ceiling of 9 waypoints between origin and destination. + */ +export const GOOGLE_MAPS_MAX_WAYPOINTS = 9; + +/** + * Max intermediate waypoints we allow in an Apple Maps `maps://` deep link. Apple's + * URL scheme chains destinations with `+to:` but does not publish a hard cap; 15 is + * a conservative practical limit that keeps the URL well under length limits. + */ +export const APPLE_MAPS_MAX_WAYPOINTS = 15; + +export type NavigationPlatform = "google-maps" | "apple-maps"; + +/** A single ordered point on the route. `label` is optional, for display only. */ +export interface NavigationStop { + latitude: number; + longitude: number; + label?: string | null; +} + +export interface NavigationExportSuccess { + platform: NavigationPlatform; + url: string; + /** Total stops included (origin + waypoints + destination). */ + stopCount: number; + /** Intermediate waypoints only (excludes origin and destination). */ + waypointCount: number; +} + +export interface NavigationExportError { + error: string; + status: 400; +} + +export type NavigationExportResult = + | NavigationExportSuccess + | NavigationExportError; + +function isError(r: NavigationExportResult): r is NavigationExportError { + return "error" in r; +} + +/** Intermediate waypoints = every stop that is neither origin nor destination. */ +export function intermediateWaypointCount(stopCount: number): number { + return Math.max(0, stopCount - 2); +} + +function coord(stop: NavigationStop): string { + return `${stop.latitude},${stop.longitude}`; +} + +/** + * Builds a Google Maps URLs API driving deep link. On mobile this opens the + * native Google Maps app; on desktop it opens maps.google.com. + */ +export function buildGoogleMapsUrl( + stops: NavigationStop[] +): NavigationExportResult { + if (stops.length === 0) { + return { error: "route has no stops to export", status: 400 }; + } + const waypointCount = intermediateWaypointCount(stops.length); + if (waypointCount > GOOGLE_MAPS_MAX_WAYPOINTS) { + return { + error: `route has ${waypointCount} intermediate waypoints, exceeding Google Maps' limit of ${GOOGLE_MAPS_MAX_WAYPOINTS}`, + status: 400, + }; + } + + const origin = stops[0]!; + const destination = stops[stops.length - 1]!; + const params = new URLSearchParams(); + params.set("api", "1"); + params.set("travelmode", "driving"); + params.set("origin", coord(origin)); + params.set("destination", coord(destination)); + if (stops.length > 2) { + const mids = stops + .slice(1, -1) + .map(coord) + .join("|"); + params.set("waypoints", mids); + } + + return { + platform: "google-maps", + url: `https://www.google.com/maps/dir/?${params.toString()}`, + stopCount: stops.length, + waypointCount, + }; +} + +/** + * Builds an Apple Maps `maps://` driving deep link. The first stop is the source + * (`saddr`); the remaining stops are chained as destinations with `+to:` (`daddr`). + * Built by hand because the `+to:` separators are part of Apple's scheme and must + * not be percent-encoded. + */ +export function buildAppleMapsUrl( + stops: NavigationStop[] +): NavigationExportResult { + if (stops.length === 0) { + return { error: "route has no stops to export", status: 400 }; + } + const waypointCount = intermediateWaypointCount(stops.length); + if (waypointCount > APPLE_MAPS_MAX_WAYPOINTS) { + return { + error: `route has ${waypointCount} intermediate waypoints, exceeding Apple Maps' limit of ${APPLE_MAPS_MAX_WAYPOINTS}`, + status: 400, + }; + } + + const params: string[] = ["dirflg=d"]; + if (stops.length === 1) { + // Single stop: destination only, no source. + params.unshift(`daddr=${coord(stops[0]!)}`); + } else { + const daddr = stops + .slice(1) + .map(coord) + .join("+to:"); + params.unshift(`daddr=${daddr}`); + params.unshift(`saddr=${coord(stops[0]!)}`); + } + + return { + platform: "apple-maps", + url: `maps://?${params.join("&")}`, + stopCount: stops.length, + waypointCount, + }; +} + +/** Dispatches to the correct builder for the requested platform. */ +export function buildNavigationUrl( + platform: NavigationPlatform, + stops: NavigationStop[] +): NavigationExportResult { + return platform === "google-maps" + ? buildGoogleMapsUrl(stops) + : buildAppleMapsUrl(stops); +} + +export { isError as isNavigationExportError }; From 37e42b3104e2f28bebe5f4d7d353d283d17c23e2 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 9 Jun 2026 00:21:03 +0000 Subject: [PATCH 4/4] ci: re-trigger checks (transient pnpm/action-setup runner flake) Co-Authored-By: Paperclip