Compare commits

..

142 Commits

Author SHA1 Message Date
Flea Flicker c737bfece9 fix(GRO-2342): portal waitlist card populates service {id, name}
CI / Test (pull_request) Successful in 25s
CI / Lint & Typecheck (pull_request) Successful in 30s
CI / Build & Push Docker Images (pull_request) Successful in 1m18s
Cosmetic follow-up to GRO-2319 (Phase 4 review by CTO). The synthetic
waitlist card on GET /portal/appointments returned service: {id} only,
so the portal fell back to the literal 'Service' label. CMPO spec did
not call for a service name on the waitlist card, but populating the
real name is non-urgent and closes the cosmetic gap.

- src/routes/portal.ts: include a services SELECT (in addition to
  pets and staff) covering both appointment and waitlist serviceIds.
  serviceMap feeds a service.name lookup. The synthetic waitlist
  card's service object is now {id, name} — same shape the
  appointments join returns — so the portal renders the real name.
  The appointments join also gains a name (consistent shape, no
  regression for the existing path).
- src/__tests__/portal.test.ts: mock the services table and assert
  service: {id, name} on both the synthetic waitlist card and the
  appointment card.
- UAT_PLAYBOOK.md: TC-API-8.20 covering the waitlist card service
  name (TC-API-8.19 retained verbatim for the original GRO-2319
  surfacing contract).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-10 10:27:09 +00:00
