Compare commits

...

143 Commits

Author SHA1 Message Date
Flea Flicker 5f01df819e fix(GRO-2299): redact googleMapsApiKey from PATCH /api/admin/settings response
CI / Test (pull_request) Successful in 24s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Images (pull_request) Successful in 1m18s
The PATCH handler returned the full businessSettings row via .returning(),
echoing the encrypted googleMapsApiKey ciphertext back to the caller. Wrap the
return in the existing redactSettings() helper (after a !updated guard) so
redaction is applied symmetrically with the GET projection (GRO-2294).

- src/routes/settings.ts: guard + redactSettings(updated) on PATCH return
- src/__tests__/settings.test.ts: assert PATCH omits googleMapsApiKey
  (existing-row and auto-create-then-update branches)
- UAT_PLAYBOOK.md §13 TC-API-13.2: assert PATCH response omits the secret

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-09 06:50:20 +00:00
Flea Flicker fe412933ea GRO-2294: Route Optimization security hardening (geocode-batch limit cap + redact settings secret) (#193)
CI / Test (push) Successful in 27s
CI / Lint & Typecheck (push) Successful in 35s
CI / Build & Push Docker Images (push) Successful in 38s
2026-06-09 06:17:42 +00:00
Flea Flicker cd2f60e282 feat(GRO-2157): navigation export endpoints (Phase 2.3) (#190)
CI / Test (push) Successful in 24s
CI / Lint & Typecheck (push) Successful in 40s
CI / Build & Push Docker Images (push) Successful in 26s
2026-06-09 00:16:42 +00:00
Flea Flicker 6702086c7b fix(GRO-2235): return 409 on duplicate portal waitlist submit (#189)
CI / Test (push) Failing after 14m19s
CI / Lint & Typecheck (push) Failing after 14m19s
CI / Build & Push Docker Images (push) Has been skipped
2026-06-08 23:50:21 +00:00
Flea Flicker 27e6674b9a feat(GRO-2225): UAT seed route cohort + receptionist credential (#187)
CI / Test (push) Successful in 30s
CI / Lint & Typecheck (push) Successful in 32s
CI / Build & Push Docker Images (push) Successful in 45s
2026-06-08 23:15:51 +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 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 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 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 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 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 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 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 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 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 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
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 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
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 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
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
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
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 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
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
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 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
Lint Roller a610ef9d39 chore: trigger CI for GRO-1757 + GRO-1764
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 00:08:02 +00:00
Flea Flicker 2a0b3cf3d3 Merge remote-tracking branch 'origin/dev' into dev-to-uat 2026-05-25 23:54:49 +00:00
Flea Flicker cf3d30f19e Merge pull request 'fix(GRO-1764): change Max coat_type short→smooth in UAT seed' (#85) from fix/gro-1764-coat-type-enum into dev 2026-05-25 23:54:36 +00:00
Flea Flicker 0625961adf fix(GRO-1764): change Max coat_type "short" to "smooth" in UAT seed
The DB coat_type enum only accepts: smooth, double, wire, curly, long, hairless.
"short" is not a valid value — corrected to "smooth".

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-25 23:52:53 +00:00
Lint Roller 78762b5278 Merge pull request 'promote: dev → uat (GRO-1757 SSO auto-provision fix)' (#84) from dev into uat
promote: dev → uat (GRO-1757 SSO auto-provision fix)
2026-05-25 23:48:09 +00:00
Scrubs McBarkley b61d899f81 fix(GRO-1757): auto-provision staff for OIDC users + UAT playbook updates (#83) 2026-05-25 23:39:57 +00:00
Flea Flicker 38047d5ea3 chore: trigger CI on dev for GRO-1754 2026-05-25 23:27:16 +00:00
Flea Flicker fbcaedf155 chore: trigger CI for GRO-1754 UAT bump check 2026-05-25 23:20:51 +00:00
Flea Flicker 7cfb24d542 Merge pull request 'chore: trigger CI for GRO-1754' (#80) from fix/gro-1754-trigger-ci-v2 into dev 2026-05-25 23:16:05 +00:00
Flea Flicker b0d9e5816f chore: trigger CI v2 for GRO-1754 2026-05-25 23:14:01 +00:00
Flea Flicker 7a0662541d chore: trigger CI for GRO-1754 2026-05-25 19:20:51 +00:00
Flea Flicker 5e78df85f1 chore: trigger CI for GRO-1754 UAT bump 2026-05-25 19:16:53 +00:00
The Dogfather aa9670d4dc Merge pull request 'promote(dev→uat): add missing extended pet profile fields (GRO-1752)' (#79) from dev into uat
promote(dev→uat): add missing extended pet profile fields (GRO-1752)
2026-05-25 19:08:13 +00:00
The Dogfather 0a2259b67f Merge pull request 'fix(db): add missing extended pet profile fields to buildPet factory' (#78) from fix/gro-1752-factories-v2 into dev 2026-05-25 18:57:45 +00:00
Flea Flicker cc09a8e1e8 trigger CI again 2026-05-25 18:55:38 +00:00
Flea Flicker 74da042d13 fix(db): add missing extended pet profile fields to buildPet factory
Lint Roller (QA) flagged that buildPet in factories.ts was missing the
4 fields added to the pets table schema, causing TS2739 in the Docker
build job (run 1701, job 3717).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:53:56 +00:00
Flea Flicker ad1b210de1 fix(schema): add missing extended pet profile fields to packages/db (#73) 2026-05-25 18:20:57 +00:00
Flea Flicker e5f16a5fe5 Merge pull request 'chore: promote dev → uat (GRO-1749 seed data sync)' (#72) from promo/gro-1749-uat into uat 2026-05-25 18:02:30 +00:00
Flea Flicker a03771f7e7 fix(gro-1749): sync UAT seed data to root src and fix route path (#71)
Co-authored-by: Flea Flicker <flea@groombook.dev>
Co-committed-by: Flea Flicker <flea@groombook.dev>
2026-05-25 17:45:56 +00:00
The Dogfather baeff6c4f5 Merge pull request 'chore: promote dev → uat (GRO-1743 seed data)' (#70) from dev into uat
Merge PR #70: chore: promote dev → uat (GRO-1743 seed data)
2026-05-25 15:37:38 +00:00
The Dogfather 040ff4a253 Merge pull request 'feat(gro-1743): add UAT customer and pets to admin seed endpoint' (#69) from fix/gro-1743-uat-seed-data into dev
CI / Lint & Typecheck (push) Successful in 11s
CI / Test (push) Successful in 12s
CI / Build & Push Docker Images (push) Successful in 18s
Merge PR #69: feat(gro-1743): add UAT customer and pets to admin seed endpoint
2026-05-25 15:37:10 +00:00
Flea Flicker a1466b44c9 feat(gro-1743): add UAT customer and pets to admin seed endpoint
CI / Lint & Typecheck (pull_request) Successful in 12s
CI / Test (pull_request) Successful in 12s
CI / Build & Push Docker Images (pull_request) Successful in 1m12s
Add UAT Customer (uat-customer@groombook.dev) with two pets (Bella and Max)
to the idempotent admin seed endpoint for portal UAT testing.

- Client: UAT Customer, email: uat-customer@groombook.dev, phone: 555-0100, status: active
- Pet 1: Bella, Dog, Poodle, coatType: curly
- Pet 2: Max, Dog, Labrador Retriever, coatType: short

Issue: GRO-1743
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-25 15:29:36 +00:00
The Dogfather 8d9a9d8dba Merge pull request 'chore: promote dev → uat (GRO-1678 TCP resilience + backlog fixes)' (#67) from dev into uat
chore: promote dev → uat (GRO-1678 TCP resilience + backlog fixes)
2026-05-24 23:49:11 +00:00
Scrubs McBarkley b486c44a82 fix(api): add timeouts for OIDC discovery fetch and DB connection (#66)
CI / Lint & Typecheck (push) Successful in 10s
CI / Test (push) Successful in 9s
CI / Build & Push Docker Images (push) Successful in 45s
2026-05-24 20:11:44 +00:00
The Dogfather 2380698128 Merge pull request 'Promote dev → uat: provenance: false CI fix' (#65) from dev into uat
Promote dev → uat: provenance: false CI fix (#65)

Includes fix(GRO-1576): add provenance: false to all build-push-action steps.

Approved-by: The Dogfather (CTO)
2026-05-23 01:40:59 +00:00
The Dogfather 00c6a36021 Merge pull request 'Promote dev to UAT: GRO-1566 auth bypass fix' (#62) from dev into uat 2026-05-22 22:39:58 +00:00
The Dogfather f4561b539f Merge pull request 'chore: promote dev → uat (GRO-1544 health endpoint fix)' (#59) from dev into uat
chore: promote dev → uat (GRO-1544 health endpoint fix)

Merge dev → uat. CI auto-deploys to UAT environment.
2026-05-22 21:50:13 +00:00
The Dogfather d847343090 Merge pull request 'promote: dev → uat (migration 0031 fix, GRO-1533)' (#58) from dev into uat
promote: dev → uat — migration 0031 fix (GRO-1533) (#58)
2026-05-22 15:22:24 +00:00
The Dogfather 190c39f905 Merge pull request 'chore: promote dev → uat (GRO-1533 migration fix)' (#56) from dev into uat
chore: promote dev → uat (GRO-1533 migration fix)

Promotes 0032_staff_read_at.sql migration file to uat branch.
Unblocks UAT migration pipeline.
2026-05-22 14:39:42 +00:00
The Dogfather 122d32d635 Merge pull request 'chore: promote dev → uat (GRO-1533 migration fix)' (#54) from dev into uat
Promote dev → uat: GRO-1533 migration fix (PR #53)
2026-05-22 14:09:56 +00:00
The Dogfather d458f93600 Merge pull request 'promote: dev → uat (revert Dockerfile + GRO-1533 CI fix)' (#51) from dev into uat
CI / Lint & Typecheck (pull_request) Successful in 9s
CI / Test (pull_request) Successful in 10s
CI / Build & Push Docker Images (pull_request) Successful in 46s
promote: dev → uat (revert Dockerfile + GRO-1533 CI fix)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 13:32:48 +00:00
The Dogfather 634e9d03e1 Merge pull request 'promote: dev → uat (GRO-1533 Dockerfile fix)' (#49) from dev into uat
CI / Lint & Typecheck (pull_request) Successful in 9s
CI / Test (pull_request) Successful in 11s
CI / Build & Push Docker Images (pull_request) Failing after 19s
promote: dev → uat (GRO-1533 Dockerfile fix)

Promotes PR #47 fix to uat. Reverts Dockerfile to build from apps/api/src/.
Fixes HTTP 500 on authenticated admin routes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 13:24:33 +00:00
The Dogfather 974dade8f7 Merge pull request 'promote: dev → uat (pnpm-lock.yaml fix + CI/enum fixes + seed Docker fix)' (#48) from dev into uat
CI / Lint & Typecheck (pull_request) Successful in 12s
CI / Test (pull_request) Successful in 11s
CI / Build & Push Docker Images (pull_request) Successful in 53s
promote: dev → uat (pnpm-lock.yaml fix + CI/enum fixes + seed Docker fix)

Includes PR #45 seed fix, lockfile, CI, and enum alignment.
2026-05-22 13:18:12 +00:00
Flea Flicker 3eaefb4911 fix: add better-auth to pnpm-lock.yaml packages/db specifiers
CI / Lint & Typecheck (pull_request) Failing after 13s
CI / Test (pull_request) Failing after 23s
CI / Build & Push Docker Image (pull_request) Has been skipped
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 13:11:39 +00:00
The Dogfather ff6f8471d5 Merge pull request 'promote: dev → uat (GRO-1509 OIDC account_not_linked fix)' (#43) from dev into uat
CI / Lint & Typecheck (pull_request) Failing after 5s
CI / Test (pull_request) Failing after 6s
CI / Build & Push Docker Image (pull_request) Has been skipped
promote: dev → uat (GRO-1509 OIDC account_not_linked fix)

Merged-by: The Dogfather (CTO)
Gitea-approved-by: Lint Roller (GRO-1512)
2026-05-21 22:53:49 +00:00
The Dogfather 6045024150 Merge pull request 'Promote dev → uat: GRO-1178 enhanced pet profile editor' (#39) from dev into uat
Promote dev → uat: GRO-1178 enhanced pet profile editor
2026-05-21 19:19:10 +00:00
The Dogfather df5e413930 Merge pull request 'chore: promote dev → uat (GRO-1463 UAT playbook expansion)' (#38) from dev into uat
chore: promote dev → uat (GRO-1463 UAT playbook expansion)
2026-05-21 16:49:18 +00:00
The Dogfather 7cb5fda3e3 Merge pull request 'promote: dev → uat (GRO-1272 auto-provision staff on OIDC login)' (#36) from dev into uat
promote: dev → uat (GRO-1272 auto-provision staff on OIDC login) (#36)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 14:17:40 +00:00
The Dogfather 76540cea0d Merge pull request 'chore(promote): dev → uat (Buffer Rules CRUD — GRO-1171)' (#34) from dev into uat
chore(promote): dev → uat (Buffer Rules CRUD — GRO-1171)

Promote PR #12 merge to UAT for regression testing.
2026-05-21 10:18:10 +00:00
Lint Roller d83210e7e2 Merge pull request 'chore(promote): dev → uat (petsExtendedFields test fix GRO-1390)' (#33) from dev into uat 2026-05-21 07:03:24 +00:00
The Dogfather 5c9cac7a28 Merge pull request 'promote: dev → uat (GRO-1395 drizzle-orm root dep fix)' (#31) from dev into uat
promote: dev → uat (GRO-1395 drizzle-orm root dep fix) (#31)
2026-05-21 04:11:29 +00:00
The Dogfather fad99dc032 Merge pull request 'promote: dev → uat (Renovate config, GRO-1081)' (#26) from dev into uat
promote: dev → uat (Renovate config, GRO-1081) (#26)
2026-05-20 12:37:23 +00:00
The Dogfather 247570abc8 Merge pull request 'Promote dev → uat: GRO-1326 UAT email+password credentials' (#25) from dev into uat
Promote dev → uat: GRO-1326 UAT email+password credentials (#25)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 04:25:29 +00:00
the-dogfather-cto[bot] 4f5ec60961 chore: promote dev to uat — Dockerfile pnpm-workspace fix (GRO-1231)
chore: promote dev to uat (GRO-1231 pnpm-workspace fix)
2026-05-14 17:15:52 +00:00
the-dogfather-cto[bot] 39ffdccac7 promote: dev → uat (rate limit override) (#13)
promote: dev → uat (rate limit override)
2026-05-14 10:55:45 +00:00
the-dogfather-cto[bot] 1ff0d4230c promote: dev → uat (UAT Tester seed fix + TypeScript CI compliance)
promote: dev → uat (UAT Tester seed fix + TypeScript CI compliance)
2026-05-14 08:07:54 +00:00
the-dogfather-cto[bot] be5e9d8fc7 chore: promote dev to uat (PR #5 mock path fix)
chore: promote dev to uat (PR #5 mock path fix)
2026-05-12 21:34:03 +00:00
57 changed files with 7812 additions and 1471 deletions
+1
View File
@@ -0,0 +1 @@
GRO-1757 direct push CI trigger - 2026-05-26T00:15:41Z
+51 -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
@@ -91,11 +93,11 @@ jobs:
- name: Build and push API image
uses: docker/build-push-action@v6
with:
provenance: false
context: .
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' || '' }}
@@ -105,25 +107,36 @@ jobs:
- name: Build and push Migrate image
uses: docker/build-push-action@v6
with:
provenance: false
context: .
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:
provenance: false
context: .
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' || '' }}
@@ -133,13 +146,42 @@ jobs:
- name: Build and push Reset image
uses: docker/build-push-action@v6
with:
provenance: false
context: .
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"]
+214 -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 |
@@ -48,6 +89,26 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-1.15 | Name fallback — no name, no email | Auto-provision where Better-Auth user has name = null, email = null | Staff name = "Unknown" |
| TC-API-1.16 | OIDC login — Terraform-provisioned user | Initiate OIDC login as any UAT persona (uat-super, uat-groomer, uat-customer, uat-tester), complete authentik callback | 200 OK, session created — no account_not_linked error |
#### SSO Login Journey (Authentik OIDC end-to-end)
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|---|----------|-------|---------------|---------------|
| TC-API-1.17 | SSO redirect to Authentik | Navigate to app → sign-in page shown → click "Sign in with SSO" | Redirected to Authentik at auth.farh.net | 403 error, redirect loop, no SSO button |
| TC-API-1.18 | Authenticate with valid OIDC credentials | At Authentik login page, enter valid credentials and authenticate | Redirected back to app with valid session | Redirect loop, 403, missing session cookie |
| TC-API-1.19 | SSO user auto-provisioned as groomer | Complete SSO login as a user with no pre-existing staff record | 200 response; groomer staff record auto-created; session active | 403 Forbidden, staff record not created |
| TC-API-1.20 | Existing staff record resolves correctly | Complete SSO login as uat-groomer (pre-existing staff) | 200 OK, correct staff identity resolved, no duplicate record created | 403, duplicate record, wrong staff data |
| TC-API-1.21 | SSO session grants dashboard access | After TC-API-1.18 SSO login, GET /api/staff/me | 200 OK, valid staff record returned, correct role displayed | 401/403, missing session, wrong identity |
#### OOBE Flow Post-Login
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|---|----------|-------|---------------|---------------|
| TC-API-1.22 | Fresh DB reports needsSetup | On a fresh DB (no super user), GET /api/setup/status | needsSetup: true returned | needsSetup: false when it should be true |
| TC-API-1.23 | Configure OIDC via auth-provider endpoint | POST /api/setup/auth-provider with valid OIDC config | 200 OK, auth provider configured, no 403 | 403, setup blocked, invalid config rejected |
| TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints |
| TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false |
| TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
### 4.2 Client Management
| # | Scenario | Steps | Expected |
@@ -59,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 |
@@ -82,6 +162,32 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| 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
@@ -108,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 |
@@ -143,6 +276,17 @@ 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.) |
### 4.9 Waitlist
@@ -188,8 +332,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 |
@@ -220,6 +364,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": {
+172 -12
View File
@@ -44,6 +44,7 @@ interface MockState {
groomingLogs: Record<string, unknown>[];
staffMembers: Record<string, unknown>[];
services: Record<string, unknown>[];
impersonationSessions: Record<string, unknown>[];
}
let mock: MockState;
@@ -168,6 +169,19 @@ function resetMock() {
{ 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"),
},
],
};
}
@@ -177,6 +191,10 @@ vi.mock("../db/index.js", () => {
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[];
@@ -188,25 +206,68 @@ vi.mock("../db/index.js", () => {
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: () => ({
from: (table: unknown) => {
const name = (table as { _name?: string })._name;
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);
return makeChainable([]);
},
}),
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: () => [{}] }) }),
@@ -216,13 +277,14 @@ vi.mock("../db/index.js", () => {
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: vi.fn((str: string) => str),
sql: sqlMock,
};
});
@@ -354,4 +416,102 @@ describe("GET /:id/profile-summary — empty history", () => {
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
+60 -2
View File
@@ -36,6 +36,19 @@ const DEMO_PET = {
weightKg: "30.00",
};
const UAT_CLIENT = {
name: "UAT Customer",
email: "uat-customer@groombook.dev",
phone: "555-0100",
address: "1 UAT Lane, Test City, CA 90210",
status: "active" as const,
};
const UAT_PETS = [
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly" as const, weightKg: "20.00" },
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "smooth" as const, weightKg: "30.00" },
];
const DEMO_SERVICES = [
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
@@ -43,7 +56,7 @@ const DEMO_SERVICES = [
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
];
adminSeedRouter.post("/seed", async (c) => {
adminSeedRouter.post("/", async (c) => {
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
if (process.env.AUTH_DISABLED === "true") {
return c.json(
@@ -128,6 +141,51 @@ adminSeedRouter.post("/seed", async (c) => {
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
}
// ── Client: UAT Customer ──────────────────────────────────────────────────
const [existingUatClient] = await db
.select()
.from(clients)
.where(eq(clients.email, UAT_CLIENT.email));
let uatClientId: string;
if (existingUatClient) {
uatClientId = existingUatClient.id;
results.push(`Client '${UAT_CLIENT.name}' already exists (id: ${uatClientId})`);
} else {
const [created] = await db.insert(clients).values(UAT_CLIENT).returning();
uatClientId = created!.id;
results.push(`Created client '${UAT_CLIENT.name}' (id: ${uatClientId})`);
}
// ── Pets: UAT Customer's Pets ─────────────────────────────────────────────
const existingUatPets = await db
.select()
.from(pets)
.where(eq(pets.clientId, uatClientId));
for (const uatPet of UAT_PETS) {
const existingPet = existingUatPets.find(
(p) => p.name === uatPet.name && p.species === uatPet.species
);
if (existingPet) {
results.push(`Pet '${uatPet.name}' already exists for UAT Customer (id: ${existingPet.id})`);
} else {
const [created] = await db
.insert(pets)
.values({
clientId: uatClientId,
name: uatPet.name,
species: uatPet.species,
breed: uatPet.breed,
coatType: uatPet.coatType,
weightKg: uatPet.weightKg,
dateOfBirth: new Date("2019-01-01T00:00:00Z"),
})
.returning();
results.push(`Created pet '${uatPet.name}' for UAT Customer (id: ${created!.id})`);
}
}
return c.json({
message: "Seed complete",
details: results,
@@ -136,4 +194,4 @@ adminSeedRouter.post("/seed", async (c) => {
staffOidcSub: KNOWN_STAFF.oidcSub,
},
});
});
});
+37 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } 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,
@@ -307,10 +307,38 @@ async function groomerLinkageCheck(
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();
@@ -321,7 +349,15 @@ petsRouter.get("/:id/profile-summary", async (c) => {
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);
}
@@ -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);
});
+7
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,
@@ -105,6 +108,10 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
photoKey: null,
photoUploadedAt: null,
image: null,
temperamentScore: null,
temperamentFlags: [],
medicalAlerts: [],
preferredCuts: [],
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
};
+1 -1
View File
@@ -12,7 +12,7 @@ export function getDb() {
if (_db) return _db;
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is not set");
const client = postgres(url, { max: 10 });
const client = postgres(url, { max: 10, connect_timeout: 5 });
_db = drizzle(client, { schema });
return _db;
}
+87
View File
@@ -1,5 +1,7 @@
import {
boolean,
date,
doublePrecision,
index,
integer,
jsonb,
@@ -11,6 +13,7 @@ import {
unique,
uuid,
} from "drizzle-orm/pg-core";
import type { MedicalAlert } from "@groombook/types";
// ─── Enums ────────────────────────────────────────────────────────────────────
@@ -139,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(),
},
@@ -164,6 +171,10 @@ export const pets = pgTable(
specialCareNotes: text("special_care_notes"),
coatType: coatTypeEnum("coat_type"),
petSizeCategory: petSizeCategoryEnum("pet_size_category"),
temperamentScore: integer("temperament_score"),
temperamentFlags: jsonb("temperament_flags").$type<string[]>().default([]),
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().default([]),
preferredCuts: jsonb("preferred_cuts").$type<string[]>().default([]),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
@@ -550,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(),
});
@@ -653,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),
]
);
+781 -109
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -970,66 +970,79 @@ packages:
resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.3':
resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.3':
resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.3':
resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.3':
resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.3':
resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.3':
resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.3':
resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.3':
resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.3':
resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.3':
resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.3':
resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.3':
resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.3':
resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==}
+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);
});
});
+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",
+21 -4
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();
@@ -285,14 +300,16 @@ startReminderScheduler();
function shutdown() {
console.log("Shutting down gracefully...");
// SIGTERM/SIGINT → server.close() → callback → process.exit(0)
// If graceful close takes >8s, force-exit to avoid being killed undrained
setTimeout(() => {
console.error("Graceful close timeout — forcing exit");
process.exit(1);
}, 8_000);
server.close(() => {
console.log("HTTP server closed");
process.exit(0);
});
setTimeout(() => {
console.error("Forced shutdown after timeout");
process.exit(1);
}, 10_000);
}
process.on("SIGTERM", shutdown);
+4 -2
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)");
}
@@ -186,7 +186,9 @@ export async function initAuth(): Promise<void> {
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
let oidcConfig: Record<string, string> = {};
try {
const discoveryRes = await fetch(discoveryUrlStr);
const discoveryRes = await fetch(discoveryUrlStr, {
signal: AbortSignal.timeout(5000),
});
if (discoveryRes.ok) {
const discovery = await discoveryRes.json() as {
authorization_endpoint?: string;
+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();
+90 -4
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff } 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();
@@ -110,6 +110,92 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
return;
}
}
// 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. 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 })
.from(account)
.where(
and(
eq(account.userId, jwt.sub),
sql`${account.providerId} IN (\'authentik\', \'google\', \'github\')`
)
)
.limit(1);
if (oidcAccount) {
// Derive name: prefer jwt.name, fall back to email prefix, then "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 ?? "") as string,
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 OIDC user: ${jwt.sub} -> staff:${newStaff.id} (${name})`
);
c.set("staff", newStaff);
await next();
return;
}
}
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
@@ -135,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
);
@@ -168,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
);
+59 -1
View File
@@ -36,6 +36,19 @@ const DEMO_PET = {
weightKg: "30.00",
};
const UAT_CLIENT = {
name: "UAT Customer",
email: "uat-customer@groombook.dev",
phone: "555-0100",
address: "1 UAT Lane, Test City, CA 90210",
status: "active" as const,
};
const UAT_PETS = [
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly", weightKg: "20.00" },
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "smooth", weightKg: "30.00" },
];
const DEMO_SERVICES = [
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
@@ -43,7 +56,7 @@ const DEMO_SERVICES = [
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
];
adminSeedRouter.post("/seed", async (c) => {
adminSeedRouter.post("/", async (c) => {
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
if (process.env.AUTH_DISABLED === "true") {
return c.json(
@@ -128,6 +141,51 @@ adminSeedRouter.post("/seed", async (c) => {
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
}
// ── Client: UAT Customer ──────────────────────────────────────────────────
const [existingUatClient] = await db
.select()
.from(clients)
.where(eq(clients.email, UAT_CLIENT.email));
let uatClientId: string;
if (existingUatClient) {
uatClientId = existingUatClient.id;
results.push(`Client '${UAT_CLIENT.name}' already exists (id: ${uatClientId})`);
} else {
const [created] = await db.insert(clients).values(UAT_CLIENT).returning();
uatClientId = created!.id;
results.push(`Created client '${UAT_CLIENT.name}' (id: ${uatClientId})`);
}
// ── Pets: UAT Customer's Pets ─────────────────────────────────────────────
const existingUatPets = await db
.select()
.from(pets)
.where(eq(pets.clientId, uatClientId));
for (const uatPet of UAT_PETS) {
const existing = existingUatPets.find(
(p) => p.name === uatPet.name && p.species === uatPet.species
);
if (existing) {
results.push(`Pet '${uatPet.name}' already exists for UAT Customer (id: ${existing.id})`);
} else {
const [created] = await db
.insert(pets)
.values({
clientId: uatClientId,
name: uatPet.name,
species: uatPet.species,
breed: uatPet.breed,
coatType: uatPet.coatType as any,
weightKg: uatPet.weightKg,
dateOfBirth: new Date("2019-01-01T00:00:00Z"),
})
.returning();
results.push(`Created pet '${uatPet.name}' for UAT Customer (id: ${created!.id})`);
}
}
return c.json({
message: "Seed complete",
details: results,
+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);
}
);
+235 -1
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),
@@ -97,6 +128,209 @@ 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");
+283 -18
View File
@@ -3,7 +3,7 @@ import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, inArray } from "@groombook/db";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
import { validatePortalSession } from "../middleware/portalSession.js";
import { validatePortalSession, PORTAL_SESSION_IDLE_TTL_MS } from "../middleware/portalSession.js";
import { portalAudit } from "../middleware/portalAudit.js";
import type { PortalEnv } from "../middleware/portalSession.js";
@@ -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);
@@ -149,9 +225,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 +559,33 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
// ─── Client-facing waitlist routes ────────────────────────────────────────────
// Postgres `date` / `time` columns reject arbitrary strings (e.g. a full ISO
// datetime), throwing a DateTimeParseError that surfaces as an unhandled 500.
// Constrain client input here so malformed values are rejected with a 400 by
// zValidator before they ever reach the DB (GRO-2211 defense-in-depth).
const preferredDateSchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "preferredDate must be YYYY-MM-DD");
const preferredTimeSchema = z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/, "preferredTime must be HH:MM or HH:MM:SS");
// Normalize HH:MM → HH:MM:SS so it matches the Postgres `time` column format.
function normalizeTime(value: string): string {
return value.length === 5 ? `${value}:00` : value;
}
const createWaitlistEntrySchema = z.object({
petId: z.string().uuid(),
serviceId: z.string().uuid(),
preferredDate: z.string(),
preferredTime: z.string(),
preferredDate: preferredDateSchema,
preferredTime: preferredTimeSchema,
});
const updateWaitlistEntrySchema = z.object({
status: z.literal("cancelled").optional(),
preferredDate: z.string().optional(),
preferredTime: z.string().optional(),
preferredDate: preferredDateSchema.optional(),
preferredTime: preferredTimeSchema.optional(),
});
portalRouter.post(
@@ -347,16 +596,32 @@ portalRouter.post(
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
const [entry] = await db
.insert(waitlistEntries)
.values({
clientId,
petId: body.petId,
serviceId: body.serviceId,
preferredDate: body.preferredDate,
preferredTime: 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 +650,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