From de16c50040021108b3fc1f3e0842fb636502d7c8 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 2 Jun 2026 18:09:31 +0000 Subject: [PATCH 01/23] =?UTF-8?q?fix(seed):=20GRO-2100=20deterministic=20u?= =?UTF-8?q?at-groomer=20=E2=86=94=20UAT=20Pup=20Alpha=20linkage=20(#151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/db/src/seed.ts | 102 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index c647098..13cf103 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -668,6 +668,108 @@ async function seedUatStaffAccounts(db: ReturnType) { console.log(`✓ Created UAT pet '${pet.name}' with extended fields`); } } + + // ── GRO-2100: deterministic uat-groomer ↔ pet linkage ─────────────────────── + // The UAT groomer (`uat-groomer@groombook.dev`, staffId 00000000-0000-0000-0000-000000000004) + // needs at least one linked pet/appointment or GRO-1987 TC-UAT-2/3 cannot run + // (the pet profile-summary endpoint returns 404 instead of 200/403). + // + // We deterministically link the UAT groomer to the UAT customer's first pet + // ("UAT Pup Alpha") and leave the second pet ("UAT Pup Beta") UNLINKED so + // TC-UAT-2 (200) and TC-UAT-3 (403) can both hardcode the stable petIds. + await seedUatGroomerLinkage(db, uatCustomerClientId); +} + +/** + * GRO-2100: create a deterministic completed appointment linking the UAT groomer + * to "UAT Pup Alpha" (c0000001-0000-0000-0000-000000000002). "UAT Pup Beta" + * (c0000001-0000-0000-0000-000000000003) is intentionally left UNLINKED so + * GRO-1987 TC-UAT-3 can verify the 403 forbidden response. + * + * Idempotent: the deterministic appointment id (`a0000001-…-0001`) is the + * upsert key, so re-running the seed on every reset-demo-data CronJob + * (hourly per apps/overlays/uat/reset-cronjob.yaml) is safe. + */ +async function seedUatGroomerLinkage( + db: ReturnType, + customerClientId: string, +): Promise { + const uatGroomerEmail = "uat-groomer@groombook.dev"; + const LINKED_PET_ID = "c0000001-0000-0000-0000-000000000002"; // UAT Pup Alpha + const APPT_ID = "a0000001-0000-0000-0000-000000000001"; + + // Only run if the UAT groomer staff record actually exists — dev/test seeds + // that don't set SEED_UAT_STAFF_OIDC_SUB should not crash. + const [uatGroomerStaff] = await db + .select({ id: schema.staff.id }) + .from(schema.staff) + .where(eq(schema.staff.email, uatGroomerEmail)) + .limit(1); + if (!uatGroomerStaff) { + return; + } + + // Skip if this exact appointment already exists (idempotent on re-seed). + const [existing] = await db + .select({ id: schema.appointments.id }) + .from(schema.appointments) + .where(eq(schema.appointments.id, APPT_ID)) + .limit(1); + if (existing) { + console.log(`✓ GRO-2100: uat-groomer linkage appointment already exists — skipping`); + return; + } + + // The "Bath & Brush" service id is stable across the reset; falls back to + // any active service if it has not been seeded yet (e.g. seedKnownUsers + // runs in isolation). + 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-2100: no active services found — skipping uat-groomer linkage`); + return; + } + serviceId = fallback.id; + } + + // Schedule the completed appointment 7 days ago so the profile-summary's + // "recentGroomingHistory" window (last 10) reliably includes it. + const startTime = new Date(); + startTime.setDate(startTime.getDate() - 7); + startTime.setHours(10, 0, 0, 0); + const endTime = new Date(startTime.getTime() + 45 * 60 * 1000); + + await db.insert(schema.appointments).values({ + id: APPT_ID, + clientId: customerClientId, + petId: LINKED_PET_ID, + serviceId, + staffId: uatGroomerStaff.id, + batherStaffId: null, + status: "completed", + startTime, + endTime, + notes: "GRO-2100: deterministic uat-groomer linkage for TC-UAT-2/3.", + priceCents: null, + confirmationStatus: "confirmed", + }); + console.log( + `✓ GRO-2100: linked uat-groomer (${uatGroomerStaff.id}) → UAT Pup Alpha (${LINKED_PET_ID}) via appointment ${APPT_ID}`, + ); } // ── Known-users-only seed (prod/demo) ─────────────────────────────────────── From e9f94a2bd77181ac4e3b321af36963dd30bc3e12 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 2 Jun 2026 20:11:45 +0000 Subject: [PATCH 02/23] fix(seed): GRO-2100 run uat-groomer linkage AFTER services seed (regression in #151) (#153) fix(seed): GRO-2100 run uat-groomer linkage after services seed (#153) Co-authored-by: Flea Flicker Co-committed-by: Flea Flicker --- packages/db/src/seed.ts | 48 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 13cf103..8e9d376 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -401,7 +401,9 @@ const servicesDef = [ * * In seedKnownUsers() this replaces the inline UAT-staff block. */ -async function seedUatStaffAccounts(db: ReturnType) { +async function seedUatStaffAccounts( + db: ReturnType, +): Promise { // ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ── const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB; if (uatSuperOidcSub) { @@ -677,7 +679,12 @@ async function seedUatStaffAccounts(db: ReturnType) { // We deterministically link the UAT groomer to the UAT customer's first pet // ("UAT Pup Alpha") and leave the second pet ("UAT Pup Beta") UNLINKED so // TC-UAT-2 (200) and TC-UAT-3 (403) can both hardcode the stable petIds. - await seedUatGroomerLinkage(db, uatCustomerClientId); + // + // The linkage call itself is performed by the caller AFTER the `services` + // catalogue has been seeded (this helper runs before services exist, + // which previously caused the linkage to be silently skipped on every + // reset). GRO-2100 follow-up. + return uatCustomerClientId; } /** @@ -692,12 +699,18 @@ async function seedUatStaffAccounts(db: ReturnType) { */ async function seedUatGroomerLinkage( db: ReturnType, - customerClientId: string, + customerClientId: string | null, ): Promise { const uatGroomerEmail = "uat-groomer@groombook.dev"; const LINKED_PET_ID = "c0000001-0000-0000-0000-000000000002"; // UAT Pup Alpha const APPT_ID = "a0000001-0000-0000-0000-000000000001"; + // Skip silently if the UAT Customer client wasn't created (non-UAT seed + // profile, e.g. seedKnownUsers() in an env without the UAT personas). + if (!customerClientId) { + return; + } + // Only run if the UAT groomer staff record actually exists — dev/test seeds // that don't set SEED_UAT_STAFF_OIDC_SUB should not crash. const [uatGroomerStaff] = await db @@ -720,6 +733,19 @@ async function seedUatGroomerLinkage( return; } + // Skip if the linked pet hasn't been seeded yet (defensive: caller should + // ensure pets exist; if the helper is re-ordered later we don't want to + // crash here). + const [linkedPet] = await db + .select({ id: schema.pets.id }) + .from(schema.pets) + .where(eq(schema.pets.id, LINKED_PET_ID)) + .limit(1); + if (!linkedPet) { + console.warn(`⚠ GRO-2100: UAT Pup Alpha (${LINKED_PET_ID}) not found — skipping uat-groomer linkage`); + return; + } + // The "Bath & Brush" service id is stable across the reset; falls back to // any active service if it has not been seeded yet (e.g. seedKnownUsers // runs in isolation). @@ -847,7 +873,7 @@ async function seedKnownUsers() { // ── UAT staff accounts + Better Auth credentials (shared impl) ────────────── // Extracted into seedUatStaffAccounts() so it runs in both seedKnownUsers() // and the full seed() UAT branch. - await seedUatStaffAccounts(db); + const uatCustomerClientId = await seedUatStaffAccounts(db); // ── Services: idempotent upsert keyed on `id` ───────────────────────────── // GRO-2064: previously keyed on `services.name` while writing a @@ -875,6 +901,12 @@ async function seedKnownUsers() { } console.log(`✓ Seeded ${demoSvcs.length} services`); + // GRO-2100: deterministic uat-groomer ↔ UAT Pup Alpha linkage. Must run + // AFTER services are seeded (this helper looks up an active service id + // to attach to the appointment; on a fresh reset there are none yet at + // the time seedUatStaffAccounts() returns). + await seedUatGroomerLinkage(db, uatCustomerClientId); + // ── Client: Demo Client ── const [existingClient] = await db .select() @@ -1031,7 +1063,7 @@ async function seed() { // ── UAT staff accounts + Better Auth credentials (shared impl) ────────────── // Seeds deterministic UAT staff with numeric OIDC subs and Better Auth credentials. // Must run AFTER random staff are created so upserts land correctly. - await seedUatStaffAccounts(db); + const uatCustomerClientId = await seedUatStaffAccounts(db); // ── Services ── // GRO-2064: key the upsert on `services.id` (not `name`) so deterministic @@ -1058,6 +1090,12 @@ async function seed() { } console.log(`✓ Created ${servicesDef.length} services`); + // GRO-2100: deterministic uat-groomer ↔ UAT Pup Alpha linkage. Must run + // AFTER services are seeded (this helper looks up an active service id + // to attach to the appointment; on a fresh reset there are none yet at + // the time seedUatStaffAccounts() returns). + await seedUatGroomerLinkage(db, uatCustomerClientId); + // ── Clients & Pets ── const now = new Date(); const appointmentsBackDate = new Date(now); From d1a68d93de2f4708491e56fedcbbee674e1451c4 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 4 Jun 2026 11:12:17 +0000 Subject: [PATCH 03/23] fix(GRO-2123): serialize seed.ts with Postgres advisory lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reset-demo-data CronJob in groombook-uat intermittently failed with FK 23503 on invoice_tip_splits because two pods could run the seed concurrently: the new pod's TRUNCATE deleted rows the old pod was still inserting. Acquire a session-level advisory lock for the full duration of the seed. CRITICAL: with postgres-js connection pooling, a pg_advisory_lock acquired on one pooled connection and released on a different one is a no-op (the lock is bound to the pg-backend that took it). We therefore reserve a dedicated connection for the lock, take pg_advisory_lock(KEY) on it, run the seed on the pooled connections, and release the lock + reserved connection in a try/finally so a thrown seed error cannot leak the lock or the connection. Defence-in-depth with the infra PR that switches concurrencyPolicy: Replace → Forbid on the reset-demo-data CronJob. - Adds withSeedAdvisoryLock helper and runSeedBody extracted function - Wraps seed() body in the helper; client.end() runs after the lock releases so a reserved connection is not returned to a closed pool - SEED_ADVISORY_LOCK_KEY = 0x47524f4f ("GROO" in ASCII) — arbitrary stable 32-bit key, referenced in runbooks - UAT_PLAYBOOK.md §3.29 documents the regression check cc @cpfarhood Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 1 + packages/db/src/seed.ts | 75 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index b8949de..42d4cf9 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -166,6 +166,7 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-3.26 | Verify 25-35% medicalAlerts distribution | GET /api/pets (first 30 pets), count how many have non-empty medicalAlerts | Ratio is 25-35% (seed uses rand() < 0.3 for ~30% distribution) | | TC-API-3.27 | Verify coat_type enum has all seed values | After UAT seed completes, inspect the coat_type enum on the UAT DB — it must contain: short, medium, long, double, wire, silky, curly, hairless | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; coat_type includes all 8 values used by seed.ts `coatTypePool` | | TC-API-3.28 | Verify pet_size_category enum has all seed values | After UAT seed completes, inspect the pet_size_category enum on the UAT DB — it must contain: small, medium, large, extra_large | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; pet_size_category includes all 4 values used by seed.ts `petSizeCategoryPool` (regression for GRO-1999, mirrors TC-API-3.27) | +| TC-API-3.29 | Verify `reset-demo-data` CronJob does not fail with FK 23503 on `invoice_tip_splits` (GRO-2123) | Trigger the CronJob manually: `kubectl create job --from=cronjob/reset-demo-data verify-gro2123 -n groombook-uat`. Wait for pod to terminate. Inspect logs: `kubectl logs -n groombook-uat -l job-name=verify-gro2123` | Pod reaches `Completed` state; logs show `✓ Acquired seed advisory lock` and `✓ Released seed advisory lock` from `seed.ts`; no `PostgresError: … violates foreign key constraint "invoice_tip_splits_invoice_id_invoices_id_fk"` (code 23503); final counts unchanged (500 clients, ~4000 invoices) | ### 4.4 Appointment Scheduling diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 8e9d376..0959be0 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -976,6 +976,63 @@ async function seedKnownUsers() { // ── Main seed ──────────────────────────────────────────────────────────────── +// ── GRO-2123: serialize reset+seed with a Postgres advisory lock ──────── +// The reset-demo-data CronJob runs on an hourly schedule. With +// concurrencyPolicy=Replace, a new pod can start while the previous one +// is still mid-seed; the new pod's TRUNCATE then deletes rows the old pod +// is still inserting, producing FK 23503 errors non-deterministically +// (see GRO-2123: invoice_tip_splits → invoices). +// +// We hold a session-level advisory lock for the full duration of the +// seed so that overlapping invocations block then proceed in order — +// not skip. The key is a stable 32-bit constant so it can be referenced +// from runbooks without ambiguity and binds to the single-argument +// `pg_advisory_lock(int)` form, which postgres-js serializes as a plain +// number (no bigint type plumbing required). +const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable + +/** + * Reserve a dedicated connection from `pool`, take the seed advisory lock + * on it, run `fn`, and release the lock + connection in a try/finally. + * + * CRITICAL: with postgres-js connection pooling, a session-level + * `pg_advisory_lock(KEY)` acquired on one pooled connection and released + * on a *different* one is a no-op (the lock is bound to the session / + * pg-backend that took it). We therefore reserve a dedicated connection + * for the lock and release it from the same reserved connection. The + * seed work itself still runs on the pooled connections. + */ +async function withSeedAdvisoryLock( + pool: ReturnType, + fn: () => Promise, +): Promise { + const lockConnection = await pool.reserve(); + let lockHeld = false; + try { + await lockConnection`SELECT pg_advisory_lock(${SEED_ADVISORY_LOCK_KEY})`; + lockHeld = true; + console.log(`✓ Acquired seed advisory lock (key=${SEED_ADVISORY_LOCK_KEY})`); + const result = await fn(); + await lockConnection`SELECT pg_advisory_unlock(${SEED_ADVISORY_LOCK_KEY})`; + lockHeld = false; + console.log(`✓ Released seed advisory lock`); + return result; + } finally { + if (lockHeld) { + try { + await lockConnection`SELECT pg_advisory_unlock(${SEED_ADVISORY_LOCK_KEY})`; + } catch (err) { + console.error("Failed to release seed advisory lock during cleanup:", err); + } + } + try { + lockConnection.release(); + } catch (err) { + console.error("Failed to release reserved lock connection:", err); + } + } +} + async function seed() { const url = process.env.DATABASE_URL; if (!url) { @@ -993,6 +1050,22 @@ async function seed() { const client = postgres(url, { max: 5 }); const db = drizzle(client, { schema }); + // GRO-2123: hold the seed advisory lock for the full body of runSeedBody. + // See the withSeedAdvisoryLock comment for why a reserved connection is + // required (postgres-js pooling would silently drop the lock otherwise). + await withSeedAdvisoryLock(client, async () => { + return await runSeedBody(client, db, profile, cfg); + }); + + await client.end(); +} + +async function runSeedBody( + client: ReturnType, + db: ReturnType, + profile: SeedProfile, + cfg: ProfileConfig, +): Promise { console.log(`Seeding Groom Book database (profile: ${profile})...\n`); // ── Staff ── @@ -1614,8 +1687,6 @@ async function seed() { } console.log(`✓ Created ${visitLogCount} grooming visit logs`); console.log("\nSeed complete!"); - - await client.end(); } seed().catch((err) => { From 93be4d8f72bb6da7636130cfc79834099af2a22d Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Thu, 4 Jun 2026 12:44:46 +0000 Subject: [PATCH 04/23] chore: delete stale apps/api/src/db/seed.ts duplicate (GRO-2129) (#158) chore: delete stale apps/api/src/db/seed.ts duplicate (GRO-2129) (#158) --- apps/api/package.json | 4 +- apps/api/src/db/seed.ts | 1349 --------------------------------------- 2 files changed, 2 insertions(+), 1351 deletions(-) delete mode 100644 apps/api/src/db/seed.ts diff --git a/apps/api/package.json b/apps/api/package.json index cb340f4..af29e16 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,8 +12,8 @@ "test": "vitest run", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", - "db:seed": "tsx src/db/seed.ts", - "db:reset": "tsx src/db/reset.ts && drizzle-kit migrate && tsx src/db/seed.ts", + "db:seed": "pnpm --filter @groombook/db seed", + "db:reset": "pnpm --filter @groombook/db reset", "db:studio": "drizzle-kit studio" }, "dependencies": { diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts deleted file mode 100644 index bf57144..0000000 --- a/apps/api/src/db/seed.ts +++ /dev/null @@ -1,1349 +0,0 @@ -/** - * Seed script — generates deterministic, PII-free test data for Groom Book. - * - * Creates: - * - 1 manager + 1 receptionist + 3 groomers + 3 bathers (8 staff total) - * - 10 services - * - 500 clients, each with 1-3 dogs - * - ~2 500 appointments spread across the past 12 months - * - Invoices for completed appointments with line items and tip splits - * - Grooming visit logs for completed appointments - * - * Output is fully deterministic: the same seed value always produces the - * same rows with the same IDs. - * - * Usage: - * DATABASE_URL=postgres://... npx tsx packages/db/src/seed.ts - */ - -import postgres from "postgres"; -import { drizzle } from "drizzle-orm/postgres-js"; -import { eq, and, sql } from "drizzle-orm"; -import * as schema from "./schema.js"; -import type { MedicalAlert, MedicalAlertSeverity } from "./schema.js"; - -// ── Seed profile configuration ───────────────────────────────────────────── - -type SeedProfile = "dev" | "uat" | "demo"; - -interface ProfileConfig { - staffCount: { manager: number; receptionist: number; groomer: number; bather: number }; - clientCount: number; - appointmentsBackDays: number; - appointmentsForwardDays: number; - invoiceCount: number; - includeUatClients: boolean; -} - -const profiles: Record = { - dev: { - staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 }, - clientCount: 100, - appointmentsBackDays: 7, - appointmentsForwardDays: 30, - invoiceCount: 1000, - includeUatClients: false, - }, - uat: { - staffCount: { manager: 1, receptionist: 1, groomer: 3, bather: 3 }, - clientCount: 500, - appointmentsBackDays: 30, - appointmentsForwardDays: 90, - invoiceCount: 4000, - includeUatClients: true, - }, - demo: { - staffCount: { manager: 1, receptionist: 1, groomer: 3, bather: 3 }, - clientCount: 500, - appointmentsBackDays: 30, - appointmentsForwardDays: 90, - invoiceCount: 4000, - includeUatClients: true, - }, -}; - -function getProfile(): SeedProfile { - const raw = process.env.SEED_PROFILE?.toLowerCase(); - if (raw === "dev" || raw === "uat" || raw === "demo") { - return raw; - } - return "uat"; -} - -// ── Deterministic PRNG (Mulberry32) ────────────────────────────────────────── - -/** - * Returns a seeded pseudo-random number generator. - * Same seed → identical sequence of numbers every run. - */ -function createPrng(seed: number): () => number { - let s = seed | 0; - return function (): number { - s = (s + 0x6d2b79f5) | 0; - let t = Math.imul(s ^ (s >>> 15), 1 | s); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -const rand = createPrng(42); - -// ── Helpers ────────────────────────────────────────────────────────────────── - -/** Return a random element from an array using the seeded PRNG. */ -function pick(arr: T[]): T { - return arr[Math.floor(rand() * arr.length)]!; -} - - -function randInt(min: number, max: number): number { - return Math.floor(rand() * (max - min + 1)) + min; -} - -function randDate(start: Date, end: Date): Date { - return new Date(start.getTime() + rand() * (end.getTime() - start.getTime())); -} - -/** - * Generate a deterministic UUID v4 from the seeded PRNG. - * Conforms to RFC 4122 §4.4 (variant bits set correctly). - */ -function uuid(): string { - const hex = (n: number) => n.toString(16).padStart(2, "0"); - const bytes = Array.from({ length: 16 }, () => Math.floor(rand() * 256)); - bytes[6] = ((bytes[6]! & 0x0f) | 0x40); // version 4 - bytes[8] = ((bytes[8]! & 0x3f) | 0x80); // variant bits - return [ - bytes.slice(0, 4).map(hex).join(""), - bytes.slice(4, 6).map(hex).join(""), - bytes.slice(6, 8).map(hex).join(""), - bytes.slice(8, 10).map(hex).join(""), - bytes.slice(10, 16).map(hex).join(""), - ].join("-"); -} - -// ── Data pools ─────────────────────────────────────────────────────────────── - -const firstNames = [ - "Emma", "Liam", "Olivia", "Noah", "Ava", "Ethan", "Sophia", "Mason", - "Isabella", "Lucas", "Mia", "Logan", "Charlotte", "Aiden", "Amelia", - "James", "Harper", "Benjamin", "Evelyn", "Elijah", "Abigail", "William", - "Emily", "Sebastian", "Elizabeth", "Henry", "Sofia", "Alexander", "Avery", - "Daniel", "Scarlett", "Michael", "Grace", "Jackson", "Chloe", "Owen", - "Victoria", "Jack", "Riley", "Caleb", "Aria", "Luke", "Luna", "Ryan", - "Zoey", "Nathan", "Penelope", "Carter", "Layla", "Dylan", "Nora", - "Andrew", "Lily", "Gabriel", "Eleanor", "Samuel", "Hannah", "David", - "Lillian", "Matthew", "Addison", "Joseph", "Aubrey", "Isaac", "Stella", - "Joshua", "Natalie", "Wyatt", "Zoe", "John", "Leah", "Leo", "Hazel", - "Julian", "Violet", "Christopher", "Aurora", "Jonathan", "Savannah", - "Lincoln", "Audrey", "Thomas", "Brooklyn", "Asher", "Bella", "Theodore", - "Claire", "Jaxon", "Skylar", "Robert", "Lucy", "Charles", "Paisley", - "Adrian", "Anna", "Miles", "Caroline", "Dominic", "Genesis", "Connor", -]; - -const lastNames = [ - "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", - "Davis", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", - "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin", - "Lee", "Perez", "Thompson", "White", "Harris", "Sanchez", "Clark", - "Ramirez", "Lewis", "Robinson", "Walker", "Young", "Allen", "King", - "Wright", "Scott", "Torres", "Nguyen", "Hill", "Flores", "Green", - "Adams", "Nelson", "Baker", "Hall", "Rivera", "Campbell", "Mitchell", - "Carter", "Roberts", "Gomez", "Phillips", "Evans", "Turner", "Diaz", - "Parker", "Cruz", "Edwards", "Collins", "Reyes", "Stewart", "Morris", - "Morales", "Murphy", "Cook", "Rogers", "Gutierrez", "Ortiz", "Morgan", - "Cooper", "Peterson", "Bailey", "Reed", "Kelly", "Howard", "Ramos", - "Kim", "Cox", "Ward", "Richardson", "Watson", "Brooks", "Chavez", - "Wood", "James", "Bennett", "Gray", "Mendoza", "Ruiz", "Hughes", - "Price", "Alvarez", "Castillo", "Sanders", "Patel", "Myers", "Long", - "Ross", "Foster", "Jimenez", -]; - -const dogNames = [ - "Buddy", "Max", "Charlie", "Cooper", "Rocky", "Bear", "Duke", "Tucker", - "Jack", "Oliver", "Milo", "Bentley", "Zeus", "Winston", "Beau", "Finn", - "Leo", "Teddy", "Louie", "Toby", "Harley", "Bailey", "Murphy", "Rex", - "Bruno", "Gus", "Diesel", "Moose", "Henry", "Archie", "Luna", "Bella", - "Daisy", "Lucy", "Sadie", "Molly", "Maggie", "Chloe", "Sophie", "Stella", - "Penny", "Zoey", "Ruby", "Rosie", "Lola", "Willow", "Nala", "Ginger", - "Coco", "Roxy", "Ellie", "Piper", "Gracie", "Millie", "Lady", "Pepper", - "Hazel", "Dixie", "Winnie", "Bonnie", "Maple", "Ivy", "Pearl", "Olive", -]; - -const dogBreeds = [ - "Golden Retriever", "Labrador Retriever", "Poodle", "German Shepherd", - "Bulldog", "Beagle", "Rottweiler", "Dachshund", "Yorkshire Terrier", - "Boxer", "Siberian Husky", "Cavalier King Charles Spaniel", - "Doberman Pinscher", "Great Dane", "Miniature Schnauzer", - "Shih Tzu", "Boston Terrier", "Bernese Mountain Dog", "Pomeranian", - "Havanese", "Cocker Spaniel", "Border Collie", "Shetland Sheepdog", - "Brittany", "English Springer Spaniel", "Maltese", "Bichon Frise", - "West Highland White Terrier", "Vizsla", "Chihuahua", "Collie", - "Basset Hound", "Newfoundland", "Samoyed", "Australian Shepherd", - "Pembroke Welsh Corgi", "French Bulldog", "Weimaraner", "Puggle", - "Mixed Breed", "Mixed Breed", "Mixed Breed", -]; - -const cutStyles = [ - "Puppy Cut", "Teddy Bear Cut", "Lion Cut", "Breed Standard", - "Summer Shave", "Kennel Cut", "Lamb Cut", "Continental Clip", - "Sporting Clip", "Sanitary Trim", "Face & Feet Trim", "Full Groom", - null, -]; - -const shampoos = [ - "Oatmeal Sensitive", "Whitening Formula", "Flea & Tick", "Hypoallergenic", - "De-shedding", "Puppy Gentle", "Medicated", "Coconut Oil", - "Lavender Calm", null, -]; - -const healthAlerts = [ - null, null, null, null, null, // Most pets have none - "Sensitive skin — avoid harsh shampoos", - "Ear infection prone — dry ears thoroughly", - "Hip dysplasia — handle with care", - "Anxious — needs slow approach", - "Seizure history — avoid stress triggers", - "Skin allergies — use hypoallergenic products only", - "Aggressive when nails trimmed — muzzle required", - "Heart murmur — monitor during grooming", - "Diabetic — owner brings treats", -]; - -const streetNames = [ - "Main St", "Oak Ave", "Maple Dr", "Cedar Ln", "Elm St", "Pine Rd", - "Birch Way", "Walnut Ct", "Cherry Blvd", "Willow Pl", "Spruce Ter", - "Chestnut Cir", "Hickory Ln", "Magnolia Ave", "Sycamore Dr", - "Dogwood Rd", "Aspen Way", "Redwood Ct", "Juniper Blvd", "Poplar St", -]; - -const cities = [ - "Springfield", "Riverside", "Fairview", "Madison", "Georgetown", - "Clinton", "Salem", "Greenville", "Franklin", "Bristol", - "Manchester", "Oakland", "Burlington", "Arlington", "Ashland", -]; - -const states = ["CA", "TX", "NY", "FL", "IL", "PA", "OH", "GA", "NC", "MI"]; - -const groomingNotes = [ - null, null, null, - "Matting prone — brush out before bath", - "Loves the dryer", - "Nippy around paws", - "Very calm, easy to handle", - "Needs extra time for drying (thick coat)", - "Sensitive around face — use caution", - "Doesn't like water, use minimal bath time", - "Loves belly rubs — great way to calm down", - "Double coat — needs thorough de-shedding", - "Previous clipper burn — be gentle on belly", -]; - -const appointmentNotes = [ - null, null, null, null, - "Client requested extra brushing", - "Nail trim only — no bath", - "Teeth brushing added", - "Ear cleaning requested", - "New puppy — first groom, be gentle", - "Matted — may need extra time", - "Owner wants shorter cut than usual", - "Anal glands need expressing", - "Use gentle shampoo per vet recommendation", - "Client running late, pushed start by 15min", -]; - -const temperamentScores = [3, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9]; - -const temperamentFlags = [ - [], ["anxious"], ["friendly"], ["nippy"], ["anxious", "sensitive"], - ["friendly", "calm"], ["nippy", "territorial"], ["calm"], ["sensitive"], - ["friendly", "nippy"], ["anxious", "territorial"], -]; - -const medicalAlertsList = [ - [] as MedicalAlert[], - [] as MedicalAlert[], - [{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }], - [{ type: "ear", description: "Ear infection prone — dry ears thoroughly", severity: "medium" as MedicalAlertSeverity }], - [{ type: "mobility", description: "Hip dysplasia — handle with care", severity: "high" as MedicalAlertSeverity }], - [{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }], - [{ type: "medical", description: "Seizure history — avoid stress triggers", severity: "high" as MedicalAlertSeverity }], - [{ type: "skin", description: "Skin allergies — use hypoallergenic products only", severity: "medium" as MedicalAlertSeverity }], - [{ type: "behavioral", description: "Aggressive when nails trimmed — muzzle required", severity: "high" as MedicalAlertSeverity }], - [{ type: "cardiac", description: "Heart murmur — monitor during grooming", severity: "high" as MedicalAlertSeverity }], - [{ type: "dietary", description: "Diabetic — owner brings treats", severity: "medium" as MedicalAlertSeverity }], -]; - -const preferredCutsList = [ - [], ["Puppy Cut"], ["Teddy Bear Cut"], ["Breed Standard"], - ["Puppy Cut", "Sanitary Trim"], ["Full Groom"], ["Lion Cut"], - ["Kennel Cut", "Face & Feet Trim"], ["Teddy Bear Cut", "Sanitary Trim"], - ["Breed Standard", "Sanitary Trim"], ["Summer Shave"], - ["Puppy Cut", "Face & Feet Trim", "Sanitary Trim"], -]; - -const coatTypes: string[] = ["short", "medium", "long", "curly", "wire", "double", "silky"]; - -const visitLogNotes = [ - null, null, - "Coat in great condition", - "Found a small mat behind left ear, brushed out", - "Nails were very long, trimmed carefully", - "Light shedding, used de-shedding tool", - "Slight skin irritation noticed on belly — flagged to owner", - "Pet was very well-behaved today", - "Required two rinse cycles — very dirty", - "Applied conditioning treatment for dry coat", -]; - -const productsUsed = [ - null, - "Oatmeal shampoo, conditioner", - "Whitening shampoo, detangler", - "De-shedding shampoo, FURminator", - "Hypoallergenic shampoo, ear cleaner", - "Flea & tick shampoo, nail grinder", - "Puppy shampoo, gentle conditioner", - "Medicated shampoo (vet prescribed), moisturizer", - "Coconut oil shampoo, leave-in conditioner, cologne", -]; - -const demoPetImages = [ - "/demo-pets/dog-golden-after.png", - "/demo-pets/dog-poodle-groomed.png", - "/demo-pets/dog-black-lab.png", - "/demo-pets/dog-shih-tzu.png", - "/demo-pets/dog-cocker-spaniel.png", - "/demo-pets/dog-schnauzer.png", - "/demo-pets/dog-maltese.png", - "/demo-pets/dog-dachshund.png", - "/demo-pets/dog-pomeranian.png", - "/demo-pets/dog-bichon-frise.png", - "/demo-pets/dog-golden-retriever.png", - "/demo-pets/dog-labrador.png", - "/demo-pets/dog-mixed-breed.png", - "/demo-pets/dog-poodle.png", - "/demo-pets/dog-terrier.png", - "/demo-pets/dog-afghan-hound.png", - "/demo-pets/dog-basset-brown-white.png", - "/demo-pets/dog-bichon-white-groomed.png", - "/demo-pets/dog-boxer-fawn-athletic.png", - "/demo-pets/dog-cavalier-cream-gentle.png", - "/demo-pets/dog-cocker-buff-friendly.png", - "/demo-pets/dog-corgi.png", - "/demo-pets/dog-dachshund-black-tan.png", - "/demo-pets/dog-golden-before.png", - "/demo-pets/dog-pomeranian-white-studio.png", - "/demo-pets/dog-schnauzer-black-groomed.png", - "/demo-pets/dog-setter-red-sunlit.png", - "/demo-pets/dog-sheepdog-merle-running.png", -]; - -const puggleImages = [ - "/demo-pets/dog-puggle-fawn-playful.png", - "/demo-pets/dog-puggle-black-sitting.png", - "/demo-pets/dog-puggle-cream-groomed.png", - "/demo-pets/dog-puggle-fawn-grooming.png", -]; - -// ── Service definitions ────────────────────────────────────────────────────── -// Deterministic service IDs + UNIQUE(name) constraint make seed fully idempotent: -// first run inserts, subsequent runs update existing rows via ON CONFLICT (name). -const servicesDef = [ - { id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", desc: "Full bath, blow-dry, brush out, and ear cleaning", price: 4500, dur: 45 }, - { id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", desc: "Complete grooming for dogs under 25 lbs", price: 6500, dur: 60 }, - { id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", desc: "Complete grooming for dogs 25-50 lbs", price: 8000, dur: 75 }, - { id: "b0000001-0000-0000-0000-000000000004", name: "Full Groom — Large", desc: "Complete grooming for dogs over 50 lbs", price: 9500, dur: 90 }, - { id: "b0000001-0000-0000-0000-000000000005", name: "Nail Trim", desc: "Nail clipping and filing", price: 1500, dur: 15 }, - { id: "b0000001-0000-0000-0000-000000000006", name: "Teeth Brushing", desc: "Dental cleaning with enzymatic toothpaste", price: 1000, dur: 10 }, - { id: "b0000001-0000-0000-0000-000000000007", name: "De-shedding Treatment", desc: "Specialised de-shedding bath and blowout", price: 5500, dur: 60 }, - { id: "b0000001-0000-0000-0000-000000000008", name: "Puppy First Groom", desc: "Gentle introduction to grooming for puppies under 6 months", price: 4000, dur: 30 }, - { id: "b0000001-0000-0000-0000-000000000009", name: "Flea & Tick Treatment", desc: "Medicated bath with flea and tick shampoo", price: 5000, dur: 45 }, - { id: "b0000001-0000-0000-0000-00000000000a", name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 }, -]; - -// ── Known-users-only seed (prod/demo) ─────────────────────────────────────── - -/** - * Seeds only the minimal known users for prod/demo environments. - * Creates: Demo Manager staff + Demo Client + Demo Dog + basic services. - * Idempotent: skips creation if records already exist. - */ -async function seedKnownUsers() { - const url = process.env.DATABASE_URL; - if (!url) { - console.error("DATABASE_URL is not set"); - process.exit(1); - } - - const client = postgres(url, { max: 5 }); - const db = drizzle(client, { schema }); - - console.log("Seeding known users (prod/demo mode)...\n"); - - const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001"; - const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002"; - const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003"; - - // ── Staff: Demo Manager ── - const [existingStaff] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, "demo-manager@groombook.dev")) - .limit(1); - - if (existingStaff) { - console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`); - } else { - await db.insert(schema.staff).values({ - id: KNOWN_STAFF_ID, - name: "Demo Manager", - email: "demo-manager@groombook.dev", - oidcSub: "demo-manager-001", - role: "manager", - isSuperUser: true, - active: true, - }); - console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)"); - } - - // ── Staff: SEED_ADMIN_EMAIL admin ── - const adminEmail = process.env.SEED_ADMIN_EMAIL; - if (adminEmail) { - const adminName = process.env.SEED_ADMIN_NAME ?? "Admin"; - const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002"; - const [existingAdmin] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, adminEmail)) - .limit(1); - - if (existingAdmin) { - console.log(`✓ Staff admin '${existingAdmin.name}' already exists — skipping`); - } else { - await db.insert(schema.staff).values({ - id: ADMIN_STAFF_ID, - name: adminName, - email: adminEmail, - oidcSub: adminEmail, - role: "manager", - isSuperUser: true, - active: true, - }); - console.log(`✓ Created staff admin '${adminName}' (${adminEmail})`); - } - } - - // ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ── - const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB; - if (uatSuperOidcSub) { - const UAT_SUPER_STAFF_ID = "00000000-0000-0000-0000-000000000003"; - const [existingUatSuper] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, "uat-super@groombook.dev")) - .limit(1); - - if (existingUatSuper) { - console.log(`✓ Staff 'UAT Super User' already exists — skipping`); - } else { - await db.insert(schema.staff).values({ - id: UAT_SUPER_STAFF_ID, - name: "UAT Super User", - email: "uat-super@groombook.dev", - oidcSub: uatSuperOidcSub, - role: "manager", - isSuperUser: true, - active: true, - }); - console.log(`✓ Created staff 'UAT Super User' (oidcSub: ${uatSuperOidcSub})`); - } - } - - // ── Staff: UAT Staff Groomer (oidcSub from SEED_UAT_STAFF_OIDC_SUB env var) ── - const uatStaffOidcSub = process.env.SEED_UAT_STAFF_OIDC_SUB; - if (uatStaffOidcSub) { - const UAT_STAFF_STAFF_ID = "00000000-0000-0000-0000-000000000004"; - const [existingUatStaff] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, "uat-groomer@groombook.dev")) - .limit(1); - - if (existingUatStaff) { - console.log(`✓ Staff 'UAT Staff Groomer' already exists — skipping`); - } else { - await db.insert(schema.staff).values({ - id: UAT_STAFF_STAFF_ID, - name: "UAT Staff Groomer", - email: "uat-groomer@groombook.dev", - oidcSub: uatStaffOidcSub, - role: "groomer", - isSuperUser: false, - active: true, - }); - console.log(`✓ Created staff 'UAT Staff Groomer' (oidcSub: ${uatStaffOidcSub})`); - } - } - - // ── Staff: UAT Tester (oidcSub from SEED_UAT_TESTER_OIDC_SUB env var) ── - const uatTesterOidcSub = process.env.SEED_UAT_TESTER_OIDC_SUB; - if (uatTesterOidcSub) { - const UAT_TESTER_STAFF_ID = "00000000-0000-0000-0000-000000000007"; - const [existingUatTester] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, "uat-tester@groombook.dev")) - .limit(1); - - if (existingUatTester) { - console.log(`✓ Staff 'UAT Tester' already exists — skipping`); - } else { - await db.insert(schema.staff).values({ - id: UAT_TESTER_STAFF_ID, - name: "UAT Tester", - email: "uat-tester@groombook.dev", - oidcSub: uatTesterOidcSub, - role: "groomer", - isSuperUser: false, - active: true, - }); - console.log(`✓ Created staff 'UAT Tester' (oidcSub: ${uatTesterOidcSub})`); - } - } - - // ── 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) ?? []; - const groomerCount = Math.min(groomerEmails.length, groomerNames.length); - for (let i = 0; i < groomerCount; i++) { - const email = groomerEmails[i]!; - const name = groomerNames[i]!; - // Use deterministic IDs in the 00000000-0000-0000-0000-000000000005+ range - const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`; - const [existingGroomer] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, email)) - .limit(1); - - if (existingGroomer) { - console.log(`✓ Staff groomer '${existingGroomer.name}' already exists — skipping`); - } else { - await db.insert(schema.staff).values({ - id: staffId, - name, - email, - oidcSub: email, - role: "groomer", - isSuperUser: false, - active: true, - }); - console.log(`✓ Created staff groomer '${name}' (${email})`); - } - } - - // ── Better-Auth email+password credentials for UAT accounts ────────────────── - // Provisions Better-Auth user + account records so UAT testers can log in - // via email+password (POST /api/auth/sign-in/email) instead of Authentik SSO. - const uatPasswordAccounts = [ - { email: "uat-super@groombook.dev", name: "UAT Super User", passwordEnv: "SEED_UAT_SUPER_PASSWORD", staffEmail: "uat-super@groombook.dev" }, - { 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" }, - ]; - - for (const acct of uatPasswordAccounts) { - const password = process.env[acct.passwordEnv]; - if (!password) { - console.warn(`⚠ Skipping ${acct.email} — ${acct.passwordEnv} not set`); - continue; - } - - // 1. Find or create the Better-Auth user - const [existingUser] = await db - .select() - .from(schema.user) - .where(eq(schema.user.email, acct.email)) - .limit(1); - - let userId: string; - if (existingUser) { - userId = existingUser.id; - console.log(`✓ Better-Auth user '${acct.name}' already exists — skipping user creation`); - } else { - userId = uuid(); - await db.insert(schema.user).values({ - id: userId, - name: acct.name, - email: acct.email, - emailVerified: true, - }); - console.log(`✓ Created Better-Auth user '${acct.name}' (${acct.email})`); - } - - // 2. Check if credential account already exists - const [existingAccount] = await db - .select() - .from(schema.account) - .where(and( - eq(schema.account.userId, userId), - eq(schema.account.providerId, "credential") - )) - .limit(1); - - if (existingAccount) { - // Re-hash and update the password so that re-seeding rotates credentials - // when the env var changes (e.g. after a password rotation). Previously - // this branch skipped entirely, freezing the hash at first-seed. - const { hashPassword } = await import("better-auth/crypto"); - const passwordHash = await hashPassword(password); - await db.update(schema.account) - .set({ password: passwordHash }) - .where(eq(schema.account.id, existingAccount.id)); - console.log(`✓ Updated credential account password for '${acct.email}'`); - } else { - // Use Better-Auth's own hashPassword to guarantee parameter/encoding match. - // better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64, salt as 16-byte random - // hex string, key hex-encoded, format saltHex:keyHex. - const { hashPassword } = await import("better-auth/crypto"); - const passwordHash = await hashPassword(password); - - await db.insert(schema.account).values({ - id: uuid(), - accountId: userId, - providerId: "credential", - userId, - password: passwordHash, - }); - console.log(`✓ Created credential account for '${acct.email}'`); - } - - // 3. Link staff record to Better-Auth user (for accounts that have staff records) - if (acct.staffEmail) { - const [existingStaff] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, acct.staffEmail)) - .limit(1); - if (existingStaff && !existingStaff.userId) { - await db.update(schema.staff) - .set({ userId }) - .where(eq(schema.staff.id, existingStaff.id)); - console.log(`✓ Linked staff '${acct.staffEmail}' → Better-Auth user`); - } - } - } - - // ── Services: idempotent upsert keyed on `id` ───────────────────────────── - // GRO-2064: previously keyed on `services.name` while writing a - // deterministic `id`. If a stale row existed with the same `id` but a - // different `name`, PostgreSQL raised `services_pkey` (id collision) - // before the name-targeted ON CONFLICT could fire. Switch the conflict - // target to `services.id` so deterministic ids always win; pair with - // `TRUNCATE services … CASCADE` above so each reset rebuilds the - // catalogue from `servicesDef` cleanly. GRO-2033 close-out. - // Id↔name map MUST stay in sync with `servicesDef` (the canonical source - // of truth in the main `seed()` function). - const demoSvcs = [ - { id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, - { id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, - { id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 }, - { id: "b0000001-0000-0000-0000-000000000005", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, - ]; - for (const svc of demoSvcs) { - await db.insert(schema.services) - .values({ ...svc, active: true }) - .onConflictDoUpdate({ - target: schema.services.id, - set: { name: svc.name, description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true }, - }); - } - console.log(`✓ Seeded ${demoSvcs.length} services`); - - // ── Client: Demo Client ── - const [existingClient] = await db - .select() - .from(schema.clients) - .where(eq(schema.clients.email, "demo-client@example.com")) - .limit(1); - - let clientId: string; - if (existingClient) { - clientId = existingClient.id; - console.log(`✓ Client '${existingClient.name}' already exists — skipping`); - } else { - const [created] = await db - .insert(schema.clients) - .values({ - id: DEMO_CLIENT_ID, - name: "Demo Client", - email: "demo-client@example.com", - phone: "555-0001", - address: "1 Demo Street, Demo City, CA 90210", - }) - .returning(); - clientId = created!.id; - console.log("✓ Created client 'Demo Client'"); - } - - // ── Pets: Demo Dogs & Cats ── - const demoPets = [ - { id: DEMO_PET_ID, name: "Demo Dog", species: "Dog", breed: "Golden Retriever", weight: "30.00", dob: "2020-06-15", image: "/demo-pets/dog-golden-after.png" }, - { id: uuid(), name: "Fluffy", species: "Dog", breed: "Poodle", weight: "8.50", dob: "2019-03-22", image: "/demo-pets/dog-poodle-groomed.png" }, - { id: uuid(), name: "Shadow", species: "Dog", breed: "Black Labrador", weight: "35.00", dob: "2018-11-10", image: "/demo-pets/dog-black-lab.png" }, - { id: uuid(), name: "Bella", species: "Dog", breed: "Shih Tzu", weight: "4.50", dob: "2021-02-14", image: "/demo-pets/dog-shih-tzu.png" }, - { id: uuid(), name: "Max", species: "Dog", breed: "Cocker Spaniel", weight: "15.00", dob: "2019-07-08", image: "/demo-pets/dog-cocker-spaniel.png" }, - { id: uuid(), name: "Buddy", species: "Dog", breed: "Schnauzer", weight: "12.00", dob: "2020-05-20", image: "/demo-pets/dog-schnauzer.png" }, - { id: uuid(), name: "Daisy", species: "Dog", breed: "Maltese", weight: "3.50", dob: "2021-09-03", image: "/demo-pets/dog-maltese.png" }, - { id: uuid(), name: "Charlie", species: "Dog", breed: "Dachshund", weight: "6.00", dob: "2020-01-15", image: "/demo-pets/dog-dachshund.png" }, - { id: uuid(), name: "Lucy", species: "Dog", breed: "Pomeranian", weight: "2.50", dob: "2022-04-10", image: "/demo-pets/dog-pomeranian.png" }, - ]; - - for (const pet of demoPets) { - const [existing] = await db - .select() - .from(schema.pets) - .where(eq(schema.pets.id, pet.id)) - .limit(1); - - if (existing) { - console.log(`✓ Pet '${existing.name}' already exists — skipping`); - } else { - await db.insert(schema.pets).values({ - id: pet.id, - clientId, - name: pet.name, - species: pet.species, - breed: pet.breed, - weightKg: pet.weight, - dateOfBirth: new Date(`${pet.dob}T00:00:00Z`), - image: pet.image, - }); - console.log(`✓ Created pet '${pet.name}'`); - } - } - - console.log("\nKnown-users seed complete!"); - await client.end(); -} - -// ── Main seed ──────────────────────────────────────────────────────────────── - -async function seed() { - const url = process.env.DATABASE_URL; - if (!url) { - console.error("DATABASE_URL is not set"); - process.exit(1); - } - - if (process.env.SEED_KNOWN_USERS_ONLY === "true") { - await seedKnownUsers(); - return; - } - - const profile = getProfile(); - const cfg = profiles[profile]; - const client = postgres(url, { max: 5 }); - const db = drizzle(client, { schema }); - - console.log(`Seeding Groom Book database (profile: ${profile})...\n`); - - // ── Staff ── - const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) => - ({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 }) - ); - const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) => - ({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false }) - ); - const groomers = Array.from({ length: cfg.staffCount.groomer }, (_, i) => - ({ id: uuid(), name: `Groomer ${i + 1}`, email: `groomer${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false }) - ); - const bathers = Array.from({ length: cfg.staffCount.bather }, (_, i) => - ({ id: uuid(), name: `Bather ${i + 1}`, email: `bather${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false }) - ); - - // GRO-2064: also TRUNCATE `services` so each reset rebuilds the catalogue - // from `servicesDef` (deterministic IDs + UNIQUE(name)). Stale service rows - // (e.g. a prior `seedKnownUsers` run that wrote a different `name` for the - // same `id`) would otherwise cause the deterministic upsert to PK-collide - // on `services.id` — see CTO review on infra PR #605 (rev #4230). TRUNCATE - // CASCADE handles appointments/invoices FKs to services.id. - await db.execute(sql`TRUNCATE services, impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`); - - const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers]; - for (const s of allStaff) { - await db.insert(schema.staff) - .values({ - id: s.id, - name: s.name, - email: s.email, - role: s.role, - isSuperUser: s.isSuperUser, - active: true, - }) - .onConflictDoUpdate({ - target: schema.staff.email, - set: { id: s.id, name: s.name, role: s.role, isSuperUser: s.isSuperUser, active: true }, - }); - } - const staffLabel = cfg.staffCount.bather > 0 - ? `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers, ${cfg.staffCount.bather} bathers)` - : `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers)`; - console.log(`✓ Created ${staffLabel}`); - - // ── SEED_ADMIN_EMAIL admin ── - const adminEmail = process.env.SEED_ADMIN_EMAIL; - if (adminEmail) { - const adminName = process.env.SEED_ADMIN_NAME ?? "Admin"; - const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002"; - await db.insert(schema.staff) - .values({ - id: ADMIN_STAFF_ID, - name: adminName, - email: adminEmail, - oidcSub: adminEmail, - role: "manager", - isSuperUser: true, - active: true, - }) - .onConflictDoUpdate({ - target: schema.staff.email, - set: { id: ADMIN_STAFF_ID, name: adminName, role: "manager", isSuperUser: true, active: true }, - }); - console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`); - } - - // ── 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) ?? []; - const groomerCount = Math.min(groomerEmails.length, groomerNames.length); - for (let i = 0; i < groomerCount; i++) { - const email = groomerEmails[i]!; - const name = groomerNames[i]!; - const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`; - await db.insert(schema.staff) - .values({ - id: staffId, - name, - email, - oidcSub: email, - role: "groomer", - isSuperUser: false, - active: true, - }) - .onConflictDoUpdate({ - target: schema.staff.email, - set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true }, - }); - console.log(`✓ Upserted groomer '${name}' (${email})`); - } - - // ── Services ── - // GRO-2064: key the upsert on `services.id` (not `name`) so deterministic - // ids always win, and rely on the TRUNCATE above to clear stale rows before - // the catalogue is rebuilt. The previous name-targeted upsert failed with - // `services_pkey` when a prior run had left a row with the same id but a - // different name (CTO review on infra PR #605, rev #4230). - const serviceIds: string[] = []; - for (const s of servicesDef) { - serviceIds.push(s.id); - await db.insert(schema.services) - .values({ - id: s.id, - name: s.name, - description: s.desc, - basePriceCents: s.price, - durationMinutes: s.dur, - active: true, - }) - .onConflictDoUpdate({ - target: schema.services.id, - set: { name: s.name, description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true }, - }); - } - console.log(`✓ Created ${servicesDef.length} services`); - - // ── Clients & Pets ── - const now = new Date(); - const appointmentsBackDate = new Date(now); - appointmentsBackDate.setDate(appointmentsBackDate.getDate() - cfg.appointmentsBackDays); - const appointmentsForwardDate = new Date(now); - appointmentsForwardDate.setDate(appointmentsForwardDate.getDate() + cfg.appointmentsForwardDays); - - interface ClientRecord { id: string; name: string } - interface PetRecord { id: string; clientId: string } - - const clientRecords: ClientRecord[] = []; - const petRecords: PetRecord[] = []; - - let petIndex = 0; // Track pet count to assign Puggle images to first 250 pets - const clientBatchSize = 50; - for (let batch = 0; batch < Math.ceil(cfg.clientCount / clientBatchSize); batch++) { - const clientBatch: (typeof schema.clients.$inferInsert)[] = []; - const petBatch: (typeof schema.pets.$inferInsert)[] = []; - - for (let i = 0; i < clientBatchSize; i++) { - const clientId = uuid(); - const first = pick(firstNames); - const last = pick(lastNames); - const name = `${first} ${last}`; - const emailDomain = pick(["gmail.com", "yahoo.com", "outlook.com", "icloud.com", "hotmail.com"]); - const email = `${first.toLowerCase()}.${last.toLowerCase()}${randInt(1, 99)}@${emailDomain}`; - const phone = `(${randInt(200, 999)}) ${randInt(200, 999)}-${String(randInt(1000, 9999))}`; - const addr = `${randInt(100, 9999)} ${pick(streetNames)}, ${pick(cities)}, ${pick(states)} ${String(randInt(10000, 99999))}`; - - clientBatch.push({ - id: clientId, - name, - email, - phone, - address: addr, - notes: rand() < 0.2 ? pick(["Prefers morning appointments", "Always pays cash", "VIP client", "Referred by a friend", "Has multiple pets — check all in"]) : null, - emailOptOut: rand() < 0.1, - }); - - clientRecords.push({ id: clientId, name }); - - // 1-3 pets per client - const petCount = rand() < 0.5 ? 1 : rand() < 0.7 ? 2 : 3; - for (let p = 0; p < petCount; p++) { - const petId = uuid(); - const breed = petIndex < 250 ? "Puggle" : pick(dogBreeds); - const dob = new Date(now); - dob.setFullYear(dob.getFullYear() - randInt(1, 14)); - dob.setMonth(randInt(0, 11)); - - petBatch.push({ - id: petId, - clientId, - name: pick(dogNames), - species: "Dog", - breed, - weightKg: String(randInt(3, 60) + rand().toFixed(1).slice(1)), - dateOfBirth: dob, - healthAlerts: pick(healthAlerts), - groomingNotes: pick(groomingNotes), - cutStyle: pick(cutStyles), - shampooPreference: pick(shampoos), - specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null, - coatType: pick(coatTypes), - temperamentScore: pick(temperamentScores), - temperamentFlags: pick(temperamentFlags), - medicalAlerts: pick(medicalAlertsList), - preferredCuts: pick(preferredCutsList), - customFields: {}, - image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages), - }); - - petRecords.push({ id: petId, clientId }); - petIndex++; - } - } - - for (const client of clientBatch) { - await db.insert(schema.clients) - .values(client) - .onConflictDoUpdate({ - target: schema.clients.id, - set: { name: client.name, email: client.email, phone: client.phone, address: client.address, notes: client.notes, emailOptOut: client.emailOptOut }, - }); - } - - for (const pet of petBatch) { - await db.insert(schema.pets) - .values(pet) - .onConflictDoUpdate({ - target: schema.pets.id, - set: { - clientId: pet.clientId, - name: pet.name, - species: pet.species, - breed: pet.breed, - weightKg: pet.weightKg, - dateOfBirth: pet.dateOfBirth, - healthAlerts: pet.healthAlerts, - groomingNotes: pet.groomingNotes, - cutStyle: pet.cutStyle, - shampooPreference: pet.shampooPreference, - specialCareNotes: pet.specialCareNotes, - coatType: pet.coatType, - temperamentScore: pet.temperamentScore, - temperamentFlags: pet.temperamentFlags, - medicalAlerts: pet.medicalAlerts, - preferredCuts: pet.preferredCuts, - customFields: pet.customFields, - image: pet.image, - }, - }); - } - } - - console.log(`✓ Created ${cfg.clientCount} clients with ${petRecords.length} pets`); - - // ── UAT test clients (guaranteed pending invoices) ───────────────────────────── - // These 5 clients are deterministic and documented in Shedward AGENTS.md so - // UAT can reliably find billing test data without searching. - if (cfg.includeUatClients) { - interface UatClient { - id: string; - name: string; - email: string; - phone: string; - address: string; - petId: string; - petName: string; - petBreed: string; - petCoatType: string; - petTemperamentScore: number; - petTemperamentFlags: string[]; - petMedicalAlerts: MedicalAlert[]; - petPreferredCuts: string[]; - } - const uatClients: UatClient[] = [ - { id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever", petCoatType: "double", petTemperamentScore: 7, petTemperamentFlags: ["calm", "friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Breed Standard"] }, - { id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever", petCoatType: "short", petTemperamentScore: 8, petTemperamentFlags: ["friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Bath & Brush", "Sanitary Trim"] }, - { id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle", petCoatType: "curly", petTemperamentScore: 9, petTemperamentFlags: ["calm"], petMedicalAlerts: [{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }], petPreferredCuts: ["Teddy Bear Cut"] }, - { id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog", petCoatType: "short", petTemperamentScore: 6, petTemperamentFlags: ["nippy"], petMedicalAlerts: [{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }], petPreferredCuts: ["Puppy Cut"] }, - { id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle", petCoatType: "short", petTemperamentScore: 7, petTemperamentFlags: ["friendly", "energetic"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Full Groom", "Nail Trim"] }, - ]; - - for (const uc of uatClients) { - await db.insert(schema.clients) - .values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address }) - .onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } }); - await db.insert(schema.pets) - .values({ - id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, - weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), - coatType: uc.petCoatType, - temperamentScore: uc.petTemperamentScore, - temperamentFlags: uc.petTemperamentFlags, - medicalAlerts: uc.petMedicalAlerts, - preferredCuts: uc.petPreferredCuts, - image: pick(demoPetImages), - }) - .onConflictDoUpdate({ target: schema.pets.id, set: { - clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, - weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), - coatType: uc.petCoatType, - temperamentScore: uc.petTemperamentScore, - temperamentFlags: uc.petTemperamentFlags, - medicalAlerts: uc.petMedicalAlerts, - preferredCuts: uc.petPreferredCuts, - image: pick(demoPetImages), - } }); - // Create one completed appointment for this client - const apptId = uuid(); - const svcIdx = 0; - const svc = servicesDef[svcIdx]!; - const completedTime = randDate(appointmentsBackDate, now); - completedTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0); - const endTime = new Date(completedTime.getTime() + svc.dur * 60 * 1000); - const uatGroomer = groomers[0]!; - const uatBather = bathers.length > 0 ? bathers[0]! : uatGroomer; - await db.insert(schema.appointments).values({ - id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: uatGroomer.id, - batherStaffId: uatBather.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price, - }); - // Create a PENDING invoice for that appointment - const invoiceId = uuid(); - const taxCents = Math.round(svc.price * 0.08); - const totalCents = svc.price + taxCents; - await db.insert(schema.invoices).values({ - id: invoiceId, appointmentId: apptId, clientId: uc.id, subtotalCents: svc.price, - taxCents, tipCents: 0, totalCents, status: "pending" as const, - paymentMethod: null, paidAt: null, notes: null, - }); - await db.insert(schema.invoiceLineItems).values({ - id: uuid(), invoiceId, description: svc.name, quantity: 1, unitPriceCents: svc.price, totalCents: svc.price, - }); - await db.insert(schema.groomingVisitLogs).values({ - id: uuid(), petId: uc.petId, appointmentId: apptId, staffId: groomers[0]!.id, - cutStyle: null, productsUsed: null, notes: null, groomedAt: endTime, - }); - } - console.log(`✓ Created ${uatClients.length} UAT test clients with guaranteed pending invoices`); - } - - // ── Appointments, Invoices, Visit Logs ── - // Generate ~5 appointments per client on average = ~2500 total - const statuses: (typeof schema.appointmentStatusEnum.enumValues)[number][] = [ - "completed", "completed", "completed", "completed", "completed", - "completed", "completed", "scheduled", "confirmed", "cancelled", "no_show", - ]; - - let appointmentCount = 0; - let invoiceCount = 0; - let visitLogCount = 0; - let paidInvoiceCounter = 0; - - // Process in batches per client to keep memory manageable - const apptBatchSize = 100; - let apptBatch: (typeof schema.appointments.$inferInsert)[] = []; - let invoiceBatch: (typeof schema.invoices.$inferInsert)[] = []; - let lineItemBatch: (typeof schema.invoiceLineItems.$inferInsert)[] = []; - let tipSplitBatch: (typeof schema.invoiceTipSplits.$inferInsert)[] = []; - let visitLogBatch: (typeof schema.groomingVisitLogs.$inferInsert)[] = []; - - async function flushBatches() { - if (apptBatch.length > 0) { - await db.insert(schema.appointments).values(apptBatch); - apptBatch = []; - } - if (invoiceBatch.length > 0) { - await db.insert(schema.invoices).values(invoiceBatch); - invoiceBatch = []; - } - if (lineItemBatch.length > 0) { - await db.insert(schema.invoiceLineItems).values(lineItemBatch); - lineItemBatch = []; - } - if (tipSplitBatch.length > 0) { - await db.insert(schema.invoiceTipSplits).values(tipSplitBatch); - tipSplitBatch = []; - } - if (visitLogBatch.length > 0) { - await db.insert(schema.groomingVisitLogs).values(visitLogBatch); - visitLogBatch = []; - } - } - - // Group pets by client for efficient appointment generation - const petsByClient = new Map(); - for (const pet of petRecords) { - const arr = petsByClient.get(pet.clientId) ?? []; - arr.push(pet.id); - petsByClient.set(pet.clientId, arr); - } - - for (const client of clientRecords) { - const pets = petsByClient.get(client.id) ?? []; - // Each client visits ~3-8 times over the year - const visitCount = randInt(3, 8); - - for (let v = 0; v < visitCount; v++) { - // Pick a random pet for this visit - const petId = pick(pets); - const serviceIdx = randInt(0, serviceIds.length - 1); - const serviceId = serviceIds[serviceIdx]!; - const svc = servicesDef[serviceIdx]!; - const groomer = pick(groomers); - const bather = rand() < 0.6 ? pick(bathers) : null; - const status = pick(statuses); - - // Schedule within the configured appointment window - let startTime: Date; - if (status === "scheduled" || status === "confirmed") { - startTime = randDate(now, appointmentsForwardDate); - } else { - startTime = randDate(appointmentsBackDate, now); - } - // Snap to business hours (8am - 5pm) - startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0); - const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000); - - const apptId = uuid(); - const priceCents = rand() < 0.2 ? svc.price + randInt(-500, 1000) : null; - const effectivePrice = priceCents ?? svc.price; - - apptBatch.push({ - id: apptId, - clientId: client.id, - petId, - serviceId, - staffId: groomer.id, - batherStaffId: bather?.id ?? null, - status, - startTime, - endTime, - notes: pick(appointmentNotes), - priceCents, - }); - appointmentCount++; - - // Create invoice for completed appointments - if (status === "completed") { - const invoiceId = uuid(); - const tipCents = rand() < 0.7 ? randInt(200, 3000) : 0; - const taxCents = Math.round(effectivePrice * 0.08); - const totalCents = effectivePrice + taxCents + tipCents; - - const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const; - const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null; - paidInvoiceCounter++; - const stripePaymentIntentId = invoiceStatus === "paid" - ? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}` - : null; - - invoiceBatch.push({ - id: invoiceId, - appointmentId: apptId, - clientId: client.id, - subtotalCents: effectivePrice, - taxCents, - tipCents, - totalCents, - status: invoiceStatus, - paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null, - paidAt, - stripePaymentIntentId, - notes: rand() < 0.05 ? "Added extra service at checkout" : null, - }); - - // Line item - lineItemBatch.push({ - id: uuid(), - invoiceId, - description: svc.name, - quantity: 1, - unitPriceCents: effectivePrice, - totalCents: effectivePrice, - }); - - // Tip splits for paid invoices with tips - if (tipCents > 0 && invoiceStatus === "paid") { - if (bather) { - // 60/40 split groomer/bather - const groomerShare = Math.round(tipCents * 0.6); - const batherShare = tipCents - groomerShare; - tipSplitBatch.push( - { id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "60.00", shareCents: groomerShare }, - { id: uuid(), invoiceId, staffId: bather.id, staffName: bather.name, sharePct: "40.00", shareCents: batherShare }, - ); - } else { - tipSplitBatch.push({ - id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "100.00", shareCents: tipCents, - }); - } - } - - invoiceCount++; - - // Visit log - visitLogBatch.push({ - id: uuid(), - petId, - appointmentId: apptId, - staffId: groomer.id, - cutStyle: pick(cutStyles), - productsUsed: pick(productsUsed), - notes: pick(visitLogNotes), - groomedAt: endTime, - }); - visitLogCount++; - } - - // Flush periodically - if (apptBatch.length >= apptBatchSize) { - await flushBatches(); - } - } - } - - // Final flush - await flushBatches(); - - console.log(`✓ Created ${appointmentCount} appointments`); - console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`); - - // ── Enforce target invoice count ─────────────────────────────────────────── - // If current invoice count is below target (due to profile having fewer - // clients/appointments than the target ratio), generate supplemental - // completed appointments for existing clients to fill the gap. - if (invoiceCount < cfg.invoiceCount) { - const additionalNeeded = cfg.invoiceCount - invoiceCount; - console.log(` → Generating ${additionalNeeded} supplemental completed appointments to meet profile target...`); - - const existingClientIds = clientRecords.map(c => c.id); - const apptsToGenerate = Math.min(additionalNeeded, existingClientIds.length * 20); - let supplementalCount = 0; - let supplementalInvoices = 0; - - for (let i = 0; i < apptsToGenerate && supplementalInvoices < additionalNeeded; i++) { - const clientId = pick(existingClientIds); - const pets = petsByClient.get(clientId) ?? []; - if (pets.length === 0) continue; - - const petId = pick(pets); - const serviceIdx = randInt(0, serviceIds.length - 1); - const serviceId = serviceIds[serviceIdx]!; - const svc = servicesDef[serviceIdx]!; - const groomer = pick(groomers); - const bather = bathers.length > 0 && rand() < 0.6 ? pick(bathers) : null; - - const startTime = randDate(appointmentsBackDate, now); - startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0); - const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000); - const effectivePrice = svc.price; - - const apptId = uuid(); - apptBatch.push({ - id: apptId, clientId, petId, serviceId, - staffId: groomer.id, batherStaffId: bather?.id ?? null, - status: "completed", startTime, endTime, notes: null, priceCents: null, - }); - appointmentCount++; - supplementalCount++; - - const invoiceId = uuid(); - const tipCents = rand() < 0.7 ? randInt(200, 3000) : 0; - const taxCents = Math.round(effectivePrice * 0.08); - const totalCents = effectivePrice + taxCents + tipCents; - const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000); - paidInvoiceCounter++; - - invoiceBatch.push({ - id: invoiceId, appointmentId: apptId, clientId, - subtotalCents: effectivePrice, taxCents, tipCents, totalCents, - status: "paid" as const, - paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check", - paidAt, - stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`, - notes: null, - }); - lineItemBatch.push({ - id: uuid(), invoiceId, description: svc.name, quantity: 1, - unitPriceCents: effectivePrice, totalCents: effectivePrice, - }); - if (tipCents > 0) { - if (bather) { - const groomerShare = Math.round(tipCents * 0.6); - const batherShare = tipCents - groomerShare; - tipSplitBatch.push( - { id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "60.00", shareCents: groomerShare }, - { id: uuid(), invoiceId, staffId: bather.id, staffName: bather.name, sharePct: "40.00", shareCents: batherShare }, - ); - } else { - tipSplitBatch.push({ id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "100.00", shareCents: tipCents }); - } - } - visitLogBatch.push({ - id: uuid(), petId, appointmentId: apptId, staffId: groomer.id, - cutStyle: pick(cutStyles), productsUsed: pick(productsUsed), - notes: pick(visitLogNotes), groomedAt: endTime, - }); - invoiceCount++; - supplementalInvoices++; - visitLogCount++; - - if (apptBatch.length >= apptBatchSize) { - await flushBatches(); - } - } - - await flushBatches(); - console.log(` → Added ${supplementalCount} supplemental appointments (${supplementalInvoices} invoices)`); - console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`); - } - console.log(`✓ Created ${visitLogCount} grooming visit logs`); - console.log("\nSeed complete!"); - - await client.end(); -} - -seed().catch((err) => { - console.error("Seed failed:", err); - process.exit(1); -}); From 4884961c8e79c7c01fc03f692a8feb6f99534225 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Mon, 8 Jun 2026 07:47:55 +0000 Subject: [PATCH 05/23] feat(GRO-2152): route optimization schema migration Add the database foundation for mobile groomer route optimization: - clients: latitude/longitude (double precision) + geocodedAt - groomer_routes: per-(staff, date) route with route_status enum, totals, optimizedAt; UNIQUE(staff_id, route_date) - route_stops: ordered stops FK->groomer_routes (cascade) + appointments, lat/lng, per-leg travel mins/distance, bufferMins; UNIQUE(route_id, appointment_id) and UNIQUE(route_id, stop_order) - business_settings: defaultTravelBufferMins (default 15), routeOptimizationProvider (default nominatim), googleMapsApiKey (encrypted at rest at the app layer) - Idempotent hand-authored migration 0041 + journal entry (when=max+1) Lands in packages/db (the deployed schema/migration source per the Dockerfile migrate stage); apps/api is the legacy CI-only copy. Co-Authored-By: Claude Opus 4.8 --- .../db/migrations/0041_route_optimization.sql | 66 +++++++++++++++ packages/db/migrations/meta/_journal.json | 9 +- packages/db/src/factories.ts | 3 + packages/db/src/schema.ts | 82 +++++++++++++++++++ 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 packages/db/migrations/0041_route_optimization.sql 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), + ] +); From 6be78cae3557cee5e628790fca43af0c3c821e92 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 08:18:13 +0000 Subject: [PATCH 06/23] fix(portal): implement PATCH /portal/pets/:petId + enrich GET (GRO-2187) (#165) --- UAT_PLAYBOOK.md | 4 + src/__tests__/portalPets.test.ts | 267 +++++++++++++++++++++++++++++++ src/routes/portal.ts | 146 ++++++++++++++++- 3 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/portalPets.test.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 42d4cf9..f172f07 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -259,6 +259,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/src/__tests__/portalPets.test.ts b/src/__tests__/portalPets.test.ts new file mode 100644 index 0000000..a95cb23 --- /dev/null +++ b/src/__tests__/portalPets.test.ts @@ -0,0 +1,267 @@ +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"); + expect(persisted.photoKey).toBe("pets/rex.jpg"); + 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); + }); + + 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/routes/portal.ts b/src/routes/portal.ts index 7b7b160..3f15745 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -225,9 +225,153 @@ 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(), + description: z.string(), + 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: z.string().nullable().optional(), + // 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()).nullable().optional(), + medicalAlerts: z.array(portalMedicalAlertSchema).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; + if (body.photoUrl !== undefined) updateData.photoKey = body.photoUrl; + + 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"); From 2fa6e3d87b409f7c5e796980fa0c6fd9942cd464 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Mon, 8 Jun 2026 09:01:36 +0000 Subject: [PATCH 07/23] feat(GRO-2153): abstracted geocoding service (Nominatim + Google) Phase 1.2 of Route Optimization. Adds a provider-agnostic geocoding service layer in the deployed src/ tree: - GeocodingProvider interface + GeocodeResult type - NominatimGeocodingProvider (default, free, self-hostable) with an internal rate limiter enforcing the 1 req/sec Nominatim usage policy - GoogleGeocodingProvider (optional fallback) keyed by the encrypted businessSettings.googleMapsApiKey (decrypted via decryptSecret) or GOOGLE_MAPS_API_KEY env fallback - resolveGeocodingProvider() selecting on businessSettings.routeOptimizationProvider, with safe fallback to Nominatim when google is configured but no usable key - geocodeBatch() throttled batch utility (honors provider rate limit, captures per-item errors, optional progress callback) - 20 unit tests covering both providers, selection, throttle spacing, and batch Co-Authored-By: Paperclip --- src/__tests__/geocoding.test.ts | 313 ++++++++++++++++++++++++ src/services/geocoding.ts | 419 ++++++++++++++++++++++++++++++++ 2 files changed, 732 insertions(+) create mode 100644 src/__tests__/geocoding.test.ts create mode 100644 src/services/geocoding.ts 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/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; +} From 21fb1b30d2bb665b8bc74bb19eedac6e4bdf5e2a Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Mon, 8 Jun 2026 09:40:42 +0000 Subject: [PATCH 08/23] ci: retrigger build (registry layer-pull hang on prior run) Co-Authored-By: Paperclip From eec198a661519bab36e3bb30714a140d6683e20a Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 11:09:33 +0000 Subject: [PATCH 09/23] fix(ci): GRO-2197 api lint/typecheck/test run root scripts (de-false-green) (#169) --- .gitea/workflows/ci.yml | 6 +++--- src/__tests__/petProfileSummary.test.ts | 16 ---------------- 2 files changed, 3 insertions(+), 19 deletions(-) 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/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 = []; From 582c376df973f43b9ddc7ce1b4964d7b76d994ad Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 11:45:08 +0000 Subject: [PATCH 10/23] feat(GRO-2154): geocoding endpoints + auto-geocode on client mutations (#170) --- UAT_PLAYBOOK.md | 18 +++ src/__tests__/clientGeocoding.test.ts | 192 +++++++++++++++++++++++ src/index.ts | 9 ++ src/routes/clients.ts | 124 ++++++++++++++- src/services/clientGeocoding.ts | 212 ++++++++++++++++++++++++++ 5 files changed, 552 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/clientGeocoding.test.ts create mode 100644 src/services/clientGeocoding.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index f172f07..c729065 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -120,6 +120,24 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-2.5 | Disable client | PATCH /api/clients/{id} with status: "disabled" | 200 OK, client marked as disabled | | TC-API-2.6 | Delete client | DELETE /api/clients/{id}?confirm=true | 200 OK, client deleted (if no appointments) | +#### Client Geocoding — Route Optimization (GRO-2154, Phase 1.3) + +Geocoding turns a client's street address into `latitude`/`longitude` + `geocodedAt`. Provider is driven by `businessSettings.routeOptimizationProvider` (default Nominatim/OpenStreetMap, 1 req/sec; optional Google fallback). All explicit geocode endpoints are **manager-only**. + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-API-2.7 | Geocode single client (success) | As **manager**, `POST /api/clients/{id}/geocode` for a client with a valid, real address (e.g. a seed client) | 200 OK; body `{ status: "geocoded", latitude, longitude, geocodedAt, formattedAddress, provider }`. Subsequent `GET /api/clients/{id}` shows the same non-null `latitude`/`longitude`/`geocodedAt` persisted | +| TC-API-2.8 | Geocode single client — no address | As manager, `POST /api/clients/{id}/geocode` for a client whose `address` is null/blank | 422; `{ status: "no_address", message: "...no address on file..." }` (clear, actionable) | +| TC-API-2.9 | Geocode single client — unresolvable/ambiguous address | As manager, set a nonsense address (e.g. `"asdkjhqweoui 99999"`) then `POST /api/clients/{id}/geocode` | 422; `{ status: "unresolved", message: "Address could not be resolved..." }` so groomers/managers know to correct it | +| TC-API-2.10 | Geocode single client — not found | As manager, `POST /api/clients/00000000-0000-0000-0000-000000000000/geocode` | 404 `{ error: "Not found" }` | +| TC-API-2.11 | Geocode endpoint is manager-only | As **groomer** or **receptionist**, `POST /api/clients/{id}/geocode` | 403 Forbidden (role not permitted) | +| TC-API-2.12 | Batch geocode un-geocoded clients | As manager, `POST /api/clients/geocode-batch?limit=10` on a DB with un-geocoded clients | 200 OK; body `{ provider, processed, geocoded, unresolved, errors, remaining, outcomes[] }`. `processed` ≤ 10; `remaining` reflects un-geocoded clients beyond this batch. Re-run while `remaining > 0` to finish (throttled to provider rate limit) | +| TC-API-2.13 | Batch geocode — invalid limit | As manager, `POST /api/clients/geocode-batch?limit=0` (or non-numeric) | 400 `{ error: "limit must be a positive integer" }` | +| TC-API-2.14 | Batch geocode — manager-only | As groomer/receptionist, `POST /api/clients/geocode-batch` | 403 Forbidden | +| TC-API-2.15 | Auto-geocode on create | As manager/receptionist, `POST /api/clients` with a valid `address` | 201 Created; response includes a `geocoding` object (`status: "geocoded"` for a resolvable address) and the persisted client carries `latitude`/`longitude`/`geocodedAt`. Creating without an address succeeds with no `geocoding` field | +| TC-API-2.16 | Auto-geocode on address update | As manager/receptionist, `PATCH /api/clients/{id}` changing `address` to a new valid value | 200 OK; response includes a `geocoding` object and refreshed coordinates. Patching unrelated fields (e.g. `name`) does NOT re-geocode (no `geocoding` field) | +| TC-API-2.17 | Clearing address drops coordinates | As manager/receptionist, `PATCH /api/clients/{id}` with `address: ""` | 200 OK; `latitude`/`longitude`/`geocodedAt` reset to null (no stale pin) | + ### 4.3 Pet Management | # | Scenario | Steps | Expected | diff --git a/src/__tests__/clientGeocoding.test.ts b/src/__tests__/clientGeocoding.test.ts new file mode 100644 index 0000000..675bc1e --- /dev/null +++ b/src/__tests__/clientGeocoding.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi } from "vitest"; +import { + geocodeClient, + geocodeUngeocodedClients, + resolveClientGeocodingProvider, +} from "../services/clientGeocoding.js"; +import { + NominatimGeocodingProvider, + type GeocodeResult, + type GeocodingProvider, +} from "../services/geocoding.js"; + +// ─── Fakes ────────────────────────────────────────────────────────────────── + +/** Fake provider with a scripted geocode behaviour and a call log. */ +function fakeProvider( + impl: (address: string) => Promise +): GeocodingProvider & { calls: string[] } { + const calls: string[] = []; + return { + name: "nominatim", + minRequestIntervalMs: 0, + calls, + geocode: (address: string) => { + calls.push(address); + return impl(address); + }, + }; +} + +const okResult = (lat: number, lng: number): GeocodeResult => ({ + latitude: lat, + longitude: lng, + formattedAddress: "1 Main St, Anytown", + provider: "nominatim", +}); + +/** + * Minimal db double recording update() set-values. `select()` chains return the + * preloaded `selectQueue` shift()ed per call so different statements get + * different rows (used by geocodeUngeocodedClients: count, then rows). + */ +function fakeDb(selectQueue: unknown[][]) { + const updates: Record[] = []; + const queue = [...selectQueue]; + const chain = () => { + const rows = queue.shift() ?? []; + const proxy: Record = {}; + for (const k of ["from", "where", "orderBy", "limit"]) { + proxy[k] = () => proxy; + } + // Make the chain awaitable / iterable as the resolved rows. + (proxy as { then: unknown }).then = (resolve: (v: unknown) => void) => + resolve(rows); + (proxy as { [Symbol.iterator]: unknown })[Symbol.iterator] = () => + (rows as unknown[])[Symbol.iterator](); + return proxy; + }; + const db = { + select: () => chain(), + update: () => ({ + set: (vals: Record) => ({ + where: () => { + updates.push(vals); + return { returning: async () => [] }; + }, + }), + }), + updates, + }; + return db as unknown as Parameters[0] & { + updates: Record[]; + }; +} + +const clientRow = (over: Record = {}) => + ({ + id: "client-1", + name: "Alice", + email: "a@example.com", + address: "1 Main St", + latitude: null, + longitude: null, + geocodedAt: null, + ...over, + }) as unknown as Parameters[1]; + +// ─── geocodeClient ──────────────────────────────────────────────────────────── + +describe("geocodeClient", () => { + it("persists coordinates and returns a geocoded outcome", async () => { + const db = fakeDb([]); + const provider = fakeProvider(async () => okResult(40.1, -74.2)); + const outcome = await geocodeClient(db, clientRow(), provider); + + expect(outcome.status).toBe("geocoded"); + expect(outcome.latitude).toBe(40.1); + expect(outcome.longitude).toBe(-74.2); + expect(outcome.geocodedAt).toBeTruthy(); + expect(db.updates).toHaveLength(1); + expect(db.updates[0]!.latitude).toBe(40.1); + expect(db.updates[0]!.longitude).toBe(-74.2); + expect(db.updates[0]!.geocodedAt).toBeInstanceOf(Date); + }); + + it("returns no_address and does not persist when address is blank", async () => { + const db = fakeDb([]); + const provider = fakeProvider(async () => okResult(0, 0)); + const outcome = await geocodeClient(db, clientRow({ address: " " }), provider); + + expect(outcome.status).toBe("no_address"); + expect(provider.calls).toHaveLength(0); + expect(db.updates).toHaveLength(0); + }); + + it("returns unresolved when the provider finds no match", async () => { + const db = fakeDb([]); + const provider = fakeProvider(async () => null); + const outcome = await geocodeClient(db, clientRow(), provider); + + expect(outcome.status).toBe("unresolved"); + expect(outcome.message).toMatch(/could not be resolved/i); + expect(db.updates).toHaveLength(0); + }); + + it("returns error (without throwing) when the provider fails", async () => { + const db = fakeDb([]); + const provider = fakeProvider(async () => { + throw new Error("quota exceeded"); + }); + const outcome = await geocodeClient(db, clientRow(), provider); + + expect(outcome.status).toBe("error"); + expect(outcome.message).toMatch(/quota exceeded/); + expect(db.updates).toHaveLength(0); + }); +}); + +// ─── geocodeUngeocodedClients ───────────────────────────────────────────────── + +describe("geocodeUngeocodedClients", () => { + it("geocodes candidates, tallies outcomes, and reports remaining", async () => { + // First select() = count query, second select() = candidate rows. + const db = fakeDb([ + [{ count: 5 }], + [ + clientRow({ id: "c1", address: "1 Main St" }), + clientRow({ id: "c2", address: "2 Oak Ave" }), + clientRow({ id: "c3", address: "" }), // no_address + ], + ]); + const provider = fakeProvider(async (addr) => + addr === "2 Oak Ave" ? null : okResult(1, 2) + ); + + const summary = await geocodeUngeocodedClients(db, 50, provider); + + expect(summary.processed).toBe(3); + expect(summary.geocoded).toBe(1); + expect(summary.unresolved).toBe(1); // "2 Oak Ave" + expect(summary.remaining).toBe(2); // 5 total - 3 processed + expect(summary.provider).toBe("nominatim"); + expect(db.updates).toHaveLength(1); // only the successful one persisted + }); + + it("clamps the limit to the 1..500 range", async () => { + const db = fakeDb([[{ count: 0 }], []]); + const provider = fakeProvider(async () => okResult(1, 2)); + const summary = await geocodeUngeocodedClients(db, 0, provider); + expect(summary.processed).toBe(0); + expect(summary.remaining).toBe(0); + }); +}); + +// ─── resolveClientGeocodingProvider ─────────────────────────────────────────── + +describe("resolveClientGeocodingProvider", () => { + it("defaults to Nominatim when no settings row exists", async () => { + const db = fakeDb([[]]); // businessSettings select -> empty + const provider = await resolveClientGeocodingProvider(db); + expect(provider).toBeInstanceOf(NominatimGeocodingProvider); + expect(provider.name).toBe("nominatim"); + }); + + it("defaults to Nominatim when provider is unset on settings", async () => { + const db = fakeDb([[{ routeOptimizationProvider: null, googleMapsApiKey: null }]]); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const provider = await resolveClientGeocodingProvider(db); + expect(provider.name).toBe("nominatim"); + warn.mockRestore(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 3dd8921..2845b14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -235,6 +235,15 @@ api.on( requireRole("manager", "receptionist", "groomer") ); +// Route-optimization geocoding endpoints are manager-only (GRO-2154), stricter +// than the general client write guard below. Registered FIRST so receptionists +// are rejected here before the manager+receptionist guard can admit them. +api.on( + ["POST"], + ["/clients/geocode-batch", "/clients/:clientId/geocode"], + requireRole("manager") +); + // Clients, appointments: all roles may read; only manager + receptionist may write api.on( ["POST", "PUT", "PATCH", "DELETE"], diff --git a/src/routes/clients.ts b/src/routes/clients.ts index 38104ec..e7ac65c 100644 --- a/src/routes/clients.ts +++ b/src/routes/clients.ts @@ -3,9 +3,61 @@ import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; +import { + geocodeClient, + geocodeUngeocodedClients, + resolveClientGeocodingProvider, + type ClientGeocodeOutcome, +} from "../services/clientGeocoding.js"; export const clientsRouter = new Hono(); +type ClientRow = typeof clients.$inferSelect; + +/** + * Best-effort auto-geocode of a freshly created/updated client (GRO-2154). + * Never throws: a flaky geocoding backend must not break client mutations. + * Returns the (possibly coordinate-enriched) row plus a structured outcome the + * caller surfaces under a `geocoding` field so ambiguous addresses are visible. + */ +async function autoGeocodeClient( + db: ReturnType, + row: ClientRow +): Promise<{ row: ClientRow; outcome: ClientGeocodeOutcome }> { + try { + const provider = await resolveClientGeocodingProvider(db); + const outcome = await geocodeClient(db, row, provider); + const enriched = + outcome.status === "geocoded" + ? { + ...row, + latitude: outcome.latitude, + longitude: outcome.longitude, + geocodedAt: outcome.geocodedAt + ? new Date(outcome.geocodedAt) + : row.geocodedAt, + } + : row; + return { row: enriched, outcome }; + } catch (err) { + return { + row, + outcome: { + clientId: row.id, + status: "error", + message: `Auto-geocode failed: ${ + err instanceof Error ? err.message : String(err) + }`, + latitude: null, + longitude: null, + geocodedAt: null, + formattedAddress: null, + provider: null, + }, + }; + } +} + const createClientSchema = z.object({ name: z.string().min(1).max(200), email: z.string().email(), @@ -91,9 +143,59 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => { const db = getDb(); const body = c.req.valid("json"); const [row] = await db.insert(clients).values(body).returning(); + if (!row) return c.json({ error: "Failed to create client" }, 500); + + // Auto-geocode on create when an address is supplied (GRO-2154). Best-effort: + // the client is created regardless; the `geocoding` field surfaces failures. + if (body.address && body.address.trim()) { + const { row: enriched, outcome } = await autoGeocodeClient(db, row); + return c.json({ ...enriched, geocoding: outcome }, 201); + } return c.json(row, 201); }); +// Geocode a single client's address and persist coordinates (manager-only; +// enforced by the route guard in index.ts). +clientsRouter.post("/:clientId/geocode", async (c) => { + const db = getDb(); + const clientId = c.req.param("clientId"); + const [client] = await db + .select() + .from(clients) + .where(eq(clients.id, clientId)); + if (!client) return c.json({ error: "Not found" }, 404); + + const provider = await resolveClientGeocodingProvider(db); + const outcome = await geocodeClient(db, client, provider); + + // Map outcome to an HTTP status so the result is unambiguous to the caller: + // geocoded -> 200, provider error -> 502, no_address/unresolved -> 422. + const status = + outcome.status === "geocoded" + ? 200 + : outcome.status === "error" + ? 502 + : 422; + return c.json(outcome, status); +}); + +// Batch-geocode un-geocoded clients with provider-rate-limited throttling +// (manager-only). Processes up to ?limit clients (default 50, max 500) per call; +// re-invoke while `remaining` > 0 to finish large datasets. +clientsRouter.post("/geocode-batch", async (c) => { + const db = getDb(); + const limitRaw = c.req.query("limit"); + let limit = 50; + if (limitRaw !== undefined) { + limit = Number(limitRaw); + if (!Number.isFinite(limit) || limit <= 0) { + return c.json({ error: "limit must be a positive integer" }, 400); + } + } + const summary = await geocodeUngeocodedClients(db, limit); + return c.json(summary); +}); + // Update a client (including status changes) const patchClientSchema = createClientSchema.partial().extend({ status: z.enum(["active", "disabled"]).optional(), @@ -123,13 +225,29 @@ clientsRouter.patch( } delete setValues.smsOptOut; - const [row] = await db + // Auto-geocode on address change (GRO-2154). If the address was cleared, + // drop any stale coordinates so a disabled/blank address never keeps a pin. + const addressProvided = Object.prototype.hasOwnProperty.call(body, "address"); + const trimmedAddress = + typeof body.address === "string" ? body.address.trim() : undefined; + if (addressProvided && !trimmedAddress) { + setValues.latitude = null; + setValues.longitude = null; + setValues.geocodedAt = null; + } + + const [updated] = await db .update(clients) .set(setValues) .where(eq(clients.id, c.req.param("id"))) .returning(); - if (!row) return c.json({ error: "Not found" }, 404); - return c.json(row); + if (!updated) return c.json({ error: "Not found" }, 404); + + if (addressProvided && trimmedAddress) { + const { row: enriched, outcome } = await autoGeocodeClient(db, updated); + return c.json({ ...enriched, geocoding: outcome }); + } + return c.json(updated); } ); diff --git a/src/services/clientGeocoding.ts b/src/services/clientGeocoding.ts new file mode 100644 index 0000000..eb16b29 --- /dev/null +++ b/src/services/clientGeocoding.ts @@ -0,0 +1,212 @@ +import { + getDb, + businessSettings, + clients, + and, + eq, + isNull, + sql, +} from "@groombook/db"; +import { + resolveGeocodingProvider, + type GeocodingProvider, + type GeocodeResult, +} from "./geocoding.js"; + +/** + * Client geocoding orchestration (GRO-2154, Phase 1.3 of Route Optimization). + * + * Bridges the provider-agnostic {@link GeocodingProvider} layer (GRO-2153) and + * the `clients` table: resolves the configured provider from `businessSettings`, + * geocodes a client's address, and persists `latitude`/`longitude`/`geocodedAt`. + * + * Outcomes are returned as structured {@link ClientGeocodeOutcome} values so that + * callers (the geocode endpoints and the auto-geocode create/update hook) can + * surface clear, actionable feedback — groomers need to know when an address is + * ambiguous or unresolvable, not just that "something failed". + */ + +type Db = ReturnType; +type ClientRow = typeof clients.$inferSelect; + +/** Status of a single client geocode attempt. */ +export type ClientGeocodeStatus = + /** Coordinates resolved and persisted. */ + | "geocoded" + /** Client has no (non-blank) address on file — nothing to geocode. */ + | "no_address" + /** Provider returned no match; the address is ambiguous or unrecognized. */ + | "unresolved" + /** Provider call failed (transport, quota, bad key). Coordinates unchanged. */ + | "error"; + +/** Structured, UI-surfaceable result of geocoding one client. */ +export interface ClientGeocodeOutcome { + clientId: string; + status: ClientGeocodeStatus; + /** Human-readable explanation, safe to show to managers/groomers. */ + message: string; + latitude: number | null; + longitude: number | null; + geocodedAt: string | null; + /** Provider-normalized address, when a match was found. */ + formattedAddress: string | null; + /** Provider that produced (or attempted) this result. */ + provider: string | null; +} + +/** + * Builds the geocoding provider for the current business settings. A single + * provider instance should be reused across a batch so its internal rate limiter + * throttles the whole run (e.g. Nominatim's 1 req/sec policy). + */ +export async function resolveClientGeocodingProvider( + db: Db +): Promise { + const [settings] = await db.select().from(businessSettings).limit(1); + return resolveGeocodingProvider(settings ?? null); +} + +/** + * Geocodes a single client row through the given provider and persists the + * result on success. Never throws on provider failure — transport/quota errors + * are captured as an `"error"` outcome so callers (especially the create/update + * auto-geocode hook) are not broken by a flaky geocoding backend. + */ +export async function geocodeClient( + db: Db, + client: ClientRow, + provider: GeocodingProvider +): Promise { + const base = { + clientId: client.id, + latitude: null as number | null, + longitude: null as number | null, + geocodedAt: null as string | null, + formattedAddress: null as string | null, + provider: provider.name as string | null, + }; + + const address = client.address?.trim(); + if (!address) { + return { + ...base, + status: "no_address", + message: "Client has no address on file, so it cannot be geocoded.", + }; + } + + let result: GeocodeResult | null; + try { + result = await provider.geocode(address); + } catch (err) { + return { + ...base, + status: "error", + message: `Geocoding provider (${provider.name}) failed: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + + if (!result) { + return { + ...base, + status: "unresolved", + message: `Address could not be resolved to a location: "${address}". Please verify or correct the address.`, + }; + } + + const geocodedAt = new Date(); + await db + .update(clients) + .set({ + latitude: result.latitude, + longitude: result.longitude, + geocodedAt, + updatedAt: geocodedAt, + }) + .where(eq(clients.id, client.id)); + + return { + clientId: client.id, + status: "geocoded", + message: `Geocoded via ${result.provider} to ${result.latitude}, ${result.longitude}.`, + latitude: result.latitude, + longitude: result.longitude, + geocodedAt: geocodedAt.toISOString(), + formattedAddress: result.formattedAddress, + provider: result.provider, + }; +} + +/** Summary returned by {@link geocodeUngeocodedClients}. */ +export interface BatchGeocodeSummary { + provider: string; + /** Number of clients processed in this invocation. */ + processed: number; + geocoded: number; + unresolved: number; + errors: number; + /** Un-geocoded clients with an address that were NOT processed (over `limit`). */ + remaining: number; + /** Per-client outcomes for everything processed this invocation. */ + outcomes: ClientGeocodeOutcome[]; +} + +/** + * Batch-geocodes clients that have an address but no `geocodedAt` yet, throttled + * by the active provider's rate limiter. + * + * Because Nominatim allows only ~1 req/sec, geocoding every un-geocoded client in + * a single HTTP request would risk timeouts on large datasets. Each invocation + * therefore processes at most `limit` clients (default 50, clamped 1..500) and + * reports `remaining`; managers re-run until `remaining` is 0. + */ +export async function geocodeUngeocodedClients( + db: Db, + limit = 50, + injectedProvider?: GeocodingProvider +): Promise { + const effectiveLimit = Math.min(Math.max(Math.trunc(limit) || 0, 1), 500); + const provider = + injectedProvider ?? (await resolveClientGeocodingProvider(db)); + + // Un-geocoded = geocodedAt IS NULL with a non-blank address. + const candidateFilter = and( + isNull(clients.geocodedAt), + sql`${clients.address} IS NOT NULL AND length(trim(${clients.address})) > 0` + ); + + const countRows = await db + .select({ count: sql`count(*)::int` }) + .from(clients) + .where(candidateFilter); + const totalRemaining = countRows[0]?.count ?? 0; + + const rows = await db + .select() + .from(clients) + .where(candidateFilter) + .orderBy(clients.createdAt) + .limit(effectiveLimit); + + const outcomes: ClientGeocodeOutcome[] = []; + for (const row of rows) { + outcomes.push(await geocodeClient(db, row, provider)); + } + + const geocoded = outcomes.filter((o) => o.status === "geocoded").length; + const unresolved = outcomes.filter((o) => o.status === "unresolved").length; + const errors = outcomes.filter((o) => o.status === "error").length; + + return { + provider: provider.name, + processed: outcomes.length, + geocoded, + unresolved, + errors, + remaining: Math.max(totalRemaining - outcomes.length, 0), + outcomes, + }; +} From 14d7889ec00ffca5e4baeb93a70ee8b562cbdaba Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 12:39:02 +0000 Subject: [PATCH 11/23] =?UTF-8?q?fix(portal):=20drop=20writable=20photoKey?= =?UTF-8?q?=20from=20PATCH=20/portal/pets=20=E2=80=94=20S3=20key-hijack=20?= =?UTF-8?q?(GRO-2187/GRO-2198)=20(#172)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/portalPets.test.ts | 54 +++++++++++++++++++++++++++++++- src/routes/portal.ts | 17 ++++++---- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/__tests__/portalPets.test.ts b/src/__tests__/portalPets.test.ts index a95cb23..c23bca0 100644 --- a/src/__tests__/portalPets.test.ts +++ b/src/__tests__/portalPets.test.ts @@ -177,7 +177,10 @@ describe("PATCH /portal/pets/:petId", () => { expect(persisted.weightKg).toBe("18.25"); expect(persisted.groomingNotes).toBe("old grooming notes"); expect(persisted.healthAlerts).toBe("Allergic to oatmeal shampoo"); - expect(persisted.photoKey).toBe("pets/rex.jpg"); + // 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"]); @@ -187,6 +190,55 @@ describe("PATCH /portal/pets/:petId", () => { 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; diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 3f15745..0b106fb 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -261,8 +261,8 @@ const PORTAL_PET_SIZE_ALIASES: Record = { xlarge: "extra_large" const portalMedicalAlertSchema = z.object({ id: z.string().optional(), - type: z.string(), - description: z.string(), + type: z.string().max(2000), + description: z.string().max(2000), severity: z.enum(["low", "medium", "high"]), }); @@ -275,12 +275,16 @@ const portalPetUpdateSchema = z.object({ birthDate: z.string().nullable().optional(), notes: z.string().max(2000).nullable().optional(), healthAlerts: z.string().max(2000).nullable().optional(), - photoUrl: z.string().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()).nullable().optional(), - medicalAlerts: z.array(portalMedicalAlertSchema).nullable().optional(), + preferredCuts: z.array(z.string().max(2000)).max(50).nullable().optional(), + medicalAlerts: z.array(portalMedicalAlertSchema).max(50).nullable().optional(), }); portalRouter.patch( @@ -322,7 +326,8 @@ portalRouter.patch( if (body.notes !== undefined) updateData.groomingNotes = body.notes; if (body.healthAlerts !== undefined) updateData.healthAlerts = body.healthAlerts; - if (body.photoUrl !== undefined) updateData.photoKey = body.photoUrl; + // 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)) { 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 12/23] 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); +}); 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 13/23] 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; +} 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 14/23] =?UTF-8?q?fix(portal):=20GRO-2203=20validate=20petI?= =?UTF-8?q?d=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) 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 15/23] 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) From ca62fb8ef61b6345b7266edf57a8f1fc1bbf420c Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 18:07:54 +0000 Subject: [PATCH 16/23] feat(GRO-2156): travel buffer + reorder endpoint (Phase 2.2) (#180) --- UAT_PLAYBOOK.md | 24 +++- src/__tests__/routeOptimization.test.ts | 151 +++++++++++++++++++ src/routes/routes.ts | 184 +++++++++++++++++++++++- src/services/routeOptimization.ts | 100 +++++++++++++ 4 files changed, 455 insertions(+), 4 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 0267135..d7623f1 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -366,7 +366,7 @@ A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes | # | 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.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). The first stop carries `bufferMins: 0` (no predecessor); every later stop carries `bufferMins` = `businessSettings.defaultTravelBufferMins` (default 15). Response also includes `hasConflicts` / `conflictCount` and each stop a `conflict` object (GRO-2156, see §4.17) | | 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` | @@ -377,6 +377,28 @@ A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes | 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`) | +### 4.17 Route Optimization — Travel Buffer + Reorder (GRO-2156, Phase 2.2) + +Builds on §4.16. After optimization each consecutive leg carries a travel `bufferMins` (= `businessSettings.defaultTravelBufferMins`, default 15; the first stop is `0`). The API derives a per-stop **`conflict`** object at read time on `GET /api/routes/daily`, `POST /api/routes/optimize`, and `PATCH /api/routes/:routeId/reorder`: + +- `conflict.scheduleGapMins` — minutes between the previous appointment's `endTime` and this appointment's `startTime` (null for the first stop) +- `conflict.requiredGapMins` — `travelMinsFromPrev + bufferMins` (null for the first stop) +- `conflict.shortfallMins` — `requiredGapMins − scheduleGapMins` (positive ⇒ tight) +- `conflict.hasConflict` — true when `shortfallMins > 0` ("tight schedule"); appointments are **never auto-moved**, only flagged + +`PATCH /api/routes/:routeId/reorder` accepts `{ "stopOrder": ["", …] }` (every current stop id, exactly once, first-to-last), persists the new `stopOrder`, re-estimates each leg's travel offline for the new adjacency, re-applies buffers, recomputes route totals, and returns the route with refreshed conflict flags. **Auth: manager (any route) or groomer (own route only).** + +| ID | Scenario | Steps | Expected | +|----|----------|-------|----------| +| TC-API-17.1 | Conflict flags on optimize | As manager, optimize a day with ≥2 geocoded appointments whose times are close together | 200 OK; top-level `hasConflicts` (bool) + `conflictCount` (int). First stop `conflict.hasConflict:false` with null gap fields. A later stop whose `scheduleGapMins < travelMinsFromPrev + bufferMins` has `conflict.hasConflict:true` and positive `shortfallMins` | +| TC-API-17.2 | No false conflict on a roomy schedule | Optimize a day where appointment gaps comfortably exceed travel + buffer | 200 OK; `hasConflicts:false`, `conflictCount:0`, every `conflict.shortfallMins ≤ 0` | +| TC-API-17.3 | Reorder persists new order | As manager, take an optimized route, `PATCH /api/routes/{routeId}/reorder` with the stop ids in a new order | 200 OK; `stops` returned in the requested order with contiguous `stopOrder` 1..N; first stop `travelMinsFromPrev:null`/`bufferMins:0`, others recomputed; `route.totalTravelMins`/`totalDistanceKm` updated | +| TC-API-17.4 | Reorder re-flags conflicts | Reorder so a far-apart pair becomes adjacent | 200 OK; `conflict` flags recomputed for the new adjacency (`hasConflicts`/`conflictCount` reflect the new order) | +| TC-API-17.5 | Reorder validation — wrong stop set | `PATCH …/reorder` with a missing, extra, duplicate, or unknown stop id | 400 with an explanatory `error` (e.g. "must list every stop exactly once", "unknown stop id", "duplicate stop id") | +| TC-API-17.6 | Reorder unknown route | `PATCH /api/routes/{randomUuid}/reorder` with any body | 404 `{ error: "Route not found" }` | +| 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`) | + ## Pass/Fail Criteria **Pass:** diff --git a/src/__tests__/routeOptimization.test.ts b/src/__tests__/routeOptimization.test.ts index 49877d4..706924a 100644 --- a/src/__tests__/routeOptimization.test.ts +++ b/src/__tests__/routeOptimization.test.ts @@ -4,6 +4,8 @@ import { estimateLeg, nearestNeighborOrder, optimizeRoute, + detectScheduleConflicts, + recomputeLegsForOrder, MAX_STOPS_PER_ROUTE, type RouteStopInput, } from "../services/routeOptimization.js"; @@ -182,3 +184,152 @@ describe("optimizeRoute — >25 stop chunking", () => { expect(new Set(r.stops.map((s) => s.appointmentId)).size).toBe(stops.length); }); }); + +describe("detectScheduleConflicts", () => { + const at = (iso: string) => new Date(iso); + + it("returns no conflict and null gaps for an empty or single-stop route", () => { + expect(detectScheduleConflicts([])).toEqual([]); + const one = detectScheduleConflicts([ + { + appointmentStartTime: at("2026-06-08T09:00:00Z"), + appointmentEndTime: at("2026-06-08T10:00:00Z"), + travelMinsFromPrev: null, + bufferMins: 15, + }, + ]); + expect(one).toEqual([ + { + hasConflict: false, + scheduleGapMins: null, + requiredGapMins: null, + shortfallMins: null, + }, + ]); + }); + + it("flags a tight schedule when gap < travel + buffer", () => { + // Stop 1 ends 10:00, stop 2 starts 10:20 → 20min gap. Travel 15 + buffer 15 + // = 30 required → shortfall 10 → conflict. + const flags = detectScheduleConflicts([ + { + appointmentStartTime: at("2026-06-08T09:00:00Z"), + appointmentEndTime: at("2026-06-08T10:00:00Z"), + travelMinsFromPrev: null, + bufferMins: 0, + }, + { + appointmentStartTime: at("2026-06-08T10:20:00Z"), + appointmentEndTime: at("2026-06-08T11:00:00Z"), + travelMinsFromPrev: 15, + bufferMins: 15, + }, + ]); + expect(flags[0]!.hasConflict).toBe(false); + expect(flags[1]).toEqual({ + hasConflict: true, + scheduleGapMins: 20, + requiredGapMins: 30, + shortfallMins: 10, + }); + }); + + it("does not flag when the gap comfortably covers travel + buffer", () => { + // 90min gap, 15 travel + 15 buffer = 30 required → 60 slack → no conflict. + const flags = detectScheduleConflicts([ + { + appointmentStartTime: at("2026-06-08T09:00:00Z"), + appointmentEndTime: at("2026-06-08T10:00:00Z"), + travelMinsFromPrev: null, + bufferMins: 0, + }, + { + appointmentStartTime: at("2026-06-08T11:30:00Z"), + appointmentEndTime: at("2026-06-08T12:00:00Z"), + travelMinsFromPrev: 15, + bufferMins: 15, + }, + ]); + expect(flags[1]).toEqual({ + hasConflict: false, + scheduleGapMins: 90, + requiredGapMins: 30, + shortfallMins: -60, + }); + }); + + it("treats a null travelMinsFromPrev as zero travel", () => { + const flags = detectScheduleConflicts([ + { + appointmentStartTime: at("2026-06-08T09:00:00Z"), + appointmentEndTime: at("2026-06-08T10:00:00Z"), + travelMinsFromPrev: null, + bufferMins: 0, + }, + { + appointmentStartTime: at("2026-06-08T10:05:00Z"), + appointmentEndTime: at("2026-06-08T11:00:00Z"), + travelMinsFromPrev: null, + bufferMins: 15, + }, + ]); + // 5min gap vs 0 travel + 15 buffer = 15 required → conflict, shortfall 10. + expect(flags[1]!.hasConflict).toBe(true); + expect(flags[1]!.requiredGapMins).toBe(15); + expect(flags[1]!.shortfallMins).toBe(10); + }); + + it("flags overlapping appointments (negative gap) as conflicts", () => { + const flags = detectScheduleConflicts([ + { + appointmentStartTime: at("2026-06-08T09:00:00Z"), + appointmentEndTime: at("2026-06-08T10:00:00Z"), + travelMinsFromPrev: null, + bufferMins: 0, + }, + { + appointmentStartTime: at("2026-06-08T09:30:00Z"), + appointmentEndTime: at("2026-06-08T10:30:00Z"), + travelMinsFromPrev: 10, + bufferMins: 15, + }, + ]); + expect(flags[1]!.scheduleGapMins).toBe(-30); + expect(flags[1]!.hasConflict).toBe(true); + expect(flags[1]!.shortfallMins).toBe(55); + }); +}); + +describe("recomputeLegsForOrder", () => { + it("returns null travel for an empty or single-point order", () => { + expect(recomputeLegsForOrder([])).toEqual([]); + expect(recomputeLegsForOrder([{ latitude: 40, longitude: -74 }])).toEqual([ + { travelMinsFromPrev: null, travelDistanceKmFromPrev: null }, + ]); + }); + + it("estimates each leg for the fixed given order without reordering", () => { + const pts = [ + { latitude: 0, longitude: 0 }, + { latitude: 0, longitude: 1 }, + { latitude: 0, longitude: 2 }, + ]; + const legs = recomputeLegsForOrder(pts); + expect(legs).toHaveLength(3); + expect(legs[0]).toEqual({ + travelMinsFromPrev: null, + travelDistanceKmFromPrev: null, + }); + // Each leg equals estimateLeg between adjacent points (no optimization). + const e01 = estimateLeg(pts[0]!, pts[1]!); + const e12 = estimateLeg(pts[1]!, pts[2]!); + expect(legs[1]).toEqual({ + travelMinsFromPrev: e01.mins, + travelDistanceKmFromPrev: e01.distanceKm, + }); + expect(legs[2]).toEqual({ + travelMinsFromPrev: e12.mins, + travelDistanceKmFromPrev: e12.distanceKm, + }); + }); +}); diff --git a/src/routes/routes.ts b/src/routes/routes.ts index e9a1d41..3bf905a 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -19,7 +19,10 @@ import type { AppEnv, StaffRow } from "../middleware/rbac.js"; import { optimizeRoute, resolveRouteGoogleApiKey, + detectScheduleConflicts, + recomputeLegsForOrder, type RouteStopInput, + type StopConflictFlags, } from "../services/routeOptimization.js"; export const routesRouter = new Hono(); @@ -34,6 +37,11 @@ const optimizeBodySchema = z.object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"), }); +const reorderBodySchema = z.object({ + // New visiting order expressed as routeStops.id values, first-to-last. + stopOrder: z.array(z.string().uuid()).min(1), +}); + /** * 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 @@ -96,6 +104,31 @@ async function loadRouteStops(db: ReturnType, routeId: string) { .orderBy(asc(routeStops.stopOrder)); } +type LoadedRouteStop = Awaited>[number]; + +/** + * Annotates persisted stops with "tight schedule" conflict flags for the + * frontend. Conflicts are derived at read time from the live appointment times, + * persisted travel estimates and buffers — never auto-resolved by moving stops. + */ +function annotateConflicts(stops: LoadedRouteStop[]): { + stops: Array; + hasConflicts: boolean; + conflictCount: number; +} { + const flags = detectScheduleConflicts( + stops.map((s) => ({ + appointmentStartTime: s.appointmentStartTime, + appointmentEndTime: s.appointmentEndTime, + travelMinsFromPrev: s.travelMinsFromPrev, + bufferMins: s.bufferMins, + })) + ); + const annotated = stops.map((s, i) => ({ ...s, conflict: flags[i]! })); + const conflictCount = flags.filter((f) => f.hasConflict).length; + return { stops: annotated, hasConflicts: conflictCount > 0, conflictCount }; +} + /** * GET /api/routes/daily?staffId=&date= * Fetches (creating a draft if absent) the daily route for a groomer, with all @@ -130,7 +163,13 @@ routesRouter.get("/daily", zValidator("query", dailyQuerySchema), async (c) => { } const stops = await loadRouteStops(db, route!.id); - return c.json({ route, stops }); + const annotated = annotateConflicts(stops); + return c.json({ + route, + stops: annotated.stops, + hasConflicts: annotated.hasConflicts, + conflictCount: annotated.conflictCount, + }); }); /** @@ -262,7 +301,9 @@ routesRouter.post( s.travelDistanceKmFromPrev == null ? null : s.travelDistanceKmFromPrev.toFixed(2), - bufferMins, + // Buffer applies between consecutive stops; the first stop has no + // predecessor, so it carries no travel buffer. + bufferMins: i === 0 ? 0 : bufferMins, })) ); } @@ -271,9 +312,12 @@ routesRouter.post( }); const stops = await loadRouteStops(db, route.id); + const annotated = annotateConflicts(stops); return c.json({ route, - stops, + stops: annotated.stops, + hasConflicts: annotated.hasConflicts, + conflictCount: annotated.conflictCount, provider: optimized.provider, chunked: optimized.chunked, subRouteCount: optimized.subRouteCount, @@ -282,3 +326,137 @@ routesRouter.post( }); } ); + +/** + * PATCH /api/routes/:routeId/reorder { stopOrder: string[] } + * Persists a manual stop order (array of routeStops.id, first-to-last), then + * re-runs the buffer logic: each leg's travel is re-estimated for the new + * adjacency, the default travel buffer is re-applied between consecutive stops, + * route totals are recomputed, and tight-schedule conflicts are re-flagged. + * Appointments are never moved. Auth: groomer (own route) or manager. + */ +routesRouter.patch( + "/:routeId/reorder", + zValidator("json", reorderBodySchema), + async (c) => { + const db = getDb(); + const routeId = c.req.param("routeId"); + if (!z.string().uuid().safeParse(routeId).success) { + return c.json({ error: "routeId must be a UUID" }, 400); + } + const { stopOrder: newOrderIds } = c.req.valid("json"); + + 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 existing = await db + .select({ + id: routeStops.id, + latitude: routeStops.latitude, + longitude: routeStops.longitude, + }) + .from(routeStops) + .where(eq(routeStops.routeId, routeId)); + + // The new order must be an exact permutation of the route's current stops. + const existingIds = new Set(existing.map((s) => s.id)); + if (newOrderIds.length !== existing.length) { + return c.json( + { + error: `stopOrder must list every stop exactly once (expected ${existing.length}, got ${newOrderIds.length})`, + }, + 400 + ); + } + const seen = new Set(); + for (const id of newOrderIds) { + if (!existingIds.has(id)) { + return c.json({ error: `unknown stop id: ${id}` }, 400); + } + if (seen.has(id)) { + return c.json({ error: `duplicate stop id: ${id}` }, 400); + } + seen.add(id); + } + + const [settings] = await db.select().from(businessSettings).limit(1); + const bufferMins = settings?.defaultTravelBufferMins ?? 15; + + const byId = new Map(existing.map((s) => [s.id, s])); + const legs = recomputeLegsForOrder( + newOrderIds.map((id) => { + const s = byId.get(id)!; + return { latitude: s.latitude, longitude: s.longitude }; + }) + ); + + const totalTravelMins = legs.reduce( + (sum, l) => sum + (l.travelMinsFromPrev ?? 0), + 0 + ); + const totalDistanceKm = + Math.round( + legs.reduce((sum, l) => sum + (l.travelDistanceKmFromPrev ?? 0), 0) * 100 + ) / 100; + + const now = new Date(); + await db.transaction(async (tx) => { + // Two-pass update: park stopOrder in a non-colliding negative range first + // so the unique(routeId, stopOrder) constraint never trips mid-reorder. + for (let i = 0; i < newOrderIds.length; i++) { + await tx + .update(routeStops) + .set({ stopOrder: -(i + 1), updatedAt: now }) + .where(eq(routeStops.id, newOrderIds[i]!)); + } + for (let i = 0; i < newOrderIds.length; i++) { + const leg = legs[i]!; + await tx + .update(routeStops) + .set({ + stopOrder: i + 1, + travelMinsFromPrev: leg.travelMinsFromPrev, + travelDistanceKmFromPrev: + leg.travelDistanceKmFromPrev == null + ? null + : leg.travelDistanceKmFromPrev.toFixed(2), + bufferMins: i === 0 ? 0 : bufferMins, + updatedAt: now, + }) + .where(eq(routeStops.id, newOrderIds[i]!)); + } + await tx + .update(groomerRoutes) + .set({ + totalTravelMins, + totalDistanceKm: totalDistanceKm.toFixed(2), + updatedAt: now, + }) + .where(eq(groomerRoutes.id, routeId)); + }); + + const [updatedRoute] = await db + .select() + .from(groomerRoutes) + .where(eq(groomerRoutes.id, routeId)); + const stops = await loadRouteStops(db, routeId); + const annotated = annotateConflicts(stops); + return c.json({ + route: updatedRoute, + stops: annotated.stops, + hasConflicts: annotated.hasConflicts, + conflictCount: annotated.conflictCount, + }); + } +); diff --git a/src/services/routeOptimization.ts b/src/services/routeOptimization.ts index 14de121..53ffe90 100644 --- a/src/services/routeOptimization.ts +++ b/src/services/routeOptimization.ts @@ -411,3 +411,103 @@ export async function resolveRouteGoogleApiKey( const fromEnv = process.env.GOOGLE_MAPS_API_KEY?.trim(); return fromEnv ? fromEnv : null; } + +// ─── Travel buffer & schedule-conflict logic (GRO-2156, Phase 2.2) ─────────── + +/** A single stop's timing inputs for schedule-conflict detection. */ +export interface ScheduleStopTiming { + /** Scheduled appointment start. */ + appointmentStartTime: Date; + /** Scheduled appointment end. */ + appointmentEndTime: Date; + /** Travel minutes into this stop from the previous one (null for first). */ + travelMinsFromPrev: number | null; + /** Configured buffer minutes before this stop. */ + bufferMins: number; +} + +/** Conflict annotation for one stop, surfaced for the frontend to display. */ +export interface StopConflictFlags { + /** True when the schedule gap is too tight for travel + buffer. */ + hasConflict: boolean; + /** Minutes between the previous appointment's end and this one's start. + * Null for the first stop (no predecessor). */ + scheduleGapMins: number | null; + /** travelMinsFromPrev + bufferMins. Null for the first stop. */ + requiredGapMins: number | null; + /** requiredGapMins − scheduleGapMins; positive when the schedule is tight. + * Null for the first stop. */ + shortfallMins: number | null; +} + +const MS_PER_MIN = 60_000; + +/** + * Detects "tight schedule" conflicts between consecutive stops, in visiting + * order. A conflict exists when the real gap between the previous appointment's + * end and this appointment's start is smaller than the time needed to travel + * plus the configured buffer (`travelMinsFromPrev + bufferMins`). + * + * This only *flags* conflicts — appointments are never moved. The first stop + * has no predecessor and is therefore always conflict-free. + */ +export function detectScheduleConflicts( + stops: ScheduleStopTiming[] +): StopConflictFlags[] { + return stops.map((s, i) => { + if (i === 0) { + return { + hasConflict: false, + scheduleGapMins: null, + requiredGapMins: null, + shortfallMins: null, + }; + } + const prev = stops[i - 1]!; + const scheduleGapMins = Math.round( + (s.appointmentStartTime.getTime() - prev.appointmentEndTime.getTime()) / + MS_PER_MIN + ); + const requiredGapMins = (s.travelMinsFromPrev ?? 0) + s.bufferMins; + const shortfallMins = requiredGapMins - scheduleGapMins; + return { + hasConflict: shortfallMins > 0, + scheduleGapMins, + requiredGapMins, + shortfallMins, + }; + }); +} + +/** A coordinate used when recomputing legs for a fixed (manually chosen) order. */ +export interface OrderedPoint { + latitude: number; + longitude: number; +} + +/** Recomputed per-leg travel for a fixed stop order. */ +export interface RecomputedLeg { + /** Null for the first stop. */ + travelMinsFromPrev: number | null; + /** Null for the first stop. Kilometres, 2-dp. */ + travelDistanceKmFromPrev: number | null; +} + +/** + * Recomputes per-leg travel estimates for a *fixed* visiting order (e.g. after a + * manual reorder). Unlike {@link optimizeRoute} this does not reorder anything — + * it walks the given order and estimates each leg offline via {@link estimateLeg} + * so a manual drag does not consume Google Directions quota. + */ +export function recomputeLegsForOrder(points: OrderedPoint[]): RecomputedLeg[] { + return points.map((p, i) => { + if (i === 0) { + return { travelMinsFromPrev: null, travelDistanceKmFromPrev: null }; + } + const est = estimateLeg(points[i - 1]!, p); + return { + travelMinsFromPrev: est.mins, + travelDistanceKmFromPrev: est.distanceKm, + }; + }); +} From aabedc8152dbe52ade4cc026e8a44ae01d2730cc Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 18:55:43 +0000 Subject: [PATCH 17/23] fix(GRO-2234): bounded sliding expiration for SSO portal sessions (#183) --- UAT_PLAYBOOK.md | 2 + src/__tests__/portalSessionSliding.test.ts | 188 +++++++++++++++++++++ src/middleware/portalSession.ts | 47 +++++- src/routes/portal.ts | 4 +- 4 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/portalSessionSliding.test.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index d7623f1..3d0445b 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -282,6 +282,8 @@ This means: | 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 | +| TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. | +| TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) | ### 4.9 Waitlist diff --git a/src/__tests__/portalSessionSliding.test.ts b/src/__tests__/portalSessionSliding.test.ts new file mode 100644 index 0000000..4125548 --- /dev/null +++ b/src/__tests__/portalSessionSliding.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Hono } from "hono"; +import { + validatePortalSession, + PORTAL_SESSION_IDLE_TTL_MS, + PORTAL_SESSION_MAX_LIFETIME_MS, + type PortalEnv, +} from "../middleware/portalSession.js"; + +const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003"; +const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; + +// Mutable test state driven per-case. +let selectSessionRow: Record | null = null; +let sessionUpdates: Record[] = []; + +function resetMock() { + selectSessionRow = null; + sessionUpdates = []; +} + +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 index + return target[prop]; + }, + }); + return chain; + } + + const impersonationSessions = new Proxy( + { _name: "impersonationSessions" }, + { get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "impersonationSessions") { + return makeChainable(selectSessionRow ? [selectSessionRow] : []); + } + return makeChainable([]); + }, + }), + update: () => ({ + set: (vals: Record) => { + sessionUpdates.push(vals); + return { where: () => Promise.resolve(undefined) }; + }, + }), + }), + impersonationSessions, + eq: vi.fn(), + and: vi.fn(), + }; +}); + +const app = new Hono(); +app.use("/portal/*", validatePortalSession); +app.get("/portal/ping", (c) => c.json({ ok: true, clientId: c.get("portalClientId") })); + +function ping(headers?: Record) { + return app.request("/portal/ping", { method: "GET", headers }); +} + +beforeEach(() => resetMock()); + +describe("validatePortalSession — sliding expiration (GRO-2234)", () => { + it("extends an sso-bridge session's expiresAt on each authenticated request", async () => { + const now = Date.now(); + // Session minted ~28 min ago, originally a 30-min idle window: it is still + // valid (2 min left) but a slow wizard would otherwise let it lapse. + selectSessionRow = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active", + reason: "sso-bridge", + startedAt: new Date(now - 28 * 60 * 1000), + expiresAt: new Date(now + 2 * 60 * 1000), + }; + + const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); + expect(res.status).toBe(200); + + expect(sessionUpdates).toHaveLength(1); + const newExpiry = sessionUpdates[0]!.expiresAt as Date; + // Slid forward to ~now + 30 min (well past the original 2-min-left window). + expect(newExpiry.getTime()).toBeGreaterThan(now + PORTAL_SESSION_IDLE_TTL_MS - 5_000); + expect(newExpiry.getTime()).toBeLessThanOrEqual(now + PORTAL_SESSION_IDLE_TTL_MS + 5_000); + }); + + it("keeps a slow-wizard customer authorized past the original mint TTL", async () => { + const now = Date.now(); + // Original mint window has fully elapsed in wall-clock terms, but the session + // was slid forward on the previous request, so it is still valid now. + selectSessionRow = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active", + reason: "sso-bridge", + startedAt: new Date(now - 35 * 60 * 1000), + expiresAt: new Date(now + 10 * 60 * 1000), // previously slid + }; + + const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.clientId).toBe(CLIENT_ID); + }); + + it("never extends beyond startedAt + MAX_LIFETIME (bounded)", async () => { + const now = Date.now(); + // Session started right at the absolute cap boundary minus a hair. + const startedAt = now - (PORTAL_SESSION_MAX_LIFETIME_MS - 5 * 60 * 1000); + selectSessionRow = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active", + reason: "sso-bridge", + startedAt: new Date(startedAt), + expiresAt: new Date(now + 60 * 1000), + }; + + const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); + expect(res.status).toBe(200); + expect(sessionUpdates).toHaveLength(1); + const newExpiry = (sessionUpdates[0]!.expiresAt as Date).getTime(); + // Capped at startedAt + MAX_LIFETIME, NOT now + IDLE_TTL. + expect(newExpiry).toBeLessThanOrEqual(startedAt + PORTAL_SESSION_MAX_LIFETIME_MS + 1_000); + expect(newExpiry).toBeGreaterThan(now); // still extends at least a little + }); + + it("does NOT slide a staff-initiated impersonation session (no regression)", async () => { + const now = Date.now(); + selectSessionRow = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active", + reason: "manager reviewing booking", // staff-console reason, free text + startedAt: new Date(now - 5 * 60 * 1000), + expiresAt: new Date(now + 20 * 60 * 1000), + }; + + const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); + expect(res.status).toBe(200); + expect(sessionUpdates).toHaveLength(0); + }); + + it("still rejects an already-expired session (no resurrection)", async () => { + const now = Date.now(); + selectSessionRow = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active", + reason: "sso-bridge", + startedAt: new Date(now - 40 * 60 * 1000), + expiresAt: new Date(now - 60 * 1000), // already lapsed + }; + + const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); + expect(res.status).toBe(401); + expect(sessionUpdates).toHaveLength(0); + }); + + it("skips the write when the extension is below the slide threshold", async () => { + const now = Date.now(); + // Already slid this minute: expiresAt is essentially now + IDLE_TTL already. + selectSessionRow = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active", + reason: "sso-bridge", + startedAt: new Date(now - 2 * 60 * 1000), + expiresAt: new Date(now + PORTAL_SESSION_IDLE_TTL_MS - 2_000), + }; + + const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID }); + expect(res.status).toBe(200); + expect(sessionUpdates).toHaveLength(0); + }); +}); diff --git a/src/middleware/portalSession.ts b/src/middleware/portalSession.ts index 6dfdb03..055c3fe 100644 --- a/src/middleware/portalSession.ts +++ b/src/middleware/portalSession.ts @@ -8,6 +8,32 @@ export interface PortalEnv { }; } +/** + * Idle lifetime of an SSO-bridge portal impersonation session. Each authenticated + * portal request slides `expiresAt` forward to `now + IDLE_TTL`, so an actively-used + * session (e.g. a customer working through the multi-step Book New wizard) never + * lapses mid-flow. Matches the staff-console impersonation idle window + * (SESSION_TIMEOUT_MINUTES in routes/impersonation.ts). (GRO-2234) + */ +export const PORTAL_SESSION_IDLE_TTL_MS = 30 * 60 * 1000; + +/** + * Absolute cap on a single SSO-bridge portal session's lifetime, measured from + * `startedAt`. Sliding can never extend a session beyond this bound, keeping the + * impersonation model bounded regardless of how long a customer keeps the tab + * active. Deliberately tighter than the previous static 24h mint. (GRO-2234) + */ +export const PORTAL_SESSION_MAX_LIFETIME_MS = 8 * 60 * 60 * 1000; + +/** + * Minimum extension before we issue a sliding-expiration write. Avoids a DB write + * on every rapid successive request — at most one slide per minute per session. + */ +const PORTAL_SESSION_SLIDE_THRESHOLD_MS = 60 * 1000; + +/** Reason marker for sessions minted by the Better Auth -> portal bridge. */ +const SSO_BRIDGE_REASON = "sso-bridge"; + /** * Validates the X-Impersonation-Session-Id header against the impersonationSessions table. * Must be applied to all portal routes. @@ -16,6 +42,12 @@ export interface PortalEnv { * id = sessionId AND status = 'active', and checks session.expiresAt > new Date(). * Returns 401 if session is invalid/missing/expired. * On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id). + * + * Sliding expiration (GRO-2234): for SSO-bridge sessions, each successful request + * extends `expiresAt` to `now + PORTAL_SESSION_IDLE_TTL_MS`, bounded by + * `startedAt + PORTAL_SESSION_MAX_LIFETIME_MS`. Staff-initiated impersonation + * sessions (any other `reason`) are left untouched, preserving their existing + * console-enforced timeout behavior. */ export const validatePortalSession: MiddlewareHandler = async (c, next) => { const sessionId = c.req.header("X-Impersonation-Session-Id"); @@ -24,16 +56,29 @@ export const validatePortalSession: MiddlewareHandler = async (c, nex } const db = getDb(); + const now = new Date(); const [session] = await db .select() .from(impersonationSessions) .where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active"))) .limit(1); - if (!session || session.expiresAt <= new Date()) { + if (!session || session.expiresAt <= now) { return c.json({ error: "Unauthorized" }, 401); } + // Sliding expiration for SSO-bridge portal sessions only (GRO-2234). + if (session.reason === SSO_BRIDGE_REASON) { + const maxExpiry = session.startedAt.getTime() + PORTAL_SESSION_MAX_LIFETIME_MS; + const slidExpiry = Math.min(now.getTime() + PORTAL_SESSION_IDLE_TTL_MS, maxExpiry); + if (slidExpiry - session.expiresAt.getTime() >= PORTAL_SESSION_SLIDE_THRESHOLD_MS) { + await db + .update(impersonationSessions) + .set({ expiresAt: new Date(slidExpiry) }) + .where(eq(impersonationSessions.id, session.id)); + } + } + c.set("portalClientId", session.clientId); c.set("portalSessionId", session.id); await next(); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index aa1593a..d614e51 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -3,7 +3,7 @@ import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { eq, inArray } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; -import { validatePortalSession } from "../middleware/portalSession.js"; +import { validatePortalSession, PORTAL_SESSION_IDLE_TTL_MS } from "../middleware/portalSession.js"; import { portalAudit } from "../middleware/portalAudit.js"; import type { PortalEnv } from "../middleware/portalSession.js"; @@ -129,7 +129,7 @@ portalRouter.post("/session-from-auth", async (c) => { staffId, clientId: client.id, reason: "sso-bridge", - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + expiresAt: new Date(Date.now() + PORTAL_SESSION_IDLE_TTL_MS), }) .returning(); 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 18/23] 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 19/23] 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 20/23] 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 fe412933ead4e10d01b21a9ec0feed10abd845ef Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 06:17:42 +0000 Subject: [PATCH 21/23] GRO-2294: Route Optimization security hardening (geocode-batch limit cap + redact settings secret) (#193) --- .mcp.json | 11 +++ UAT_PLAYBOOK.md | 5 +- src/__tests__/geocodeBatchLimit.test.ts | 89 ++++++++++++++++++++++++ src/__tests__/settings.test.ts | 91 +++++++++++++++++++++++++ src/routes/clients.ts | 11 ++- src/routes/settings.ts | 16 ++++- trigger-uat-1779751324.txt | 0 7 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 .mcp.json create mode 100644 src/__tests__/geocodeBatchLimit.test.ts create mode 100644 src/__tests__/settings.test.ts create mode 100644 trigger-uat-1779751324.txt diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..6efc1ca --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "gitea": { + "type": "http", + "url": "https://git-mcp.farh.net/mcp", + "headers": { + "Authorization": "Bearer ${GITEA_TOKEN}" + } + } + } +} diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index cf0a541..48082de 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -133,6 +133,7 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode | 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.13a | Batch geocode — `?limit` cap enforced (GRO-2294) | As manager, `POST /api/clients/geocode-batch?limit=100000` on a DB with un-geocoded clients | 200 OK; the request is **clamped to the documented max of 500** — `processed` ≤ 500 (never the raw 100000). A fractional `?limit` (e.g. `49.9`) is floored to `49`. Confirms a manager cannot hold one synchronous request open / accrue unbounded Google API cost via an oversized limit | | 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) | @@ -165,6 +166,8 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode | TC-API-3.19b | Get pet profile summary — customer cross-tenant blocked (GRO-2013) | Sign in as `uat-customer@groombook.dev`; reuse the customer's sessionId from TC-API-3.19a; `GET /api/pets/{otherClientPetId}/profile-summary` for a pet owned by a different client (`c0000002-...` or any non-customer pet) | 403 Forbidden (owner-bypass requires session.clientId === pet.clientId) | | TC-API-3.19c | Get pet profile summary — customer without portal session header | Same as TC-API-3.19a but omit the `X-Impersonation-Session-Id` header | 403 Forbidden (no owner-bypass without valid portal session) | | TC-API-3.19d | Get pet profile summary — owner-bypass writes audit row (GRO-2063) | Same setup as TC-API-3.19a (sign in as `uat-customer@groombook.dev`, establish a portal session for the customer's own clientId, call `GET /api/pets/{ownPetId}/profile-summary` with `X-Impersonation-Session-Id: {sessionId}` and a 200 OK response). Then call `GET /api/impersonation/sessions/{sessionId}/audit-log` and confirm there is exactly one entry with `action === "read_profile_summary"`, `pageVisited` matching the profile-summary path, and `metadata` containing `petId` and `actorStaffId` for the customer. Repeat TC-API-3.19b (cross-tenant attempt) and confirm NO new `read_profile_summary` row was written for the cross-tenant attempt. | 200 OK on the profile-summary call AND an audit log entry is present with the correct shape (defense-in-depth audit row; bypass attempts against other clients must NOT log) | +| TC-UAT-2 | Groomer accesses linked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000002/profile-summary` (UAT Pup Alpha — linked via deterministic completed appointment `a0000001-0000-0000-0000-000000000001`, service `b0000001-…-0001` "Bath & Brush", `startTime` ~7 days ago) | 200 OK, `recentGroomingHistory[]` non-empty (>=1 entry), `visitCount >= 1`, `upcomingAppointment` null (the seeded appointment is in the past) | +| TC-UAT-3 | Groomer blocked from unlinked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000003/profile-summary` (UAT Pup Beta — intentionally UNLINKED; no appointment row references this pet's clientId+groomerId combo) | 403 Forbidden (RBAC `groomer` role lacks the appointment-linkage grant for this pet). NOTE: if 404 is returned instead of 403, file a separate RBAC defect (not against the seed) — see GRO-2100 verification note | | TC-API-3.29 | Get pet profile summary — unknown UUID returns 404 (GRO-2014) | GET /api/pets/00000000-0000-0000-0000-000000000001/profile-summary while authenticated (any role) | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014) | | TC-API-3.30 | Get pet profile summary — malformed UUID returns 404 (GRO-2014) | GET /api/pets/not-a-uuid/profile-summary while authenticated | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014 — Postgres uuid cast failure) | | TC-API-3.31 | Get pet profile summary — never empty-body 500 (GRO-2014) | GET /api/pets/{anyId}/profile-summary across the test sweep | No response has status 500 with an empty body. Any 500 must include a JSON body `{"error":"Internal Server Error"}` | @@ -329,7 +332,7 @@ This means: | # | Scenario | Steps | Expected | |---|----------|-------|----------| -| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned | +| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the projection (GRO-2294, defense-in-depth); non-secret fields (`businessName`, colors, `routeOptimizationProvider`, etc.) are still present | | TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated | | TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored | | TC-API-13.4 | View logo | GET /api/admin/settings/logo | 200 OK, logo image returned | diff --git a/src/__tests__/geocodeBatchLimit.test.ts b/src/__tests__/geocodeBatchLimit.test.ts new file mode 100644 index 0000000..8731c02 --- /dev/null +++ b/src/__tests__/geocodeBatchLimit.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mocks ────────────────────────────────────────────────────────────────── +// GRO-2294: the POST /clients/geocode-batch handler must clamp ?limit to the +// documented maximum (500) before invoking the geocoding service. We mock the +// service to capture the exact limit the route forwards. + +const geocodeUngeocodedClients = vi.fn(async () => ({ + totalRemaining: 0, + processed: 0, + geocoded: 0, + failed: 0, + remaining: 0, +})); + +vi.mock("../services/clientGeocoding.js", () => ({ + geocodeUngeocodedClients, + geocodeClient: vi.fn(), + resolveClientGeocodingProvider: vi.fn(), +})); + +vi.mock("@groombook/db", () => { + const tableProxy = (name: string) => + new Proxy( + { _name: name }, + { get: (_t, p) => (p === "_name" ? name : { table: name, column: p }) } + ); + return { + getDb: () => ({}), + clients: tableProxy("clients"), + appointments: tableProxy("appointments"), + and: vi.fn(), + eq: vi.fn(), + or: vi.fn(), + exists: vi.fn(), + }; +}); + +const { clientsRouter } = await import("../routes/clients.js"); + +const app = new Hono(); +app.route("/clients", clientsRouter); + +function postBatch(query: string) { + return app.request(`/clients/geocode-batch${query}`, { method: "POST" }); +} + +describe("POST /clients/geocode-batch — ?limit cap (GRO-2294)", () => { + beforeEach(() => { + geocodeUngeocodedClients.mockClear(); + }); + + it("defaults to 50 when no ?limit is supplied", async () => { + const res = await postBatch(""); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 50); + }); + + it("passes through a value within the cap", async () => { + const res = await postBatch("?limit=120"); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 120); + }); + + it("clamps an over-cap value to 500", async () => { + const res = await postBatch("?limit=100000"); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 500); + }); + + it("floors a fractional value before clamping", async () => { + const res = await postBatch("?limit=49.9"); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 49); + }); + + it("rejects a non-positive limit with 400", async () => { + const res = await postBatch("?limit=0"); + expect(res.status).toBe(400); + expect(geocodeUngeocodedClients).not.toHaveBeenCalled(); + }); + + it("rejects a non-numeric limit with 400", async () => { + const res = await postBatch("?limit=abc"); + expect(res.status).toBe(400); + expect(geocodeUngeocodedClients).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/settings.test.ts b/src/__tests__/settings.test.ts new file mode 100644 index 0000000..c878999 --- /dev/null +++ b/src/__tests__/settings.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mocks ────────────────────────────────────────────────────────────────── +// GRO-2294: GET /api/admin/settings must not return the encrypted +// googleMapsApiKey ciphertext, on either the existing-row or auto-create branch. + +let selectRows: Record[] = []; +let insertReturning: Record[] = []; + +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 passthrough + return target[prop]; + }, + }); + return chain; +} + +vi.mock("@groombook/db", () => { + const businessSettings = new Proxy( + { _name: "business_settings" }, + { get: (_t, p) => (p === "_name" ? "business_settings" : { column: p }) } + ); + return { + getDb: () => ({ + select: () => ({ from: () => makeChainable(selectRows) }), + insert: () => ({ + values: () => ({ returning: () => insertReturning }), + }), + }), + businessSettings, + eq: vi.fn(), + }; +}); + +vi.mock("../lib/s3.js", () => ({ + getPresignedUploadUrl: vi.fn(), + deleteObject: vi.fn(), + putObject: vi.fn(), + getObject: vi.fn(), +})); + +const { settingsRouter } = await import("../routes/settings.js"); + +const app = new Hono(); +app.route("/settings", settingsRouter); + +const FULL_ROW = { + id: "settings-uuid-1", + businessName: "GroomBook", + primaryColor: "#4f8a6f", + accentColor: "#8b7355", + routeOptimizationProvider: "google", + googleMapsApiKey: "ENCRYPTED::super-secret-ciphertext", + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe("GET /settings — googleMapsApiKey redaction (GRO-2294)", () => { + beforeEach(() => { + selectRows = []; + insertReturning = []; + }); + + it("omits googleMapsApiKey from an existing settings row", async () => { + selectRows = [{ ...FULL_ROW }]; + const res = await app.request("/settings", { method: "GET" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + // Non-secret fields are still returned. + expect(body.businessName).toBe("GroomBook"); + expect(body.routeOptimizationProvider).toBe("google"); + }); + + it("omits googleMapsApiKey from the auto-create branch", async () => { + selectRows = []; + insertReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }]; + const res = await app.request("/settings", { method: "GET" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + expect(body.id).toBe("settings-uuid-new"); + }); +}); diff --git a/src/routes/clients.ts b/src/routes/clients.ts index e7ac65c..328ed31 100644 --- a/src/routes/clients.ts +++ b/src/routes/clients.ts @@ -12,6 +12,12 @@ import { export const clientsRouter = new Hono(); +// Batch-geocode bounds (GRO-2294): default 50, hard cap 500. The cap bounds how +// long one synchronous request stays open and the per-request external API cost +// when routeOptimizationProvider = "google". +const GEOCODE_BATCH_DEFAULT_LIMIT = 50; +const GEOCODE_BATCH_MAX_LIMIT = 500; + type ClientRow = typeof clients.$inferSelect; /** @@ -185,12 +191,15 @@ clientsRouter.post("/:clientId/geocode", async (c) => { clientsRouter.post("/geocode-batch", async (c) => { const db = getDb(); const limitRaw = c.req.query("limit"); - let limit = 50; + let limit = GEOCODE_BATCH_DEFAULT_LIMIT; if (limitRaw !== undefined) { limit = Number(limitRaw); if (!Number.isFinite(limit) || limit <= 0) { return c.json({ error: "limit must be a positive integer" }, 400); } + // Clamp to the documented maximum to bound synchronous request duration + // and (for the Google provider) per-request external API cost. + limit = Math.min(Math.floor(limit), GEOCODE_BATCH_MAX_LIMIT); } const summary = await geocodeUngeocodedClients(db, limit); return c.json(summary); diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 3b931db..8529135 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -7,6 +7,17 @@ import { requireSuperUser } from "../middleware/rbac.js"; export const settingsRouter = new Hono(); +type BusinessSettingsRow = typeof businessSettings.$inferSelect; + +// Strip the encrypted googleMapsApiKey ciphertext from settings responses +// (GRO-2294, defense-in-depth). The secret is never needed client-side; it is +// only written via the dedicated provider-config endpoint. +function redactSettings(row: BusinessSettingsRow) { + const rest: Partial = { ...row }; + delete rest.googleMapsApiKey; + return rest; +} + // GET /api/admin/settings — return current business settings settingsRouter.get("/", async (c) => { const db = getDb(); @@ -14,9 +25,10 @@ settingsRouter.get("/", async (c) => { if (!row) { // Auto-create default settings if none exist const [created] = await db.insert(businessSettings).values({}).returning(); - return c.json(created); + if (!created) throw new Error("Failed to create default settings"); + return c.json(redactSettings(created)); } - return c.json(row); + return c.json(redactSettings(row)); }); const hexColorRegex = /^#[0-9a-fA-F]{6}$/; diff --git a/trigger-uat-1779751324.txt b/trigger-uat-1779751324.txt new file mode 100644 index 0000000..e69de29 From b4b48f7b50d8ef9f5129024b2b65bb51d2ead802 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 06:52:48 +0000 Subject: [PATCH 22/23] fix(GRO-2299): redact googleMapsApiKey from PATCH /api/admin/settings response (#195) --- UAT_PLAYBOOK.md | 2 +- src/__tests__/settings.test.ts | 54 ++++++++++++++++++++++++++++++++++ src/routes/settings.ts | 3 +- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 48082de..ecccc77 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -333,7 +333,7 @@ This means: | # | Scenario | Steps | Expected | |---|----------|-------|----------| | TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the projection (GRO-2294, defense-in-depth); non-secret fields (`businessName`, colors, `routeOptimizationProvider`, etc.) are still present | -| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated | +| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the PATCH response symmetrically with the GET projection (GRO-2299, defense-in-depth); non-secret updated fields are still returned | | TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored | | TC-API-13.4 | View logo | GET /api/admin/settings/logo | 200 OK, logo image returned | | TC-API-13.5 | Delete logo | DELETE /api/admin/settings/logo | 200 OK, logo removed | diff --git a/src/__tests__/settings.test.ts b/src/__tests__/settings.test.ts index c878999..5cdccca 100644 --- a/src/__tests__/settings.test.ts +++ b/src/__tests__/settings.test.ts @@ -7,6 +7,7 @@ import { Hono } from "hono"; let selectRows: Record[] = []; let insertReturning: Record[] = []; +let updateReturning: Record[] = []; function makeChainable(data: unknown[]): unknown { const arr = [...data]; @@ -33,6 +34,9 @@ vi.mock("@groombook/db", () => { insert: () => ({ values: () => ({ returning: () => insertReturning }), }), + update: () => ({ + set: () => ({ where: () => ({ returning: () => updateReturning }) }), + }), }), businessSettings, eq: vi.fn(), @@ -51,6 +55,17 @@ const { settingsRouter } = await import("../routes/settings.js"); const app = new Hono(); app.route("/settings", settingsRouter); +// PATCH /settings is guarded by requireSuperUser(), which reads the staff record +// from context. Inject a super-user staff row so the handler runs. +const patchApp = new Hono<{ + Variables: { staff: { id: string; isSuperUser: boolean } }; +}>(); +patchApp.use("*", async (c, next) => { + c.set("staff", { id: "staff-1", isSuperUser: true }); + await next(); +}); +patchApp.route("/settings", settingsRouter); + const FULL_ROW = { id: "settings-uuid-1", businessName: "GroomBook", @@ -89,3 +104,42 @@ describe("GET /settings — googleMapsApiKey redaction (GRO-2294)", () => { expect(body.id).toBe("settings-uuid-new"); }); }); + +describe("PATCH /settings — googleMapsApiKey redaction (GRO-2299)", () => { + beforeEach(() => { + selectRows = []; + insertReturning = []; + updateReturning = []; + }); + + function patchRequest(body: Record) { + return patchApp.request("/settings", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("omits googleMapsApiKey from the PATCH response", async () => { + selectRows = [{ ...FULL_ROW }]; + updateReturning = [{ ...FULL_ROW, businessName: "Updated Name" }]; + const res = await patchRequest({ businessName: "Updated Name" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + // Non-secret updated fields are still returned. + expect(body.businessName).toBe("Updated Name"); + expect(body.routeOptimizationProvider).toBe("google"); + }); + + it("omits googleMapsApiKey on the auto-create-then-update branch", async () => { + selectRows = []; + insertReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }]; + updateReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }]; + const res = await patchRequest({ primaryColor: "#123456" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + expect(body.id).toBe("settings-uuid-new"); + }); +}); diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 8529135..bcb4476 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -65,7 +65,8 @@ settingsRouter.patch( .where(eq(businessSettings.id, settingsId)) .returning(); - return c.json(updated); + if (!updated) throw new Error("Failed to update settings"); + return c.json(redactSettings(updated)); } ); From 1e0747324d5c1c74033d0a1323d6f472573c0bc2 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 08:44:58 +0000 Subject: [PATCH 23/23] =?UTF-8?q?fix(GRO-2139):=20serialize=20reset?= =?UTF-8?q?=E2=86=92migrate=E2=86=92seed=20under=20the=20seed=20advisory?= =?UTF-8?q?=20lock=20(#160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serialize the entire db:reset chain (DROP → migrate → seed) inside one withSeedAdvisoryLock callback so a concurrent same-PRNG seeder cannot interleave and collide on invoices_pkey. Pool sized max:6 (1 reserved for the lock + work headroom) to avoid the connection-starvation deadlock the CTO caught. Verified with three end-to-end live db:reset runs against a throwaway Postgres. cc @cpfarhood --- packages/db/package.json | 2 +- packages/db/src/reset.ts | 153 +++++++++++++++++++++++++++++---------- packages/db/src/seed.ts | 14 ++-- 3 files changed, 123 insertions(+), 46 deletions(-) diff --git a/packages/db/package.json b/packages/db/package.json index 7f97370..cb3811f 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -21,7 +21,7 @@ "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", + "reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts", "studio": "drizzle-kit studio", "typecheck": "tsc --noEmit" }, diff --git a/packages/db/src/reset.ts b/packages/db/src/reset.ts index 41c3ce8..fb88e20 100644 --- a/packages/db/src/reset.ts +++ b/packages/db/src/reset.ts @@ -1,13 +1,52 @@ /** - * reset.ts — Drop all application tables and re-run migrations + seed. + * reset.ts — Drop all application tables, re-run migrations, and re-seed. * * Intended for local development only. Never run against production. * * Usage: * DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts + * + * GRO-2139: the entire drop→migrate→seed chain runs inside a single + * Postgres advisory lock (SEED_ADVISORY_LOCK_KEY) so a concurrent + * `seed.ts` (e.g. the dev `seed-test-data-*` Job being recreated at + * the top of the hour) cannot interleave between `reset.ts` (DROP) + * and `seed.ts` (TRUNCATE+insert) and collide on `invoices_pkey`. + * + * Why this matters: `seed.ts` derives every primary key from a single + * shared Mulberry32 PRNG seeded with 42 (see `createPrng(42)` and + * `uuid()` in seed.ts). Two concurrent same-profile seeders therefore + * emit *identical* ids for the same logical row, and any moment + * between a concurrent `seed.ts` TRUNCATE and INSERT is exactly the + * window in which the second seeder's INSERT can hit a pkey already + * taken by the first. Pre-GRO-2123 this raced unconditionally; + * GRO-2123 added the advisory lock around `runSeedBody` but left + * `reset.ts` and `drizzle-kit migrate` outside the lock. This script + * now wraps the *whole* chain in the same lock: `withSeedAdvisoryLock` + * pins the lock to one reserved session and the DROP → migrate → seed + * work runs on the rest of the pool, so the lock guarantees mutual + * exclusion against any concurrent seeder for the entire chain. + * + * See: groombook/infra `apps/base/reset-cronjob.yaml` (CronJob) and + * `apps/base/seed-job.yaml` (one-shot Job) — both invoke the same + * `seed.ts` code path on the same database in `groombook-dev`. */ - import postgres from "postgres"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import * as schema from "./schema.js"; +import { + SEED_ADVISORY_LOCK_KEY, + withSeedAdvisoryLock, + getProfile, + runSeedBody, + profiles, +} from "./seed.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const MIGRATIONS_FOLDER = resolve(__dirname, "../migrations"); async function reset() { const url = process.env.DATABASE_URL; @@ -16,52 +55,88 @@ async function reset() { process.exit(1); } - if (process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true") { - console.error("[FATAL] db:reset must not be run in production without ALLOW_RESET=true."); + if ( + process.env.NODE_ENV === "production" && + process.env.ALLOW_RESET !== "true" + ) { + console.error( + "[FATAL] db:reset must not be run in production without ALLOW_RESET=true.", + ); process.exit(1); } - const client = postgres(url, { max: 1 }); + // Pool sizing is load-bearing here. `withSeedAdvisoryLock` does + // `pool.reserve()` to pin the advisory lock to one dedicated session + // (a session-level lock released on a *different* pooled connection is + // a no-op), and the DROP / migrate / seed work then runs on the + // *remaining* pooled connections. The lock provides mutual exclusion + // across processes regardless of how many connections the work uses — + // it does NOT require the work to share the lock's session. + // + // Therefore `max` must be ≥ 2: 1 reserved for the lock + ≥1 free for + // the work. `max: 1` would let `reserve()` consume the only connection + // and every query inside the callback would block forever waiting for + // a connection that never frees (connection-starvation deadlock). We + // use `max: 6` to match `seed()`'s headroom (1 reserved + 5 work). + const client = postgres(url, { max: 6 }); + const db = drizzle(client, { schema }); - console.log("Dropping all application tables...\n"); + try { + await withSeedAdvisoryLock(client, async () => { + console.log("Dropping all application tables...\n"); - // Drop in dependency order (children before parents) - await client` - DO $$ DECLARE - r RECORD; - BEGIN - FOR r IN ( - SELECT tablename FROM pg_tables - WHERE schemaname = 'public' - ) LOOP - EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; - END LOOP; - END $$; - `; + // Drop dependencies (tables) first + await client` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN ( + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + ) LOOP + EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; + END LOOP; + END $$; + `; - // Drop custom enums - await client` - DO $$ DECLARE - r RECORD; - BEGIN - FOR r IN ( - SELECT typname FROM pg_type - WHERE typtype = 'e' AND typnamespace = ( - SELECT oid FROM pg_namespace WHERE nspname = 'public' - ) - ) LOOP - EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE'; - END LOOP; - END $$; - `; + // Drop custom enums + await client` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN ( + SELECT typname FROM pg_type + WHERE typtype = 'e' AND typnamespace = ( + SELECT oid FROM pg_namespace WHERE nspname = 'public' + ) + ) LOOP + EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE'; + END LOOP; + END $$; + `; - // Drop the drizzle migrations tracking table - await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`; - await client`DROP SCHEMA IF EXISTS drizzle CASCADE`; + // Drop the drizzle migrations tracking table + await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`; + await client`DROP SCHEMA IF EXISTS drizzle CASCADE`; - console.log("✓ All tables and enums dropped\n"); + console.log("✓ All tables and enums dropped\n"); - await client.end(); + console.log("Running migrations..."); + await migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); + console.log("✓ Migrations applied\n"); + + console.log("Seeding database..."); + const profile = getProfile(); + const cfg = profiles[profile]; + await runSeedBody(client, db, profile, cfg); + }); + + console.log( + `\n✓ Reset complete (advisory lock key=0x${SEED_ADVISORY_LOCK_KEY.toString(16)})`, + ); + } finally { + await client.end(); + } } reset().catch((err) => { diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 55b2ee4..b519c04 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -24,9 +24,9 @@ import type { MedicalAlert } from "@groombook/types"; // ── Seed profile configuration ───────────────────────────────────────────── -type SeedProfile = "dev" | "uat" | "demo"; +export type SeedProfile = "dev" | "uat" | "demo"; -interface ProfileConfig { +export interface ProfileConfig { staffCount: { manager: number; receptionist: number; groomer: number; bather: number }; clientCount: number; appointmentsBackDays: number; @@ -35,7 +35,7 @@ interface ProfileConfig { includeUatClients: boolean; } -const profiles: Record = { +export const profiles: Record = { dev: { staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 }, clientCount: 100, @@ -70,6 +70,8 @@ function getProfile(): SeedProfile { return "uat"; } +export { getProfile }; + // ── Deterministic PRNG (Mulberry32) ────────────────────────────────────────── /** @@ -1194,7 +1196,7 @@ async function seedKnownUsers() { // from runbooks without ambiguity and binds to the single-argument // `pg_advisory_lock(int)` form, which postgres-js serializes as a plain // number (no bigint type plumbing required). -const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable +export const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable /** * Reserve a dedicated connection from `pool`, take the seed advisory lock @@ -1207,7 +1209,7 @@ const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, sta * for the lock and release it from the same reserved connection. The * seed work itself still runs on the pooled connections. */ -async function withSeedAdvisoryLock( +export async function withSeedAdvisoryLock( pool: ReturnType, fn: () => Promise, ): Promise { @@ -1265,7 +1267,7 @@ async function seed() { await client.end(); } -async function runSeedBody( +export async function runSeedBody( client: ReturnType, db: ReturnType, profile: SeedProfile,