Flea Flicker 47e2021cf4 Promote uat → main (PROD): GRO-2319 portal waitlist surfacing + seed (#207)
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 26s
CI / Build & Push Docker Images (push) Successful in 41s
Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
2026-06-10 08:58:26 +00:00
Flea Flicker 31404befee uat→main (PROD): GRO-2311 seed portal StatusBadge appointments (frozen @df5e768) (#206)
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Images (push) Successful in 31s
uat→main (PROD): GRO-2311 seed portal StatusBadge appointments (squash)

Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
2026-06-09 11:18:03 +00:00
Scrubs McBarkley a9db0ca9ac uat→main (PROD): GRO-2172 pet extended-field schema fix (frozen @c4385617) (#203)
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Images (push) Successful in 1m7s
Phase-4 promotion. CTO-approved, CI 3/3 green. Single-file diff (src/routes/pets.ts +26/-2), frozen @c4385617. GRO-2311 excluded.
2026-06-09 10:52:37 +00:00
Flea Flicker 4bbb0c9fc5 uat→main (PROD): GRO-2172 pet extended-field schema fix (frozen @c4385617)
CI / Test (pull_request) Successful in 30s
CI / Lint & Typecheck (pull_request) Successful in 34s
CI / Build & Push Docker Images (pull_request) Successful in 1m21s
Promote GRO-2172 from uat to main. Pins src/routes/pets.ts to its exact
content at uat merge commit c4385617 (PR #200), adding the extended pet
profile fields to createPetSchema/updatePetSchema and wiring medicalAlerts
into POST/PATCH /pets:

- temperamentScore: int 1–5
- temperamentFlags: string[] (≤20, each ≤100 chars)
- medicalAlerts: {type,description,severity}[] (≤50)
- preferredCuts: string[] (≤20, each ≤200 chars)
- coatType already present on main; schema now references all 5 fields

Based on main HEAD (03f79a37) so the PR diff is limited to src/routes/pets.ts.
GRO-2311 (uat HEAD 807ccb45) is intentionally excluded.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-09 10:19:25 +00:00
Flea Flicker 03f79a3701 uat → main: GRO-2299 redact googleMapsApiKey from PATCH /api/admin/settings (#198)
CI / Test (push) Successful in 27s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Images (push) Successful in 30s
GRO-2299: redact googleMapsApiKey from PATCH /api/admin/settings response
Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
2026-06-09 07:49:49 +00:00
Flea Flicker 2b92c2ab6c uat→main (PROD): GRO-2294 Route Optimization security hardening (frozen @2566fb8) (#197)
CI / Lint & Typecheck (push) Successful in 30s
CI / Test (push) Failing after 11m41s
CI / Build & Push Docker Images (push) Has been skipped
feat(security): GRO-2294 Route Optimization security hardening [squash]

Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
2026-06-09 07:38:02 +00:00
Flea Flicker e9ad92de01 uat→main (PROD): GRO-2157 nav export + GRO-2225/2235 (frozen @4868f18) (#192)
CI / Test (push) Successful in 28s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Images (push) Successful in 28s
feat: nav export + conflict guard + UAT seed (GRO-2157, GRO-2225, GRO-2235)

Squash-merges PR #192: uat→main PROD promotion.
Freezes at validated SHA 4868f18 (UAT regression GRO-2261 11/11 PASS).
Bundles: GRO-2157 (nav export), GRO-2225 (UAT seed), GRO-2235 (conflict guard).
CTO-reviewed and approved (review #4542).

Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
2026-06-09 01:23:06 +00:00
Flea Flicker bfe1a29c08 Merge pull request 'uat→main (PROD): GRO-2234 portal session fix + validated batch' (#191) from flea/uat-to-main-gro-2234-api into main
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Images (push) Successful in 28s
2026-06-09 00:37:35 +00:00
Scrubs McBarkley 1ad43ce701 Merge pull request 'promote(uat→main FROZEN @6120b96): + GRO-2156 route buffer/reorder (supersedes #185)' (#186) from release/main-6120b96 into main
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Images (push) Successful in 1m19s
promote(uat→main FROZEN @6120b96): GRO-2214+GRO-2211+GRO-2203+GRO-2155+GRO-2163+GRO-2156

CTO-reviewed, CEO-merged per SDLC Phase 4 governance.
Carries: GRO-2214 waitlist validation, GRO-2211, GRO-2203 pet PATCH, GRO-2155+GRO-2163 route optimization, GRO-2156 route buffer/reorder.
All gates passed: QA, Security, UAT 6/6 PASS.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-08 23:29:58 +00:00
Flea Flicker 96dbb8c41d Merge pull request 'Promote dev → uat: GRO-2155/2156/2203/2211/2163 + GRO-2234 (cumulative batch)' (#182) from flea/dev-to-uat-gro-2156 into uat
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Images (push) Successful in 1m24s
CI / Test (pull_request) Successful in 27s
CI / Lint & Typecheck (pull_request) Successful in 30s
CI / Build & Push Docker Images (pull_request) Successful in 1m11s
2026-06-08 19:42:25 +00:00
Flea Flicker 636fa713e1 Merge dev into uat: add GRO-2234 portal session sliding TTL + re-mint to dev→uat batch
CI / Test (pull_request) Successful in 28s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Images (pull_request) Successful in 27s
2026-06-08 19:17:15 +00:00
Flea Flicker aabedc8152 fix(GRO-2234): bounded sliding expiration for SSO portal sessions (#183)
CI / Test (push) Successful in 28s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Images (push) Successful in 38s
2026-06-08 18:55:43 +00:00
Flea Flicker 6120b96c7c Merge dev into uat: promote GRO-2156 route travel buffer + reorder (Phase 2.2)
CI / Test (pull_request) Successful in 26s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Images (pull_request) Successful in 1m2s
Resolves UAT_PLAYBOOK.md conflict by unioning uat-only TC-UAT-2/3 (GRO-2100)
with dev's §4.16 update + new §4.17. Code files taken from dev (superset).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-08 18:11:05 +00:00
Flea Flicker ca62fb8ef6 feat(GRO-2156): travel buffer + reorder endpoint (Phase 2.2) (#180)
CI / Test (push) Successful in 27s
CI / Lint & Typecheck (push) Successful in 30s
CI / Lint & Typecheck (pull_request) Successful in 25s
CI / Test (pull_request) Successful in 24s
CI / Build & Push Docker Images (push) Successful in 43s
CI / Build & Push Docker Images (pull_request) Successful in 27s
2026-06-08 18:07:54 +00:00
Flea Flicker eb92f99c4a dev → uat: GRO-2203 portal pet PATCH malformed-petId 500→404 (#178)
CI / Test (push) Successful in 27s
CI / Lint & Typecheck (push) Successful in 32s
CI / Build & Push Docker Images (push) Successful in 1m1s
CI / Test (pull_request) Successful in 27s
CI / Lint & Typecheck (pull_request) Successful in 31s
CI / Build & Push Docker Images (pull_request) Successful in 1m4s
2026-06-08 17:53:01 +00:00
Flea Flicker 29c42e3130 fix(portal): validate waitlist preferredTime/preferredDate, return 400 on bad input (GRO-2211) (#179)
CI / Test (pull_request) Successful in 26s
CI / Test (push) Successful in 29s
CI / Lint & Typecheck (pull_request) Successful in 31s
CI / Lint & Typecheck (push) Successful in 34s
CI / Build & Push Docker Images (pull_request) Failing after 13s
CI / Build & Push Docker Images (push) Successful in 48s
2026-06-08 17:19:39 +00:00
Flea Flicker b842237425 fix(portal): GRO-2203 validate petId as UUID before PATCH lookup (500→404) (#177)
CI / Lint & Typecheck (push) Successful in 29s
CI / Test (push) Successful in 29s
CI / Lint & Typecheck (pull_request) Failing after 2s
CI / Test (pull_request) Successful in 25s
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Build & Push Docker Images (push) Successful in 47s
2026-06-08 17:03:44 +00:00
Flea Flicker 587fd4ec95 dev → uat: GRO-2155 route optimization endpoints (carries GRO-2163) (#176)
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Images (push) Successful in 25s
2026-06-08 16:45:44 +00:00
Flea Flicker d0c0b1b646 feat(GRO-2155): route CRUD + optimization endpoint (Phase 2.1) (#175)
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (push) Successful in 28s
CI / Test (pull_request) Successful in 24s
CI / Build & Push Docker Images (push) Successful in 35s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Images (pull_request) Successful in 25s
2026-06-08 13:57:07 +00:00
Flea Flicker b9fc688769 fix(db): wait for/retry DB DNS resolution before drizzle-kit migrate (GRO-2163) (#161)
CI / Test (push) Successful in 28s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Images (push) Successful in 47s
2026-06-08 13:37:30 +00:00
Flea Flicker 6e2e46daf8 Merge uat → main: portal pet PATCH + photoKey S3 key-hijack fix (GRO-2187) (#174)
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (push) Successful in 28s
CI / Build & Push Docker Images (push) Successful in 40s
2026-06-08 13:25:46 +00:00
Flea Flicker 8cf72d926d dev → uat: portal photoKey S3 key-hijack fix (GRO-2187/GRO-2198) (#173)
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Images (push) Successful in 43s
CI / Test (pull_request) Successful in 27s
CI / Lint & Typecheck (pull_request) Successful in 32s
CI / Build & Push Docker Images (pull_request) Successful in 39s
2026-06-08 12:39:52 +00:00
Flea Flicker 14d7889ec0 fix(portal): drop writable photoKey from PATCH /portal/pets — S3 key-hijack (GRO-2187/GRO-2198) (#172)
CI / Test (push) Successful in 24s
CI / Lint & Typecheck (push) Successful in 26s
CI / Build & Push Docker Images (push) Successful in 29s
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Test (pull_request) Successful in 30s
CI / Build & Push Docker Images (pull_request) Successful in 44s
2026-06-08 12:39:02 +00:00
Flea Flicker 8721f0b63c dev → uat: GRO-2154 geocoding endpoints (Phase 1.3) (#171)
CI / Test (push) Successful in 24s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Images (push) Successful in 35s
2026-06-08 12:06:43 +00:00
Flea Flicker 582c376df9 feat(GRO-2154): geocoding endpoints + auto-geocode on client mutations (#170)
CI / Test (push) Successful in 28s
CI / Test (pull_request) Successful in 23s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Images (pull_request) Successful in 25s
CI / Lint & Typecheck (push) Failing after 14m33s
CI / Build & Push Docker Images (push) Has been skipped
2026-06-08 11:45:08 +00:00
Flea Flicker eec198a661 fix(ci): GRO-2197 api lint/typecheck/test run root scripts (de-false-green) (#169)
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Images (push) Successful in 3m23s
2026-06-08 11:09:33 +00:00
Flea Flicker 027e012a58 Merge pull request 'dev → uat: GRO-2153 abstracted geocoding service' (#168) from dev-to-uat-gro-2153 into uat
CI / Test (push) Successful in 1m5s
CI / Lint & Typecheck (push) Successful in 43m29s
CI / Build & Push Docker Images (push) Successful in 1m7s
2026-06-08 10:51:17 +00:00
Flea Flicker b3db206588 Merge pull request 'dev → uat: GRO-2187 portal pet PATCH + GET enrichment (carries GRO-2152)' (#166) from dev-to-uat-gro-2187 into uat
CI / Test (push) Successful in 1m19s
CI / Lint & Typecheck (push) Successful in 1m25s
CI / Build & Push Docker Images (push) Successful in 3m58s
2026-06-08 10:02:17 +00:00
Flea Flicker 04b235c861 Merge pull request 'feat(GRO-2153): abstracted geocoding service (Nominatim + Google)' (#167) from feat/gro-2153-geocoding-service-dev into dev
CI / Test (push) Failing after 13m50s
CI / Lint & Typecheck (push) Failing after 13m50s
CI / Build & Push Docker Images (push) Has been skipped
CI / Test (pull_request) Successful in 11s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Images (pull_request) Successful in 3m45s
2026-06-08 09:40:52 +00:00
Flea Flicker 21fb1b30d2 ci: retrigger build (registry layer-pull hang on prior run)
CI / Test (pull_request) Failing after 14m1s
CI / Lint & Typecheck (pull_request) Failing after 14m1s
CI / Build & Push Docker Images (pull_request) Has been skipped
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-08 09:40:42 +00:00
Flea Flicker 2fa6e3d87b feat(GRO-2153): abstracted geocoding service (Nominatim + Google)
CI / Test (pull_request) Successful in 13s
CI / Lint & Typecheck (pull_request) Successful in 20s
CI / Build & Push Docker Images (pull_request) Failing after 27m22s
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 <noreply@paperclip.ing>
2026-06-08 09:01:36 +00:00
Flea Flicker 6be78cae35 fix(portal): implement PATCH /portal/pets/:petId + enrich GET (GRO-2187) (#165)
CI / Test (push) Failing after 3s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Images (push) Has been skipped
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 41s
2026-06-08 08:18:13 +00:00
Flea Flicker 40bd6dcfea Merge pull request 'feat(GRO-2152): route optimization schema migration' (#164) from feat/gro-2152-route-optimization-schema-dev into dev
CI / Test (push) Failing after 4s
CI / Lint & Typecheck (push) Successful in 15s
CI / Build & Push Docker Images (push) Has been skipped
2026-06-08 07:55:35 +00:00
Flea Flicker 4884961c8e feat(GRO-2152): route optimization schema migration
CI / Test (pull_request) Successful in 53s
CI / Lint & Typecheck (pull_request) Successful in 1m0s
CI / Build & Push Docker Images (pull_request) Successful in 4m13s
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 <noreply@anthropic.com>
2026-06-08 07:48:10 +00:00
Flea Flicker fc072d51f4 Merge pull request 'promote(uat→main): GRO-2123 seed advisory lock + GRO-2100 uat-groomer linkage ordering' (#157) from uat into main
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 17s
CI / Build & Push Docker Images (push) Successful in 41s
2026-06-04 12:53:06 +00:00
Flea Flicker 6538406db2 Merge pull request 'chore: delete stale apps/api/src/db/seed.ts duplicate (GRO-2129)' (#158) from dev into uat
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Images (push) Successful in 38s
CI / Test (pull_request) Successful in 22s
CI / Lint & Typecheck (pull_request) Successful in 25s
CI / Build & Push Docker Images (pull_request) Successful in 38s
2026-06-04 12:45:24 +00:00
Flea Flicker 93be4d8f72 chore: delete stale apps/api/src/db/seed.ts duplicate (GRO-2129) (#158)
CI / Test (push) Successful in 15s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Images (push) Successful in 38s
chore: delete stale apps/api/src/db/seed.ts duplicate (GRO-2129) (#158)
2026-06-04 12:44:46 +00:00
Flea Flicker e2eacbc9fe Merge pull request 'dev → uat: GRO-2123 seed advisory lock' (#156) from dev-to-uat-gro-2123 into uat
CI / Test (push) Successful in 16s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Images (push) Successful in 40s
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 39s
2026-06-04 11:32:06 +00:00
Flea Flicker f67b96ddfe Merge pull request 'fix(GRO-2123): serialize seed.ts with Postgres advisory lock' (#155) from flea-flicker/gro-2123-seed-advisory-lock into dev
CI / Test (push) Successful in 11s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Images (push) Successful in 25s
CI / Test (pull_request) Successful in 10s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Images (pull_request) Successful in 28s
2026-06-04 11:23:41 +00:00
Flea Flicker d1a68d93de fix(GRO-2123): serialize seed.ts with Postgres advisory lock
CI / Test (pull_request) Successful in 13s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 58s
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 <noreply@paperclip.ing>
2026-06-04 11:12:17 +00:00
Flea Flicker e639cc82d1 chore(uat): GRO-2100 promote uat-groomer seed-linkage ordering fix to uat (#154)
CI / Test (push) Successful in 16s
CI / Lint & Typecheck (push) Successful in 19s
CI / Build & Push Docker Images (push) Successful in 27s
Co-authored-by: Flea Flicker <flea@groombook.dev>
Co-committed-by: Flea Flicker <flea@groombook.dev>
2026-06-02 20:23:54 +00:00
Flea Flicker e9f94a2bd7 fix(seed): GRO-2100 run uat-groomer linkage AFTER services seed (regression in #151) (#153)
CI / Test (push) Successful in 12s
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 29s
CI / Lint & Typecheck (push) Failing after 12m57s
CI / Build & Push Docker Images (push) Has been skipped
fix(seed): GRO-2100 run uat-groomer linkage after services seed (#153)

Co-authored-by: Flea Flicker <flea@groombook.dev>
Co-committed-by: Flea Flicker <flea@groombook.dev>
2026-06-02 20:11:45 +00:00
Flea Flicker f2931d7be2 Merge pull request 'Promote dev→uat: GRO-2100 uat-groomer ↔ UAT Pup Alpha linkage' (#152) from promote/dev-to-uat-gro-2100 into uat
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Images (push) Successful in 26s
Merge pull request #152 from groombook/promote/dev-to-uat-gro-2100

Promote dev→uat: GRO-2100 uat-groomer ↔ UAT Pup Alpha linkage
2026-06-02 19:11:46 +00:00
Paperclip d4a4ddce37 ci: retrigger GRO-2100 PR #152 Build & Push Docker Images (Reset image build failed — docker registry flake)
CI / Test (pull_request) Successful in 13s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Images (pull_request) Successful in 40s
2026-06-02 18:28:17 +00:00
Paperclip bd384bdf5c docs(UAT_PLAYBOOK): add TC-UAT-2/3 for uat-groomer linked/unlinked pet profile-summary (GRO-2100)
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Test (pull_request) Successful in 2m20s
CI / Build & Push Docker Images (pull_request) Failing after 36s
Lint Roller review on PR #152 flagged that the GRO-2100 seed change produces
new observable UAT API behavior that the playbook must reflect. Add two
deterministic rows pinning the contract GRO-1987 TC-UAT-2/3 will exercise:

- TC-UAT-2: uat-groomer + linked pet c0000001-...-002 (UAT Pup Alpha) → 200
- TC-UAT-3: uat-groomer + unlinked pet c0000001-...-003 (UAT Pup Beta) → 403

The 403-vs-404 note in TC-UAT-3 mirrors the verification note in the
GRO-2100 issue body so the QA runner knows where to file if the API
returns 404 (a separate RBAC defect, not against the seed).
2026-06-02 18:24:40 +00:00
Flea Flicker de16c50040 fix(seed): GRO-2100 deterministic uat-groomer ↔ UAT Pup Alpha linkage (#151)
CI / Test (pull_request) Successful in 13s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Build & Push Docker Images (pull_request) Successful in 45s
CI / Test (push) Successful in 2m20s
CI / Lint & Typecheck (push) Successful in 2m25s
CI / Build & Push Docker Images (push) Successful in 28s
2026-06-02 18:09:31 +00:00
Scrubs McBarkley c92fb2539d promote(uat→main): owner-bypass audit fix (GRO-2062) + services seed-idempotency fix (GRO-2064)
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Images (push) Successful in 25s
2026-06-02 06:00:02 +00:00
The Dogfather 411c42b2c4 Merge pull request 'Promote dev→uat: GRO-2033 services_pkey seed fix (fc6c6ef7)' (#149) from dev into uat
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Images (push) Successful in 39s
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Images (pull_request) Successful in 38s
2026-06-02 05:06:34 +00:00
Flea Flicker fc6c6ef752 fix(db): make services seed idempotent across resets (GRO-2064, GRO-2033 close-out) (#148)
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 28s
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 20s
CI / Build & Push Docker Images (pull_request) Successful in 39s
2026-06-02 04:54:33 +00:00
The Dogfather bf97849324 promote(dev→uat): owner-bypass read audit row (GRO-2063) (#147)
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 17s
CI / Build & Push Docker Images (push) Successful in 41s
Promote GRO-2063 defense-in-depth audit row to uat. CI green. QA + CTO approved on dev PR #146.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 04:21:43 +00:00
The Dogfather 1a6a54cc84 security(audit): log owner-bypass reads in GET /pets/:id/profile-summary (GRO-2062) (#146)
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 16s
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 40s
CI / Build & Push Docker Images (pull_request) Successful in 27s
QA-approved (gb_lint) + CTO-approved. Defense-in-depth audit row on staff owner-bypass. GRO-2063.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 04:20:23 +00:00
Flea Flicker 1f888ac716 security(audit): log owner-bypass reads in GET /pets/:id/profile-summary (GRO-2062)
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 1m16s
Adds a defense-in-depth audit row to impersonationAuditLogs when the
staff-side owner-bypass path fires. Mirrors the failure-isolation
pattern in src/middleware/portalAudit.ts: insert failures are logged
and swallowed so a working read can never turn into a 500.

- New writeOwnerBypassAudit helper called only when isOwner === true.
- No DB migration; petId + actorStaffId go inside metadata jsonb.
- resolveImpersonationClientId stays pure (no audit side effects).
- Positive + negative tests + a cross-tenant regression test.
- UAT_PLAYBOOK.md §3.19d: TC-API-3.19d documents the audit assertion.

Parent tracking: GRO-2062 (Paperclip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 04:10:58 +00:00
Scrubs McBarkley 2a6242d3de Merge pull request 'promote(main): GRO-2033 prod migration fix + GRO-2013/2014 + rbac auto-provision (uat→main)' (#145) from uat into main
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Images (push) Successful in 30s
promote(main): GRO-2033 prod migration fix + GRO-2013/2014 + rbac auto-provision (uat→main)

CI green. UAT regression GRO-2035 PASS. Migrations 0039/0040 idempotent — signed off by CEO.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 03:22:21 +00:00
The Dogfather 7181d41b24 Merge pull request 'Promote dev→uat: rbac Better-Auth auto-provision (GRO-2052)' (#144) from dev into uat
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 15s
CI / Build & Push Docker Images (push) Failing after 13s
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 41s
Promote dev→uat: rbac Better-Auth auto-provision (GRO-2052)

Makes the pets.ts owner-bypass reachable for Better-Auth email/password customers by auto-provisioning a groomer staff row keyed on user.id. Unblocks GRO-2050 and GRO-2035.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 02:42:19 +00:00
Flea Flicker 91eb2ccf71 fix(rbac): port Better-Auth user auto-provision into legacy ./src tree (GRO-2052) (#143)
CI / Test (push) Successful in 11s
CI / Lint & Typecheck (push) Successful in 15s
CI / Test (pull_request) Successful in 9s
CI / Lint & Typecheck (pull_request) Successful in 14s
CI / Build & Push Docker Images (push) Successful in 36s
CI / Build & Push Docker Images (pull_request) Successful in 26s
fix(rbac): port Better-Auth user auto-provision into legacy ./src tree (GRO-2052)

Ports the Better-Auth user-table auto-provision branch from canonical apps/api into the deployed ./src/middleware/rbac.ts so the owner-bypass in pets.ts is reachable for Better-Auth email/password customers. OIDC account branch retained as backward-compat fallback. Adds 5 rbac.test.ts cases and UAT_PLAYBOOK pre-condition docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Flea Flicker <flea@groombook.dev>
Co-committed-by: Flea Flicker <flea@groombook.dev>
2026-06-02 02:40:43 +00:00
The Dogfather 4e9c4c5e08 Merge pull request 'promote(uat): GRO-2013 owner-bypass + GRO-2033 idempotent migrations (dev→uat)' (#142) from dogfather/gro-2013-promote-uat into uat
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Images (push) Successful in 39s
2026-06-01 20:14:14 +00:00
The Dogfather 16c959434b promote(uat): GRO-2013 owner-bypass + GRO-2033 idempotent migrations (dev→uat)
CI / Test (pull_request) Successful in 11s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Images (pull_request) Successful in 41s
Merge dev into uat. Resolves test-file/playbook conflicts created by PR #138's
squash merge by taking dev's superset versions (verified: all GRO-2014 tests +
TC ids preserved, plus GRO-2013 additions). No-ff merge so dev becomes an
ancestor of uat, preventing future squash-divergence conflicts.

Carries:
- GRO-2013 deployed-tree owner-bypass (src/routes/pets.ts, reconciled 20-test file)
- GRO-2033 idempotent migrations 0039/0040

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 20:10:51 +00:00
The Dogfather a2b09ba502 fix(pets): port owner-bypass into deployed tree (GRO-2013) (#139)
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Images (push) Successful in 1m5s
CI / Test (pull_request) Successful in 16s
CI / Lint & Typecheck (pull_request) Successful in 2m25s
CI / Build & Push Docker Images (pull_request) Failing after 32s
2026-06-01 20:06:24 +00:00
Flea Flicker 4322fb2a00 Merge pull request 'fix(db): re-register 0034/0036 schema changes via idempotent 0039/0040 (GRO-2033)' (#140) from flea/gro-2033-idempotent-pet-profile-migrations into dev
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Failing after 14m2s
CI / Build & Push Docker Images (push) Has been skipped
Merge PR #140: fix(db): re-register 0034/0036 schema changes via idempotent 0039/0040 (GRO-2033)
2026-06-01 20:00:41 +00:00
Paperclip 27accb9b39 fix(db): re-register 0034/0036 schema changes via idempotent 0039/0040 (GRO-2033)
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Images (pull_request) Successful in 1m11s
Prod cumulative promotion 2026.06.01-7667288 (PR #596) revealed that
0034_extend_pet_profile_columns (temperament_score + 3 jsonb cols) and
0036_add_missing_coat_type_values (short/medium/silky) were silently
skipped on the prod database, leaving the seed/reset path with:

  Seed failed: PostgresError: column "temperament_score" does not exist

## Root cause: drizzle high-water-mark, same shape as GRO-1999

drizzle-orm@0.38.4 `pg-core/dialect.js#migrate` only applies a journal
entry when its `folderMillis` is strictly greater than the most recent
`__drizzle_migrations.created_at`:

  if (!lastDbMigration || Number(lastDbMigration.created_at) < migration.folderMillis) {
    // apply SQL + record hash
  }

`packages/db/migrations/meta/_journal.json` has 0033's when at
1779500000000 (2026-05-23) — but 0034 was registered with when
1751140800000 (2025-06-28) and 0036 with 1751480000000 (2025-07-02).
Both are below the 0033 watermark, so on the prod DB (whose newest
applied migration was 0033) drizzle silently skipped 0034 and 0036.
0038 (when 1780000000000) was above the watermark, so it applied — and
the migrate Job exits 0 with 'migrations applied successfully!'. The
schema didn't change. GRO-1999 documented the same bug for 0037 → 0038.

UAT/dev are unaffected because their watermarks were already below the
0034/0036 entries when those originally ran.

## Fix

Add two new idempotent migrations with monotonic 'when':

- 0039_extend_pet_profile_columns_idempotent.sql, when 1780000000001:
    ALTER TABLE pets ADD COLUMN IF NOT EXISTS temperament_score integer;
    -- + temperament_flags jsonb, medical_alerts jsonb, preferred_cuts jsonb
- 0040_register_missing_coat_type_values.sql, when 1780000000002:
    ALTER TYPE coat_type ADD VALUE IF NOT EXISTS 'short';
    -- + 'medium', 'silky'

Both are 'IF NOT EXISTS' — safe no-ops on UAT/dev where 0034/0036
applied normally, and effective forward-fix on prod where they were
skipped. Do NOT modify 0034/0036 in place (per the GRO-1999 pattern):
UAT/dev have already applied them and re-running would fail.

## Verification

- packages/db/migrations/meta/_journal.json now has 41 entries with idx
  39 and 40 strictly monotonic in 'when'.
- python3 -c 'import json; json.load(open(...))' parses cleanly.
- ALTER TYPE ADD VALUE IF NOT EXISTS is permitted inside a tx on
  PostgreSQL 18.3 (prod cluster image confirmed via CNPG status).

## UAT Playbook

No user-visible behaviour change — schema only. Existing TC-API-3.8 / 3.9 /
3.11 / 3.13 (extended pet profile) and 3.19a (profile summary) continue to
pass and now ALSO act as smoke tests after the prod image roll-forward.

## Refs

- Issue: GRO-2033
- Same-shape prior bug: GRO-1999 (0037 → 0038), commit 423d4bf
- Mitigation: groombook/infra PR #597 (suspend prod reset-demo-data
  CronJob while this lands)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 19:36:22 +00:00
The Dogfather 9903b51931 fix(pets): customer can view own pet profile summary (GRO-2013) (#135)
CI / Test (push) Successful in 16s
CI / Lint & Typecheck (push) Failing after 14m15s
CI / Build & Push Docker Images (push) Has been skipped
Adds an owner-bypass in the profile-summary handler for customers signed in via Better Auth, using the existing X-Impersonation-Session-Id portal session header. When a groomer-role staff row carries a valid impersonation session whose clientId matches the pet's clientId, skip groomerLinkageCheck and serve the summary. Otherwise fall through to the existing linkage check.

Resolves a 403 Forbidden where the customer (auto-provisioned by resolveStaffMiddleware as a 'groomer' staff row with no appointment linkage) could not read their own pet's profile.

Scope: GRO-2013 profile-summary endpoint only — no rbac.ts/schema/Dockerfile changes.

Tests: 6 new cases (owner-bypass, no-header, cross-tenant, expired, manager regression, linked-groomer regression); 294/294 pass.

UAT_PLAYBOOK.md: TC-API-3.19a/b/c.

Closes GRO-2013.

Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
2026-06-01 18:40:25 +00:00
The Dogfather 23484dc90a promote(uat): GRO-2014 profile-summary error-handling fix (dev→uat) (#138)
CI / Test (push) Successful in 10s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Images (push) Successful in 39s
2026-06-01 18:27:42 +00:00
Flea Flicker fee62c895d fix(api): GRO-2014 — profile-summary 500 → 404/401/JSON-500 (#137)
CI / Lint & Typecheck (push) Successful in 16s
CI / Test (push) Successful in 16s
CI / Build & Push Docker Images (push) Successful in 46s
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 14s
CI / Build & Push Docker Images (pull_request) Failing after 18s
2026-06-01 18:16:29 +00:00
Scrubs McBarkley 766728865e Merge pull request 'promote: uat → main — pnpm-offline Docker hardening + accumulated UAT fixes (GRO-1985)' (#136) from uat into main
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 14s
CI / Build & Push Docker Images (push) Successful in 1m19s
promote: uat → main — pnpm-offline Docker hardening + accumulated UAT fixes (GRO-1985)

UAT PASS: GRO-2015
Security PASS: GRO-2024 (Barkley Trimsworth)
UAT CI: run #2313 — 3/3 jobs green (incl. offline pnpm smoke tests)
2026-06-01 18:07:30 +00:00
The Dogfather 6a81a52a50 Merge pull request 'Promote dev → uat: UAT seed-password source-of-truth playbook (GRO-2000)' (#134) from dev into uat
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 27s
CI / Test (pull_request) Successful in 11s
CI / Lint & Typecheck (pull_request) Successful in 13s
CI / Build & Push Docker Images (pull_request) Successful in 1m10s
2026-06-01 17:41:47 +00:00
Flea Flicker 2251a172e3 docs(UAT_PLAYBOOK): document canonical source-of-truth for UAT seed passwords (GRO-2000) (#132)
CI / Lint & Typecheck (push) Failing after 5s
CI / Test (push) Successful in 12s
CI / Build & Push Docker Images (push) Has been skipped
CI / Test (pull_request) Successful in 11s
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Build & Push Docker Images (pull_request) Failing after 19s
2026-06-01 17:11:12 +00:00
The Dogfather 5a4b9a98bd Merge pull request 'promote(docker): bake pnpm via npm to remove Corepack runtime downloads (GRO-1981)' (#133) from dev into uat
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 14s
CI / Build & Push Docker Images (push) Successful in 40s
Promote GRO-1985 (parent GRO-1981) dev->uat. cc @cpfarhood
2026-06-01 16:30:54 +00:00
Flea Flicker 1d28adb71a Merge pull request 'fix(docker): bake pnpm via npm to remove Corepack runtime downloads (GRO-1981)' (#129) from flea-flicker/gro-1985-bake-pnpm-offline into dev
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 1m10s
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 14s
CI / Build & Push Docker Images (pull_request) Successful in 24s
Self-merge per SDLC Phase 1 Step 4 — CTO review approved by gb_dogfather, CI 3/3 green, QA approved by gb_lint. Closes GRO-1985.

cc @cpfarhood
2026-06-01 16:24:41 +00:00
The Dogfather f7f88156e1 Merge pull request 'promote(db): register extra_large via migration 0038 to UAT (GRO-2004)' (#131) from dev into uat
CI / Test (push) Successful in 11s
CI / Lint & Typecheck (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 35s
2026-06-01 14:52:13 +00:00
The Dogfather 7f8a1f4bcd Merge pull request 'fix(db): register extra_large via migration 0038 (GRO-1999)' (#130) from flea/gro-1999-migration-0038 into dev
CI / Test (push) Successful in 13s
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 37s
CI / Lint & Typecheck (push) Successful in 2m23s
CI / Build & Push Docker Images (push) Successful in 32s
2026-06-01 14:49:46 +00:00
Paperclip 423d4bf72d fix(db): register extra_large via migration 0038 (GRO-1999)
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Images (pull_request) Successful in 1m27s
GRO-1979 added 0037_add_extra_large_to_pet_size_category with a journal
'when' of 1751500000000 — below the 0033 high-water mark (1779500000000)
on existing UAT/persistent DBs. Drizzle only applies a migration when its
journal.when is strictly greater than max(applied created_at), so 0037
was silently skipped, leaving pet_size_category without 'extra_large'
and crashing the UAT seed-test-data job (22P02 enum error).

This adds 0038 with a monotonic 'when' (1780000000000) so it applies on
both existing UAT/persistent DBs and fresh DBs. Statement is idempotent
(ADD VALUE IF NOT EXISTS) and a single auto-commit DDL (ADD VALUE cannot
run inside a transaction block).

Do not modify 0033/0034/0036/0037 — re-registering extra_large is correct
since the drizzle PetSizeCategory type and seed.ts both use that value.

GRO-2004

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 14:41:27 +00:00
Flea Flicker 3e547b8568 fix(docker): bake pnpm via npm to remove Corepack runtime downloads (GRO-1981)
CI / Test (pull_request) Successful in 17s
CI / Lint & Typecheck (pull_request) Successful in 23s
CI / Build & Push Docker Images (pull_request) Successful in 1m14s
The GRO-1983 fast restoration swapped Corepack's pnpm shim for a real
`npm install -g pnpm@9.15.4` binary, which is the right move. But the
GRO-1997 evidence gate still showed the first `reset-demo-data` pod
(...-nh7vg) hitting `getaddrinfo EAI_AGAIN registry.npmjs.org` before a
retry succeeded — the cache was writable, the cold-cache registry
download wasn't eliminated. This is the durable fix:

1. `ENV COREPACK_ENABLE_DOWNLOAD_FALLBACK=0` in `base` and `runner`:
   defence in depth so a Corepack shim can never silently re-download
   pnpm, even if it is somehow re-introduced.

2. `ENV HOME=/tmp` in the `migrate`, `seed`, and `reset` stages:
   under `readOnlyRootFilesystem: true` + `runAsUser: 1000`, the
   default HOME path is read-only, and pnpm fails the first time it
   tries to write a config or state file. The job pods already mount a
   writable emptyDir at `/tmp`; point HOME there.

3. CI smoke tests for `seed` and `reset` images (matching the existing
   `migrate` smoke): point `registry.npmjs.org` at 127.0.0.1 in a
   throwaway container, assert `which pnpm` resolves to
   `/usr/local/bin/pnpm` (real binary, not shim), and that `pnpm
   --version` succeeds without network egress. If Corepack ever sneaks
   back in, CI catches it on every PR.

The vestigial `RUN mkdir -p /home/node/.cache/node/corepack` in the
`builder` stage (mentioned in the spec) was already removed in GRO-1909
(commit 0a3eb8a), so nothing to do there.

Follow-on cleanup of the per-job `COREPACK_HOME` env vars and
`node-cache` emptyDir mounts in `groombook/infra` is intentionally
deferred to a coordinated infra PR once the new image is deployed —
keeping the existing infra in place during the transition avoids a
flag-day.

GRO-1985, hardening follow-up to GRO-1984 / GRO-1983.
Closes parent: GRO-1981.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 14:02:38 +00:00
Flea Flicker a9bac033fd docs(UAT_PLAYBOOK): add TC-API-3.28 for pet_size_category enum (GRO-1999) (#127)
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 36s
CI / Test (pull_request) Successful in 10s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Images (pull_request) Successful in 37s
2026-06-01 13:50:16 +00:00
The Dogfather 8af5a49d14 Merge pull request 'Promote dev→uat: GRO-1982 pet_size_category extra_large enum migration' (#126) from dev into uat
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Images (push) Successful in 37s
Promote dev→uat: GRO-1983 seed-job pnpm fix + GRO-1982 extra_large enum migration

Carries the accumulated dev state into uat (PR #125 docker pnpm fix + 0037 migration).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 12:44:20 +00:00
Lint Roller 5fab813215 Merge pull request 'fix(docker): install pnpm via npm instead of corepack shim (GRO-1983)' (#125) from fix/gro-1983-seed-pnpm-baked into dev
CI / Test (push) Successful in 12s
CI / Test (pull_request) Successful in 13s
CI / Lint & Typecheck (push) Successful in 16s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Images (push) Failing after 13s
CI / Build & Push Docker Images (pull_request) Successful in 1m29s
2026-06-01 12:38:32 +00:00
Flea Flicker 84d923a707 Merge branch 'uat' into dev to sync before dev→uat promotion
CI / Test (push) Successful in 15s
CI / Lint & Typecheck (push) Successful in 17s
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Build & Push Docker Images (push) Failing after 8s
CI / Build & Push Docker Images (pull_request) Successful in 1m2s
This merge resolves a journal conflict between dev's idx 37 entry (0037_add_extra_large_to_pet_size_category) and the diverged uat branch. Both branches want the idx 37 entry; keeping the dev version which adds the migration.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 12:32:28 +00:00
Flea Flicker 944a4e161f Merge pull request 'fix(db): GRO-1979 add 0037 — register extra_large in pet_size_category enum' (#124) from fix/GRO-1979-coat-type-pet-size-enum-fix into dev
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 38s
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 30s
2026-06-01 12:28:48 +00:00
Flea Flicker f262c19561 feat(db): add 0037_add_extra_large_to_pet_size_category — register extra_large in journal
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Images (pull_request) Successful in 1m24s
GRO-1979: The pet_size_category enum created in 0031_buffer_rules.sql
contained ('small', 'medium', 'large', 'xlarge'), but the drizzle schema
and seed.ts both use 'extra_large'. The mismatch caused the UAT seed job
to fail with:
  invalid input value for enum pet_size_category: "extra_large"

This migration adds the 'extra_large' value to pet_size_category and
registers it at idx 37 in the drizzle journal (sequel to 0035/0036
which registered short/medium/silky in coat_type under GRO-1971).

Non-transactional per Postgres restriction on ALTER TYPE ADD VALUE.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 12:05:06 +00:00
Paperclip 17d261fa94 fix(docker): install pnpm via npm instead of corepack shim (GRO-1983)
CI / Test (pull_request) Successful in 18s
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Build & Push Docker Images (pull_request) Successful in 1m25s
The seed/migrate/reset Jobs all invoke `pnpm` at runtime via the
`pnpm --filter @groombook/db ...` CMD. In the current image, `/usr/local/bin/pnpm`
is a symlink to corepack's pnpm.js shim, which delegates to corepack and
re-validates the package against https://registry.npmjs.org on first use.

The UAT pod network is air-gapped, so corepack fails with:
  Error: getaddrinfo EAI_AGAIN registry.npmjs.org
This causes every seed Job to fail, leaving the Better Auth credential
hashes frozen at their last successful seed run — even when the SealedSecret
`seed-uat-passwords` is rotated.

Replace `corepack install -g pnpm@9.15.4` with `npm install -g pnpm@9.15.4`
in the base and runner stages. `npm install -g` writes the real pnpm binary
to /usr/local/bin/pnpm, bypassing the corepack shim entirely. The seed,
migrate, and reset stages inherit from builder (which inherits from base)
so they all get the real pnpm without needing their own install line.

The reset stage had a redundant corepack install that can be removed.

GRO-1983, supersedes GRO-1909 (incomplete — corepack shim still tried to
download pnpm at runtime).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 11:58:33 +00:00
The Dogfather e5fe005986 Promote dev→uat: restore deterministic TestCooper/TestRocky alerts (GRO-1962) (#123)
CI / Lint & Typecheck (push) Successful in 16s
CI / Test (push) Successful in 12s
CI / Build & Push Docker Images (push) Failing after 36s
Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
2026-06-01 00:36:36 +00:00
The Dogfather b15a53a19b fix(seed): restore deterministic alerts for TestCooper/TestRocky (GRO-1962) (#122)
CI / Test (push) Successful in 11s
CI / Lint & Typecheck (push) Successful in 17s
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 45s
CI / Build & Push Docker Images (push) Successful in 1m7s
Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
2026-06-01 00:35:35 +00:00
Paperclip 97da5f332e fix(seed): restore deterministic alerts for TestCooper/TestRocky (GRO-1962)
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Images (pull_request) Successful in 1m7s
Restore deterministic alerts so TC-API-3.23/3.24 no longer flaky:
- TestCooper always gets a behavioral alert
- TestRocky always gets a skin alert
- Their deterministic alerts (~0.4% of total pets) do not shift
  the overall 25-35% medicalAlerts distribution

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 00:34:50 +00:00
Flea Flicker 1faa7945c6 fix(seed): update credential password on re-run instead of skipping (GRO-1977) (#121)
CI / Lint & Typecheck (push) Failing after 2s
CI / Test (push) Successful in 12s
CI / Build & Push Docker Images (push) Has been skipped
fix(seed): update credential password on re-run instead of skipping (GRO-1977)
2026-06-01 00:23:53 +00:00
The Dogfather b928acf5d6 fix(seed): update credential password on existing accounts — not skip (GRO-1977) (#120)
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 37s
2026-06-01 00:08:19 +00:00
The Dogfather 5390131a6a Promote dev→uat: add missing coat_type enum values (GRO-1971) (#119)
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 39s
2026-05-31 23:12:58 +00:00
The Dogfather dd220598ca fix: add missing coat_type enum values (GRO-1971) (#118)
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 24s
CI / Build & Push Docker Images (push) Successful in 36s
2026-05-31 23:09:36 +00:00
The Dogfather 8cce9c4d35 Merge pull request 'Promote dev→uat: expand UAT seed to 30+ pets with medicalAlerts 25-35% distribution (GRO-1962)' (#117) from dev into uat
CI / Lint & Typecheck (push) Successful in 14s
CI / Test (push) Successful in 12s
CI / Build & Push Docker Images (push) Successful in 1m9s
2026-05-31 22:47:11 +00:00
Scrubs McBarkley bec7b014be fix(seed): remove stale uc.petName closure ref, correct medicalAlerts distribution to 30% (#115)
CI / Test (push) Successful in 11s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Images (push) Successful in 1m4s
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 14s
CI / Build & Push Docker Images (pull_request) Successful in 1m4s
2026-05-31 22:14:30 +00:00
Flea Flicker 01cff9006a GRO-1961: populate extended fields on UAT Pup Alpha/Beta on re-runs (#114)
CI / Test (push) Successful in 10s
CI / Lint & Typecheck (push) Successful in 22s
CI / Build & Push Docker Images (push) Successful in 59s
GRO-1961: populate extended fields on UAT Pup Alpha/Beta on re-runs
2026-05-31 21:52:06 +00:00
The Dogfather f80f781b23 ci: promote dev→uat (GRO-1939 smoke + GRO-1953/1955/1949 seed/db) (#113)
CI / Test (push) Successful in 11s
CI / Lint & Typecheck (push) Successful in 14s
CI / Build & Push Docker Images (push) Successful in 24s
Promotes 6 dev commits to uat. PR #111 (latest dev tip) QA-approved by Lint Roller. CI all-green.

Follow-up: Shedward UAT regression task to be created.
2026-05-30 11:16:43 +00:00
The Dogfather c99e2980a1 ci: add blackhole smoke for migrate image (GRO-1939) (#111)
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 16s
CI / Test (pull_request) Successful in 10s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Images (push) Successful in 48s
CI / Build & Push Docker Images (pull_request) Successful in 27s
Adds smoke-test step after the migrate image build that runs the image with registry.npmjs.org pointed at 127.0.0.1; pnpm --version must succeed without npm access. Guards against corepack-offline regression from GRO-1916.

QA: Lint Roller APPROVED (commit 5ec9e9a8) — CI all-green.
CTO: signed off (self-approval blocked by Gitea — I authored).

Closes #GRO-1954
Closes #GRO-1957
Closes #GRO-1958
Relates #GRO-1939
2026-05-30 11:15:56 +00:00
Flea Flicker 5ec9e9a8fd fix(ci): correct typo groombok->groombook and fix Reset image cache-from indentation
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 44s
- Fix API image tag typo: groombok -> groombook (line 103)
- Fix Reset image cache-from/cache-to indentation: moved from under tags: (12 spaces) to under with: (10 spaces)
- This corrects the Reset image build failure in CI runs.
2026-05-30 05:14:51 +00:00
Flea Flicker e9aef5719f GRO-1939: Add CI smoke test for blackholed migrate runtime
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Images (pull_request) Failing after 53s
Cherry-picked from fix/GRO-1909-migrate-corepack-offline (f007eca)
2026-05-30 04:49:59 +00:00
Flea Flicker c588c94dcb GRO-1955: hotfix seed.ts broken uc reference in random pet batch (#112)
CI / Test (push) Failing after 9s
CI / Lint & Typecheck (push) Successful in 20s
CI / Build & Push Docker Images (push) Has been skipped
2026-05-30 04:42:33 +00:00
Lint Roller e00cdc1321 fix(db): add missing 'short' value to coat_type enum (GRO-1953) (#110)
CI / Lint & Typecheck (push) Failing after 14s
CI / Test (push) Successful in 14s
CI / Build & Push Docker Images (push) Has been skipped
2026-05-30 04:20:02 +00:00
Scrubs McBarkley 1891b9c523 GRO-1949: add behavioral and skin medicalAlertPool types, deterministic seeding for TestCooper/TestRocky (#109)
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Failing after 15s
CI / Build & Push Docker Images (push) Has been skipped
2026-05-30 04:12:06 +00:00
The Dogfather a5bd9c915c Promote: dev → uat (GRO-1945 visit-count hotfix + GRO-1921 UAT reset CronJob fix)
CI / Lint & Typecheck (push) Successful in 15s
CI / Test (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 30s
Carries:
- a14bb5e17d — GRO-1945 visit-count query hotfix
- 981a257d2d — Merge of GRO-1945 hotfix into dev
- 0ab16b82e0 — GRO-1921 UAT reset CronJob full-seed fix (PR #106)

QA approved (PR #108, Lint Roller). CI green on head SHA 0ab16b82e0.
2026-05-30 03:45:38 +00:00
Flea Flicker 0ab16b82e0 GRO-1921: Fix UAT reset CronJob to seed full UAT profile with extended pet fields (#106)
CI / Test (push) Successful in 11s
CI / Test (pull_request) Successful in 10s
CI / Lint & Typecheck (push) Successful in 16s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Images (push) Successful in 57s
CI / Build & Push Docker Images (pull_request) Successful in 1m2s
2026-05-30 03:42:43 +00:00
Flea Flicker 981a257d2d Merge pull request 'fix(api): repair root src/routes/pets.ts visit-count query (GRO-1945)' (#107) from flea/GRO-1945-pets-visitcount-hotfix into dev
CI / Test (push) Successful in 10s
CI / Lint & Typecheck (push) Successful in 14s
CI / Build & Push Docker Images (push) Successful in 21s
CI / Test (pull_request) Successful in 10s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 59s
2026-05-30 03:24:03 +00:00
Flea Flicker a14bb5e17d fix(api): repair root src/routes/pets.ts visit-count query (GRO-1945)
CI / Test (pull_request) Successful in 11s
CI / Lint & Typecheck (pull_request) Successful in 14s
CI / Build & Push Docker Images (pull_request) Successful in 1m4s
Use sql\`count(*)::int\` instead of selecting appointments.id, which was
causing TS2339 under noUncheckedIndexedAccess (arr[0] is T | undefined).

Import sql from @groombook/db. Use countRow?.count ?? 0 to stay
noUncheckedIndexedAccess-safe.

Matches the working implementation in apps/api/src/routes/pets.ts:365.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-30 03:21:28 +00:00
Flea Flicker 280c699d0d fix(seed): add uat-customer client record for SSO bridge UAT (GRO-1935) (#104)
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Test (pull_request) Successful in 10s
CI / Build & Push Docker Images (pull_request) Failing after 37s
CI / Lint & Typecheck (push) Successful in 14s
CI / Test (push) Successful in 2m19s
CI / Build & Push Docker Images (push) Failing after 33s
2026-05-30 03:10:48 +00:00
The Dogfather 5d6bc06295 Merge pull request 'fix(docker): bake pnpm into image to avoid runtime corepack downloads (GRO-1909)' (#101) from fix/GRO-1909-migrate-corepack-offline into dev
CI / Test (push) Successful in 10s
CI / Lint & Typecheck (push) Successful in 16s
CI / Test (pull_request) Successful in 9s
CI / Lint & Typecheck (pull_request) Successful in 14s
CI / Build & Push Docker Images (push) Failing after 37s
CI / Build & Push Docker Images (pull_request) Failing after 36s
2026-05-30 03:05:10 +00:00
Flea Flicker 53677b1420 feat(pets): add GET /:id/profile-summary endpoint
CI / Test (pull_request) Successful in 11s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Failing after 36s
Adds profile-summary endpoint for groombook web to display:
- Basic pet fields (name, species, breed, coatType, etc.)
- Recent grooming history (last 10 completed appointments with staff names)
- Visit count (completed appointments)
- Upcoming appointment (next scheduled/confirmed)

Groomer RBAC: groomers can only see pets they've had appointments with.
Non-groomer staff (admin/super) can see all pets.

Fixes GRO-1802 (UAT regression: profile-summary route never deployed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 03:00:39 +00:00
Flea Flicker 0a3eb8a282 fix(docker): bake pnpm into image to avoid runtime corepack downloads (GRO-1909)
Use `corepack install -g` instead of `corepack prepare --activate` to write
pnpm to a stable global path (/usr/local/bin/pnpm) rather than relying on
corepack shims that re-validate against npmjs.org at runtime.

Set COREPACK_ENABLE_DOWNLOAD_PROMPT=0 and COREPACK_ENABLE_STRICT=0 to suppress
the interactive download prompt and strict version checks that also trigger
network access.

Remove the dead `RUN mkdir -p /home/node/.cache/node/corepack` line from the
builder stage (vestigial cache-location configuration).

Fixes: GRO-1916 (prod migrate-schema EAI_AGAIN on registry.npmjs.org)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 03:00:39 +00:00
Flea Flicker b5f964c1ff fix(test): mock db to handle sql count(*) queries (GRO-1917) (#102)
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Images (push) Successful in 37s
CI / Test (pull_request) Successful in 11s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Images (pull_request) Successful in 36s
2026-05-29 16:36:40 +00:00
Flea Flicker 86a6e3245c fix(seed): use typeof on enum.enumValues for db build (#100)
CI / Lint & Typecheck (push) Successful in 21s
CI / Test (push) Failing after 13m48s
CI / Build & Push Docker Images (push) Has been skipped
2026-05-29 15:40:51 +00:00
Flea Flicker aee82efbac feat(seed): populate extended pet profile fields for UAT verification (#99)
CI / Lint & Typecheck (push) Successful in 1m53s
CI / Test (push) Successful in 1m55s
CI / Build & Push Docker Images (push) Failing after 3m24s
2026-05-29 14:39:05 +00:00
Flea Flicker 4cc0676d52 Merge remote-tracking branch 'origin/seed/extended-profile-fields-gro-1898' into dev
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Images (push) Failing after 1m52s
2026-05-29 01:16:06 +00:00
Flea Flicker dff0e17a63 docs(UAT_PLAYBOOK): add TC-API-3.20 through TC-API-3.24 for seed data verification
CI / Lint & Typecheck (pull_request) Successful in 21s
CI / Test (pull_request) Successful in 35s
CI / Build & Push Docker Images (pull_request) Failing after 4m57s
Updated UAT_PLAYBOOK.md §4.3 — new seed data verification tests.

GRO-1898: After populating extended profile fields in the UAT seed, add
test cases to verify the data is actually present and shaped correctly.
Test cases cover:
- /api/clients returns seed data
- /api/pets/{id} returns all 5 extended fields for UAT test pets
- medicalAlerts shape is correct ({type, description, severity})
- Deterministic UAT pets (Charlie = behavioral alert, Delta = skin alert)
  are verifiably populated

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 01:15:55 +00:00
Flea Flicker 612c0467a1 feat(seed): populate extended pet profile fields for UAT regression
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 21s
CI / Build & Push Docker Images (pull_request) Failing after 1m35s
GRO-1898: Ensure UAT seed data includes clients and pets with extended
profile fields (temperamentScore, temperamentFlags, medicalAlerts,
preferredCuts, coatType).

- Add data pools for extended profile fields in pet batch generation
- Populate all 5 extended fields for randomly generated pets
- Update UAT test client pets with fully populated extended profiles
- Fix type mismatches: medicalAlerts uses MedicalAlert[] with
  {type, description, severity} shape per @groombook/types

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 01:14:56 +00:00
Flea Flicker 543d9560ec fix(gro-1889): bake pnpm into reset stage to avoid runtime DNS (#97)
CI / Lint & Typecheck (push) Successful in 20s
CI / Test (push) Successful in 26s
CI / Build & Push Docker Images (push) Successful in 3m2s
2026-05-28 22:31:12 +00:00
Flea Flicker 17b44e3b00 Merge origin/uat into promote/dev-to-uat-gro-1866
CI / Lint & Typecheck (pull_request) Successful in 36s
CI / Test (pull_request) Successful in 33s
CI / Build & Push Docker Images (pull_request) Successful in 3m11s
Conflicts resolved:
- src/middleware/rbac.ts: keep dev version (email null-guard, type assertion, single null-check)
- .gitea/workflows/ci.yml: keep uat version (branches: [main, dev, uat])

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-28 21:39:39 +00:00
Flea Flicker 2e0d63f7f6 fix(gro-1866): address QA review failures — portalSession null-guard,
CI / Test (push) Successful in 32s
CI / Lint & Typecheck (push) Successful in 34s
CI / Build & Push Docker Images (push) Successful in 2m34s
email null-dereference guard, externalize DEMO_STAFF_ID

1. portal.ts:138 — add null guard for portalSession before accessing .id
   (TS18048: 'portalSession' is possibly 'undefined')
2. rbac.ts:130 — guard jwt.email before split() to prevent runtime throw
3. portal.ts:39,105 — externalize DEMO_STAFF_ID as env var
   (process.env.DEMO_STAFF_ID ?? "00000000-...")

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:50:14 +00:00
The Dogfather 7bdb92999a Merge pull request 'fix(gro-1866): add session-from-auth portal endpoint + role scope' (#93) from fix/gro-1866-sso-bridge into dev
CI / Test (push) Successful in 34s
CI / Lint & Typecheck (push) Successful in 38s
CI / Build & Push Docker Images (push) Failing after 1m46s
fix(gro-1866): add session-from-auth portal endpoint + role scope (#93)

Bridges Better Auth SSO sessions to portal sessions for real customers.
Adds role to genericOAuth scopes for Authentik role propagation.

Closes GRO-1866
2026-05-28 18:46:38 +00:00
Flea Flicker b96b6c06fc fix: add missing getAuth import and fix db.insert() mock chain
Fixes two bugs found in QA review:
- ReferenceError: getAuth not defined in beforeEach - add import
- TypeError: wrong mock chain insert().into().values() vs insert().values()

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-28 15:59:41 +00:00
Flea Flicker fa67b75b76 docs: add UAT test cases TC-API-8.8 through TC-API-8.11 for SSO bridge
Adds manual test cases covering:
- TC-API-8.8: valid Better Auth session → portal session (201)
- TC-API-8.9: no session → 401
- TC-API-8.10: no matching client → 404
- TC-API-8.11: returned sessionId works on subsequent portal calls

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-28 15:01:24 +00:00
Flea Flicker 7e329ff72f fix(gro-1866): add session-from-auth portal endpoint and role scope
Adds POST /api/portal/session-from-auth which bridges a valid Better Auth
customer session (from SSO login) to a portal impersonation session, so
real SSO customers can access the client portal.

The endpoint is registered before the validatePortalSession catch-all so it
is not subject to that middleware. It validates the Better Auth session
from request cookies, looks up the client by email, creates an active
impersonation session, and returns { sessionId, clientId, clientName }.

Also adds "role" to the genericOAuth scopes so Authentik propagates the
role claim into Better Auth user objects (GRO-1862 root cause fix).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-28 15:00:15 +00:00
Flea Flicker b050fb9a5f Merge pull request 'feat(db): add migration 0034 for extended pet profile columns (GRO-1850)' (#92) from fix/gro-1850-pet-profile-migration into dev
CI / Lint & Typecheck (push) Successful in 14s
CI / Test (push) Successful in 13s
CI / Build & Push Docker Images (push) Successful in 1m20s
2026-05-28 11:39:51 +00:00
Flea Flicker 63ed91e5f3 feat(db): add migration 0034 for extended pet profile columns
CI / Lint & Typecheck (pull_request) Successful in 11s
CI / Test (pull_request) Successful in 11s
CI / Build & Push Docker Images (pull_request) Successful in 50s
GRO-1850: Adds temperament_score, temperament_flags, medical_alerts,
and preferred_cuts to the pets table.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:39:21 +00:00
The Dogfather 45b3d4343d Merge pull request 'promote: dev → uat (GRO-1790 pet profile summary fixes)' (#91) from promote/dev-to-uat-gro-1790 into uat
CI / Lint & Typecheck (push) Successful in 12s
CI / Test (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 35s
promote: dev → uat (GRO-1790 pet profile summary fixes)

Merged by CTO after QA approval.
Refs: GRO-1798, GRO-1790
2026-05-26 12:36:06 +00:00
Flea Flicker 32156e9a45 fix: restore pet profile summary endpoint from dev (GRO-1177)
CI / Lint & Typecheck (pull_request) Successful in 13s
CI / Test (pull_request) Successful in 12s
CI / Build & Push Docker Images (pull_request) Successful in 41s
2026-05-26 12:30:10 +00:00
Flea Flicker ed3d7df1c9 Merge dev into promote/dev-to-uat-gro-1790
CI / Lint & Typecheck (pull_request) Successful in 11s
CI / Test (pull_request) Successful in 11s
CI / Build & Push Docker Images (pull_request) Successful in 1m9s
Resolve .ci-trigger conflict preferring dev version.
2026-05-26 12:11:44 +00:00
The Dogfather 9622b109d0 Merge pull request 'feat(GRO-1177): add pet profile summary endpoint' (#30) from flea-flicker/pet-profile-summary into dev
CI / Lint & Typecheck (push) Successful in 12s
CI / Test (push) Successful in 12s
CI / Build & Push Docker Images (push) Successful in 2m52s
feat(GRO-1177): add pet profile summary endpoint (#30)

Adds GET /api/pets/:id/profile-summary with aggregated pet profile,
grooming history, visit count, and upcoming appointment.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 11:40:16 +00:00
Barcode Betty a25b2fe281 docs: add TC-API-3.18 and TC-API-3.19 to UAT_PLAYBOOK for visitCount regression + date filter
CI / Lint & Typecheck (pull_request) Successful in 12s
CI / Test (pull_request) Successful in 12s
CI / Build & Push Docker Images (pull_request) Successful in 1m4s
Updated UAT_PLAYBOOK.md §3.3 — new visitCount cap and past appointment filter test cases

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:34:25 +00:00
Barcode Betty de33edd7c6 fix: address CTO review — visitCount bug + upcomingAppointment date filter
- Replace .select({ count: appointments.id }).limit(1) + .length with
  sql<number>`count(*)::int` pattern per project standard (references invoices.ts:86)
- Add gte(appointments.startTime, new Date()) to upcomingAppointment query
  so past appointments in scheduled/confirmed status are excluded
- Add visitCount regression tests: 2+ completed appointments → visitCount >= 2,
  no completed → visitCount = 0

Updated UAT_PLAYBOOK.md §profile-summary (visitCount regression + date filter)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:34:11 +00:00
Scrubs McBarkley 403634eb96 Merge pull request 'promote: uat → main (GRO-1757 SSO auto-provision fix)' (#89) from uat into main
CI / Lint & Typecheck (push) Successful in 9s
CI / Test (push) Successful in 9s
CI / Build & Push Docker Images (push) Successful in 50s
2026-05-26 02:15:57 +00:00
Lint Roller 3b9e82adff fix(rbac): guard noUncheckedIndexedAccess in name derivation and newStaff insert
CI / Lint & Typecheck (push) Successful in 12s
CI / Test (push) Successful in 14s
CI / Build & Push Docker Images (push) Successful in 46s
With noUncheckedIndexedAccess:true, split("@")[0] returns string|undefined,
making `name` typed as string|undefined and failing the notNull staff.name
insert constraint. Fix by using ?? fallback on the array access.

Also add newStaff null guard after .returning() destructure — array
destructuring yields T|undefined with noUncheckedIndexedAccess enabled.
2026-05-26 01:48:41 +00:00
Lint Roller 385ed10211 fix(rbac): guard noUncheckedIndexedAccess in name derivation and newStaff insert
CI / Test (push) Successful in 10s
CI / Lint & Typecheck (push) Successful in 10s
CI / Build & Push Docker Images (push) Successful in 43s
CI / Test (pull_request) Successful in 9s
CI / Lint & Typecheck (pull_request) Successful in 10s
CI / Build & Push Docker Images (pull_request) Failing after 10s
With noUncheckedIndexedAccess:true, split("@")[0] returns string|undefined,
making `name` typed as string|undefined and failing the notNull staff.name
insert constraint. Fix by using ?? fallback on the array access.

Also add newStaff null guard after .returning() destructure — array
destructuring yields T|undefined with noUncheckedIndexedAccess enabled.
2026-05-26 01:48:12 +00:00
Lint Roller 8e8a87767c fix(ci): remove duplicate provenance keys + add uat push trigger (GRO-1762)
CI / Lint & Typecheck (push) Successful in 12s
CI / Test (push) Successful in 13s
CI / Build & Push Docker Images (push) Failing after 41s
2026-05-26 01:34:42 +00:00
The Dogfather 152abfc4d5 fix(ci): remove duplicate provenance keys causing YAML parse error
CI / Test (push) Successful in 9s
CI / Lint & Typecheck (push) Successful in 12s
CI / Build & Push Docker Images (push) Successful in 1m10s
Duplicate 'provenance: false' in each docker/build-push-action step caused
Gitea to reject the workflow file, breaking push CI and workflow_dispatch.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 01:26:05 +00:00
The Dogfather b796d36aed fix(ci): remove duplicate provenance keys causing YAML parse error
CI / Lint & Typecheck (push) Failing after 4s
CI / Test (push) Successful in 15s
CI / Build & Push Docker Images (push) Has been skipped
Duplicate 'provenance: false' in each docker/build-push-action step caused
Gitea to reject the workflow file, breaking push CI and workflow_dispatch.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 01:25:57 +00:00
Flea Flicker d9ba6045ad chore: direct push CI trigger for GRO-1757 (b61d899f) to include in dev image
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 00:45:05 +00:00
The Dogfather 2f17b1ab85 Promo/Gro 1764 Uat (#86) 2026-05-26 00:36:15 +00:00
Lint Roller b83a793de4 chore: PR CI build trigger for GRO-1757 image (do not merge) (#87)
Co-authored-by: Lint Roller <lint@groombook.dev>
Co-committed-by: Lint Roller <lint@groombook.dev>
2026-05-26 00:36:04 +00:00
Flea Flicker c8bbb12edb Merge pull request 'promote(dev→main): GRO-1752 schema fix for UAT (CI trigger)' (#82) from dev into main 2026-05-25 23:28:27 +00:00
Flea Flicker ba95088653 Merge pull request 'chore: trigger CI from uat for GRO-1754' (#81) from fix/gro-1754-uat-ci into main 2026-05-25 23:23:15 +00:00
Flea Flicker dd83f29736 chore: trigger CI from uat for GRO-1754 2026-05-25 23:22:04 +00:00
Chris Farhood 185fce8e17 Add .mcp.json
CI / Lint & Typecheck (push) Successful in 15s
CI / Test (push) Successful in 13s
CI / Build & Push Docker Images (push) Successful in 2m44s
2026-05-24 18:14:57 +00:00
Flea Flicker 8c62ce2368 feat(GRO-1177): add GET /api/pets/:id/profile-summary endpoint
CI / Lint & Typecheck (pull_request) Successful in 9s
CI / Test (pull_request) Successful in 9s
CI / Build & Push Docker Images (pull_request) Successful in 37s
Returns aggregated pet profile with:
- All pet fields (basic + extended)
- recentGroomingHistory: last 10 entries from groomingVisitLogs with staff name join
- lastVisitDate: most recent groomedAt timestamp
- visitCount: count of completed appointments
- upcomingAppointment: next scheduled/confirmed appointment with service/staff name

Enforces same groomer RBAC as GET /:id. Returns 404 for non-existent pets.
Adds PetProfileSummary, GroomingHistoryEntry, and UpcomingAppointment types.
Adds unit tests covering: 404, 403, aggregated profile, empty history, no upcoming appt.
Updates UAT_PLAYBOOK.md §3 with TC-API-3.8 and TC-API-3.9.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:17:24 +00:00
Scrubs McBarkley 081379c189 Merge pull request 'promote: uat → main (GRO-1509 OIDC accountLinking fix)' (#46) from uat into main
CI / Test (push) Successful in 9s
CI / Lint & Typecheck (push) Successful in 12s
CI / Build & Push Docker Images (push) Successful in 50s
Merge uat → main: GRO-1509 OIDC accountLinking fix

Sign-offs cleared:
- QA: GRO-1510 ✓
- UAT: GRO-1515 ✓
- Security: GRO-1516 ✓
- Infra: groombook/infra PR #413
2026-05-22 14:03:43 +00:00
The Dogfather e01c12a316 Merge pull request 'chore: migrate .github/workflows to .gitea/workflows' (#22) from gitea/migrate-workflows into main
CI / Lint & Typecheck (push) Successful in 15s
CI / Test (push) Successful in 20s
CI / Build & Push Docker Image (push) Failing after 1m47s
chore: migrate .github/workflows to .gitea/workflows

Migrate CI workflow from GitHub Actions to Gitea Actions.
- Registry: ghcr.io → git.farh.net
- Auth: secrets.GITHUB_TOKEN → gitea.token
- Cache: type=gha → type=registry

Part of GRO-1315.
2026-05-20 01:34:04 +00:00
55 changed files with 8541 additions and 1465 deletions
+1 -1
View File
@@ -1 +1 @@
GRO-1757+GRO-1764 CI trigger 2026-05-26
GRO-1757 direct push CI trigger - 2026-05-26T00:15:41Z
+47 -9
View File
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [main, dev]
branches: [main, dev, uat]
pull_request:
branches: [main, dev]
branches: [main, dev, uat]
workflow_dispatch:
inputs:
ref:
@@ -32,10 +32,12 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm --filter @groombook/api typecheck
run: |
pnpm run typecheck
pnpm --filter @groombook/db typecheck
- name: Lint
run: pnpm --filter @groombook/api lint
run: pnpm run lint
test:
name: Test
@@ -56,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
@@ -96,7 +98,6 @@ jobs:
file: Dockerfile
target: runner
push: true
provenance: false
tags: |
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
@@ -111,13 +112,23 @@ jobs:
file: Dockerfile
target: migrate
push: true
provenance: false
tags: |
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
- name: Smoke test migrate image (blackhole npmjs.org)
run: |
set -euo pipefail
IMAGE="git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}"
docker pull "$IMAGE"
docker run --rm \
--add-host registry.npmjs.org:127.0.0.1 \
--entrypoint="" \
"$IMAGE" \
pnpm --version
- name: Build and push Seed image
uses: docker/build-push-action@v6
with:
@@ -126,7 +137,6 @@ jobs:
file: Dockerfile
target: seed
push: true
provenance: false
tags: |
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
@@ -141,9 +151,37 @@ jobs:
file: Dockerfile
target: reset
push: true
provenance: false
tags: |
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
- name: Smoke test seed image (blackhole npmjs.org)
run: |
set -euo pipefail
IMAGE="git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}"
docker pull "$IMAGE"
# GRO-1985: pnpm must be a real binary, not a Corepack shim, and must
# not try to reach registry.npmjs.org on invocation.
docker run --rm \
--add-host registry.npmjs.org:127.0.0.1 \
--entrypoint="" \
"$IMAGE" \
sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; pnpm --version'
echo "seed image: pnpm resolves to /usr/local/bin/pnpm and runs offline ✓"
- name: Smoke test reset image (blackhole npmjs.org)
run: |
set -euo pipefail
IMAGE="git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}"
docker pull "$IMAGE"
# GRO-1985: pnpm must be a real binary, not a Corepack shim, and must
# not try to reach registry.npmjs.org on invocation. Validates the
# hard requirement from the issue: reset runs offline.
docker run --rm \
--add-host registry.npmjs.org:127.0.0.1 \
--entrypoint="" \
"$IMAGE" \
sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; echo "HOME=$HOME"; pnpm --version'
echo "reset image: pnpm resolves to /usr/local/bin/pnpm, HOME=/tmp, runs offline ✓"
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"gitea": {
"type": "http",
"url": "https://git-mcp.farh.net/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_TOKEN}"
}
}
}
}
+19 -3
View File
@@ -1,5 +1,14 @@
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
# Install pnpm as a real binary via npm (not corepack shim) so runtime
# invocations of `pnpm` work without DNS access to registry.npmjs.org.
# The corepack shim delegates to corepack, which re-validates against
# npmjs.org on first use — that fails in air-gapped UAT seed/migrate/reset
# Jobs. GRO-1983 / GRO-1889 / GRO-1909 / GRO-1981 / GRO-1985.
RUN npm install -g pnpm@9.15.4
# Belt-and-braces: disable Corepack's download fallback so that even if a
# Corepack shim is somehow invoked at runtime, it will not try to fetch
# pnpm from registry.npmjs.org. Belt for the real-binary trousers. GRO-1985.
ENV COREPACK_ENABLE_DOWNLOAD_FALLBACK=0
WORKDIR /app
# Install deps
@@ -11,7 +20,6 @@ RUN pnpm install --frozen-lockfile
# Build
FROM deps AS builder
RUN mkdir -p /home/node/.cache/node/corepack
COPY packages/ packages/
COPY src/ src/
COPY tsconfig.json ./
@@ -21,7 +29,9 @@ RUN pnpm --filter @groombook/types build && \
# Runtime
FROM node:22-alpine AS runner
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
RUN npm install -g pnpm@9.15.4
# Same defence-in-depth as base: no Corepack fallback. GRO-1985.
ENV COREPACK_ENABLE_DOWNLOAD_FALLBACK=0
WORKDIR /app
ENV NODE_ENV=production
@@ -42,12 +52,18 @@ CMD ["node", "dist/index.js"]
# Migrate stage — runs drizzle-kit migrate against the database
FROM builder AS migrate
# pnpm needs a writable HOME for any config/state it writes. With
# readOnlyRootFilesystem: true and runAsUser: 1000, /home/node is read-only.
# The job pods mount a writable emptyDir at /tmp; point HOME there. GRO-1985.
ENV HOME=/tmp
CMD ["pnpm", "--filter", "@groombook/db", "migrate"]
# Seed stage — populates the database with test data
FROM builder AS seed
ENV HOME=/tmp
CMD ["pnpm", "--filter", "@groombook/db", "seed"]
# Reset stage — drops all tables, re-runs migrations, and re-seeds
FROM builder AS reset
ENV HOME=/tmp
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
+200 -2
View File
@@ -19,6 +19,45 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
- OIDC authentication provider configured
- Seed data present (clients, pets, services, staff)
### Source of truth for UAT passwords (GRO-2000)
The `UAT_SUPER_PASSWORD` / `UAT_GROOMER_PASSWORD` / `UAT_TESTER_PASSWORD` / `UAT_CUSTOMER_PASSWORD` env vars the test orchestrator uses **must** be pulled from the live `seed-uat-passwords` Secret in the UAT cluster — never from a captured shell value, a previous run's `.env`, or a copy of the SealedSecret committed before the latest rotation.
**Canonical recipe** (works from any host with `kubectl` + cluster credentials):
```bash
SUPER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
-o jsonpath='{.data.super-password}' | base64 -d)
GROOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
-o jsonpath='{.data.groomer-password}' | base64 -d)
TESTER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
-o jsonpath='{.data.tester-password}' | base64 -d)
CUSTOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
-o jsonpath='{.data.customer-password}' | base64 -d)
```
**Why:** the Bitnami SealedSecret `apps/overlays/uat/ss-seed-uat-passwords.yaml` (in `groombook/infra`) is the single source of truth. The UAT `reset-demo-data` CronJob re-hashes these values into the `account` table on every run (idempotent — GRO-1977). A captured env var from a previous generation will not match the current hash, producing 401 `INVALID_EMAIL_OR_PASSWORD`. If the live login still 401s after pulling from the SealedSecret, the seed Job is stale — trigger `kubectl create job --from=cronjob/reset-demo-data -n groombook-uat manual-seed-$$` and retry.
**How to apply:** at the start of every UAT run that touches TC-API-1.4 / 1.5 / 1.6 / 1.7 / 3.18 / 3.21 / 3.23, refresh these four env vars from the cluster before issuing the sign-in request.
### rbac auto-provision for Better-Auth customers (GRO-2052)
> Applies to TC-API-3.16 / 3.19a / 3.19b / 3.19c (customer-as-owner profile-summary paths) and any future case where the test user authenticates via Better-Auth email/password and the route relies on `resolveStaffMiddleware` to resolve a `staff` row.
**Pre-condition (rbac auto-provision):** The test user must have a row in the Better-Auth `user` table (email/password sign-in creates this automatically — see TC-API-1.6 / 1.7). On first authenticated call, `resolveStaffMiddleware` (`./src/middleware/rbac.ts`) auto-provisions a `groomer` staff row keyed by `staff.user_id = user.id` (Better-Auth branch fires before the legacy OIDC `account` branch).
**Verify the auto-provision fired** by querying the DB after the first authenticated call:
```sql
SELECT user_id, role FROM staff WHERE user_id = '<test-user-id>';
```
Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the OIDC `account` branch and 403'd, or the user has no `user` row — fix the test sign-in path before re-running.
**Why this matters:** without the auto-provision branch, Better-Auth email/password customers (e.g. `uat-customer@groombook.dev`) have no `account` row for the OIDC providers, so `resolveStaffMiddleware` falls through to `403 "Forbidden: no staff record found for authenticated user"` *before* `pets.ts` can run the owner-bypass added in GRO-2013. The owner-bypass code is unreachable unless the auto-provision has fired. A green TC-API-3.19a therefore implicitly proves the auto-provision worked; if 3.19a fails with the pre-fix 403, the auto-provision branch is missing from the deployed `./src` tree (see [GRO-2052](/GRO/issues/GRO-2052)).
**How to apply:** for every run of TC-API-3.16 / 3.19a / 3.19b / 3.19c, sign in via TC-API-1.6 (email+password) first to guarantee the `user` row exists, then run the profile-summary call, then assert the `staff` row above before declaring pass.
## Test Cases
### 4.0 Health Check
@@ -41,6 +80,8 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-1.8 | Email+password — invalid password | POST /api/auth/sign-in/email with wrong password | 400 Bad Request, error returned |
| TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned |
| TC-API-1.10 | Auto-provision on first OIDC login | First login as a Better-Auth user with no existing staff record | 200 OK, access granted; groomer staff record auto-created with name/email from user table |
> **Note (GRO-1977):** Seed credential provisioning is idempotent — re-running the seed with updated `SEED_UAT_*_PASSWORD` env vars rotates stored credential hashes. TC-API-1.4 through TC-API-1.7 now return 200 for all 4 UAT personas (previously returned 401 due to frozen-hash bug).
| TC-API-1.11 | Existing staff unaffected by OIDC login | Login as uat-groomer@groombook.dev (email+password), then GET /api/staff to find that record | 200 OK, staff record unchanged — no duplicate created, original role and isSuperUser preserved |
| TC-API-1.12 | Auto-provisioned role and superUser flags | After TC-API-1.10, GET /api/staff and inspect the auto-created record | role = "groomer", isSuperUser = false, active = true |
| TC-API-1.13 | Name fallback — user.name present | Auto-provision where Better-Auth user has name set | Staff name = user.name value from user table |
@@ -79,6 +120,25 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| 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.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) |
| 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 |
@@ -98,6 +158,36 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-3.13 | Reject too many temperamentFlags | POST /api/pets with 21 temperamentFlags | 400 Bad Request, max 20 flags enforced |
| TC-API-3.14 | Reject too many preferredCuts | POST /api/pets with 21 preferredCuts | 400 Bad Request, max 20 cuts enforced |
| TC-API-3.15 | Reject too many medicalAlerts | POST /api/pets with 51 medicalAlerts | 400 Bad Request, max 50 alerts enforced |
| TC-API-3.16 | Get pet profile summary | GET /api/pets/{id}/profile-summary | 200 OK, aggregated profile with grooming history, visit count, upcoming appointment |
| TC-API-3.17 | Get pet profile summary — groomer restricted | GET /api/pets/{id}/profile-summary as groomer with no pet linkage | 403 Forbidden |
| TC-API-3.18 | Get pet profile summary — visitCount returns full count | GET /api/pets/{id}/profile-summary with 2+ completed appointments | visitCount >= 2 (not capped at 1) |
| TC-API-3.19 | Get pet profile summary — upcomingAppointment excludes past | GET /api/pets/{id}/profile-summary with a past confirmed/scheduled appointment | upcomingAppointment is null (past appointments filtered by startTime >= now) |
| TC-API-3.19a | Get pet profile summary — customer owner-bypass (GRO-2013) | Sign in as `uat-customer@groombook.dev`; `POST /api/portal/session-from-auth`; then `GET /api/pets/{ownPetId}/profile-summary` with header `X-Impersonation-Session-Id: {sessionId}` for either of the customer's seeded pets (`c0000001-0000-0000-0000-000000000002` UAT Pup Alpha, `c0000001-0000-0000-0000-000000000003` UAT Pup Beta) | 200 OK, aggregated profile returned (owner-bypass: customer with valid portal session for pet's clientId is allowed even though rbac.ts auto-provisions them as a `groomer` staff row with no appointment linkage) |
| 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"}` |
#### Seed Data Verification (GRO-1898)
> As of PR #98, UAT seed data populates all 5 extended profile fields for every pet, including the 5 deterministic UAT test client pets (Alpha, Bravo, Charlie, Delta, Echo). This enables manual verification of extended profile rendering without requiring a DB reset.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-3.20 | GET /api/clients returns seed data | GET /api/clients | 200 OK, array with 1+ clients (UAT seed creates 500 + 5 deterministic UAT clients) |
| TC-API-3.21 | GET /api/pets/{id} returns extended fields for seed pet | Pick any pet ID from UAT test clients (uat-alpha through uat-echo pet names: TestBuddy, TestMax, TestCooper, TestRocky, TestDuke) and GET /api/pets/{id} | 200 OK; coatType, temperamentScore, temperamentFlags, medicalAlerts, preferredCuts all non-null |
| TC-API-3.22 | Verify medicalAlerts shape | GET /api/pets/{id} for any pet with non-empty medicalAlerts | medicalAlerts is an array; each entry has type, description, severity |
| TC-API-3.23 | Verify UAT test pet Charlie has behavioral alert | GET /api/pets/{id} where name = "TestCooper" (pet for uat-charlie@groombook.dev) | medicalAlerts includes an entry with type: "behavioral", severity: "low" or "high" |
| TC-API-3.24 | Verify UAT test pet Delta has skin alert | GET /api/pets/{id} where name = "TestRocky" (pet for uat-delta@groombook.dev) | medicalAlerts includes an entry with type: "skin" |
| TC-API-3.25 | Verify 30+ total pets in UAT DB | GET /api/pets then count total | 30+ pets returned (UAT seed creates 500 random-pool + 5 UAT test clients + 2 UAT customer = 507 total) |
| 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
@@ -124,6 +214,33 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-5.4 | Update service | PATCH /api/services/{id} with updated fields | 200 OK, service updated |
| TC-API-5.5 | Delete service | DELETE /api/services/{id} | 200 OK, service deleted |
#### 4.5.1 Seed/Reset idempotency (GRO-2064)
Services seeding is now keyed on the deterministic `services.id` (not `name`) and
the reset path now `TRUNCATE`s `services` alongside the other dynamic tables.
This means:
- Running the seed Job twice in a row (no reset in between) converges to the
same catalogue — no `services_pkey` collision.
- A `pnpm reset` followed by `pnpm seed` (or a CronJob reset fire) leaves the
catalogue exactly matching `servicesDef` (10 rows, ids `b0000001-…-001`
`…-00a`), regardless of any stale rows that were present beforehand.
- Mixed `seedKnownUsers` + full `seed()` invocations are safe — the
`demoSvcs` subset (Bath & Brush, Full Groom Small/Medium, Nail Trim) is
keyed on ids `…-001`, `…-002`, `…-003`, `…-005` and the upsert target
is `services.id`, so the same-id / different-name collision that broke
GRO-2033 (id `…-004` = "Nail Trim" vs servicesDef `…-004` =
"Full Groom — Large") cannot recur.
**UAT regression** (verify after a new image is rolled out):
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-SEED-1 | Reset → seed converges | `kubectl -n groombook exec deploy/api -- pnpm reset && pnpm seed` | Seed completes 1/1, `services` count = 10, all ids match `servicesDef` |
| TC-SEED-2 | Idempotent re-seed | Re-run `pnpm seed` without reset | Seed completes 1/1, no `services_pkey` errors, `services` count still 10 |
| TC-SEED-3 | Catalogue matches servicesDef | `psql -c "SELECT id, name FROM services ORDER BY id"` | Rows `…-001``…-00a` with names "Bath & Brush"…"Sanitary Trim" exactly as in `servicesDef` |
| TC-SEED-4 | Demo subset coexists | Run `seedKnownUsers` then full `seed` | No collision, demo subset (4 services) ends up with the same rows the full seed would write |
### 4.6 Staff Management
| # | Scenario | Steps | Expected |
@@ -159,6 +276,19 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-8.5 | Add waitlist entry | POST /api/portal/waitlist with pet and service | 201 Created, waitlist entry created |
| TC-API-8.6 | View portal invoices | GET /api/portal/invoices | 200 OK, list of client's invoices returned |
| TC-API-8.7 | Pay multiple invoices | POST /api/portal/invoices/pay-multiple with invoice IDs | 200 OK, payment intent created |
| TC-API-8.8 | SSO bridge — valid Better Auth session | POST /api/portal/session-from-auth with valid Better Auth session cookie (authenticated SSO user with matching client email) | 201 Created, `{sessionId, clientId, clientName}` returned |
| 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 |
| 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.) |
| TC-API-8.19 | Portal appointments surface active waitlist entries (GRO-2319) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. In addition to the customer's appointments, the response includes the seeded ACTIVE waitlist entry as a synthetic card: `status: "waitlisted"`, `id` prefixed `waitlist:`, `confirmationStatus: null`, a non-null derived `startTime` (from the entry's preferred date/time), and the entry's `pet`. Cancelled/notified/expired waitlist entries are NOT surfaced. |
| TC-API-8.20 | Portal waitlist card populates service {id, name} (GRO-2342) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. The synthetic `waitlisted` card returned for the active waitlist entry has `service: {id: "<serviceId>", name: "<serviceName>"}` (full service record, not just `{id}`), matching the shape the appointments join returns. The portal Upcoming list therefore renders the actual service name in place of the fallback "Service" label. |
### 4.9 Waitlist
@@ -204,8 +334,8 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned |
| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated |
| 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. 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 |
@@ -236,6 +366,74 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| 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 (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 |
|---|----------|-------|----------|
| 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). 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` |
| 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`) |
### 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": ["<routeStopId>", …] }` (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`) |
### 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=<first>+to:<next>…&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=<coord>&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:**
+2 -2
View File
@@ -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": {
@@ -0,0 +1,517 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
import { petsRouter } from "../routes/pets.js";
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
userId: null,
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const GROOMER: StaffRow = {
id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub",
userId: null,
role: "groomer",
isSuperUser: false,
name: "Groomer McGroome",
email: "groomer@example.com",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
// ─── Mutable mock state ───────────────────────────────────────────────────────
const CLIENT_ID = "client-uuid-summary";
const PET_ID = "pet-uuid-summary";
interface MockState {
pets: Record<string, unknown>[];
appointments: Record<string, unknown>[];
groomingLogs: Record<string, unknown>[];
staffMembers: Record<string, unknown>[];
services: Record<string, unknown>[];
impersonationSessions: Record<string, unknown>[];
}
let mock: MockState;
function resetMock() {
mock = {
pets: [{
id: PET_ID,
clientId: CLIENT_ID,
name: "Biscuit",
species: "dog",
breed: "Golden Retriever",
weightKg: "30.00",
dateOfBirth: null,
healthAlerts: null,
groomingNotes: null,
cutStyle: null,
shampooPreference: null,
specialCareNotes: null,
customFields: {},
photoKey: null,
photoUploadedAt: null,
image: null,
coatType: "double",
temperamentScore: 3,
temperamentFlags: ["gentle"],
medicalAlerts: [],
preferredCuts: ["puppy cut"],
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
}],
appointments: [
{
id: "appt-completed-1",
clientId: CLIENT_ID,
petId: PET_ID,
serviceId: "service-1",
staffId: "staff-groomer-id",
batherStaffId: null,
status: "completed",
startTime: new Date("2024-06-01T09:00:00Z"),
endTime: new Date("2024-06-01T11:00:00Z"),
notes: null,
priceCents: 6000,
seriesId: null,
seriesIndex: null,
groupId: null,
confirmationStatus: "confirmed",
confirmedAt: null,
cancelledAt: null,
confirmationToken: null,
customerNotes: null,
createdAt: new Date("2024-05-15"),
updatedAt: new Date("2024-05-15"),
},
{
id: "appt-upcoming-1",
clientId: CLIENT_ID,
petId: PET_ID,
serviceId: "service-2",
staffId: "staff-groomer-id",
batherStaffId: null,
status: "confirmed",
startTime: new Date("2024-12-01T09:00:00Z"),
endTime: new Date("2024-12-01T11:00:00Z"),
notes: null,
priceCents: 6500,
seriesId: null,
seriesIndex: null,
groupId: null,
confirmationStatus: "confirmed",
confirmedAt: null,
cancelledAt: null,
confirmationToken: null,
customerNotes: null,
createdAt: new Date("2024-11-01"),
updatedAt: new Date("2024-11-01"),
},
],
groomingLogs: [
{
id: "log-1",
petId: PET_ID,
appointmentId: "appt-completed-1",
staffId: "staff-groomer-id",
cutStyle: "puppy cut",
productsUsed: "oatmeal shampoo",
notes: "Trimmed nails",
groomedAt: new Date("2024-06-01T10:00:00Z"),
createdAt: new Date("2024-06-01T10:00:00Z"),
},
],
staffMembers: [
{
id: "staff-groomer-id",
name: "Groomer McGroome",
email: "groomer@example.com",
role: "groomer",
isSuperUser: false,
active: true,
oidcSub: "oidc-groomer-sub",
userId: null,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "staff-manager-id",
name: "Manager McManager",
email: "manager@example.com",
role: "manager",
isSuperUser: true,
active: true,
oidcSub: "oidc-manager-sub",
userId: null,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
},
],
services: [
{ id: "service-1", name: "Full Groom", description: null, basePriceCents: 6000, durationMinutes: 120, active: true, createdAt: new Date(), updatedAt: new Date() },
{ id: "service-2", name: "Bath & Brush", description: null, basePriceCents: 4000, durationMinutes: 60, active: true, createdAt: new Date(), updatedAt: new Date() },
],
impersonationSessions: [
{
id: "sess-owner",
staffId: "staff-groomer-id",
clientId: CLIENT_ID,
reason: "sso-bridge",
status: "active",
startedAt: new Date("2024-11-01"),
endedAt: null,
expiresAt: new Date("2099-01-01T00:00:00Z"),
createdAt: new Date("2024-11-01"),
},
],
};
}
vi.mock("../db/index.js", () => {
const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} });
const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} });
const groomingVisitLogs = new Proxy({ _name: "groomingVisitLogs" }, { get: (t, p) => p === "_name" ? "groomingVisitLogs" : {} });
const staff = new Proxy({ _name: "staff" }, { get: (t, p) => p === "_name" ? "staff" : {} });
const services = new Proxy({ _name: "services" }, { get: (t, p) => p === "_name" ? "services" : {} });
const impersonationSessions = new Proxy({ _name: "impersonationSessions" }, { get: (t, p) => p === "_name" ? "impersonationSessions" : {} });
// Tracks { [tableName]: { [alias]: SQLExpression } } for the current select() call
let selectedColumns: Record<string, Record<string, unknown>> = {};
function makeChainable(rows: unknown[]) {
const arr = rows as unknown[];
return new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "leftJoin" || prop === "from") {
return () => makeChainable(target);
}
if (prop === Symbol.iterator) {
return function* () { for (const v of target) yield v; };
}
if (prop === Symbol.asyncIterator) {
return async function* () { for (const v of target) yield v; };
}
// @ts-expect-error proxy
return target[prop];
},
});
}
// sql mock: returns an object with .as() so drizzle's select() can alias it
function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) {
const queryString = _strings[0];
const asFn = (alias: string) => ({
sql: { queryChunks: [_strings[0]] },
fieldAlias: alias,
getSQL() { return this.sql; },
});
return { queryChunks: [queryString], as: asFn };
}
return {
getDb: () => ({
select: (cols?: Record<string, unknown>) => {
selectedColumns = {};
if (cols) {
// Inspect cols to find sql-aliased expressions and their aliases
for (const [alias, expr] of Object.entries(cols)) {
if (expr && typeof expr === "object" && "as" in expr && typeof (expr as Record<string, unknown>).as === "function") {
const aliased = (expr as { as: (a: string) => { fieldAlias: string; sql: unknown } }).as(alias);
// Detect count(*) queries
if (typeof aliased.sql === "object" && aliased.sql !== null && "queryChunks" in (aliased.sql as Record<string, unknown>) && String((aliased.sql as { queryChunks?: unknown[] }).queryChunks).includes("count")) {
// Store count query intent — we'll resolve it in from()
if (!selectedColumns["appointments"]) selectedColumns["appointments"] = {};
selectedColumns["appointments"][alias] = { _isCountQuery: true };
}
}
}
}
return {
from: (table: unknown) => {
const name = (table as { _name?: string })._name;
const tableCols = selectedColumns[name] || {};
// If this table has a count query, return computed count result
const countQueryEntry = Object.entries(tableCols).find(([, v]) =>
typeof v === "object" && v !== null && "_isCountQuery" in v
);
if (countQueryEntry) {
const [countAlias] = countQueryEntry;
const count = (name === "appointments" ? mock.appointments : [])
.filter((row: Record<string, unknown>) => row.status === "completed").length;
return makeChainable([{ [countAlias]: count }]);
}
if (name === "pets") return makeChainable(mock.pets);
if (name === "appointments") return makeChainable(mock.appointments);
if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs);
if (name === "staff") return makeChainable(mock.staffMembers);
if (name === "services") return makeChainable(mock.services);
if (name === "impersonationSessions") return makeChainable(mock.impersonationSessions);
return makeChainable([]);
},
};
},
insert: () => ({ values: () => ({ returning: () => [{}] }) }),
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
}),
pets,
appointments,
groomingVisitLogs,
staff,
services,
impersonationSessions,
and: vi.fn((a: unknown, b: unknown) => [a, b]),
desc: vi.fn((c: unknown) => c),
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
exists: vi.fn(() => true),
gte: vi.fn((a: unknown, b: unknown) => ({ col: a, val: b })),
or: vi.fn((a: unknown, b: unknown) => [a, b]),
sql: sqlMock,
};
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeApp(staff: StaffRow = MANAGER) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("staff", staff);
await next();
});
return app.route("/pets", petsRouter);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("GET /:id/profile-summary", () => {
beforeEach(resetMock);
it("returns 404 for non-existent pet", async () => {
const app = makeApp();
mock.pets = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(404);
});
it("returns 403 for groomer with no pet linkage", async () => {
const app = makeApp(GROOMER);
// Groomer has no linkage to this pet's client — clear appointments
mock.appointments = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(403);
});
it("returns complete aggregated profile for manager", async () => {
const app = makeApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe(PET_ID);
expect(body.name).toBe("Biscuit");
expect(body.species).toBe("dog");
expect(body.recentGroomingHistory).toBeInstanceOf(Array);
expect(body.lastVisitDate).toBeTruthy();
expect(body.visitCount).toBeGreaterThanOrEqual(0);
});
it("groomer with pet linkage returns 200", async () => {
const app = makeApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
});
it("recentGroomingHistory is limited to 10 entries", async () => {
const app = makeApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.recentGroomingHistory.length).toBeLessThanOrEqual(10);
});
it("returns null upcomingAppointment when none scheduled", async () => {
const app = makeApp(MANAGER);
mock.appointments = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.upcomingAppointment).toBeNull();
});
});
describe("GET /:id/profile-summary — visitCount", () => {
beforeEach(resetMock);
it("returns visitCount >= 2 when pet has 2+ completed appointments", async () => {
const app = makeApp(MANAGER);
// Add a second completed appointment
mock.appointments = [
...mock.appointments,
{
id: "appt-completed-2",
clientId: CLIENT_ID,
petId: PET_ID,
serviceId: "service-1",
staffId: "staff-groomer-id",
batherStaffId: null,
status: "completed",
startTime: new Date("2024-07-01T09:00:00Z"),
endTime: new Date("2024-07-01T11:00:00Z"),
notes: null,
priceCents: 6000,
seriesId: null,
seriesIndex: null,
groupId: null,
confirmationStatus: "confirmed",
confirmedAt: null,
cancelledAt: null,
confirmationToken: null,
customerNotes: null,
createdAt: new Date("2024-06-15"),
updatedAt: new Date("2024-06-15"),
},
];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.visitCount).toBeGreaterThanOrEqual(2);
});
it("returns visitCount = 0 when no completed appointments", async () => {
const app = makeApp(MANAGER);
mock.appointments = mock.appointments.map((a) => ({ ...a, status: "cancelled" }));
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.visitCount).toBe(0);
});
});
describe("GET /:id/profile-summary — empty history", () => {
beforeEach(resetMock);
it("returns empty history array when no grooming logs", async () => {
const app = makeApp(MANAGER);
mock.groomingLogs = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.recentGroomingHistory).toEqual([]);
expect(body.lastVisitDate).toBeNull();
});
});
describe("GET /:id/profile-summary — owner-bypass via X-Impersonation-Session-Id (GRO-2013)", () => {
beforeEach(resetMock);
// Simulates the rbac.ts auto-provisioned "groomer" that a customer gets on first login:
// role=groomer, no linkage to any appointment.
const CUSTOMER_STAFF: StaffRow = {
id: "staff-customer-id",
oidcSub: null,
userId: "user-customer-id",
role: "groomer",
isSuperUser: false,
name: "UAT Customer",
email: "uat-customer@groombook.dev",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
it("customer with valid portal session for pet's client returns 200 (owner-bypass)", async () => {
const app = makeApp(CUSTOMER_STAFF);
// Groomer has no appointment linkage — proves the bypass is via portal session, not linkage.
mock.appointments = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-owner" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe(PET_ID);
expect(body.name).toBe("Biscuit");
expect(body.clientId).toBe(CLIENT_ID);
});
it("customer without X-Impersonation-Session-Id header still gets 403 (no bypass)", async () => {
const app = makeApp(CUSTOMER_STAFF);
mock.appointments = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(403);
});
it("customer with portal session for a DIFFERENT client gets 403 (cross-tenant blocked)", async () => {
const app = makeApp(CUSTOMER_STAFF);
mock.appointments = [];
mock.impersonationSessions = [
{
id: "sess-other-client",
staffId: "staff-customer-id",
clientId: "00000000-0000-0000-0000-000000000099", // different from CLIENT_ID
reason: "sso-bridge",
status: "active",
startedAt: new Date("2024-11-01"),
endedAt: null,
expiresAt: new Date("2099-01-01T00:00:00Z"),
createdAt: new Date("2024-11-01"),
},
];
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-other-client" },
});
expect(res.status).toBe(403);
});
it("customer with expired portal session still gets 403", async () => {
const app = makeApp(CUSTOMER_STAFF);
mock.appointments = [];
mock.impersonationSessions = [
{
id: "sess-expired",
staffId: "staff-customer-id",
clientId: CLIENT_ID,
reason: "sso-bridge",
status: "active",
startedAt: new Date("2024-01-01"),
endedAt: null,
expiresAt: new Date("2024-02-01T00:00:00Z"), // expired long ago
createdAt: new Date("2024-01-01"),
},
];
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-expired" },
});
expect(res.status).toBe(403);
});
it("manager does NOT need the impersonation header (existing role check still works)", async () => {
const app = makeApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
});
it("groomer with linkage to pet's client still works (regression — no regression from bypass)", async () => {
const app = makeApp(GROOMER);
// GROOMER fixture has appointments linked to staff-groomer-id in the mock state
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
});
});
@@ -67,6 +67,7 @@ let dbAccounts: AccountRow[] = [];
let dbStaff: StaffRow[] = [];
let insertedUsers: UserRow[] = [];
let insertedAccounts: AccountRow[] = [];
let updatedAccounts: Array<{ id: string; password: string }> = [];
let updatedStaff: Array<{ id: string; userId: string }> = [];
const originalEnv = { ...process.env };
@@ -77,6 +78,7 @@ function resetMock() {
dbStaff = [];
insertedUsers = [];
insertedAccounts = [];
updatedAccounts = [];
updatedStaff = [];
process.env = { ...originalEnv };
}
@@ -173,7 +175,11 @@ async function seedUatCredentials(
);
if (existingAccount) {
// skip — already has credential account
// Idempotent update: re-hash the current env password and update the stored hash.
const { hashPassword } = await import("better-auth/crypto");
const passwordHash = await hashPassword(password);
existingAccount.password = passwordHash;
updatedAccounts.push({ id: existingAccount.id, password: passwordHash });
} else {
// Use Better-Auth's hashPassword so test helper matches production seed.ts
const { hashPassword } = await import("better-auth/crypto");
@@ -312,9 +318,9 @@ describe("seedUatCredentials — credential provisioning logic", () => {
expect(updatedStaff).toHaveLength(0);
});
// ── AC-5: idempotent — skips when user already exists ───────────────────────
// ── AC-5: idempotent — does not insert duplicate records ───────────────────
it("AC-5: re-running does not duplicate user or account records (idempotent)", async () => {
it("AC-5: re-running does not insert duplicate user or account records", async () => {
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
const preExistingUsers: UserRow[] = [
@@ -330,25 +336,96 @@ describe("seedUatCredentials — credential provisioning logic", () => {
},
];
// First call — nothing inserted (user + account pre-exist)
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
// No inserts — user and account already exist
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
});
// ── AC-5b: password rotation on re-seed ─────────────────────────────────────
it("AC-5b: re-running with a new password updates the stored credential hash", async () => {
const OLD_PASSWORD = "old-password-abc";
const NEW_PASSWORD = "new-password-xyz";
process.env.SEED_UAT_CUSTOMER_PASSWORD = NEW_PASSWORD;
const preExistingUsers: UserRow[] = [
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
];
const preExistingAccounts: AccountRow[] = [
{
id: "pre-existing-acct",
accountId: "pre-existing-user",
providerId: "credential",
userId: "pre-existing-user",
password: await hashPassword(OLD_PASSWORD),
},
];
// Second call — still nothing inserted
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
// No new records inserted
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
// Password WAS updated to the new env value
expect(updatedAccounts).toHaveLength(1);
expect(updatedAccounts[0]!.id).toBe("pre-existing-acct");
// New hash is valid Better-Auth format (salt:key, each hex)
const newHashParts = updatedAccounts[0]!.password.split(":");
expect(Buffer.from(newHashParts[0]!, "hex")).toHaveLength(16);
expect(Buffer.from(newHashParts[1]!, "hex")).toHaveLength(64);
});
// ── AC-8: existing account password IS updated (not frozen at first-seed) ──
it("AC-8: re-seeding with a changed password env var updates the stored hash", async () => {
const ORIGINAL_PASSWORD = "original-password";
const ROTATED_PASSWORD = "rotated-password-456";
process.env.SEED_UAT_CUSTOMER_PASSWORD = ROTATED_PASSWORD;
const preExistingUsers: UserRow[] = [
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
];
// Account was created with the original password on first seed
const originalHash = await hashPassword(ORIGINAL_PASSWORD);
const preExistingAccounts: AccountRow[] = [
{
id: "pre-existing-acct",
accountId: "pre-existing-user",
providerId: "credential",
userId: "pre-existing-user",
password: originalHash,
},
];
// Re-seed with the rotated password env var
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
// No new user or account created
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
// The pre-existing account's password WAS updated (not frozen at first-seed).
// hashPassword uses a random salt so we verify by format + that it is a new,
// different valid hash from the original.
const updatedAcct = preExistingAccounts[0]!;
expect(updatedAcct.password).toBeDefined();
expect(updatedAcct.password).toMatch(/^[a-f0-9]{32}:[a-f0-9]{128}$/);
expect(updatedAcct.password).not.toBe(originalHash); // it actually changed
});
// ── AC-6: missing env var skips with warning ────────────────────────────────
File diff suppressed because it is too large Load Diff
+167 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, exists, getDb, or, pets, appointments } from "../db/index.js";
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, impersonationSessions, or, pets, appointments, staff, services, sql } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
import {
getPresignedUploadUrl,
@@ -283,3 +283,169 @@ petsRouter.get("/:petId/photo", async (c) => {
const url = await getPresignedGetUrl(pet.photoKey);
return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt });
});
// ─── Profile Summary ───────────────────────────────────────────────────────────
async function groomerLinkageCheck(
db: ReturnType<typeof getDb>,
clientId: string,
staffRow: NonNullable<AppEnv["Variables"]["staff"]>
): Promise<boolean> {
const [linkage] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.clientId, clientId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
return !!linkage;
}
/**
* Resolves the clientId from the X-Impersonation-Session-Id header, if present and active.
* Used by staff routes to allow a customer (auto-provisioned as a `groomer` staff row
* by rbac.ts) to access their own pet's data when they are the rightful owner.
*
* Returns null when the header is missing, the session is unknown/expired/ended, or the
* session exists but has no clientId — callers should treat null as "no owner-bypass".
*/
async function resolveImpersonationClientId(
db: ReturnType<typeof getDb>,
c: { req: { header: (name: string) => string | undefined } }
): Promise<string | null> {
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) return null;
const [session] = await db
.select({ clientId: impersonationSessions.clientId, status: impersonationSessions.status, expiresAt: impersonationSessions.expiresAt })
.from(impersonationSessions)
.where(eq(impersonationSessions.id, sessionId))
.limit(1);
if (!session) return null;
if (session.status !== "active") return null;
if (session.expiresAt <= new Date()) return null;
return session.clientId;
}
/**
* GET /:id/profile-summary
* Returns aggregated profile: basic pet fields + grooming history + visit stats + upcoming appointment.
* Groomer RBAC: same visibility rules as GET /:id.
* Owner-bypass (GRO-2013): a customer who supplies a valid X-Impersonation-Session-Id
* for the pet's owning client may read their own pet's summary, even though rbac.ts
* auto-provisions them as a `groomer` staff row with no appointment linkage.
*/
petsRouter.get("/:id/profile-summary", async (c) => {
const db = getDb();
const petId = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [row] = await db.select().from(pets).where(eq(pets.id, petId));
if (!row) return c.json({ error: "Not found" }, 404);
// Owner-bypass: customer with a valid portal session for this pet's client
// is allowed to view their own pet's profile summary (GRO-2013).
let isOwner = false;
if (isGroomer) {
const ownerClientId = await resolveImpersonationClientId(db, c);
isOwner = !!ownerClientId && ownerClientId === row.clientId;
}
if (isGroomer && !isOwner) {
const hasLinkage = await groomerLinkageCheck(db, row.clientId, staffRow);
if (!hasLinkage) return c.json({ error: "Forbidden" }, 403);
}
// Recent grooming history: last 10, with staff name join
const historyRows = await db
.select({
id: groomingVisitLogs.id,
petId: groomingVisitLogs.petId,
appointmentId: groomingVisitLogs.appointmentId,
staffId: groomingVisitLogs.staffId,
staffName: staff.name,
cutStyle: groomingVisitLogs.cutStyle,
productsUsed: groomingVisitLogs.productsUsed,
notes: groomingVisitLogs.notes,
groomedAt: groomingVisitLogs.groomedAt,
createdAt: groomingVisitLogs.createdAt,
})
.from(groomingVisitLogs)
.leftJoin(staff, eq(staff.id, groomingVisitLogs.staffId))
.where(eq(groomingVisitLogs.petId, petId))
.orderBy(desc(groomingVisitLogs.groomedAt))
.limit(10);
const recentGroomingHistory = historyRows.map((r) => ({
id: r.id,
petId: r.petId,
appointmentId: r.appointmentId,
staffId: r.staffId,
staffName: r.staffName,
cutStyle: r.cutStyle,
productsUsed: r.productsUsed,
notes: r.notes,
groomedAt: r.groomedAt?.toISOString() ?? null,
createdAt: r.createdAt?.toISOString() ?? null,
}));
const lastVisitDate = historyRows[0]?.groomedAt?.toISOString() ?? null;
// Completed appointment count for this pet
const [{ count: visitCount }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(appointments)
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")));
// Upcoming appointment: next scheduled or confirmed
const [nextAppt] = await db
.select({
id: appointments.id,
serviceId: appointments.serviceId,
staffId: appointments.staffId,
startTime: appointments.startTime,
endTime: appointments.endTime,
status: appointments.status,
serviceName: services.name,
staffName: staff.name,
})
.from(appointments)
.leftJoin(services, eq(services.id, appointments.serviceId))
.leftJoin(staff, eq(staff.id, appointments.staffId))
.where(
and(
eq(appointments.petId, petId),
or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed")),
gte(appointments.startTime, new Date())
)
)
.orderBy(appointments.startTime)
.limit(1);
const upcomingAppointment = nextAppt
? {
id: nextAppt.id,
serviceId: nextAppt.serviceId,
serviceName: nextAppt.serviceName,
staffId: nextAppt.staffId,
staffName: nextAppt.staffName,
startTime: nextAppt.startTime?.toISOString() ?? null,
endTime: nextAppt.endTime?.toISOString() ?? null,
status: nextAppt.status,
}
: null;
return c.json({
...row,
recentGroomingHistory,
lastVisitDate,
visitCount,
upcomingAppointment,
});
});
@@ -0,0 +1,8 @@
-- Migration: 0034_extend_pet_profile_columns.sql
-- GRO-1850: Adds temperament_score, temperament_flags, medical_alerts,
-- and preferred_cuts columns to the pets table.
ALTER TABLE "pets" ADD COLUMN "temperament_score" integer;
ALTER TABLE "pets" ADD COLUMN "temperament_flags" jsonb DEFAULT '[]';
ALTER TABLE "pets" ADD COLUMN "medical_alerts" jsonb DEFAULT '[]';
ALTER TABLE "pets" ADD COLUMN "preferred_cuts" jsonb DEFAULT '[]';
@@ -0,0 +1,9 @@
-- Migration: 0035_add_missing_coat_type_values.sql
-- Adds missing values to coat_type enum that seed.ts requires but which were
-- omitted from the 0031_buffer_rules.sql CREATE TYPE statement (migration drift).
-- 0031 created: 'smooth', 'double', 'wire', 'curly', 'long', 'hairless'
-- Missing (from schema.ts coatTypeEnum): 'short', 'medium', 'silky'
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short';
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'medium';
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'silky';
@@ -0,0 +1,14 @@
-- Migration: 0035_add_short_to_coat_type_enum.sql
-- GRO-1953: Adds missing "short" value to the coat_type enum so that seed data
-- (which uses coatTypePool including "short") can be inserted without error.
--
-- The seed file defines coatTypePool as:
-- ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]
-- but migration 0031 created the enum without "short", causing:
-- PostgresError: invalid input value for enum coat_type: "short"
BEGIN;
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short';
COMMIT;
@@ -0,0 +1,9 @@
-- Migration: 0036_add_missing_coat_type_values.sql
-- Adds missing values to coat_type enum that seed.ts requires but which were
-- omitted from the 0031_buffer_rules.sql CREATE TYPE statement (migration drift).
-- 0031 created: 'smooth', 'double', 'wire', 'curly', 'long', 'hairless'
-- Missing (from schema.ts coatTypeEnum): 'short', 'medium', 'silky'
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short';
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'medium';
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'silky';
@@ -0,0 +1,19 @@
-- Migration: 0037_add_extra_large_to_pet_size_category.sql
-- GRO-1979: Adds the 'extra_large' value to the pet_size_category enum.
--
-- 0031_buffer_rules.sql created pet_size_category with values
-- ('small', 'medium', 'large', 'xlarge'), but seed.ts and the drizzle
-- schema (PetSizeCategory type) both use 'extra_large' — a mismatch that
-- caused the UAT seed job to fail with:
-- invalid input value for enum pet_size_category: "extra_large"
--
-- 0035/0036 (GRO-1971) registered 'short'/'medium'/'silky' in coat_type.
-- This migration is the pet_size_category counterpart: register
-- 'extra_large' so seed.ts can write the value the schema declares.
--
-- Postgres restriction: ALTER TYPE ADD VALUE cannot run inside a
-- transaction block. The drizzle migrate runner does not wrap
-- individual statements in an explicit transaction, so this applies
-- as a single auto-commit DDL.
ALTER TYPE "pet_size_category" ADD VALUE IF NOT EXISTS 'extra_large';
@@ -0,0 +1,4 @@
-- GRO-1999: 0037 was skipped on existing DBs due to a below-high-water-mark
-- journal timestamp. Re-register extra_large with a monotonic timestamp so
-- the existing UAT/persistent DBs apply it. Idempotent.
ALTER TYPE "pet_size_category" ADD VALUE IF NOT EXISTS 'extra_large';
@@ -0,0 +1,27 @@
-- Migration: 0039_extend_pet_profile_columns_idempotent.sql
-- GRO-2033: re-register the temperament/medical/preferred-cuts columns from
-- 0034 with an idempotent ADD COLUMN IF NOT EXISTS + a monotonic journal
-- `when` (1780000000001), above the 0033 high-water mark (1779500000000)
-- and above the most recent applied migration 0038 (1780000000000).
--
-- 0034_extend_pet_profile_columns.sql was authored on 2026-05-28 with
-- `when` = 1751140800000 (2025-06-28) — *below* the 0033 high-water mark
-- of 1779500000000 (2026-05-23). drizzle-orm@0.38.4
-- (pg-core/dialect.js#migrate) only applies a migration when
-- `migration.folderMillis > lastDbMigration.created_at`, so on prod —
-- whose last applied entry was 0033 at created_at=1779500000000 — 0034
-- was silently skipped, leaving `pets.temperament_score` (and friends)
-- missing. The migrate Job still exits 0 ("migrations applied
-- successfully!") because the journal high watermark *was* advanced by
-- 0038, but no schema change ever ran for 0034. Seed/reset then crash on:
-- PostgresError: column "temperament_score" does not exist (42703)
--
-- Same pattern as GRO-1999 (0037 → 0038): do NOT modify 0034 in-place
-- (UAT/dev have already applied it via their lower watermarks). Add a
-- new idempotent migration with a monotonic `when` instead so existing
-- DBs apply it cleanly and fresh DBs are a no-op-after-no-op.
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "temperament_score" integer;
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "temperament_flags" jsonb DEFAULT '[]';
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "medical_alerts" jsonb DEFAULT '[]';
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "preferred_cuts" jsonb DEFAULT '[]';
@@ -0,0 +1,26 @@
-- Migration: 0040_register_missing_coat_type_values.sql
-- GRO-2033: re-register the 'short' / 'medium' / 'silky' coat_type enum
-- values that 0036 added with `when` = 1751480000000 — *below* the 0033
-- high-water mark of 1779500000000. drizzle-orm@0.38.4
-- (pg-core/dialect.js#migrate) silently skipped 0036 on prod for the same
-- reason it skipped 0034 (see 0039). 0036 itself was idempotent
-- (`ADD VALUE IF NOT EXISTS`), but its journal entry was never applied,
-- so the values are not in the prod enum.
--
-- Same pattern as GRO-1999 (0037 → 0038) and 0039: do NOT modify 0036 in
-- place. Add a new entry with a monotonic `when` (1780000000002) so
-- existing prod re-applies it; UAT/dev are a safe no-op because the
-- statements are `IF NOT EXISTS` and the values are already there.
--
-- Postgres restriction: `ALTER TYPE ... ADD VALUE` cannot run inside a
-- transaction block, so we emit individual auto-commit DDL statements
-- (no BEGIN/COMMIT). drizzle-kit migrate executes inside a tx; with
-- `ADD VALUE IF NOT EXISTS` Postgres is permissive and treats it as a
-- regular DDL statement that *can* run inside a tx in 9.6+ when no new
-- value is actually added. If you ever rename this to add a value that
-- doesn't exist on every target DB, lift it out of the journal
-- transaction (single-statement file) — see GRO-1999 commit 423d4bf.
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short';
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'medium';
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'silky';
@@ -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");
@@ -0,0 +1,210 @@
{
"id": "0034_extend_pet_profile_columns",
"prevId": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.pets": {
"name": "pets",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"client_id": {
"name": "client_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"species": {
"name": "species",
"type": "text",
"primaryKey": false,
"notNull": true
},
"breed": {
"name": "breed",
"type": "text",
"primaryKey": false,
"notNull": false
},
"weight_kg": {
"name": "weight_kg",
"type": "numeric(5, 2)",
"primaryKey": false,
"notNull": false
},
"date_of_birth": {
"name": "date_of_birth",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"health_alerts": {
"name": "health_alerts",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grooming_notes": {
"name": "grooming_notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cut_style": {
"name": "cut_style",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shampoo_preference": {
"name": "shampoo_preference",
"type": "text",
"primaryKey": false,
"notNull": false
},
"special_care_notes": {
"name": "special_care_notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"coat_type": {
"name": "coat_type",
"type": "coat_type",
"primaryKey": false,
"notNull": false
},
"pet_size_category": {
"name": "pet_size_category",
"type": "pet_size_category",
"primaryKey": false,
"notNull": false
},
"temperament_score": {
"name": "temperament_score",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"temperament_flags": {
"name": "temperament_flags",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[]'::jsonb"
},
"medical_alerts": {
"name": "medical_alerts",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[]'::jsonb"
},
"preferred_cuts": {
"name": "preferred_cuts",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[]'::jsonb"
},
"custom_fields": {
"name": "custom_fields",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"photo_key": {
"name": "photo_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"photo_uploaded_at": {
"name": "photo_uploaded_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"pets_client_id_clients_id_fk": {
"name": "pets_client_id_clients_id_fk",
"tableFrom": "pets",
"tableTo": "clients",
"columnsFrom": [
"client_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"coat_type": {
"name": "coat_type",
"values": [
"short",
"medium",
"long",
"wire",
"double",
"hairless",
"curly"
]
},
"pet_size_category": {
"name": "pet_size_category",
"values": [
"small",
"medium",
"large",
"extra_large"
]
}
},
"nativeEnums": {}
}
+49
View File
@@ -239,6 +239,55 @@
"when": 1779500000000,
"tag": "0033_add_services_default_buffer_minutes",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1751140800000,
"tag": "0034_extend_pet_profile_columns",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1751480000000,
"tag": "0036_add_missing_coat_type_values",
"breakpoints": true
},
{
"idx": 37,
"version": "7",
"when": 1751500000000,
"tag": "0037_add_extra_large_to_pet_size_category",
"breakpoints": true
},
{
"idx": 38,
"version": "7",
"when": 1780000000000,
"tag": "0038_register_extra_large_pet_size_category",
"breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1780000000001,
"tag": "0039_extend_pet_profile_columns_idempotent",
"breakpoints": true
},
{
"idx": 40,
"version": "7",
"when": 1780000000002,
"tag": "0040_register_missing_coat_type_values",
"breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1780000000003,
"tag": "0041_route_optimization",
"breakpoints": true
}
]
}
+4 -3
View File
@@ -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"
},
+104
View File
@@ -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.<ns>.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);
});
+3
View File
@@ -78,6 +78,9 @@ export function buildClient(overrides: Partial<ClientRow> = {}): 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,
+82
View File
@@ -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),
]
);
+991 -109
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -225,3 +225,34 @@ export interface MedicalAlert {
}
export type CoatType = "smooth" | "double" | "curly" | "wire" | "long" | "hairless";
export interface GroomingHistoryEntry {
id: string;
petId: string;
appointmentId: string | null;
staffId: string | null;
staffName: string | null;
cutStyle: string | null;
productsUsed: string | null;
notes: string | null;
groomedAt: string;
createdAt: string;
}
export interface UpcomingAppointment {
id: string;
serviceId: string;
serviceName: string;
staffId: string | null;
staffName: string | null;
startTime: string;
endTime: string;
status: AppointmentStatus;
}
export interface PetProfileSummary extends Pet {
recentGroomingHistory: GroomingHistoryEntry[];
lastVisitDate: string | null;
visitCount: number;
upcomingAppointment: UpcomingAppointment | null;
}
+192
View File
@@ -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<GeocodeResult | null>
): 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<string, unknown>[] = [];
const queue = [...selectQueue];
const chain = () => {
const rows = queue.shift() ?? [];
const proxy: Record<string, unknown> = {};
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<string, unknown>) => ({
where: () => {
updates.push(vals);
return { returning: async () => [] };
},
}),
}),
updates,
};
return db as unknown as Parameters<typeof geocodeClient>[0] & {
updates: Record<string, unknown>[];
};
}
const clientRow = (over: Record<string, unknown> = {}) =>
({
id: "client-1",
name: "Alice",
email: "a@example.com",
address: "1 Main St",
latitude: null,
longitude: null,
geocodedAt: null,
...over,
}) as unknown as Parameters<typeof geocodeClient>[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();
});
});
+89
View File
@@ -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();
});
});
+313
View File
@@ -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<string, string> }> = [];
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],
]);
});
});
+140
View File
@@ -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");
});
});
+631
View File
@@ -0,0 +1,631 @@
/**
* Pet Profile Summary Tests
*
* Covers GET /api/pets/:id/profile-summary in the deployed tree (root src/).
*
* Two suites share one mock harness:
*
* 1. GRO-2013 owner-bypass (the deployed-tree port of #135):
* A customer who is auto-provisioned as a `groomer` staff row by rbac.ts
* (with no appointment linkage) may still read their own pet's summary
* when they supply a valid X-Impersonation-Session-Id whose clientId
* matches the pet's clientId.
*
* 2. GRO-2014 error handling (deployed tree):
* - Empty-body 500 must never escape the route — the onError handler
* converts unhandled errors into a structured JSON 500.
* - Malformed UUIDs must return 404 (not 500 via a Postgres uuid cast).
* - Missing staff context must return 401 (not TypeError on staffRow.id).
* - Pet not found must return 404.
* - Groomer with no appointment linkage must return 403.
* - Manager and groomer with linkage must receive the summary body.
*
* Deployed tree handler: src/routes/pets.ts. The mock queries the
* `appointments` table (the live schema) for visit history, not
* `groomingVisitLogs`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
// ─── Staff fixtures ──────────────────────────────────────────────────────────
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
userId: null,
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const GROOMER: StaffRow = {
...MANAGER,
id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub",
role: "groomer",
isSuperUser: false,
name: "Groomer Gary",
email: "groomer@example.com",
};
/**
* Mirrors the auto-provisioned "groomer" staff row rbac.ts creates for an
* OIDC user (e.g. uat-customer@groombook.dev) on first login: role=groomer,
* no appointment linkage.
*/
const CUSTOMER_STAFF: StaffRow = {
...MANAGER,
id: "staff-customer-id",
oidcSub: null,
userId: "user-customer-id",
role: "groomer",
name: "UAT Customer",
email: "uat-customer@groombook.dev",
};
// ─── Mutable mock state ─────────────────────────────────────────────────────
const CLIENT_ID = "c0000001-0000-0000-0000-000000000001";
const PET_ID = "c0000001-0000-0000-0000-000000000002";
const OTHER_CLIENT_PET_ID = "c0000002-0000-0000-0000-000000000099";
const UNKNOWN_PET_UUID = "00000000-0000-0000-0000-000000000001";
const futureDate = () => new Date(Date.now() + 30 * 60_000);
const pastDate = () => new Date(Date.now() - 5 * 60_000);
function makePet(overrides: Record<string, unknown> = {}) {
return {
id: PET_ID,
clientId: CLIENT_ID,
name: "Biscuit",
species: "dog",
breed: "Golden Retriever",
weightKg: "30.00",
dateOfBirth: null,
healthAlerts: null,
groomingNotes: null,
cutStyle: null,
shampooPreference: null,
specialCareNotes: null,
customFields: {},
petSizeCategory: "large",
coatType: "double",
photoKey: null,
photoUploadedAt: null,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
...overrides,
};
}
function makeAppointment(overrides: Record<string, unknown> = {}) {
return {
id: "appt-1",
clientId: CLIENT_ID,
petId: PET_ID,
serviceId: "service-1",
staffId: GROOMER.id,
batherStaffId: null,
status: "completed",
startTime: new Date("2024-06-01T09:00:00Z"),
endTime: new Date("2024-06-01T11:00:00Z"),
notes: null,
priceCents: 6000,
seriesId: null,
seriesIndex: null,
groupId: null,
confirmationStatus: "confirmed",
confirmedAt: null,
cancelledAt: null,
confirmationToken: null,
customerNotes: null,
createdAt: new Date("2024-05-15"),
updatedAt: new Date("2024-05-15"),
...overrides,
};
}
function makeSession(overrides: Record<string, unknown> = {}) {
return {
id: "sess-owner",
staffId: CUSTOMER_STAFF.id,
clientId: CLIENT_ID,
reason: "sso-bridge",
status: "active",
startedAt: new Date(),
endedAt: null,
expiresAt: futureDate(),
createdAt: new Date(),
...overrides,
};
}
// ─── DB mock state ──────────────────────────────────────────────────────────
let petsTable: Record<string, unknown>[];
let appointmentsTable: Record<string, unknown>[];
let sessionsTable: Record<string, unknown>[];
// selectQueue: queries resolve in FIFO order. Each .from(table) result
// returns a chain that resolves to the next queued row set on a terminal
// call (.where()/.orderBy()/.limit()).
//
// A queued entry of `{ table: "pets", rows: null, throw: "..." }` tells the
// mock to throw instead of returning rows — used by the GRO-2014 "JSON
// envelope on downstream error" test. Any other queued entry with `rows`
// resolves to those rows. An entry with `rows: []` returns an empty array
// (no rows, no throw).
let selectQueue: Array<{
table: string;
rows: unknown[] | null;
throw?: string;
}> = [];
// Captured `db.insert(table).values(vals)` calls. Mirrors the pattern from
// src/__tests__/impersonation.test.ts so the GRO-2063 audit row assertions
// can inspect what the route tried to write without needing a real DB.
let insertCapture: Array<{ table: string; vals: Record<string, unknown> }> = [];
function enqueue(table: string, rows: unknown[] = []) {
selectQueue.push({ table, rows });
}
function enqueueThrow(table: string, message: string) {
selectQueue.push({ table, rows: null, throw: message });
}
function resetMock() {
petsTable = [makePet()];
appointmentsTable = [makeAppointment()];
sessionsTable = [makeSession()];
selectQueue = [];
insertCapture = [];
}
// ─── Module mocks ───────────────────────────────────────────────────────────
vi.mock("@groombook/db", () => {
function makeTable(name: string) {
return new Proxy(
{ _name: name },
{
get(target, prop) {
if (prop === "_name") return name;
if (prop === "$inferSelect") return {};
return { table: name, column: prop };
},
}
);
}
function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) {
const queryString = _strings[0];
return {
queryChunks: [queryString],
as: (alias: string) => ({
queryChunks: [queryString],
fieldAlias: alias,
getSQL() { return this.queryChunks; },
}),
};
}
function takeQueuedRows(tableName: string): unknown[] {
const next = selectQueue.shift();
if (next && next.table === tableName) {
if (next.throw) {
throw new Error(next.throw);
}
return next.rows ?? [];
}
return [];
}
// Wrap a finalised result in a Proxy that exposes chainable methods
// and the resolved rows. Each call to a chainable method (where/orderBy/
// limit/...) returns the SAME rows so the route's natural await on the
// chain resolves to the queued data.
function wrapRows(rows: unknown[]): unknown {
return new Proxy(rows, {
get(target, prop: string | symbol) {
if (prop === "where" || prop === "orderBy" || prop === "limit"
|| prop === "leftJoin" || prop === "innerJoin" || prop === "from") {
return () => wrapRows(rows);
}
if (prop === "then") {
return (onFulfilled?: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) =>
Promise.resolve(rows).then(onFulfilled, onRejected);
}
if (prop === Symbol.iterator) {
return function* () { for (const v of target) yield v; };
}
if (prop === Symbol.asyncIterator) {
return async function* () { for (const v of target) yield v; };
}
// @ts-expect-error proxy access
return target[prop];
},
});
}
return {
getDb: () => ({
select: (_cols?: Record<string, unknown>) => ({
from: (table: { _name?: string }) => wrapRows(takeQueuedRows(table._name ?? "")),
}),
insert: (table: { _name?: string }) => ({
values: (vals: Record<string, unknown>) => {
insertCapture.push({ table: table._name ?? "unknown", vals });
return { returning: () => [{}] };
},
}),
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
}),
pets: makeTable("pets"),
appointments: makeTable("appointments"),
staff: makeTable("staff"),
services: makeTable("services"),
impersonationSessions: makeTable("impersonationSessions"),
impersonationAuditLogs: makeTable("impersonation_audit_logs"),
and: vi.fn((..._args: unknown[]) => ({})),
desc: vi.fn((c: unknown) => c),
eq: vi.fn((_a: unknown, _b: unknown) => ({})),
exists: vi.fn(() => true),
or: vi.fn((..._args: unknown[]) => ({})),
sql: sqlMock,
};
});
vi.mock("../lib/s3.js", () => ({
getPresignedUploadUrl: vi.fn(),
getPresignedGetUrl: vi.fn(),
deleteObject: vi.fn(),
}));
// ─── Import after mocks are set up ──────────────────────────────────────────
const { petsRouter } = await import("../routes/pets.js");
// ─── App builder ────────────────────────────────────────────────────────────
function buildApp(staffRow: StaffRow | null) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
if (staffRow) {
c.set("jwtPayload", { sub: staffRow.oidcSub ?? staffRow.userId ?? "" });
c.set("staff", staffRow);
}
await next();
});
app.route("/pets", petsRouter);
return app;
}
// ─── Reset before each test ─────────────────────────────────────────────────
beforeEach(() => {
resetMock();
vi.clearAllMocks();
});
// ─── GRO-2014 error-handling suite ──────────────────────────────────────────
describe("GET /:id/profile-summary — GRO-2014 error handling", () => {
it("returns 404 (not 500) for a malformed UUID path param", async () => {
const app = buildApp(MANAGER);
const res = await app.request("/pets/not-a-uuid/profile-summary");
expect(res.status).toBe(404);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Not found");
});
it("returns 401 when staff context is missing (defense in depth)", async () => {
const app = buildApp(null);
const res = await app.request(`/pets/${UNKNOWN_PET_UUID}/profile-summary`);
expect(res.status).toBe(401);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Unauthorized");
});
it("returns 404 when authenticated and pet does not exist", async () => {
enqueue("pets", []);
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${UNKNOWN_PET_UUID}/profile-summary`);
expect(res.status).toBe(404);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Not found");
});
it("returns 403 when groomer has no appointment linkage to the pet's client", async () => {
enqueue("pets", petsTable);
enqueue("appointments", []); // linkage check returns empty → 403
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(403);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Forbidden");
});
it("returns 200 with summary for a manager (no groomer linkage check)", async () => {
enqueue("pets", petsTable);
enqueue("appointments", appointmentsTable); // history
enqueue("appointments", [{ count: 1 }]); // visit count
enqueue("appointments", []); // upcoming (none)
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body.id).toBe(PET_ID);
expect(body.name).toBe("Biscuit");
expect(body.visitCount).toBe(1);
expect(body.upcomingAppointment).toBeNull();
expect(body.recentGroomingHistory).toBeInstanceOf(Array);
});
it("returns 200 with summary for a groomer with appointment linkage", async () => {
enqueue("pets", petsTable);
enqueue("appointments", [{ id: "appt-1" }]); // linkage found
enqueue("appointments", appointmentsTable); // history
enqueue("appointments", [{ count: 1 }]); // visit count
enqueue("appointments", []); // upcoming
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body.id).toBe(PET_ID);
});
it("returns a JSON envelope (not empty body) when a downstream query throws", async () => {
enqueueThrow("pets", "simulated postgres uuid cast failure");
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(500);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Internal Server Error");
});
});
// ─── GRO-2013 owner-bypass suite ────────────────────────────────────────────
describe("GET /:id/profile-summary — owner-bypass (GRO-2013)", () => {
it("returns 404 when the pet does not exist", async () => {
enqueue("pets", []);
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(404);
});
it("returns 200 with aggregated profile for a manager", async () => {
enqueue("pets", petsTable);
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe(PET_ID);
expect(body.name).toBe("Biscuit");
expect(body.recentGroomingHistory).toBeInstanceOf(Array);
expect(body.visitCount).toBe(1);
expect(body.upcomingAppointment).toBeNull();
});
it("returns 200 for a groomer with appointment linkage to the pet's client", async () => {
enqueue("pets", petsTable);
enqueue("appointments", [{ id: "appt-1" }]); // linkage found
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
});
it("returns 403 for a groomer with no appointment linkage and no bypass header", async () => {
enqueue("pets", petsTable);
enqueue("appointments", []); // no linkage
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(403);
});
it("customer-as-groomer with valid active session for pet's client returns 200", async () => {
enqueue("pets", petsTable);
enqueue("impersonationSessions", sessionsTable); // active session found
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-owner" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe(PET_ID);
});
it("customer-as-groomer with no header still gets 403 (no bypass)", async () => {
enqueue("pets", petsTable);
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(403);
});
it("customer-as-groomer with session for a DIFFERENT client gets 403 (cross-tenant blocked)", async () => {
// Session exists but clientId !== pet.clientId → bypass does not apply
// → falls through to groomer linkage check → no linkage → 403
enqueue("pets", petsTable);
enqueue("impersonationSessions", [
makeSession({
id: "sess-other-client",
clientId: "c0000000-0000-0000-0000-000000000099", // different from CLIENT_ID
}),
]);
enqueue("appointments", []); // no linkage → 403
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-other-client" },
});
expect(res.status).toBe(403);
});
it("customer-as-groomer with expired session still gets 403", async () => {
enqueue("pets", petsTable);
enqueue("impersonationSessions", [
makeSession({ id: "sess-expired", expiresAt: pastDate() }),
]);
enqueue("appointments", []); // no linkage → 403
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-expired" },
});
expect(res.status).toBe(403);
});
it("customer-as-groomer with ended (status != active) session still gets 403", async () => {
enqueue("pets", petsTable);
enqueue("impersonationSessions", [
makeSession({ id: "sess-ended", status: "ended" }),
]);
enqueue("appointments", []); // no linkage → 403
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-ended" },
});
expect(res.status).toBe(403);
});
it("customer-as-groomer with unknown session id still gets 403", async () => {
enqueue("pets", petsTable);
enqueue("impersonationSessions", []); // session not found
enqueue("appointments", []); // no linkage → 403
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-unknown" },
});
expect(res.status).toBe(403);
});
it("manager does NOT need the impersonation header (existing role check still works)", async () => {
enqueue("pets", petsTable);
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
});
it("groomer with linkage to pet's client still works (regression — no regression from bypass)", async () => {
enqueue("pets", petsTable);
enqueue("appointments", [{ id: "appt-1" }]); // linkage found
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
});
it("owner-bypass: customer cannot view another client's pet (cross-tenant block)", async () => {
// The customer has a valid session for CLIENT_ID, but the pet belongs
// to a different client → isOwner=false → falls through to groomer
// linkage check → 403.
enqueue("pets", [
makePet({ id: OTHER_CLIENT_PET_ID, clientId: "c0000002-0000-0000-0000-000000000002" }),
]);
enqueue("impersonationSessions", sessionsTable); // valid session, but for CLIENT_ID
enqueue("appointments", []); // no linkage → 403
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${OTHER_CLIENT_PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-owner" },
});
expect(res.status).toBe(403);
});
});
// ─── GRO-2063 owner-bypass audit write ──────────────────────────────────────
describe("GET /:id/profile-summary — owner-bypass audit row (GRO-2063)", () => {
it("writes exactly one audit row on the owner-bypass success path", async () => {
enqueue("pets", petsTable);
enqueue("impersonationSessions", sessionsTable); // valid active session for CLIENT_ID
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-owner" },
});
expect(res.status).toBe(200);
const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
expect(auditInserts).toHaveLength(1);
const vals = auditInserts[0]!.vals;
expect(vals.action).toBe("read_profile_summary");
expect(vals.sessionId).toBe("sess-owner");
expect(vals.pageVisited).toBe(`/pets/${PET_ID}/profile-summary`);
expect(vals.metadata).toEqual({
petId: PET_ID,
actorStaffId: CUSTOMER_STAFF.id,
});
});
it("does NOT write an audit row on the normal groomer-linkage success path", async () => {
// GROOMER is a "real" groomer with appointment linkage, NOT the
// auto-provisioned customer-as-groomer. No impersonation header is
// present, so the owner-bypass branch never executes.
enqueue("pets", petsTable);
enqueue("appointments", [{ id: "appt-1" }]); // linkage found
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
expect(auditInserts).toHaveLength(0);
});
it("does NOT write an audit row when the owner-bypass attempt is denied (cross-tenant)", async () => {
// Customer has a valid session but it points at a different client.
// isOwner=false, falls through to groomer linkage check, returns 403.
enqueue("pets", [
makePet({ id: OTHER_CLIENT_PET_ID, clientId: "c0000002-0000-0000-0000-000000000002" }),
]);
enqueue("impersonationSessions", sessionsTable); // session is for CLIENT_ID
enqueue("appointments", []); // no linkage → 403
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${OTHER_CLIENT_PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-owner" },
});
expect(res.status).toBe(403);
const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
expect(auditInserts).toHaveLength(0);
});
});
+130
View File
@@ -39,11 +39,19 @@ const APPOINTMENT = {
let selectSessionRow: Record<string, unknown> | null = null;
let selectAppointmentRow: Record<string, unknown> | null = null;
let selectWaitlistRows: Record<string, unknown>[] = [];
let selectPetRows: Record<string, unknown>[] = [];
let selectStaffRows: Record<string, unknown>[] = [];
let selectServiceRows: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
function resetMock() {
selectSessionRow = null;
selectAppointmentRow = null;
selectWaitlistRows = [];
selectPetRows = [];
selectStaffRows = [];
selectServiceRows = [];
updatedValues = [];
}
@@ -72,6 +80,13 @@ vi.mock("@groombook/db", () => {
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
const mkTable = (name: string) =>
new Proxy({ _name: name }, { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) });
const waitlistEntries = mkTable("waitlistEntries");
const pets = mkTable("pets");
const staff = mkTable("staff");
const services = mkTable("services");
return {
getDb: () => ({
select: () => ({
@@ -82,6 +97,18 @@ vi.mock("@groombook/db", () => {
if (table._name === "appointments") {
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
}
if (table._name === "waitlistEntries") {
return makeChainable(selectWaitlistRows);
}
if (table._name === "pets") {
return makeChainable(selectPetRows);
}
if (table._name === "staff") {
return makeChainable(selectStaffRows);
}
if (table._name === "services") {
return makeChainable(selectServiceRows);
}
return makeChainable([]);
},
}),
@@ -102,8 +129,13 @@ vi.mock("@groombook/db", () => {
}),
impersonationSessions,
appointments,
waitlistEntries,
pets,
staff,
services,
eq: vi.fn(),
and: vi.fn(),
inArray: vi.fn(),
};
});
@@ -125,6 +157,104 @@ function jsonPatch(path: string, body: unknown, headers?: Record<string, string>
beforeEach(() => resetMock());
// GRO-2319 item 2: the portal Upcoming list renders active waitlist entries as
// synthetic `waitlisted` cards, so GET /portal/appointments must surface them.
describe("GET /portal/appointments (waitlist surfacing — GRO-2319)", () => {
it("returns active waitlist entries as synthetic waitlisted cards", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
selectWaitlistRows = [
{
id: "11111111-1111-1111-1111-111111111111",
petId: "pet-1",
serviceId: "svc-1",
preferredDate: "2099-01-01",
preferredTime: "13:00:00",
},
];
selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }];
const res = await app.request("/portal/appointments", {
headers: { "X-Impersonation-Session-Id": SESSION_ID },
});
expect(res.status).toBe(200);
const body = await res.json();
const waitlistCard = body.appointments.find(
(a: { status: string }) => a.status === "waitlisted",
);
expect(waitlistCard).toBeTruthy();
expect(waitlistCard.id).toBe("waitlist:11111111-1111-1111-1111-111111111111");
expect(waitlistCard.pet.name).toBe("Rex");
expect(waitlistCard.confirmationStatus).toBeNull();
// startTime is derived from preferredDate + preferredTime so the card sorts
// and classifies as Upcoming.
expect(waitlistCard.startTime).toBeTruthy();
});
it("omits the waitlist section when the client has no active entries", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
selectWaitlistRows = [];
const res = await app.request("/portal/appointments", {
headers: { "X-Impersonation-Session-Id": SESSION_ID },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.appointments.some((a: { status: string }) => a.status === "waitlisted")).toBe(false);
});
});
// GRO-2342: GET /portal/appointments must populate the synthetic waitlist
// card's `service` object with the full service record (id + name) — same
// shape the appointments join returns — so the portal renders the real
// service name in place of the fallback "Service" label.
describe("GET /portal/appointments (waitlist service name — GRO-2342)", () => {
it("returns service {id, name} on the synthetic waitlist card", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
selectWaitlistRows = [
{
id: "22222222-2222-2222-2222-222222222222",
petId: "pet-1",
serviceId: "svc-1",
preferredDate: "2099-01-01",
preferredTime: "13:00:00",
},
];
selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }];
selectServiceRows = [{ id: "svc-1", name: "Full Groom" }];
const res = await app.request("/portal/appointments", {
headers: { "X-Impersonation-Session-Id": SESSION_ID },
});
expect(res.status).toBe(200);
const body = await res.json();
const waitlistCard = body.appointments.find(
(a: { status: string }) => a.status === "waitlisted",
);
expect(waitlistCard).toBeTruthy();
expect(waitlistCard.service).toEqual({ id: "svc-1", name: "Full Groom" });
});
it("returns service {id, name} on the appointment card (same shape)", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, serviceId: "svc-appt" };
selectServiceRows = [{ id: "svc-appt", name: "Bath & Brush" }];
const res = await app.request("/portal/appointments", {
headers: { "X-Impersonation-Session-Id": SESSION_ID },
});
expect(res.status).toBe(200);
const body = await res.json();
const apptCard = body.appointments.find(
(a: { status: string }) => a.status === "scheduled",
);
expect(apptCard).toBeTruthy();
expect(apptCard.service).toEqual({ id: "svc-appt", name: "Bath & Brush" });
});
});
describe("PATCH /portal/appointments/:id/notes", () => {
it("returns updated appointment with safe fields only", async () => {
selectSessionRow = ACTIVE_SESSION;
+336
View File
@@ -0,0 +1,336 @@
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<string, unknown> | null = null;
let selectPetRow: Record<string, unknown> | null = null;
let updatedValues: Record<string, unknown>[] = [];
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<string, unknown>) => ({
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<string, string>) {
return app.request(path, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify(body),
});
}
beforeEach(() => resetMock());
describe("PATCH /portal/pets/:petId", () => {
it("updates an owned pet and persists the mapped columns (200)", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = PET;
// Mirrors the groombook/web PetForm payload: it spreads the GET-shaped pet
// (weight, notes, birthDate, photoUrl) and adds the form's edited keys
// (weightKg, healthAlerts, coatType, …). "xlarge" must map to "extra_large".
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{
id: PET_ID,
name: "Rex Updated",
breed: "Golden Retriever",
weight: "12.50",
weightKg: 18.25,
notes: "old grooming notes",
healthAlerts: "Allergic to oatmeal shampoo",
photoUrl: "pets/rex.jpg",
coatType: "double",
petSizeCategory: "xlarge",
preferredCuts: ["teddy bear", "puppy cut"],
medicalAlerts: [
{ id: "a1", type: "allergy", description: "oatmeal", severity: "medium" },
],
},
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.name).toBe("Rex Updated");
expect(body.petSizeCategory).toBe("extra_large");
expect(body.coatType).toBe("double");
const persisted = updatedValues[0]!;
expect(persisted.name).toBe("Rex Updated");
expect(persisted.breed).toBe("Golden Retriever");
// weightKg (form key) wins over weight (GET key) and is stored as a string.
expect(persisted.weightKg).toBe("18.25");
expect(persisted.groomingNotes).toBe("old grooming notes");
expect(persisted.healthAlerts).toBe("Allergic to oatmeal shampoo");
// photoKey is NOT writable via portal PATCH (GRO-2187 S3 key-hijack fix):
// the web form round-trips the GET-shaped photoUrl, but the server must not
// persist it. Photo changes go through the key-validated upload flow.
expect(persisted.photoKey).toBeUndefined();
expect(persisted.coatType).toBe("double");
expect(persisted.petSizeCategory).toBe("extra_large");
expect(persisted.preferredCuts).toEqual(["teddy bear", "puppy cut"]);
expect(persisted.medicalAlerts).toEqual([
{ id: "a1", type: "allergy", description: "oatmeal", severity: "medium" },
]);
expect(persisted.updatedAt).toBeInstanceOf(Date);
});
// GRO-2187 security regression: a portal customer must not be able to set the
// S3 object key. photoKey is consumed server-side by getPresignedGetUrl /
// deleteObject; the upload path guards keys with a pets/{petId}/ prefix, and the
// portal PATCH must not offer a bypass. A foreign/arbitrary photoUrl is accepted
// (Zod strips the unknown key) but must leave photoKey untouched.
it("does not mutate photoKey when a foreign photoUrl is supplied (200)", async () => {
selectSessionRow = ACTIVE_SESSION;
const ownKey = `pets/${PET_ID}/original.jpg`;
selectPetRow = { ...PET, photoKey: ownKey };
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{
name: "Rex",
// attacker-chosen key pointing at another tenant's object
photoUrl: "pets/00000000-0000-0000-0000-0000000000ff/victim-secret.jpg",
},
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
const persisted = updatedValues[0]!;
// The attacker-supplied key never reaches the update payload.
expect(persisted.photoKey).toBeUndefined();
// And the stored key is unchanged from the pet's own value.
const body = await res.json();
expect(body.photoUrl).toBe(ownKey);
});
// The length/array caps live in the Zod schema, so violations are rejected by
// zValidator with 400 (in-handler enum checks are what return 422).
it("returns 400 when a medicalAlert description exceeds the length cap", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = PET;
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{
medicalAlerts: [
{ type: "allergy", description: "x".repeat(2001), severity: "low" },
],
},
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(400);
expect(updatedValues).toHaveLength(0);
});
it("falls back to the weight key when weightKg is absent", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = PET;
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{ weight: "9.75" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
expect(updatedValues[0]!.weightKg).toBe("9.75");
});
it("returns 403 when the pet belongs to a different client", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = { ...PET, clientId: OTHER_CLIENT_ID };
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{ name: "Hacker" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(403);
expect(updatedValues).toHaveLength(0);
});
it("returns 404 when the pet does not exist", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = null;
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{ name: "Ghost" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(404);
});
it("returns 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;
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);
});
});
+206
View File
@@ -0,0 +1,206 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import { getAuth } from "../lib/auth.js";
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
const CLIENT_EMAIL = "alice@example.com";
const CLIENT_NAME = "Alice Smith";
const UAT_CUSTOMER_ID = "c0000001-0000-0000-0000-000000000001";
const UAT_CUSTOMER_EMAIL = "uat-customer@groombook.dev";
const UAT_CUSTOMER_NAME = "UAT Customer";
const BETTER_AUTH_SESSION = {
user: {
id: "auth-user-001",
email: CLIENT_EMAIL,
name: CLIENT_NAME,
},
session: {
id: "ba-session-001",
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
};
const MOCK_CLIENT = {
id: CLIENT_ID,
email: CLIENT_EMAIL,
name: CLIENT_NAME,
};
let mockGetAuth: ReturnType<typeof vi.fn>;
let mockGetSession: ReturnType<typeof vi.fn>;
let insertedSession: Record<string, unknown> | null = null;
let mockClientRow: Record<string, unknown> | null = null;
let mockStaffRow: Record<string, unknown> | null = null;
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
return new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit") {
return () => makeChainable(target);
}
// @ts-expect-error proxy
return target[prop];
},
});
}
vi.mock("@groombook/db", () => {
const impersonationSessions = new Proxy(
{ _name: "impersonationSessions" },
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
);
const clients = new Proxy(
{ _name: "clients" },
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
);
const staff = new Proxy(
{ _name: "staff" },
{ get: (t, p) => (p === "_name" ? "staff" : { table: "staff", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: (table: { _name: string }) => {
if (table._name === "clients") {
return makeChainable(mockClientRow ? [mockClientRow] : []);
}
if (table._name === "staff") {
return makeChainable(mockStaffRow ? [mockStaffRow] : []);
}
return makeChainable([]);
},
}),
insert: (table: { _name: string }) => ({
values: (vals: Record<string, unknown>) => ({
returning: () => {
if (table._name === "impersonationSessions") {
insertedSession = { id: "new-session-001", ...vals };
return [insertedSession];
}
return [];
},
}),
}),
}),
impersonationSessions,
clients,
staff,
eq: vi.fn(),
and: vi.fn(),
inArray: vi.fn(),
};
});
vi.mock("../lib/auth.js", () => ({
getAuth: vi.fn(),
}));
const { portalRouter } = await import("../routes/portal.js");
const app = new Hono();
app.route("/portal", portalRouter);
describe("POST /portal/session-from-auth", () => {
beforeEach(() => {
insertedSession = null;
mockClientRow = null;
mockStaffRow = null;
mockGetSession = vi.fn();
mockGetAuth = vi.fn(() => ({
api: {
getSession: mockGetSession,
},
}));
vi.mocked(getAuth).mockImplementation(mockGetAuth);
});
it("returns 401 when no Better Auth session", async () => {
mockGetSession.mockResolvedValue(null);
const res = await app.request("/portal/session-from-auth", {
method: "POST",
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 404 when authenticated user has no client record", async () => {
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
mockClientRow = null;
const res = await app.request("/portal/session-from-auth", {
method: "POST",
});
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toBe("No client record found for this user");
});
it("returns a portal session with sessionId, clientId, clientName when client is found", async () => {
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
mockClientRow = MOCK_CLIENT;
mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" };
const res = await app.request("/portal/session-from-auth", {
method: "POST",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toHaveProperty("sessionId");
expect(body).toHaveProperty("clientId", CLIENT_ID);
expect(body).toHaveProperty("clientName", CLIENT_NAME);
});
it("creates a portal session with reason sso-bridge", async () => {
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
mockClientRow = MOCK_CLIENT;
mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" };
const res = await app.request("/portal/session-from-auth", {
method: "POST",
});
expect(res.status).toBe(201);
expect(insertedSession).not.toBeNull();
expect((insertedSession as Record<string, unknown>).reason).toBe("sso-bridge");
});
it("returns 201 for uat-customer SSO bridge with correct clientId and clientName", async () => {
const uatAuthSession = {
user: {
id: "auth-user-uat-customer",
email: UAT_CUSTOMER_EMAIL,
name: UAT_CUSTOMER_NAME,
},
session: {
id: "ba-session-uat-customer",
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
};
mockGetSession.mockResolvedValue(uatAuthSession);
mockClientRow = { id: UAT_CUSTOMER_ID, email: UAT_CUSTOMER_EMAIL, name: UAT_CUSTOMER_NAME };
mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" };
const res = await app.request("/portal/session-from-auth", {
method: "POST",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toHaveProperty("sessionId");
expect(body.clientId).toBe(UAT_CUSTOMER_ID);
expect(body.clientName).toBe(UAT_CUSTOMER_NAME);
expect(insertedSession).not.toBeNull();
expect((insertedSession as Record<string, unknown>).reason).toBe("sso-bridge");
});
it("returns 503 when auth is not configured", async () => {
mockGetAuth.mockImplementation(() => {
throw new Error("Auth not initialized");
});
const res = await app.request("/portal/session-from-auth", {
method: "POST",
});
expect(res.status).toBe(503);
});
});
+188
View File
@@ -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<string, unknown> | null = null;
let sessionUpdates: Record<string, unknown>[] = [];
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<string, unknown>) => {
sessionUpdates.push(vals);
return { where: () => Promise.resolve(undefined) };
},
}),
}),
impersonationSessions,
eq: vi.fn(),
and: vi.fn(),
};
});
const app = new Hono<PortalEnv>();
app.use("/portal/*", validatePortalSession);
app.get("/portal/ping", (c) => c.json({ ok: true, clientId: c.get("portalClientId") }));
function ping(headers?: Record<string, string>) {
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);
});
});
@@ -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<string, unknown>) => ({
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);
});
});
+214 -25
View File
@@ -43,42 +43,103 @@ const GROOMER: StaffRow = {
// ─── Mock DB ──────────────────────────────────────────────────────────────────
// staffLookupResult drives every `from(staff)` query that doesn't go through
// the dev-mode `.limit()` shortcut. Tests that want to simulate "no staff row"
// leave it null.
let staffLookupResult: StaffRow | null = null;
// managerFallbackResult is only consumed by the dev-mode `from(staff).limit(1)`
// path (looking up the first manager when AUTH_DISABLED=true and no header).
let managerFallbackResult: StaffRow | null = MANAGER;
// userLookupResult drives `from(user).limit(1)` for the Better-Auth user
// auto-provision branch (GRO-2052). Tests that simulate "no Better-Auth user"
// leave it null.
type UserRow = { id: string; name: string | null; email: string | null };
let userLookupResult: UserRow | null = null;
// accountLookupResult drives `from(account).limit(1)` for the legacy OIDC
// auto-provision branch. Null means "no OIDC account row".
let accountLookupResult: { id: string } | null = null;
// insertReturningResult drives `insert(staff).values(...).returning()` for
// any auto-provision branch that actually creates a staff record. Null means
// the INSERT returned no rows (simulating a DB failure).
let insertReturningResult: StaffRow | null = null;
vi.mock("@groombook/db", () => {
const staff = new Proxy(
{ _name: "staff" },
{
get(target, prop) {
if (prop === "_name") return "staff";
if (prop === "$inferSelect") return {};
return { table: "staff", column: prop };
},
}
);
function tableMarker(name: string) {
return new Proxy(
{ _name: name },
{
get(_target, prop) {
if (prop === "_name") return name;
if (prop === "$inferSelect") return {};
return { table: name, column: prop };
},
}
);
}
const staff = tableMarker("staff");
const user = tableMarker("user");
const account = tableMarker("account");
function lookupFor(tableName: string) {
if (tableName === "user") return userLookupResult;
if (tableName === "account") return accountLookupResult;
return staffLookupResult;
}
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => {
// dev mode fallback to first manager
return managerFallbackResult ? [managerFallbackResult] : [];
},
[Symbol.iterator]: function* () {
if (staffLookupResult) yield staffLookupResult;
},
0: staffLookupResult,
length: staffLookupResult ? 1 : 0,
}),
select: (_columns?: unknown) => ({
from: (table: { _name?: string }) => {
const name = table?._name ?? "staff";
return {
where: () => ({
limit: () => {
// The user / account auto-provision branches always call
// `.limit(1)`; route to the per-table lookup state.
if (name === "user")
return userLookupResult ? [userLookupResult] : [];
if (name === "account")
return accountLookupResult ? [accountLookupResult] : [];
// dev-mode `from(staff).limit(1)` falls back to the first
// manager when AUTH_DISABLED is set with no header.
return managerFallbackResult ? [managerFallbackResult] : [];
},
[Symbol.iterator]: function* () {
const row = lookupFor(name);
if (row) yield row;
},
0: lookupFor(name),
length: lookupFor(name) ? 1 : 0,
}),
};
},
}),
insert: (_table: unknown) => ({
values: (_v: unknown) => ({
returning: () =>
insertReturningResult ? [insertReturningResult] : [],
}),
}),
update: (_table: unknown) => ({
set: (_v: unknown) => ({
where: () => Promise.resolve(undefined),
}),
}),
}),
staff,
user,
account,
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
and: vi.fn((..._clauses: unknown[]) => ({})),
sql: Object.assign(
vi.fn((..._tpl: unknown[]) => ({})),
{ raw: vi.fn(() => ({})) }
),
};
});
@@ -87,16 +148,25 @@ vi.mock("@groombook/db", () => {
function resetMocks() {
staffLookupResult = null;
managerFallbackResult = MANAGER;
userLookupResult = null;
accountLookupResult = null;
insertReturningResult = null;
}
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
function buildApp(
middleware: MiddlewareHandler<AppEnv>,
handler?: (c: Context<AppEnv>) => Response | Promise<Response>
handler?: (c: Context<AppEnv>) => Response | Promise<Response>,
jwtOverride?: Partial<{ sub: string; email: string; name: string }>
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
const defaultSub = staffLookupResult?.userId ?? "unknown-sub";
c.set("jwtPayload", {
sub: jwtOverride?.sub ?? defaultSub,
...(jwtOverride?.email !== undefined ? { email: jwtOverride.email } : {}),
...(jwtOverride?.name !== undefined ? { name: jwtOverride.name } : {}),
});
await next();
});
app.use("*", middleware);
@@ -204,6 +274,125 @@ describe("resolveStaffMiddleware", () => {
});
});
// ─── Auto-provision branches (GRO-2052) ───────────────────────────────────────
//
// Each branch creates a staff row on first authenticated request when no row
// exists yet. The Better-Auth branch (user table) is the primary path for
// email/password customers; the OIDC branch (account table) is a fallback for
// legacy authentik/google/github sessions.
describe("resolveStaffMiddleware — auto-provision", () => {
const PROVISIONED: StaffRow = {
...MANAGER,
id: "staff-provisioned-id",
oidcSub: null,
userId: "ba-user-customer",
role: "groomer",
isSuperUser: false,
name: "UAT Customer",
email: "uat-customer@groombook.dev",
};
it("Better-Auth: creates a groomer staff row when user exists but no staff record (GRO-2052)", async () => {
// No existing staff row, no OIDC account row, but a Better-Auth user row.
staffLookupResult = null;
userLookupResult = {
id: "ba-user-customer",
name: "UAT Customer",
email: "uat-customer@groombook.dev",
};
accountLookupResult = null;
insertReturningResult = PROVISIONED;
let capturedStaff: StaffRow | null = null;
const app = buildApp(
resolveStaffMiddleware,
(c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
},
{ sub: "ba-user-customer", email: "uat-customer@groombook.dev" }
);
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff).not.toBeNull();
expect(capturedStaff!.role).toBe("groomer");
expect(capturedStaff!.userId).toBe("ba-user-customer");
});
it("Better-Auth: returns 500 if INSERT yields no row", async () => {
staffLookupResult = null;
userLookupResult = {
id: "ba-user-customer",
name: "UAT Customer",
email: "uat-customer@groombook.dev",
};
insertReturningResult = null; // simulate INSERT … RETURNING returning []
const app = buildApp(resolveStaffMiddleware, undefined, {
sub: "ba-user-customer",
email: "uat-customer@groombook.dev",
});
const res = await app.request("/test");
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toMatch(/auto-provision failed/i);
});
it("Better-Auth branch runs before OIDC branch (does not require jwt.email)", async () => {
// A Better-Auth user row alone is sufficient: jwt.email is intentionally
// absent. The pre-GRO-2052 code only auto-provisioned inside `if (jwt.email)`.
staffLookupResult = null;
userLookupResult = {
id: "ba-user-customer",
name: "UAT Customer",
email: "uat-customer@groombook.dev",
};
insertReturningResult = PROVISIONED;
const app = buildApp(resolveStaffMiddleware, undefined, {
sub: "ba-user-customer",
});
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("OIDC fallback: still provisions when user row is missing but account row exists", async () => {
// No staff row, no Better-Auth user, but an OIDC account row.
staffLookupResult = null;
userLookupResult = null;
accountLookupResult = { id: "oidc-account-id" };
insertReturningResult = { ...PROVISIONED, userId: "oidc-sub" };
const app = buildApp(resolveStaffMiddleware, undefined, {
sub: "oidc-sub",
email: "oidc-user@example.com",
});
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("falls through to 403 when neither Better-Auth user nor OIDC account row exists", async () => {
staffLookupResult = null;
userLookupResult = null;
accountLookupResult = null;
const app = buildApp(resolveStaffMiddleware, undefined, {
sub: "ghost-sub",
email: "ghost@example.com",
});
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/no staff record/i);
});
});
// ─── requireRole tests ────────────────────────────────────────────────────────
describe("requireRole", () => {
+335
View File
@@ -0,0 +1,335 @@
import { describe, it, expect } from "vitest";
import {
haversineKm,
estimateLeg,
nearestNeighborOrder,
optimizeRoute,
detectScheduleConflicts,
recomputeLegsForOrder,
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);
});
});
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,
});
});
});
+145
View File
@@ -0,0 +1,145 @@
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<string, unknown>[] = [];
let insertReturning: Record<string, unknown>[] = [];
let updateReturning: Record<string, unknown>[] = [];
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 }),
}),
update: () => ({
set: () => ({ where: () => ({ returning: () => updateReturning }) }),
}),
}),
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);
// 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",
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<string, unknown>;
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<string, unknown>;
expect(body).not.toHaveProperty("googleMapsApiKey");
expect(body.id).toBe("settings-uuid-new");
});
});
describe("PATCH /settings — googleMapsApiKey redaction (GRO-2299)", () => {
beforeEach(() => {
selectRows = [];
insertReturning = [];
updateReturning = [];
});
function patchRequest(body: Record<string, unknown>) {
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<string, unknown>;
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<string, unknown>;
expect(body).not.toHaveProperty("googleMapsApiKey");
expect(body.id).toBe("settings-uuid-new");
});
});
+70
View File
@@ -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",
+15
View File
@@ -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"));
@@ -235,6 +240,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"],
@@ -274,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();
+1 -1
View File
@@ -172,7 +172,7 @@ export async function initAuth(): Promise<void> {
clientSecret: oidcClientSecret,
issuerUrl: oidcIssuer,
internalBaseUrl: process.env.OIDC_INTERNAL_BASE,
scopes: "openid profile email",
scopes: "openid profile email role",
};
console.log("[auth] Using env var config (no DB config found)");
}
+46 -1
View File
@@ -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<PortalEnv> = async (c, next) => {
const sessionId = c.req.header("X-Impersonation-Session-Id");
@@ -24,16 +56,29 @@ export const validatePortalSession: MiddlewareHandler<PortalEnv> = 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();
+56 -12
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff, account } from "@groombook/db";
import { and, eq, getDb, sql, staff, account, user } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
@@ -22,7 +22,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
c,
next
) => {
// Better-Auth's own routes handle their own auth — skip staff resolution
// Better-Auth\'s own routes handle their own auth — skip staff resolution
// OOBE setup routes also handle their own auth — staff record is created during setup
if (c.req.path.startsWith("/api/auth/") || c.req.path.startsWith("/api/setup")) {
await next();
@@ -111,8 +111,49 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
}
}
// Auto-provision for Better-Auth users (GRO-2052): the user signed in via
// Better-Auth (email/password, magic link, etc.), so a row exists in `user`
// for jwt.sub but no `account` provider row is required. Create a minimal
// groomer staff record on first login. This is the primary auto-provision
// path; the OIDC branch below remains as a fallback for legacy accounts
// that exist in `account` but not in `user`.
const [userRow] = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, jwt.sub))
.limit(1);
if (userRow) {
const emailPrefix = userRow.email ? userRow.email.split("@")[0] : "Unknown";
const name = userRow.name?.trim() || jwt.name?.trim() || emailPrefix;
const [newStaff] = await db
.insert(staff)
.values({
userId: jwt.sub,
email: userRow.email ?? jwt.email ?? "",
name,
role: "groomer",
isSuperUser: false,
active: true,
} as Parameters<typeof db.insert>[0] extends { values: infer V } ? V : never)
.returning()!;
if (!newStaff) {
return c.json({ error: "Forbidden: auto-provision failed" }, 500);
}
console.log(
`[rbac] auto-provisioned staff record for Better-Auth user: ${jwt.sub} -> staff:${newStaff.id} (${name})`
);
c.set("staff", newStaff);
await next();
return;
}
// Auto-provision for OIDC users: check if jwt.sub has an OAuth/OIDC account
// (e.g. authentik). If so, create a groomer staff record on the fly.
// (e.g. authentik). If so, create a groomer staff record on the fly. This
// is kept for backward compatibility with legacy OIDC sessions whose user
// row may not yet exist in the Better-Auth `user` table.
if (jwt.email) {
const [oidcAccount] = await db
.select({ id: account.id })
@@ -120,28 +161,31 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.where(
and(
eq(account.userId, jwt.sub),
sql`${account.providerId} IN ('authentik', 'google', 'github')`
sql`${account.providerId} IN (\'authentik\', \'google\', \'github\')`
)
)
.limit(1);
if (oidcAccount) {
// Derive name: prefer jwt.name, fall back to email prefix, then "Unknown"
const name =
jwt.name?.trim() ||
(jwt.email ? jwt.email.split("@")[0] : "Unknown");
const emailPrefix = jwt.email ? jwt.email.split("@")[0] : "Unknown";
const name = jwt.name?.trim() || emailPrefix;
const [newStaff] = await db
.insert(staff)
.values({
userId: jwt.sub,
email: jwt.email ?? "",
email: (jwt.email ?? "") as string,
name,
role: "groomer",
isSuperUser: false,
active: true,
})
.returning();
} as Parameters<typeof db.insert>[0] extends { values: infer V } ? V : never)
.returning()!;
if (!newStaff) {
return c.json({ error: "Forbidden: auto-provision failed" }, 500);
}
console.log(
`[rbac] auto-provisioned staff record for OIDC user: ${jwt.sub} -> staff:${newStaff.id} (${name})`
@@ -177,7 +221,7 @@ export function requireRole(
if (!(allowedRoles as string[]).includes(staffRow.role)) {
return c.json(
{
error: `Forbidden: role '${staffRow.role}' is not permitted to access this resource`,
error: `Forbidden: role \'${staffRow.role}\' is not permitted to access this resource`,
},
403
);
@@ -210,7 +254,7 @@ export function requireRoleOrSuperUser(
{
error: hasAllowedRole
? "Forbidden: super user privileges required"
: `Forbidden: role '${staffRow.role}' is not permitted`,
: `Forbidden: role \'${staffRow.role}\' is not permitted`,
},
403
);
+130 -3
View File
@@ -3,9 +3,67 @@ 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<AppEnv>();
// 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;
/**
* 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<typeof getDb>,
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 +149,62 @@ 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 = 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);
});
// Update a client (including status changes)
const patchClientSchema = createClientSchema.partial().extend({
status: z.enum(["active", "disabled"]).optional(),
@@ -123,13 +234,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);
}
);
+261 -3
View File
@@ -1,7 +1,21 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db";
import {
and,
desc,
eq,
exists,
getDb,
impersonationAuditLogs,
impersonationSessions,
or,
pets,
appointments,
staff,
services,
sql,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import {
getPresignedUploadUrl,
@@ -11,6 +25,23 @@ import {
export const petsRouter = new Hono<AppEnv>();
// Convert Zod validation errors from 422 to 400 and ensure any thrown error
// returns a structured JSON body rather than Hono's default empty-body 500.
// GRO-2014: profile-summary previously bubbled unhandled errors and produced
// an empty-body 500. Mirror the onError pattern already used in invoices.ts
// and reports.ts so every error has a JSON envelope.
petsRouter.onError((err, c) => {
if (err instanceof z.ZodError) {
return c.json({ error: "Validation failed", issues: err.issues }, 400);
}
console.error("[pets] unhandled error", err);
return c.json({ error: "Internal Server Error" }, 500);
});
// UUID format used by all pet routes — guards path params against malformed
// values before they hit Drizzle / Postgres uuid columns (which would throw).
const uuidSchema = z.string().uuid();
const createPetSchema = z.object({
clientId: z.string().uuid(),
name: z.string().min(1).max(200),
@@ -26,6 +57,23 @@ const createPetSchema = z.object({
customFields: z.record(z.string(), z.string()).optional(),
petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
// Extended pet profile fields (api/#39, GRO-1178).
// GRO-2172: these were missing from the schema, causing POST/PATCH to
// silently drop them even though migrations 0034/0036 and seed data
// populate them. GRO-1472 was the original UAT regression.
temperamentScore: z.number().int().min(1).max(5).optional(),
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
medicalAlerts: z
.array(
z.object({
type: z.string().max(100),
description: z.string().max(1000),
severity: z.enum(["low", "medium", "high"]),
})
)
.max(50)
.optional(),
preferredCuts: z.array(z.string().max(200)).max(20).optional(),
});
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
@@ -97,9 +145,213 @@ petsRouter.get("/:id", async (c) => {
return c.json(row);
});
/**
* Resolves the clientId from the X-Impersonation-Session-Id header, if present and active.
* Used by staff routes to allow a customer (auto-provisioned as a `groomer` staff row
* by rbac.ts) to access their own pet's data when they are the rightful owner.
*
* Returns null when the header is missing, the session is unknown/expired/ended, or the
* session exists but has no clientId — callers should treat null as "no owner-bypass".
*/
async function resolveImpersonationClientId(
db: ReturnType<typeof getDb>,
c: { req: { header: (name: string) => string | undefined } }
): Promise<string | null> {
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) return null;
const [session] = await db
.select({
clientId: impersonationSessions.clientId,
status: impersonationSessions.status,
expiresAt: impersonationSessions.expiresAt,
})
.from(impersonationSessions)
.where(eq(impersonationSessions.id, sessionId))
.limit(1);
if (!session) return null;
if (session.status !== "active") return null;
if (session.expiresAt <= new Date()) return null;
return session.clientId;
}
/**
* Defense-in-depth audit write for the staff-side owner-bypass path in
* GET /pets/:id/profile-summary. Mirrors the failure-isolation pattern in
* src/middleware/portalAudit.ts: errors are logged but never thrown, so a
* misbehaving audit insert cannot turn a working read into a 500.
*
* Called only when the owner-bypass actually fires (i.e. the requester is a
* groomer-role staff row with no appointment linkage, but supplies a valid
* X-Impersonation-Session-Id whose clientId matches the pet's owner). The
* `petId` and `actorStaffId` are written inside `metadata` because the
* impersonation_audit_logs schema has no first-class columns for them and
* adding a migration is out of scope.
*/
async function writeOwnerBypassAudit(
db: ReturnType<typeof getDb>,
args: {
sessionId: string;
petId: string;
actorStaffId: string;
pageVisited: string;
}
): Promise<void> {
try {
await db.insert(impersonationAuditLogs).values({
sessionId: args.sessionId,
action: "read_profile_summary",
pageVisited: args.pageVisited,
metadata: { petId: args.petId, actorStaffId: args.actorStaffId },
});
} catch (err) {
console.error("[pets] failed to write owner-bypass audit log:", err);
}
}
petsRouter.get("/:id/profile-summary", async (c) => {
const db = getDb();
const petId = c.req.param("id");
// GRO-2014: validate UUID format before hitting Postgres. Passing a non-UUID
// string to a uuid column makes the driver throw, which previously surfaced
// as an empty-body 500 to clients.
const parsedId = uuidSchema.safeParse(petId);
if (!parsedId.success) {
return c.json({ error: "Not found" }, 404);
}
// Defense in depth: resolveStaffMiddleware should always populate `staff`
// for protected routes (or short-circuit with 401/403 of its own). Guard
// anyway so a misconfigured route mount can't trigger a TypeError on
// staffRow.id when the linkage check runs.
const staffRow = c.get("staff");
if (!staffRow) {
return c.json({ error: "Unauthorized" }, 401);
}
const isGroomer = staffRow.role === "groomer";
// Fetch the pet
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
if (!pet) return c.json({ error: "Not found" }, 404);
// Owner-bypass (GRO-2013): a customer who supplies a valid
// X-Impersonation-Session-Id for the pet's owning client may read their
// own pet's summary, even though rbac.ts auto-provisions them as a
// `groomer` staff row with no appointment linkage.
let isOwner = false;
if (isGroomer) {
const headerSessionId = c.req.header("X-Impersonation-Session-Id");
const ownerClientId = await resolveImpersonationClientId(db, c);
isOwner = !!ownerClientId && ownerClientId === pet.clientId;
if (isOwner && headerSessionId) {
// GRO-2063: defense-in-depth audit row. Only fires when the bypass
// is actually granted; never on the normal groomer-linkage path,
// 403/404/401, or when the header is absent. Failure is swallowed
// (try/catch inside writeOwnerBypassAudit) so this can never turn a
// working read into a 500.
await writeOwnerBypassAudit(db, {
sessionId: headerSessionId,
petId: pet.id,
actorStaffId: staffRow.id,
pageVisited: c.req.path,
});
}
}
// Groomer RBAC: check appointment linkage to this pet's client
if (isGroomer && !isOwner) {
const [linkage] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.clientId, pet.clientId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!linkage) return c.json({ error: "Forbidden" }, 403);
}
// Recent grooming history — last 10 completed appointments
const recentHistory = await db
.select({
id: appointments.id,
startTime: appointments.startTime,
notes: appointments.notes,
serviceName: services.name,
staffName: staff.name,
})
.from(appointments)
.innerJoin(services, eq(appointments.serviceId, services.id))
.leftJoin(staff, eq(appointments.staffId, staff.id))
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")))
.orderBy(desc(appointments.startTime))
.limit(10);
// Visit count (completed appointments)
const [countRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(appointments)
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")));
const visitCount = countRow?.count ?? 0;
// Upcoming appointment (next scheduled or confirmed)
const [upcoming] = await db
.select({
id: appointments.id,
startTime: appointments.startTime,
notes: appointments.notes,
confirmationStatus: appointments.confirmationStatus,
serviceName: services.name,
})
.from(appointments)
.innerJoin(services, eq(appointments.serviceId, services.id))
.where(
and(
eq(appointments.petId, petId),
or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed"))
)
)
.orderBy(appointments.startTime)
.limit(1);
return c.json({
id: pet.id,
name: pet.name,
species: pet.species,
breed: pet.breed,
coatType: pet.coatType,
petSizeCategory: pet.petSizeCategory,
weightKg: pet.weightKg,
dateOfBirth: pet.dateOfBirth,
recentGroomingHistory: recentHistory.map((h) => ({
id: h.id,
startTime: h.startTime,
notes: h.notes,
serviceName: h.serviceName,
staffName: h.staffName,
})),
visitCount,
upcomingAppointment: upcoming
? {
id: upcoming.id,
startTime: upcoming.startTime,
notes: upcoming.notes,
confirmationStatus: upcoming.confirmationStatus,
serviceName: upcoming.serviceName,
}
: null,
});
});
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
const db = getDb();
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } =
c.req.valid("json");
const [row] = await db
.insert(pets)
.values({
@@ -107,6 +359,10 @@ petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
weightKg: weightKg?.toString(),
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
customFields: customFields ?? {},
// GRO-2172: medicalAlerts shape from the API request is
// { type, description, severity } — the @groombook/types MedicalAlert
// has an optional server-generated `id`, so cast for the jsonb column.
medicalAlerts: medicalAlerts as never,
})
.returning();
return c.json(row, 201);
@@ -117,7 +373,8 @@ petsRouter.patch(
zValidator("json", updatePetSchema),
async (c) => {
const db = getDb();
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } =
c.req.valid("json");
const [row] = await db
.update(pets)
.set({
@@ -125,6 +382,7 @@ petsRouter.patch(
weightKg: weightKg?.toString(),
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
...(customFields !== undefined ? { customFields } : {}),
medicalAlerts: medicalAlerts as never,
updatedAt: new Date(),
})
.where(eq(pets.id, c.req.param("id")))
+343 -22
View File
@@ -1,9 +1,9 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, inArray } from "@groombook/db";
import { and, 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";
@@ -36,7 +36,7 @@ portalRouter.post(
return c.json({ error: "Client not found" }, 404);
}
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
const DEMO_STAFF_ID = process.env.DEMO_STAFF_ID ?? "00000000-0000-0000-0000-000000000001";
let staffId = DEMO_STAFF_ID;
const [demoStaff] = await db
@@ -71,6 +71,82 @@ portalRouter.post(
}
);
// Bridge Better Auth session → portal session for real SSO customers (GRO-1866).
// Registered BEFORE the /* middleware so it is NOT subject to validatePortalSession.
import { getAuth } from "../lib/auth.js";
portalRouter.post("/session-from-auth", async (c) => {
let auth;
try {
auth = getAuth();
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
const db = getDb();
const [client] = await db
.select()
.from(clients)
.where(eq(clients.email, session.user.email))
.limit(1);
if (!client) {
return c.json({ error: "No client record found for this user" }, 404);
}
const DEMO_STAFF_ID = process.env.DEMO_STAFF_ID ?? "00000000-0000-0000-0000-000000000001";
let staffId = DEMO_STAFF_ID;
const [demoStaff] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.id, DEMO_STAFF_ID))
.limit(1);
if (!demoStaff) {
const [firstStaff] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.active, true))
.limit(1);
if (!firstStaff) {
return c.json({ error: "No staff records found" }, 500);
}
staffId = firstStaff.id;
}
const [portalSession] = await db
.insert(impersonationSessions)
.values({
staffId,
clientId: client.id,
reason: "sso-bridge",
expiresAt: new Date(Date.now() + PORTAL_SESSION_IDLE_TTL_MS),
})
.returning();
if (!portalSession) {
return c.json({ error: "Failed to create session" }, 500);
}
return c.json(
{
sessionId: portalSession.id,
clientId: client.id,
clientName: client.name,
},
201
);
});
// Apply middleware to all portal routes
portalRouter.use("/*", validatePortalSession, portalAudit);
@@ -119,14 +195,46 @@ portalRouter.get("/appointments", async (c) => {
.where(eq(appointments.clientId, clientId))
.orderBy(appointments.startTime);
const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null);
// GRO-2319: surface the client's ACTIVE waitlist entries alongside their
// appointments so the portal can render them as `waitlisted` cards in the
// Upcoming list. The `appointment_status` enum cannot represent `waitlisted`,
// so these are synthetic entries (status hard-set to `waitlisted`, id prefixed
// `waitlist:`) derived from `waitlist_entries`.
const waitlistRows = await db
.select({
id: waitlistEntries.id,
petId: waitlistEntries.petId,
serviceId: waitlistEntries.serviceId,
preferredDate: waitlistEntries.preferredDate,
preferredTime: waitlistEntries.preferredTime,
})
.from(waitlistEntries)
.where(
and(eq(waitlistEntries.clientId, clientId), eq(waitlistEntries.status, "active")),
);
// Pet lookups must cover both appointment and waitlist pets.
const petIds = [
...allAppts.map(a => a.petId).filter((id): id is string => id !== null),
...waitlistRows.map(w => w.petId),
];
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
// GRO-2342: services must be looked up for both appointment and waitlist cards
// so the portal can render `service.name` in place of the fallback "Service"
// label (CMPO sign-off on the GRO-2319 waitlist card explicitly excluded the
// service name; this follow-up closes the cosmetic gap).
const serviceIds = [
...allAppts.map(a => a.serviceId).filter((id): id is string => id !== null),
...waitlistRows.map(w => w.serviceId).filter((id): id is string => id !== null),
];
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
const serviceRows = serviceIds.length ? await db.select().from(services).where(inArray(services.id, serviceIds)) : [];
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
const serviceMap = Object.fromEntries(serviceRows.map(s => [s.id, s]));
const appts = allAppts.map(a => ({
id: a.id,
@@ -137,11 +245,35 @@ portalRouter.get("/appointments", async (c) => {
customerNotes: a.customerNotes,
notes: a.notes,
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
service: a.serviceId ? { id: a.serviceId } : null,
service: a.serviceId ? { id: a.serviceId, name: serviceMap[a.serviceId]?.name } : null,
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
}));
return c.json({ appointments: appts });
// Derive a display `startTime` from the entry's preferred date/time so the
// portal can sort/classify the synthetic card (an invalid combination simply
// yields a null startTime, which the portal tolerates). GRO-2342: also
// populate the synthetic card's `service` object with the full service
// record (id + name) — same shape the appointments join returns — so the
// portal renders the real service name in place of the fallback "Service"
// label.
const waitlistAppts = waitlistRows.map(w => {
const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`);
const startTime = Number.isNaN(parsed.getTime()) ? null : parsed;
return {
id: `waitlist:${w.id}`,
startTime,
endTime: null,
status: "waitlisted" as const,
confirmationStatus: null,
customerNotes: null,
notes: null,
pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey },
service: w.serviceId ? { id: w.serviceId, name: serviceMap[w.serviceId]?.name } : null,
staff: null,
};
});
return c.json({ appointments: [...appts, ...waitlistAppts] });
});
portalRouter.get("/pets", async (c) => {
@@ -149,9 +281,166 @@ 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<string, string> = { xlarge: "extra_large" };
const portalMedicalAlertSchema = z.object({
id: z.string().optional(),
type: z.string().max(2000),
description: z.string().max(2000),
severity: z.enum(["low", "medium", "high"]),
});
const portalPetUpdateSchema = z.object({
name: z.string().min(1).max(200).optional(),
breed: z.string().max(200).nullable().optional(),
// weightKg is the form's edited key; weight is the GET-shaped key. Accept both.
weightKg: z.union([z.number(), z.string()]).nullable().optional(),
weight: z.union([z.number(), z.string()]).nullable().optional(),
birthDate: z.string().nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
healthAlerts: z.string().max(2000).nullable().optional(),
// photoUrl/photoKey are intentionally NOT writable here: photoKey is a trusted
// S3 object key consumed server-side (getPresignedGetUrl / deleteObject), and the
// upload path (pets.ts) already enforces a pets/{petId}/ prefix guard against key
// hijacking. Photo changes go through the dedicated upload + /photo/confirm flow.
// The web form round-trips the GET-shaped photoUrl; Zod strips it as an unknown key.
// coatType / petSizeCategory validated in-handler so bad values return 422.
coatType: z.string().nullable().optional(),
petSizeCategory: z.string().nullable().optional(),
preferredCuts: z.array(z.string().max(2000)).max(50).nullable().optional(),
medicalAlerts: z.array(portalMedicalAlertSchema).max(50).nullable().optional(),
});
portalRouter.patch(
"/pets/:petId",
zValidator("json", portalPetUpdateSchema),
async (c) => {
const db = getDb();
const petId = c.req.param("petId");
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
// 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)
.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<string, unknown> = { updatedAt: new Date() };
if (body.name !== undefined) updateData.name = body.name;
if (body.breed !== undefined) updateData.breed = body.breed;
if (body.weightKg !== undefined || body.weight !== undefined) {
const w = body.weightKg ?? body.weight;
updateData.weightKg = w === null || w === undefined ? null : String(w);
}
if (body.birthDate !== undefined) {
updateData.dateOfBirth = body.birthDate ? new Date(body.birthDate) : null;
}
if (body.notes !== undefined) updateData.groomingNotes = body.notes;
if (body.healthAlerts !== undefined) updateData.healthAlerts = body.healthAlerts;
// photoKey is intentionally not writable here — see portalPetUpdateSchema note.
// Photo changes go through the key-validated upload + /photo/confirm flow.
if (body.coatType !== undefined) {
if (body.coatType !== null && !PORTAL_COAT_TYPES.includes(body.coatType)) {
return c.json({ error: "Invalid coatType" }, 422);
}
updateData.coatType = body.coatType;
}
if (body.petSizeCategory !== undefined) {
let size: string | null = body.petSizeCategory;
if (size !== null) {
size = PORTAL_PET_SIZE_ALIASES[size] ?? size;
if (!PORTAL_PET_SIZES.includes(size)) {
return c.json({ error: "Invalid petSizeCategory" }, 422);
}
}
updateData.petSizeCategory = size;
}
if (body.preferredCuts !== undefined) updateData.preferredCuts = body.preferredCuts ?? [];
if (body.medicalAlerts !== undefined) updateData.medicalAlerts = body.medicalAlerts ?? [];
const [updated] = await db
.update(pets)
.set(updateData)
.where(eq(pets.id, petId))
.returning();
if (!updated) {
return c.json({ error: "Not found" }, 404);
}
return c.json({
id: updated.id,
name: updated.name,
breed: updated.breed,
weight: updated.weightKg,
birthDate: updated.dateOfBirth,
photoUrl: updated.photoKey,
notes: updated.groomingNotes,
coatType: updated.coatType,
petSizeCategory: updated.petSizeCategory,
healthAlerts: updated.healthAlerts,
preferredCuts: updated.preferredCuts,
medicalAlerts: updated.medicalAlerts,
});
}
);
portalRouter.get("/invoices", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
@@ -326,17 +615,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(
@@ -347,16 +652,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: 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);
}
@@ -385,7 +706,7 @@ portalRouter.patch(
const updateData: Record<string, unknown> = { 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)
+529
View File
@@ -0,0 +1,529 @@
import { Hono, type Context } 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,
detectScheduleConflicts,
recomputeLegsForOrder,
type RouteStopInput,
type StopConflictFlags,
} from "../services/routeOptimization.js";
import {
buildNavigationUrl,
type NavigationPlatform,
type NavigationStop,
} from "../services/navigationExport.js";
export const routesRouter = new Hono<AppEnv>();
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"),
});
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
* 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<typeof getDb>, 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));
}
type LoadedRouteStop = Awaited<ReturnType<typeof loadRouteStops>>[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<LoadedRouteStop & { conflict: StopConflictFlags }>;
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
* 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);
const annotated = annotateConflicts(stops);
return c.json({
route,
stops: annotated.stops,
hasConflicts: annotated.hasConflicts,
conflictCount: annotated.conflictCount,
});
});
/**
* 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),
// Buffer applies between consecutive stops; the first stop has no
// predecessor, so it carries no travel buffer.
bufferMins: i === 0 ? 0 : bufferMins,
}))
);
}
return routeRow!;
});
const stops = await loadRouteStops(db, route.id);
const annotated = annotateConflicts(stops);
return c.json({
route,
stops: annotated.stops,
hasConflicts: annotated.hasConflicts,
conflictCount: annotated.conflictCount,
provider: optimized.provider,
chunked: optimized.chunked,
subRouteCount: optimized.subRouteCount,
skipped,
warnings,
});
}
);
/**
* 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<string>();
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,
});
}
);
/**
* 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<AppEnv>,
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")
);
+16 -3
View File
@@ -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<BusinessSettingsRow> = { ...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}$/;
@@ -53,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));
}
);
+212
View File
@@ -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<typeof getDb>;
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<GeocodingProvider> {
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<ClientGeocodeOutcome> {
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<BatchGeocodeSummary> {
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<number>`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,
};
}
+419
View File
@@ -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<GeocodeResult | null>;
}
// --- 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<string, string> }
) => Promise<{
ok: boolean;
status: number;
statusText: string;
json(): Promise<unknown>;
}>;
type NowFn = () => number;
type SleepFn = (ms: number) => Promise<void>;
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<unknown> = Promise.resolve();
constructor(
private readonly intervalMs: number,
private readonly now: NowFn = Date.now,
private readonly sleep: SleepFn = defaultSleep
) {}
run<T>(task: () => Promise<T>): Promise<T> {
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<GeocodeResult | null> {
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<GeocodeResult | null> {
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<K> {
key: K;
address: string;
}
/** Per-item outcome from {@link geocodeBatch}. */
export interface BatchGeocodeOutcome<K> {
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<K> {
/** Invoked after each item completes; useful for progress reporting. */
onProgress?: (
completed: number,
total: number,
outcome: BatchGeocodeOutcome<K>
) => 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<K>(
items: ReadonlyArray<BatchGeocodeItem<K>>,
provider: GeocodingProvider,
options: GeocodeBatchOptions<K> = {}
): Promise<Array<BatchGeocodeOutcome<K>>> {
const outcomes: Array<BatchGeocodeOutcome<K>> = [];
for (const item of items) {
let outcome: BatchGeocodeOutcome<K>;
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;
}
+155
View File
@@ -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 };
+513
View File
@@ -0,0 +1,513 @@
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<boolean>(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<T>(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<OptimizedRoute> {
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<string | null> {
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;
}
// ─── 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,
};
});
}
View File