Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f20ef7287 |
@@ -102,7 +102,7 @@ jobs:
|
|||||||
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max,ignore-error=true
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
||||||
|
|
||||||
- name: Build and push Migrate image
|
- name: Build and push Migrate image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -116,7 +116,7 @@ jobs:
|
|||||||
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max,ignore-error=true
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
|
||||||
|
|
||||||
- name: Smoke test migrate image (blackhole npmjs.org)
|
- name: Smoke test migrate image (blackhole npmjs.org)
|
||||||
run: |
|
run: |
|
||||||
@@ -141,7 +141,7 @@ jobs:
|
|||||||
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:seed
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:seed
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max,ignore-error=true
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max
|
||||||
|
|
||||||
- name: Build and push Reset image
|
- name: Build and push Reset image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max,ignore-error=true
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
|
||||||
|
|
||||||
- name: Smoke test seed image (blackhole npmjs.org)
|
- name: Smoke test seed image (blackhole npmjs.org)
|
||||||
run: |
|
run: |
|
||||||
@@ -185,4 +185,3 @@ jobs:
|
|||||||
"$IMAGE" \
|
"$IMAGE" \
|
||||||
sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; echo "HOME=$HOME"; pnpm --version'
|
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 ✓"
|
echo "reset image: pnpm resolves to /usr/local/bin/pnpm, HOME=/tmp, runs offline ✓"
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"gitea": {
|
|
||||||
"type": "http",
|
|
||||||
"url": "https://git-mcp.farh.net/mcp",
|
|
||||||
"headers": {
|
|
||||||
"Authorization": "Bearer ${GITEA_TOKEN}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-103
@@ -65,11 +65,8 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the
|
|||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
|---|----------|-------|----------|
|
|---|----------|-------|----------|
|
||||||
| TC-API-0.1 | Unauthenticated health check | GET /api/health | 200 OK, `{"status":"ok"}` |
|
| TC-API-0.1 | Unauthenticated health check | GET /api/health | 200 OK, `{"status":"ok"}` |
|
||||||
| TC-API-0.2 | DB-touching readiness check — healthy (GRO-2678) | GET /api/readyz | 200 OK, `{"status":"ready"}` |
|
|
||||||
| TC-API-0.3 | DB-touching readiness check — response body safe | GET /api/readyz and inspect body | Body contains only `status` and (on error) `check` fields — no raw SQL, driver messages, or stack traces |
|
|
||||||
|
|
||||||
> **Note (GRO-1544):** Health endpoint registered on `api` basePath before auth middleware at `/api/health`. The old path `/health` was incorrect (routed to web pod via HTTPRoute `/*` rule).
|
> **Note (GRO-1544):** Health endpoint registered on `api` basePath before auth middleware at `/api/health`. The old path `/health` was incorrect (routed to web pod via HTTPRoute `/*` rule).
|
||||||
> **Note (GRO-2678):** `/api/readyz` is a separate DB-touching endpoint for monitoring. It is intentionally NOT used for K8s liveness/readiness probes — those remain DB-less to avoid pod cycling on transient DB blips.
|
|
||||||
|
|
||||||
### 4.1 Authentication
|
### 4.1 Authentication
|
||||||
|
|
||||||
@@ -111,11 +108,6 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the
|
|||||||
| 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.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.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 |
|
| 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 |
|
||||||
| TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" |
|
|
||||||
| TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" |
|
|
||||||
| TC-API-1.29 | CORS — untrusted origin blocked (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://evil.example.com` header | Response has **no** `Access-Control-Allow-Origin` header — attacker origin is not reflected | `Access-Control-Allow-Origin: https://evil.example.com` present in response |
|
|
||||||
| TC-API-1.30 | CORS — trusted origin allowed (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://uat.groombook.dev` header | `Access-Control-Allow-Origin: https://uat.groombook.dev` + `Access-Control-Allow-Credentials: true` | CORS header absent or trusted origin rejected |
|
|
||||||
| TC-API-1.31 | CORS — untrusted preflight blocked (GRO-2586) | `curl -i -X OPTIONS https://uat.groombook.dev/api/auth/sign-in/social -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'` | Response has **no** `Access-Control-Allow-Origin: https://evil.example.com` | Preflight reflects attacker origin |
|
|
||||||
|
|
||||||
### 4.2 Client Management
|
### 4.2 Client Management
|
||||||
|
|
||||||
@@ -141,7 +133,6 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
|
|||||||
| TC-API-2.11 | Geocode endpoint is manager-only | As **groomer** or **receptionist**, `POST /api/clients/{id}/geocode` | 403 Forbidden (role not permitted) |
|
| TC-API-2.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.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.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.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.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.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) |
|
||||||
@@ -174,8 +165,6 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
|
|||||||
| TC-API-3.19b | Get pet profile summary — customer cross-tenant blocked (GRO-2013) | Sign in as `uat-customer@groombook.dev`; reuse the customer's sessionId from TC-API-3.19a; `GET /api/pets/{otherClientPetId}/profile-summary` for a pet owned by a different client (`c0000002-...` or any non-customer pet) | 403 Forbidden (owner-bypass requires session.clientId === pet.clientId) |
|
| TC-API-3.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.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-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.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.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"}` |
|
| 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"}` |
|
||||||
@@ -196,7 +185,6 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
|
|||||||
| 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.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.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) |
|
| 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) |
|
||||||
| TC-API-3.30 | Verify `reset-demo-data` CronJob is schema-safe — TRUNCATE only, no DDL drops (GRO-2722) | 1. Trigger: `kubectl -n groombook-uat create job --from=cronjob/reset-demo-data reset-verify-$(date +%s) --dry-run=client -o name \| xargs kubectl -n groombook-uat apply -f -` or simply `kubectl -n groombook-uat create job --from=cronjob/reset-demo-data reset-verify-$(date +%s)`. 2. Wait for job completion: `kubectl -n groombook-uat wait job/reset-verify-<ts> --for=condition=complete --timeout=300s`. 3. Check logs: `kubectl -n groombook-uat logs job/reset-verify-<ts>`. 4. Verify schema survival: `kubectl -n groombook-uat exec deploy/api -- psql $DATABASE_URL -c "\dt public.*"` and `kubectl -n groombook-uat exec deploy/api -- psql $DATABASE_URL -c "\dt drizzle.*"`. 5. Verify readyz: `curl -s https://uat.groombook.dev/api/readyz` | Job reaches `Completed` (exit 0); logs show `✓ All public tables truncated, sequences reset` — no `DROP TABLE`/`DROP TYPE`/`DROP SCHEMA` in output; all `public.*` tables still present after reset; `drizzle.__drizzle_migrations` still populated (row count unchanged); `https://uat.groombook.dev/api/readyz` returns HTTP 200; demo data reseeded (500 clients visible via GET /api/clients) |
|
|
||||||
|
|
||||||
### 4.4 Appointment Scheduling
|
### 4.4 Appointment Scheduling
|
||||||
|
|
||||||
@@ -294,10 +282,6 @@ This means:
|
|||||||
| TC-API-8.14 | Portal pet update — non-owner blocked (GRO-2187) | `PATCH /api/portal/pets/{petId}` for a pet owned by a different client, using another client's portal session | 403 Forbidden (or 404 if pet id is unknown); no mutation persisted |
|
| TC-API-8.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.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.16 | Portal pet update — malformed (non-UUID) petId returns 404 (GRO-2203) | With a valid portal session, `PATCH /api/portal/pets/not-a-uuid` with header `X-Impersonation-Session-Id` and body `{"coatType":"short"}` | 404 Not Found with body `{"error":"Not found"}` (was an unhandled 500 from the Postgres uuid cast in GRO-2203; mirrors the GRO-2014 guard). No mutation persisted |
|
||||||
| TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. |
|
|
||||||
| TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) |
|
|
||||||
| TC-API-8.19 | Portal appointments surface active waitlist entries (GRO-2319) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. In addition to the customer's appointments, the response includes the seeded ACTIVE waitlist entry as a synthetic card: `status: "waitlisted"`, `id` prefixed `waitlist:`, `confirmationStatus: null`, a non-null derived `startTime` (from the entry's preferred date/time), and the entry's `pet`. Cancelled/notified/expired waitlist entries are NOT surfaced. |
|
|
||||||
| TC-API-8.20 | Portal waitlist card populates service {id, name} (GRO-2342) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. The synthetic `waitlisted` card returned for the active waitlist entry has `service: {id: "<serviceId>", name: "<serviceName>"}` (full service record, not just `{id}`), matching the shape the appointments join returns. The portal Upcoming list therefore renders the actual service name in place of the fallback "Service" label. |
|
|
||||||
|
|
||||||
### 4.9 Waitlist
|
### 4.9 Waitlist
|
||||||
|
|
||||||
@@ -343,8 +327,8 @@ This means:
|
|||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
|---|----------|-------|----------|
|
|---|----------|-------|----------|
|
||||||
| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the projection (GRO-2294, defense-in-depth); non-secret fields (`businessName`, colors, `routeOptimizationProvider`, etc.) are still present |
|
| TC-API-13.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. 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.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated |
|
||||||
| TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored |
|
| TC-API-13.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.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 |
|
| TC-API-13.5 | Delete logo | DELETE /api/admin/settings/logo | 200 OK, logo removed |
|
||||||
@@ -377,17 +361,12 @@ This means:
|
|||||||
|
|
||||||
### 4.16 Route Optimization — Route CRUD + Optimize (GRO-2155, Phase 2.1)
|
### 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.**
|
A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes`, with ordered `route_stops`. `POST /api/routes/optimize` pulls the day's non-cancelled appointments whose client is geocoded (GRO-2154), orders them (Google Directions `optimizeWaypoints` when a key is configured in `businessSettings.googleMapsApiKey`, else an offline nearest-neighbor heuristic), and persists `stopOrder`, `travelMinsFromPrev`, `travelDistanceKmFromPrev` plus route `totalTravelMins`/`totalDistanceKm`/`optimizedAt`. **Auth: manager (any groomer's route) or groomer (own route only); receptionists have no access.** Pre-condition: at least one geocoded client with appointments on the target date for the staff member (use §4.2 geocoding + a seed groomer).
|
||||||
|
|
||||||
**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 |
|
| # | 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.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.2 | Optimize a multi-stop day | As manager, with ≥2 geocoded appointments for the groomer on the date, `POST /api/routes/optimize` body `{ "staffId": "{groomerId}", "date": "YYYY-MM-DD" }` | 200 OK; `route.status: "optimized"`, `optimizedAt` set, `totalTravelMins`/`totalDistanceKm` populated. `stops` ordered by `stopOrder` (1..N); first stop has `travelMinsFromPrev: null`, the rest positive. `provider` is `"nearest_neighbor"` (no Google key in UAT). Each stop carries `bufferMins` (default 15) |
|
||||||
| TC-API-16.3 | Re-optimize replaces prior order | As manager, run TC-API-16.2 twice | Second call returns 200; stops fully replaced (no duplicate `route_stops`, `stopOrder` still contiguous 1..N), `optimizedAt` refreshed |
|
| TC-API-16.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.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.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` |
|
||||||
@@ -398,83 +377,6 @@ A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes
|
|||||||
| TC-API-16.10 | Manager must supply staffId | As manager, `POST /api/routes/optimize` body `{ "date": "YYYY-MM-DD" }` (no staffId) | 400 `{ error: "staffId is required" }` |
|
| TC-API-16.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`) |
|
| 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) |
|
|
||||||
|
|
||||||
|
|
||||||
### 4.19 Boot Resilience — ECONNRESET Recovery (GRO-2652)
|
|
||||||
|
|
||||||
Verifies the API process does not crash on transient boot-time DB connection resets and that auth routes degrade gracefully until initialization succeeds.
|
|
||||||
|
|
||||||
| TC | Test Case | Steps | Expected Result |
|
|
||||||
|----|-----------|-------|-----------------|
|
|
||||||
| TC-API-19.1 | Health endpoint available before auth init | 1. Deploy the image (or restart the api pod)<br>2. `GET /health` immediately (within first 2 s of pod start) | 200 `{"status":"ok"}` — server accepts requests before `initAuth()` completes |
|
|
||||||
| TC-API-19.2 | Auth routes return 503 when auth not yet initialized | 1. Temporarily set `OIDC_ISSUER` to an unreachable host so `initAuth()` keeps retrying<br>2. `POST /api/auth/sign-in/email` during the retry window | 503 `{"error":"Authentication not configured"}` — process stays alive, does not exit |
|
|
||||||
| TC-API-19.3 | Pod does not crash on first-attempt DB reset | 1. Review pod restart count after normal deployment<br>2. Confirm `kubectl get pod -n groombook` shows `RESTARTS: 0` (or same as before deploy) for the new pod | No new restarts — ECONNRESET causes retry, not process exit |
|
|
||||||
| TC-API-19.4 | DB query retry log lines visible | After deploy, `kubectl logs -n groombook <api-pod>` | If any DB retry occurred, log lines matching `[auth] DB query attempt N failed` are present; on clean boot no retry lines appear |
|
|
||||||
| TC-API-19.5 | Auth init retry log lines visible | When auth init fails and retries, check pod logs | Log lines matching `[auth] initAuth attempt N failed` present; process continues; no `process.exit` |
|
|
||||||
| TC-API-19.6 | Auth succeeds after transient DB hiccup | 1. Allow pod to retry until DB is available<br>2. `POST /api/auth/sign-in/email` with valid credentials after init succeeds | 200 with session cookie — auth recovers without pod restart |
|
|
||||||
| TC-API-19.7 | Normal sign-in still works end-to-end | Follow TC-WEB-SSO-3 (SSO sign-in) on UAT | Successful sign-in, staff list visible — no regression from resilience changes |
|
|
||||||
| TC-API-19.8 | Public routes unaffected during auth retry | While auth is retrying (TC-API-19.2 setup), `GET /api/branding` | 200 with branding data — public routes bypass auth and serve normally |
|
|
||||||
|
|
||||||
### 4.20 Portal OOBE — Create Client from Auth (GRO-2359)
|
|
||||||
|
|
||||||
Verifies the `POST /api/portal/clients-from-auth` endpoint that creates a new `clients` row for a first-time SSO user (out-of-box-experience registration). This endpoint requires a valid Better Auth session but does NOT require a portal session; it is the pre-portal step in the new-user OOBE flow.
|
|
||||||
|
|
||||||
| TC | Test Case | Steps | Expected Result |
|
|
||||||
|----|-----------|-------|-----------------|
|
|
||||||
| TC-API-20.1 | Successful client creation | 1. Sign in via SSO to obtain a Better Auth session<br>2. `POST /api/portal/clients-from-auth` with `{ "name": "Test User" }` | 201 `{ "id": "<uuid>", "name": "Test User", "email": "<sso-email>" }` — new `clients` row created |
|
|
||||||
| TC-API-20.2 | All optional fields accepted | `POST /api/portal/clients-from-auth` with `{ "name": "Test User", "phone": "555-1234", "address": "1 Main St", "notes": "VIP" }` (authenticated) | 201 with `id`, `name`, `email`; row in DB has all four fields |
|
|
||||||
| TC-API-20.3 | Invalid body — missing name | `POST /api/portal/clients-from-auth` with `{}` (authenticated) | 400 (Zod validation failure); no row created |
|
|
||||||
| TC-API-20.4 | Invalid body — empty name | `POST /api/portal/clients-from-auth` with `{ "name": "" }` (authenticated) | 400 — name must be at least 1 character |
|
|
||||||
| TC-API-20.5 | No session — 401 | `POST /api/portal/clients-from-auth` with a valid body but **no** Better Auth session cookie | 401 `{ "error": "Unauthorized" }` |
|
|
||||||
| TC-API-20.6 | Existing email — 409 | 1. Create a client row whose email matches the signed-in SSO user's email<br>2. `POST /api/portal/clients-from-auth` as that user | 409 `{ "error": "A customer record with this email already exists" }` — no duplicate row |
|
|
||||||
| TC-API-20.7 | Auth not configured — 503 | Temporarily disable auth (e.g., point `OIDC_ISSUER` to an invalid host) so `getAuth()` throws<br>2. `POST /api/portal/clients-from-auth` | 503 `{ "error": "Authentication not configured" }` — graceful degradation |
|
|
||||||
| TC-API-20.8 | Concurrent insert race — 409 | Simulate two near-simultaneous requests from the same SSO user (before any row exists) | At most one request returns 201; the other returns 409 — no duplicate row, no 500 |
|
|
||||||
|
|
||||||
|
|
||||||
## Pass/Fail Criteria
|
## Pass/Fail Criteria
|
||||||
|
|
||||||
**Pass:**
|
**Pass:**
|
||||||
@@ -496,4 +398,3 @@ Verifies the `POST /api/portal/clients-from-auth` endpoint that creates a new `c
|
|||||||
## Update Policy
|
## Update Policy
|
||||||
|
|
||||||
Any PR that changes user-facing behaviour MUST update this file. Test cases must be added, modified, or removed to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.4 — new appointment rescheduling flow").
|
Any PR that changes user-facing behaviour MUST update this file. Test cases must be added, modified, or removed to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.4 — new appointment rescheduling flow").
|
||||||
|
|
||||||
|
|||||||
@@ -69,14 +69,9 @@ describe("auth init", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
dbSelectResult = [];
|
dbSelectResult = [];
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Stub fetch so OIDC discovery requests resolve instantly during tests.
|
|
||||||
// Without this, AbortSignal.timeout(5000) in auth.ts races with vitest's
|
|
||||||
// 5000ms default test timeout and causes flaky failures.
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 503 }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { authProviderRouter } from "../routes/authProvider.js";
|
import { authProviderRouter } from "../routes/authProvider.js";
|
||||||
|
|
||||||
@@ -227,7 +227,6 @@ describe("PUT /admin/auth-provider", () => {
|
|||||||
|
|
||||||
describe("POST /admin/auth-provider/test", () => {
|
describe("POST /admin/auth-provider/test", () => {
|
||||||
beforeEach(resetMock);
|
beforeEach(resetMock);
|
||||||
afterEach(() => vi.restoreAllMocks());
|
|
||||||
|
|
||||||
it("returns ok=false for unreachable issuer", async () => {
|
it("returns ok=false for unreachable issuer", async () => {
|
||||||
const app = makeApp(mockSuperUser);
|
const app = makeApp(mockSuperUser);
|
||||||
@@ -243,12 +242,7 @@ describe("POST /admin/auth-provider/test", () => {
|
|||||||
expect(body.error).toBeTruthy();
|
expect(body.error).toBeTruthy();
|
||||||
}, 15000); // timeout must exceed the 10s fetch timeout in the route handler
|
}, 15000); // timeout must exceed the 10s fetch timeout in the route handler
|
||||||
|
|
||||||
it("returns 200 when clientSecret is omitted (not required by test-connection schema)", async () => {
|
it("returns 400 for missing clientSecret (not required for test)", async () => {
|
||||||
// Mock fetch so the route does not make a real network request.
|
|
||||||
vi.spyOn(global, "fetch").mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ issuer: "https://auth.example.com" }),
|
|
||||||
} as Response);
|
|
||||||
const app = makeApp(mockSuperUser);
|
const app = makeApp(mockSuperUser);
|
||||||
const { status } = await post(app, "/admin/auth-provider/test", {
|
const { status } = await post(app, "/admin/auth-provider/test", {
|
||||||
providerId: "authentik",
|
providerId: "authentik",
|
||||||
@@ -256,7 +250,7 @@ describe("POST /admin/auth-provider/test", () => {
|
|||||||
issuerUrl: "https://auth.example.com",
|
issuerUrl: "https://auth.example.com",
|
||||||
clientId: "client",
|
clientId: "client",
|
||||||
}, mockSuperUser);
|
}, mockSuperUser);
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200); // clientSecret omitted intentionally for test
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+36
-30
@@ -1,17 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* reset.ts — Truncate all public tables and restart identity sequences.
|
* reset.ts — Drop all application tables and re-run migrations + seed.
|
||||||
*
|
*
|
||||||
* Schema-safe: never issues destructive DDL against any schema.
|
* Intended for local development only. Never run against production.
|
||||||
* The drizzle schema and __drizzle_migrations table are preserved so
|
|
||||||
* drizzle-kit migrate remains a no-op on an already-migrated DB.
|
|
||||||
*
|
|
||||||
* NOTE: this file is NOT the deployed reset entrypoint — the reset image
|
|
||||||
* builds from packages/db and runs `pnpm --filter @groombook/db reset`.
|
|
||||||
* apps/api db:reset delegates there too (see apps/api/package.json).
|
|
||||||
* Keep in sync with packages/db/src/reset.ts (GRO-2722).
|
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* DATABASE_URL=postgres://... npx tsx apps/api/src/db/reset.ts
|
* DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
@@ -30,30 +23,43 @@ async function reset() {
|
|||||||
|
|
||||||
const client = postgres(url, { max: 1 });
|
const client = postgres(url, { max: 1 });
|
||||||
|
|
||||||
console.log("Truncating all public tables...\n");
|
console.log("Dropping all application tables...\n");
|
||||||
|
|
||||||
// Enumerate all base tables in the public schema dynamically, then
|
// Drop in dependency order (children before parents)
|
||||||
// issue a single TRUNCATE with RESTART IDENTITY CASCADE so FK cycles
|
await client`
|
||||||
// are not a problem. The drizzle schema and __drizzle_migrations are
|
DO $$ DECLARE
|
||||||
// intentionally excluded (different schema) so drizzle-kit migrate
|
r RECORD;
|
||||||
// stays a no-op on an already-migrated DB.
|
BEGIN
|
||||||
const tables = await client<{ tablename: string }[]>`
|
FOR r IN (
|
||||||
SELECT tablename FROM pg_tables
|
SELECT tablename FROM pg_tables
|
||||||
WHERE schemaname = 'public'
|
WHERE schemaname = 'public'
|
||||||
|
) LOOP
|
||||||
|
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (tables.length > 0) {
|
// Drop custom enums
|
||||||
// Double-quote each identifier (escaping embedded quotes) to handle
|
await client`
|
||||||
// any table name safely without a pg-specific quote_ident helper.
|
DO $$ DECLARE
|
||||||
const tableList = tables
|
r RECORD;
|
||||||
.map((t) => `"${t.tablename.replace(/"/g, '""')}"`)
|
BEGIN
|
||||||
.join(", ");
|
FOR r IN (
|
||||||
await client.unsafe(
|
SELECT typname FROM pg_type
|
||||||
`TRUNCATE ${tableList} RESTART IDENTITY CASCADE`,
|
WHERE typtype = 'e' AND typnamespace = (
|
||||||
);
|
SELECT oid FROM pg_namespace WHERE nspname = 'public'
|
||||||
}
|
)
|
||||||
|
) LOOP
|
||||||
|
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
`;
|
||||||
|
|
||||||
console.log("✓ All public tables truncated, sequences reset\n");
|
// Drop the drizzle migrations tracking table
|
||||||
|
await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`;
|
||||||
|
await client`DROP SCHEMA IF EXISTS drizzle CASCADE`;
|
||||||
|
|
||||||
|
console.log("✓ All tables and enums dropped\n");
|
||||||
|
|
||||||
await client.end();
|
await client.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"wait-for-db": "node ./scripts/wait-for-db.mjs",
|
"wait-for-db": "node ./scripts/wait-for-db.mjs",
|
||||||
"migrate": "node ./scripts/wait-for-db.mjs && drizzle-kit migrate",
|
"migrate": "node ./scripts/wait-for-db.mjs && drizzle-kit migrate",
|
||||||
"seed": "node ./scripts/wait-for-db.mjs && tsx src/seed.ts",
|
"seed": "node ./scripts/wait-for-db.mjs && tsx src/seed.ts",
|
||||||
"reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts",
|
"reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts",
|
||||||
"studio": "drizzle-kit studio",
|
"studio": "drizzle-kit studio",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
+40
-112
@@ -1,54 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* reset.ts — Truncate all public tables and restart identity sequences.
|
* reset.ts — Drop all application tables and re-run migrations + seed.
|
||||||
*
|
*
|
||||||
* Schema-safe: never issues destructive DDL against any schema.
|
* Intended for local development only. Never run against production.
|
||||||
* The drizzle schema and __drizzle_migrations table are preserved so
|
|
||||||
* drizzle-kit migrate remains a no-op on an already-migrated DB.
|
|
||||||
*
|
*
|
||||||
* GRO-2139: the entire truncate→migrate→seed chain runs inside a single
|
* Usage:
|
||||||
* Postgres advisory lock (SEED_ADVISORY_LOCK_KEY) so a concurrent
|
* DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts
|
||||||
* `seed.ts` (e.g. the dev `seed-test-data-*` Job being recreated at
|
|
||||||
* the top of the hour) cannot interleave between `reset.ts` (TRUNCATE)
|
|
||||||
* and `seed.ts` (TRUNCATE+insert) and collide on `invoices_pkey`.
|
|
||||||
*
|
|
||||||
* Why this matters: `seed.ts` derives every primary key from a single
|
|
||||||
* shared Mulberry32 PRNG seeded with 42 (see `createPrng(42)` and
|
|
||||||
* `uuid()` in seed.ts). Two concurrent same-profile seeders therefore
|
|
||||||
* emit *identical* ids for the same logical row, and any moment
|
|
||||||
* between a concurrent `seed.ts` TRUNCATE and INSERT is exactly the
|
|
||||||
* window in which the second seeder's INSERT can hit a pkey already
|
|
||||||
* taken by the first. Pre-GRO-2123 this raced unconditionally;
|
|
||||||
* GRO-2123 added the advisory lock around `runSeedBody` but left
|
|
||||||
* `reset.ts` and `drizzle-kit migrate` outside the lock. This script
|
|
||||||
* now wraps the *whole* chain in the same lock: `withSeedAdvisoryLock`
|
|
||||||
* pins the lock to one reserved session and the TRUNCATE → migrate → seed
|
|
||||||
* work runs on the rest of the pool, so the lock guarantees mutual
|
|
||||||
* exclusion against any concurrent seeder for the entire chain.
|
|
||||||
*
|
|
||||||
* For a full local schema teardown (nuke tables, enums, drizzle schema)
|
|
||||||
* use `pnpm --filter @groombook/db db:nuke` instead — never change this
|
|
||||||
* script to be destructive (GRO-2722 / GRO-2678 prod incident).
|
|
||||||
*
|
|
||||||
* See: groombook/infra `apps/base/reset-cronjob.yaml` (CronJob) and
|
|
||||||
* `apps/base/seed-job.yaml` (one-shot Job) — both invoke the same
|
|
||||||
* `seed.ts` code path on the same database in `groombook-dev`.
|
|
||||||
*/
|
*/
|
||||||
import postgres from "postgres";
|
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
import * as schema from "./schema.js";
|
|
||||||
import {
|
|
||||||
SEED_ADVISORY_LOCK_KEY,
|
|
||||||
withSeedAdvisoryLock,
|
|
||||||
getProfile,
|
|
||||||
runSeedBody,
|
|
||||||
profiles,
|
|
||||||
} from "./seed.js";
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
import postgres from "postgres";
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
async function reset() {
|
async function reset() {
|
||||||
const url = process.env.DATABASE_URL;
|
const url = process.env.DATABASE_URL;
|
||||||
@@ -57,83 +16,52 @@ async function reset() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true") {
|
||||||
process.env.NODE_ENV === "production" &&
|
console.error("[FATAL] db:reset must not be run in production without ALLOW_RESET=true.");
|
||||||
process.env.ALLOW_RESET !== "true"
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
"[FATAL] db:reset must not be run in production without ALLOW_RESET=true.",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pool sizing is load-bearing here. `withSeedAdvisoryLock` does
|
const client = postgres(url, { max: 1 });
|
||||||
// `pool.reserve()` to pin the advisory lock to one dedicated session
|
|
||||||
// (a session-level lock released on a *different* pooled connection is
|
|
||||||
// a no-op), and the TRUNCATE / migrate / seed work then runs on the
|
|
||||||
// *remaining* pooled connections. The lock provides mutual exclusion
|
|
||||||
// across processes regardless of how many connections the work uses —
|
|
||||||
// it does NOT require the work to share the lock's session.
|
|
||||||
//
|
|
||||||
// Therefore `max` must be >= 2: 1 reserved for the lock + >=1 free for
|
|
||||||
// the work. `max: 1` would let `reserve()` consume the only connection
|
|
||||||
// and every query inside the callback would block forever waiting for
|
|
||||||
// a connection that never frees (connection-starvation deadlock). We
|
|
||||||
// use `max: 6` to match `seed()`'s headroom (1 reserved + 5 work).
|
|
||||||
const client = postgres(url, { max: 6 });
|
|
||||||
const db = drizzle(client, { schema });
|
|
||||||
|
|
||||||
try {
|
console.log("Dropping all application tables...\n");
|
||||||
await withSeedAdvisoryLock(client, async () => {
|
|
||||||
console.log("Truncating all public tables...\n");
|
|
||||||
|
|
||||||
// Enumerate all base tables in the public schema dynamically, then
|
// Drop in dependency order (children before parents)
|
||||||
// issue a single TRUNCATE with RESTART IDENTITY CASCADE so FK cycles
|
await client`
|
||||||
// are not a problem. The drizzle schema and __drizzle_migrations are
|
DO $$ DECLARE
|
||||||
// intentionally excluded (different schema) so drizzle-kit migrate
|
r RECORD;
|
||||||
// stays a no-op on an already-migrated DB.
|
BEGIN
|
||||||
const tables = await client<{ tablename: string }[]>`
|
FOR r IN (
|
||||||
SELECT tablename FROM pg_tables
|
SELECT tablename FROM pg_tables
|
||||||
WHERE schemaname = 'public'
|
WHERE schemaname = 'public'
|
||||||
`;
|
) LOOP
|
||||||
|
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
`;
|
||||||
|
|
||||||
if (tables.length > 0) {
|
// Drop custom enums
|
||||||
// Double-quote each identifier (escaping embedded quotes) to handle
|
await client`
|
||||||
// any table name safely without a pg-specific quote_ident helper.
|
DO $$ DECLARE
|
||||||
const tableList = tables
|
r RECORD;
|
||||||
.map((t) => `"${t.tablename.replace(/"/g, '""')}"`)
|
BEGIN
|
||||||
.join(", ");
|
FOR r IN (
|
||||||
await client.unsafe(
|
SELECT typname FROM pg_type
|
||||||
`TRUNCATE ${tableList} RESTART IDENTITY CASCADE`,
|
WHERE typtype = 'e' AND typnamespace = (
|
||||||
);
|
SELECT oid FROM pg_namespace WHERE nspname = 'public'
|
||||||
}
|
)
|
||||||
|
) LOOP
|
||||||
|
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
`;
|
||||||
|
|
||||||
console.log("✓ All public tables truncated, sequences reset\n");
|
// Drop the drizzle migrations tracking table
|
||||||
|
await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`;
|
||||||
|
await client`DROP SCHEMA IF EXISTS drizzle CASCADE`;
|
||||||
|
|
||||||
console.log("Running migrations...");
|
console.log("✓ All tables and enums dropped\n");
|
||||||
// GRO-2672: drizzle-orm's migrate() has a high-water-mark bug that skips
|
|
||||||
// migrations with stale `when` timestamps (0001, 0003, 0010, 0011). Use
|
|
||||||
// drizzle-kit instead -- it applies migrations by hash, matching the K8s
|
|
||||||
// migrate Job behaviour exactly.
|
|
||||||
execSync("pnpm exec drizzle-kit migrate", {
|
|
||||||
stdio: "inherit",
|
|
||||||
env: { ...process.env },
|
|
||||||
cwd: resolve(__dirname, ".."),
|
|
||||||
});
|
|
||||||
console.log("✓ Migrations applied\n");
|
|
||||||
|
|
||||||
console.log("Seeding database...");
|
await client.end();
|
||||||
const profile = getProfile();
|
|
||||||
const cfg = profiles[profile];
|
|
||||||
await runSeedBody(client, db, profile, cfg);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`\n✓ Reset complete (advisory lock key=0x${SEED_ADVISORY_LOCK_KEY.toString(16)})`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reset().catch((err) => {
|
reset().catch((err) => {
|
||||||
|
|||||||
+6
-428
@@ -24,9 +24,9 @@ import type { MedicalAlert } from "@groombook/types";
|
|||||||
|
|
||||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||||
|
|
||||||
export type SeedProfile = "dev" | "uat" | "demo";
|
type SeedProfile = "dev" | "uat" | "demo";
|
||||||
|
|
||||||
export interface ProfileConfig {
|
interface ProfileConfig {
|
||||||
staffCount: { manager: number; receptionist: number; groomer: number; bather: number };
|
staffCount: { manager: number; receptionist: number; groomer: number; bather: number };
|
||||||
clientCount: number;
|
clientCount: number;
|
||||||
appointmentsBackDays: number;
|
appointmentsBackDays: number;
|
||||||
@@ -35,7 +35,7 @@ export interface ProfileConfig {
|
|||||||
includeUatClients: boolean;
|
includeUatClients: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const profiles: Record<SeedProfile, ProfileConfig> = {
|
const profiles: Record<SeedProfile, ProfileConfig> = {
|
||||||
dev: {
|
dev: {
|
||||||
staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 },
|
staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 },
|
||||||
clientCount: 100,
|
clientCount: 100,
|
||||||
@@ -70,8 +70,6 @@ function getProfile(): SeedProfile {
|
|||||||
return "uat";
|
return "uat";
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getProfile };
|
|
||||||
|
|
||||||
// ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
|
// ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -458,36 +456,6 @@ async function seedUatStaffAccounts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Staff: UAT Receptionist (GRO-2225) ──────────────────────────────────────
|
|
||||||
// Standing receptionist staff record so the route-optimization 403 path
|
|
||||||
// (TC-API-16.9: receptionist GET/POST /api/routes → 403) is reproducible
|
|
||||||
// without a hand-built session. The matching Better-Auth credential is
|
|
||||||
// provisioned below from SEED_UAT_RECEPTIONIST_PASSWORD. Created here (gated
|
|
||||||
// on the password env) so the credential loop's staff-link step finds it.
|
|
||||||
if (process.env.SEED_UAT_RECEPTIONIST_PASSWORD) {
|
|
||||||
const UAT_RECEPTIONIST_STAFF_ID = "00000000-0000-0000-0000-000000000099";
|
|
||||||
const [existingReceptionist] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.staff)
|
|
||||||
.where(eq(schema.staff.email, "uat-receptionist@groombook.dev"))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existingReceptionist) {
|
|
||||||
console.log(`✓ Staff 'UAT Receptionist' already exists — skipping`);
|
|
||||||
} else {
|
|
||||||
await db.insert(schema.staff).values({
|
|
||||||
id: UAT_RECEPTIONIST_STAFF_ID,
|
|
||||||
name: "UAT Receptionist",
|
|
||||||
email: "uat-receptionist@groombook.dev",
|
|
||||||
oidcSub: "uat-receptionist@groombook.dev",
|
|
||||||
role: "receptionist",
|
|
||||||
isSuperUser: false,
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
console.log(`✓ Created staff 'UAT Receptionist' (uat-receptionist@groombook.dev)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
||||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
||||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
||||||
@@ -527,8 +495,6 @@ async function seedUatStaffAccounts(
|
|||||||
{ email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" },
|
{ email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" },
|
||||||
{ email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null },
|
{ email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null },
|
||||||
{ email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" },
|
{ email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" },
|
||||||
// GRO-2225: standing receptionist login for the route-optimization 403 path (TC-API-16.9).
|
|
||||||
{ email: "uat-receptionist@groombook.dev", name: "UAT Receptionist", passwordEnv: "SEED_UAT_RECEPTIONIST_PASSWORD", staffEmail: "uat-receptionist@groombook.dev" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const acct of uatPasswordAccounts) {
|
for (const acct of uatPasswordAccounts) {
|
||||||
@@ -832,381 +798,6 @@ async function seedUatGroomerLinkage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GRO-2311 / GRO-2313: portal customer StatusBadge coverage ────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GRO-2311 / GRO-2313: give the UAT portal customer (`uat-customer@groombook.dev`)
|
|
||||||
* a deterministic spread of appointments so the customer-portal StatusBadge
|
|
||||||
* palette can be LIVE-observed (not just code-verified against the bundle).
|
|
||||||
*
|
|
||||||
* `appointment_status` enum is (`scheduled, confirmed, in_progress, completed,
|
|
||||||
* cancelled, no_show`) — the portal's <StatusBadge> renders `appointment.status`
|
|
||||||
* verbatim. `pending` and `waitlisted` are NOT valid appointment statuses, so
|
|
||||||
* GRO-2319 derives them in the portal: `pending` from an upcoming appointment's
|
|
||||||
* `confirmationStatus` (the `scheduled` row below carries `pending`), and
|
|
||||||
* `waitlisted` from an ACTIVE `waitlist_entries` row (seeded at the end of this
|
|
||||||
* function) which `GET /api/portal/appointments` surfaces as a synthetic card.
|
|
||||||
* The `no_show`→`no-show` badge-key fix is the web side of GRO-2319.
|
|
||||||
*
|
|
||||||
* - confirmed → future startTime → renders as an Upcoming card (Confirmed badge)
|
|
||||||
* - scheduled → future startTime → renders as an Upcoming card (Scheduled badge)
|
|
||||||
* - cancelled → past startTime → Past tab (isUpcoming excludes cancelled)
|
|
||||||
* - no_show → past startTime → Past tab (raw `no_show` label until GRO-2319)
|
|
||||||
*
|
|
||||||
* The existing GRO-2100 `completed` appointment (a0000001-…-0001) is left
|
|
||||||
* untouched (AC #4), so Completed is also covered.
|
|
||||||
*
|
|
||||||
* Idempotent: each appointment uses a fixed UUID and is upserted with
|
|
||||||
* onConflictDoNothing, so the hourly reset-demo-data CronJob (which TRUNCATEs
|
|
||||||
* then re-seeds) and non-truncating dev re-seeds never dup-key
|
|
||||||
* (see GRO-2033 for the dup-key class).
|
|
||||||
*/
|
|
||||||
async function seedUatCustomerPortalAppointments(
|
|
||||||
db: ReturnType<typeof drizzle>,
|
|
||||||
customerClientId: string | null,
|
|
||||||
): Promise<void> {
|
|
||||||
const LINKED_PET_ID = "c0000001-0000-0000-0000-000000000002"; // UAT Pup Alpha
|
|
||||||
|
|
||||||
// Skip silently outside the UAT persona profile (e.g. a dev/test seed that
|
|
||||||
// never created the UAT Customer client).
|
|
||||||
if (!customerClientId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The customer's pet must exist (pets are NOT truncated on reset, so this is
|
|
||||||
// stable). Defensive: bail cleanly if the persona pet is absent.
|
|
||||||
const [linkedPet] = await db
|
|
||||||
.select({ id: schema.pets.id })
|
|
||||||
.from(schema.pets)
|
|
||||||
.where(eq(schema.pets.id, LINKED_PET_ID))
|
|
||||||
.limit(1);
|
|
||||||
if (!linkedPet) {
|
|
||||||
console.warn(`⚠ GRO-2311: UAT Pup Alpha (${LINKED_PET_ID}) not found — skipping portal appointment seed`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stable "Bath & Brush" service; fall back to any active service.
|
|
||||||
const BATH_AND_BRUSH_ID = "b0000001-0000-0000-0000-000000000001";
|
|
||||||
const [bathService] = await db
|
|
||||||
.select({ id: schema.services.id })
|
|
||||||
.from(schema.services)
|
|
||||||
.where(eq(schema.services.id, BATH_AND_BRUSH_ID))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let serviceId: string;
|
|
||||||
if (bathService) {
|
|
||||||
serviceId = bathService.id;
|
|
||||||
} else {
|
|
||||||
const [fallback] = await db
|
|
||||||
.select({ id: schema.services.id })
|
|
||||||
.from(schema.services)
|
|
||||||
.where(eq(schema.services.active, true))
|
|
||||||
.limit(1);
|
|
||||||
if (!fallback) {
|
|
||||||
console.warn(`⚠ GRO-2311: no active services found — skipping portal appointment seed`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
serviceId = fallback.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach the UAT groomer when present (nicer "with <groomer>" card); else null
|
|
||||||
// ("First Available"). Either way these are the customer's own appointments —
|
|
||||||
// no new groomer↔pet linkage invariant is created (uses the already-linked
|
|
||||||
// Pup Alpha), so GRO-1987 TC-UAT-3 (403 on the UNLINKED Pup Beta) is unaffected.
|
|
||||||
const [uatGroomerStaff] = await db
|
|
||||||
.select({ id: schema.staff.id })
|
|
||||||
.from(schema.staff)
|
|
||||||
.where(eq(schema.staff.email, "uat-groomer@groombook.dev"))
|
|
||||||
.limit(1);
|
|
||||||
const staffId = uatGroomerStaff?.id ?? null;
|
|
||||||
|
|
||||||
// Anchor all times to local wall-clock so future/past holds regardless of the
|
|
||||||
// hourly reset cadence.
|
|
||||||
const at = (deltaDays: number, hour: number): Date => {
|
|
||||||
const d = new Date();
|
|
||||||
d.setDate(d.getDate() + deltaDays);
|
|
||||||
d.setHours(hour, 0, 0, 0);
|
|
||||||
return d;
|
|
||||||
};
|
|
||||||
const DURATION_MS = 45 * 60 * 1000;
|
|
||||||
|
|
||||||
const rows = [
|
|
||||||
{
|
|
||||||
id: "a0000001-0000-0000-0000-000000000002",
|
|
||||||
status: "confirmed" as const,
|
|
||||||
start: at(3, 10),
|
|
||||||
confirmationStatus: "confirmed",
|
|
||||||
confirmedAt: new Date(),
|
|
||||||
cancelledAt: null as Date | null,
|
|
||||||
notes: "GRO-2311: upcoming confirmed appointment for portal StatusBadge coverage.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "a0000001-0000-0000-0000-000000000003",
|
|
||||||
status: "scheduled" as const,
|
|
||||||
start: at(5, 14),
|
|
||||||
confirmationStatus: "pending",
|
|
||||||
confirmedAt: null as Date | null,
|
|
||||||
cancelledAt: null as Date | null,
|
|
||||||
notes: "GRO-2311: upcoming scheduled appointment for portal StatusBadge coverage.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "a0000001-0000-0000-0000-000000000004",
|
|
||||||
status: "cancelled" as const,
|
|
||||||
start: at(-3, 11),
|
|
||||||
confirmationStatus: "cancelled",
|
|
||||||
confirmedAt: null as Date | null,
|
|
||||||
cancelledAt: new Date(),
|
|
||||||
notes: "GRO-2311: cancelled appointment (Past tab) for portal StatusBadge coverage.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "a0000001-0000-0000-0000-000000000005",
|
|
||||||
status: "no_show" as const,
|
|
||||||
start: at(-10, 9),
|
|
||||||
confirmationStatus: "confirmed",
|
|
||||||
confirmedAt: null as Date | null,
|
|
||||||
cancelledAt: null as Date | null,
|
|
||||||
notes: "GRO-2311: no_show appointment (Past tab) for portal StatusBadge coverage.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(schema.appointments)
|
|
||||||
.values(
|
|
||||||
rows.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
clientId: customerClientId,
|
|
||||||
petId: LINKED_PET_ID,
|
|
||||||
serviceId,
|
|
||||||
staffId,
|
|
||||||
batherStaffId: null,
|
|
||||||
status: r.status,
|
|
||||||
startTime: r.start,
|
|
||||||
endTime: new Date(r.start.getTime() + DURATION_MS),
|
|
||||||
notes: r.notes,
|
|
||||||
priceCents: null,
|
|
||||||
confirmationStatus: r.confirmationStatus,
|
|
||||||
confirmedAt: r.confirmedAt,
|
|
||||||
cancelledAt: r.cancelledAt,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.onConflictDoNothing({ target: schema.appointments.id });
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`✓ GRO-2311: seeded ${rows.length} portal StatusBadge appointments (confirmed/scheduled/cancelled/no_show) for UAT customer`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// GRO-2319 item 2: seed one ACTIVE waitlist entry so the portal's `waitlisted`
|
|
||||||
// card (surfaced by GET /api/portal/appointments) is live-observable. Unlike
|
|
||||||
// appointments, `waitlist_entries` is NOT truncated on the hourly reset, so we
|
|
||||||
// upsert by fixed id and REFRESH the preferred date to a future-relative value
|
|
||||||
// each reset — otherwise the date would go stale and the card would drop out of
|
|
||||||
// the Upcoming list. (The seeded `scheduled` appointment above already carries
|
|
||||||
// `confirmationStatus: "pending"`, which drives the live Pending badge.)
|
|
||||||
const WAITLIST_ENTRY_ID = "e0000001-0000-0000-0000-000000000001";
|
|
||||||
const pad2 = (n: number): string => String(n).padStart(2, "0");
|
|
||||||
const wlStart = at(7, 13); // 7 days out, 1pm — comfortably "upcoming"
|
|
||||||
const wlPreferredDate = `${wlStart.getFullYear()}-${pad2(wlStart.getMonth() + 1)}-${pad2(wlStart.getDate())}`;
|
|
||||||
const wlPreferredTime = `${pad2(wlStart.getHours())}:00:00`;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(schema.waitlistEntries)
|
|
||||||
.values({
|
|
||||||
id: WAITLIST_ENTRY_ID,
|
|
||||||
clientId: customerClientId,
|
|
||||||
petId: LINKED_PET_ID,
|
|
||||||
serviceId,
|
|
||||||
preferredDate: wlPreferredDate,
|
|
||||||
preferredTime: wlPreferredTime,
|
|
||||||
status: "active",
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: schema.waitlistEntries.id,
|
|
||||||
set: {
|
|
||||||
preferredDate: wlPreferredDate,
|
|
||||||
preferredTime: wlPreferredTime,
|
|
||||||
status: "active",
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`✓ GRO-2319: seeded 1 active waitlist entry (${wlPreferredDate} ${wlPreferredTime}) for UAT customer portal Waitlisted card`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GRO-2225: deterministic route-optimization cohort ────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GRO-2225: seed a deterministic, pre-geocoded client cohort + a fixed-date set
|
|
||||||
* of appointments for the UAT groomer so the route-optimization endpoints
|
|
||||||
* (`GET /api/routes/daily`, `POST /api/routes/optimize`, UAT §4.16
|
|
||||||
* TC-API-16.1…16.11) are exercisable with ZERO manual PATCHing.
|
|
||||||
*
|
|
||||||
* Design (no live geocoder — UAT has no Google Maps key, provider is
|
|
||||||
* nearest_neighbor; coordinates are hand-picked fixtures clustered in the
|
|
||||||
* Seattle metro):
|
|
||||||
* - All appointments are on a FIXED calendar date (ROUTE_DATE) and assigned to
|
|
||||||
* the UAT groomer (`uat-groomer@groombook.dev`). The optimize endpoint pulls
|
|
||||||
* non-cancelled appointments in [date 00:00Z, +24h) joined to client coords.
|
|
||||||
* - 10 clients carry deterministic lat/lng → a multi-stop optimized route.
|
|
||||||
* - 2 clients are intentionally left UN-geocoded so the "skipped + surfaced"
|
|
||||||
* path (TC-API-16.5) stays reproducible.
|
|
||||||
*
|
|
||||||
* Idempotent: clients/pets are upserted by fixed UUID (they are NOT truncated on
|
|
||||||
* reset); appointments are upserted by fixed UUID too (they ARE truncated on
|
|
||||||
* reset, but the upsert keeps re-runs safe in non-truncating dev/test paths).
|
|
||||||
* Skips cleanly when the UAT groomer staff record is absent (e.g. prod/demo or a
|
|
||||||
* dev seed without the UAT personas).
|
|
||||||
*/
|
|
||||||
async function seedUatRouteCohort(db: ReturnType<typeof drizzle>): Promise<void> {
|
|
||||||
// Fixed calendar date the UAT playbook hardcodes for §4.16. Times are UTC so
|
|
||||||
// they fall inside the optimize endpoint's [date 00:00Z, +24h) day window.
|
|
||||||
const ROUTE_DATE = "2026-09-15";
|
|
||||||
|
|
||||||
const [uatGroomer] = await db
|
|
||||||
.select({ id: schema.staff.id })
|
|
||||||
.from(schema.staff)
|
|
||||||
.where(eq(schema.staff.email, "uat-groomer@groombook.dev"))
|
|
||||||
.limit(1);
|
|
||||||
if (!uatGroomer) {
|
|
||||||
console.log("✓ GRO-2225: uat-groomer not present — skipping route cohort");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve a service for the appointments: prefer Bath & Brush, else any active.
|
|
||||||
const BATH_AND_BRUSH_ID = "b0000001-0000-0000-0000-000000000001";
|
|
||||||
const [bathService] = await db
|
|
||||||
.select({ id: schema.services.id })
|
|
||||||
.from(schema.services)
|
|
||||||
.where(eq(schema.services.id, BATH_AND_BRUSH_ID))
|
|
||||||
.limit(1);
|
|
||||||
let serviceId: string;
|
|
||||||
if (bathService) {
|
|
||||||
serviceId = bathService.id;
|
|
||||||
} else {
|
|
||||||
const [fallback] = await db
|
|
||||||
.select({ id: schema.services.id })
|
|
||||||
.from(schema.services)
|
|
||||||
.where(eq(schema.services.active, true))
|
|
||||||
.limit(1);
|
|
||||||
if (!fallback) {
|
|
||||||
console.warn("⚠ GRO-2225: no active services found — skipping route cohort");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
serviceId = fallback.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hand-picked fixture coordinates clustered in the Seattle metro. `coords:null`
|
|
||||||
// marks an intentionally un-geocoded client (skip-and-surface path TC-16.5).
|
|
||||||
const cohort: Array<{
|
|
||||||
n: number;
|
|
||||||
name: string;
|
|
||||||
coords: { lat: number; lng: number } | null;
|
|
||||||
}> = [
|
|
||||||
{ n: 1, name: "Route Demo — Ada Lovelace", coords: { lat: 47.6097, lng: -122.3331 } },
|
|
||||||
{ n: 2, name: "Route Demo — Grace Hopper", coords: { lat: 47.6205, lng: -122.3493 } },
|
|
||||||
{ n: 3, name: "Route Demo — Alan Turing", coords: { lat: 47.5990, lng: -122.3300 } },
|
|
||||||
{ n: 4, name: "Route Demo — Katherine Johnson", coords: { lat: 47.6150, lng: -122.3200 } },
|
|
||||||
{ n: 5, name: "Route Demo — Edsger Dijkstra", coords: { lat: 47.6280, lng: -122.3550 } },
|
|
||||||
{ n: 6, name: "Route Demo — Barbara Liskov", coords: { lat: 47.5920, lng: -122.3150 } },
|
|
||||||
{ n: 7, name: "Route Demo — Donald Knuth", coords: { lat: 47.6350, lng: -122.3400 } },
|
|
||||||
{ n: 8, name: "Route Demo — Margaret Hamilton", coords: { lat: 47.6050, lng: -122.3600 } },
|
|
||||||
{ n: 9, name: "Route Demo — Ken Thompson", coords: { lat: 47.6420, lng: -122.3250 } },
|
|
||||||
{ n: 10, name: "Route Demo — Radia Perlman", coords: { lat: 47.5880, lng: -122.3450 } },
|
|
||||||
// Intentionally un-geocoded — exercises the skip-and-surface path.
|
|
||||||
{ n: 11, name: "Route Demo — Ungeocoded One", coords: null },
|
|
||||||
{ n: 12, name: "Route Demo — Ungeocoded Two", coords: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Stagger appointments 45 min apart starting 15:00Z on ROUTE_DATE.
|
|
||||||
const dayStartMs = new Date(`${ROUTE_DATE}T15:00:00.000Z`).getTime();
|
|
||||||
const SLOT_MS = 45 * 60 * 1000;
|
|
||||||
|
|
||||||
let geocodedCount = 0;
|
|
||||||
let ungeocodedCount = 0;
|
|
||||||
for (const c of cohort) {
|
|
||||||
const pad = String(c.n).padStart(2, "0");
|
|
||||||
const clientId = `d0000000-0000-0000-0000-0000000000${pad}`;
|
|
||||||
const petId = `d0000000-0000-0000-0000-0000000001${pad}`;
|
|
||||||
const apptId = `d0000000-0000-0000-0000-0000000002${pad}`;
|
|
||||||
const geocodedAt = c.coords ? new Date(`${ROUTE_DATE}T00:00:00.000Z`) : null;
|
|
||||||
|
|
||||||
await db.insert(schema.clients)
|
|
||||||
.values({
|
|
||||||
id: clientId,
|
|
||||||
name: c.name,
|
|
||||||
email: `route-client-${pad}@uat.groombook.dev`,
|
|
||||||
phone: `(206) 555-01${pad}`,
|
|
||||||
address: `${100 + c.n} Pike Street, Seattle, WA 98101`,
|
|
||||||
status: "active",
|
|
||||||
latitude: c.coords?.lat ?? null,
|
|
||||||
longitude: c.coords?.lng ?? null,
|
|
||||||
geocodedAt,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: schema.clients.id,
|
|
||||||
set: {
|
|
||||||
name: c.name,
|
|
||||||
address: `${100 + c.n} Pike Street, Seattle, WA 98101`,
|
|
||||||
latitude: c.coords?.lat ?? null,
|
|
||||||
longitude: c.coords?.lng ?? null,
|
|
||||||
geocodedAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(schema.pets)
|
|
||||||
.values({
|
|
||||||
id: petId,
|
|
||||||
clientId,
|
|
||||||
name: `Route Pup ${c.n}`,
|
|
||||||
species: "Dog",
|
|
||||||
breed: "Mixed",
|
|
||||||
weightKg: "18.00",
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: schema.pets.id,
|
|
||||||
set: { clientId, name: `Route Pup ${c.n}`, species: "Dog" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const startTime = new Date(dayStartMs + (c.n - 1) * SLOT_MS);
|
|
||||||
const endTime = new Date(startTime.getTime() + SLOT_MS);
|
|
||||||
await db.insert(schema.appointments)
|
|
||||||
.values({
|
|
||||||
id: apptId,
|
|
||||||
clientId,
|
|
||||||
petId,
|
|
||||||
serviceId,
|
|
||||||
staffId: uatGroomer.id,
|
|
||||||
batherStaffId: null,
|
|
||||||
status: "confirmed",
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
notes: "GRO-2225: deterministic route-optimization cohort appointment.",
|
|
||||||
priceCents: null,
|
|
||||||
confirmationStatus: "confirmed",
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: schema.appointments.id,
|
|
||||||
set: {
|
|
||||||
clientId,
|
|
||||||
petId,
|
|
||||||
serviceId,
|
|
||||||
staffId: uatGroomer.id,
|
|
||||||
status: "confirmed",
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (c.coords) geocodedCount++;
|
|
||||||
else ungeocodedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`✓ GRO-2225: seeded route cohort for ${ROUTE_DATE} — ${geocodedCount} geocoded + ${ungeocodedCount} un-geocoded appointment(s) for uat-groomer (${uatGroomer.id})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
|
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1315,10 +906,6 @@ async function seedKnownUsers() {
|
|||||||
// to attach to the appointment; on a fresh reset there are none yet at
|
// to attach to the appointment; on a fresh reset there are none yet at
|
||||||
// the time seedUatStaffAccounts() returns).
|
// the time seedUatStaffAccounts() returns).
|
||||||
await seedUatGroomerLinkage(db, uatCustomerClientId);
|
await seedUatGroomerLinkage(db, uatCustomerClientId);
|
||||||
// GRO-2311 / GRO-2313: portal customer StatusBadge palette coverage (reachable
|
|
||||||
// appointment statuses only). Runs after the groomer linkage so the customer
|
|
||||||
// client + Pup Alpha already exist.
|
|
||||||
await seedUatCustomerPortalAppointments(db, uatCustomerClientId);
|
|
||||||
|
|
||||||
// ── Client: Demo Client ──
|
// ── Client: Demo Client ──
|
||||||
const [existingClient] = await db
|
const [existingClient] = await db
|
||||||
@@ -1402,7 +989,7 @@ async function seedKnownUsers() {
|
|||||||
// from runbooks without ambiguity and binds to the single-argument
|
// from runbooks without ambiguity and binds to the single-argument
|
||||||
// `pg_advisory_lock(int)` form, which postgres-js serializes as a plain
|
// `pg_advisory_lock(int)` form, which postgres-js serializes as a plain
|
||||||
// number (no bigint type plumbing required).
|
// number (no bigint type plumbing required).
|
||||||
export const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable
|
const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reserve a dedicated connection from `pool`, take the seed advisory lock
|
* Reserve a dedicated connection from `pool`, take the seed advisory lock
|
||||||
@@ -1415,7 +1002,7 @@ export const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitra
|
|||||||
* for the lock and release it from the same reserved connection. The
|
* for the lock and release it from the same reserved connection. The
|
||||||
* seed work itself still runs on the pooled connections.
|
* seed work itself still runs on the pooled connections.
|
||||||
*/
|
*/
|
||||||
export async function withSeedAdvisoryLock<T>(
|
async function withSeedAdvisoryLock<T>(
|
||||||
pool: ReturnType<typeof postgres>,
|
pool: ReturnType<typeof postgres>,
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
@@ -1473,7 +1060,7 @@ async function seed() {
|
|||||||
await client.end();
|
await client.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runSeedBody(
|
async function runSeedBody(
|
||||||
client: ReturnType<typeof postgres>,
|
client: ReturnType<typeof postgres>,
|
||||||
db: ReturnType<typeof drizzle>,
|
db: ReturnType<typeof drizzle>,
|
||||||
profile: SeedProfile,
|
profile: SeedProfile,
|
||||||
@@ -1581,15 +1168,6 @@ export async function runSeedBody(
|
|||||||
// to attach to the appointment; on a fresh reset there are none yet at
|
// to attach to the appointment; on a fresh reset there are none yet at
|
||||||
// the time seedUatStaffAccounts() returns).
|
// the time seedUatStaffAccounts() returns).
|
||||||
await seedUatGroomerLinkage(db, uatCustomerClientId);
|
await seedUatGroomerLinkage(db, uatCustomerClientId);
|
||||||
// GRO-2311 / GRO-2313: portal customer StatusBadge palette coverage (reachable
|
|
||||||
// appointment statuses only). Runs after the groomer linkage so the customer
|
|
||||||
// client + Pup Alpha already exist.
|
|
||||||
await seedUatCustomerPortalAppointments(db, uatCustomerClientId);
|
|
||||||
|
|
||||||
// GRO-2225: deterministic pre-geocoded route cohort + fixed-date appointments
|
|
||||||
// for the UAT groomer. Must run AFTER services are seeded (it looks up a
|
|
||||||
// service id for the appointments). Skips cleanly if uat-groomer is absent.
|
|
||||||
await seedUatRouteCohort(db);
|
|
||||||
|
|
||||||
// ── Clients & Pets ──
|
// ── Clients & Pets ──
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -69,14 +69,9 @@ describe("auth init", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
dbSelectResult = [];
|
dbSelectResult = [];
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Stub fetch so OIDC discovery requests resolve instantly during tests.
|
|
||||||
// Without this, AbortSignal.timeout(5000) in auth.ts races with vitest's
|
|
||||||
// 5000ms default test timeout and causes flaky failures.
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 503 }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { enforceAuthCors } from "../lib/auth-cors.js";
|
|
||||||
|
|
||||||
const TRUSTED = ["https://uat.groombook.dev", "https://dev.groombook.dev"];
|
|
||||||
|
|
||||||
/** Simulates Better Auth reflecting the request Origin (the pre-fix bug). */
|
|
||||||
function makeReflectedResponse(origin: string | null): Response {
|
|
||||||
return new Response('{"ok":true}', {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(origin
|
|
||||||
? {
|
|
||||||
"Access-Control-Allow-Origin": origin,
|
|
||||||
"Access-Control-Allow-Credentials": "true",
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("enforceAuthCors (GRO-2586)", () => {
|
|
||||||
it("passes trusted origin through with credentials", () => {
|
|
||||||
const origin = "https://uat.groombook.dev";
|
|
||||||
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin);
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Credentials")).toBe("true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips ACAO for attacker origin (credentialed cross-origin read blocked)", () => {
|
|
||||||
const origin = "https://evil.example.com";
|
|
||||||
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips ACAO when no Origin header (undefined)", () => {
|
|
||||||
const res = enforceAuthCors(undefined, TRUSTED, makeReflectedResponse(null));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves non-CORS response headers and status from Better Auth", () => {
|
|
||||||
const origin = "https://evil.example.com";
|
|
||||||
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
|
||||||
expect(res.headers.get("Content-Type")).toBe("application/json");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("second trusted origin is also allowed", () => {
|
|
||||||
const origin = "https://dev.groombook.dev";
|
|
||||||
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("empty string origin is treated as untrusted", () => {
|
|
||||||
const res = enforceAuthCors("", TRUSTED, makeReflectedResponse(""));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { authProviderRouter } from "../routes/authProvider.js";
|
import { authProviderRouter } from "../routes/authProvider.js";
|
||||||
|
|
||||||
@@ -227,7 +227,6 @@ describe("PUT /admin/auth-provider", () => {
|
|||||||
|
|
||||||
describe("POST /admin/auth-provider/test", () => {
|
describe("POST /admin/auth-provider/test", () => {
|
||||||
beforeEach(resetMock);
|
beforeEach(resetMock);
|
||||||
afterEach(() => vi.restoreAllMocks());
|
|
||||||
|
|
||||||
it("returns ok=false for unreachable issuer", async () => {
|
it("returns ok=false for unreachable issuer", async () => {
|
||||||
const app = makeApp(mockSuperUser);
|
const app = makeApp(mockSuperUser);
|
||||||
@@ -243,12 +242,7 @@ describe("POST /admin/auth-provider/test", () => {
|
|||||||
expect(body.error).toBeTruthy();
|
expect(body.error).toBeTruthy();
|
||||||
}, 15000); // timeout must exceed the 10s fetch timeout in the route handler
|
}, 15000); // timeout must exceed the 10s fetch timeout in the route handler
|
||||||
|
|
||||||
it("returns 200 when clientSecret is omitted (not required by test-connection schema)", async () => {
|
it("returns 400 for missing clientSecret (not required for test)", async () => {
|
||||||
// Mock fetch so the route does not make a real network request.
|
|
||||||
vi.spyOn(global, "fetch").mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ issuer: "https://auth.example.com" }),
|
|
||||||
} as Response);
|
|
||||||
const app = makeApp(mockSuperUser);
|
const app = makeApp(mockSuperUser);
|
||||||
const { status } = await post(app, "/admin/auth-provider/test", {
|
const { status } = await post(app, "/admin/auth-provider/test", {
|
||||||
providerId: "authentik",
|
providerId: "authentik",
|
||||||
@@ -256,7 +250,7 @@ describe("POST /admin/auth-provider/test", () => {
|
|||||||
issuerUrl: "https://auth.example.com",
|
issuerUrl: "https://auth.example.com",
|
||||||
clientId: "client",
|
clientId: "client",
|
||||||
}, mockSuperUser);
|
}, mockSuperUser);
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200); // clientSecret omitted intentionally for test
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -39,19 +39,11 @@ const APPOINTMENT = {
|
|||||||
|
|
||||||
let selectSessionRow: Record<string, unknown> | null = null;
|
let selectSessionRow: Record<string, unknown> | null = null;
|
||||||
let selectAppointmentRow: Record<string, unknown> | null = null;
|
let selectAppointmentRow: Record<string, unknown> | null = null;
|
||||||
let selectWaitlistRows: Record<string, unknown>[] = [];
|
|
||||||
let selectPetRows: Record<string, unknown>[] = [];
|
|
||||||
let selectStaffRows: Record<string, unknown>[] = [];
|
|
||||||
let selectServiceRows: Record<string, unknown>[] = [];
|
|
||||||
let updatedValues: Record<string, unknown>[] = [];
|
let updatedValues: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
function resetMock() {
|
function resetMock() {
|
||||||
selectSessionRow = null;
|
selectSessionRow = null;
|
||||||
selectAppointmentRow = null;
|
selectAppointmentRow = null;
|
||||||
selectWaitlistRows = [];
|
|
||||||
selectPetRows = [];
|
|
||||||
selectStaffRows = [];
|
|
||||||
selectServiceRows = [];
|
|
||||||
updatedValues = [];
|
updatedValues = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,13 +72,6 @@ vi.mock("@groombook/db", () => {
|
|||||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||||
);
|
);
|
||||||
|
|
||||||
const mkTable = (name: string) =>
|
|
||||||
new Proxy({ _name: name }, { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) });
|
|
||||||
const waitlistEntries = mkTable("waitlistEntries");
|
|
||||||
const pets = mkTable("pets");
|
|
||||||
const staff = mkTable("staff");
|
|
||||||
const services = mkTable("services");
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getDb: () => ({
|
getDb: () => ({
|
||||||
select: () => ({
|
select: () => ({
|
||||||
@@ -97,18 +82,6 @@ vi.mock("@groombook/db", () => {
|
|||||||
if (table._name === "appointments") {
|
if (table._name === "appointments") {
|
||||||
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
|
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
|
||||||
}
|
}
|
||||||
if (table._name === "waitlistEntries") {
|
|
||||||
return makeChainable(selectWaitlistRows);
|
|
||||||
}
|
|
||||||
if (table._name === "pets") {
|
|
||||||
return makeChainable(selectPetRows);
|
|
||||||
}
|
|
||||||
if (table._name === "staff") {
|
|
||||||
return makeChainable(selectStaffRows);
|
|
||||||
}
|
|
||||||
if (table._name === "services") {
|
|
||||||
return makeChainable(selectServiceRows);
|
|
||||||
}
|
|
||||||
return makeChainable([]);
|
return makeChainable([]);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -129,13 +102,8 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
impersonationSessions,
|
impersonationSessions,
|
||||||
appointments,
|
appointments,
|
||||||
waitlistEntries,
|
|
||||||
pets,
|
|
||||||
staff,
|
|
||||||
services,
|
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
and: vi.fn(),
|
and: vi.fn(),
|
||||||
inArray: vi.fn(),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,104 +125,6 @@ function jsonPatch(path: string, body: unknown, headers?: Record<string, string>
|
|||||||
|
|
||||||
beforeEach(() => resetMock());
|
beforeEach(() => resetMock());
|
||||||
|
|
||||||
// GRO-2319 item 2: the portal Upcoming list renders active waitlist entries as
|
|
||||||
// synthetic `waitlisted` cards, so GET /portal/appointments must surface them.
|
|
||||||
describe("GET /portal/appointments (waitlist surfacing — GRO-2319)", () => {
|
|
||||||
it("returns active waitlist entries as synthetic waitlisted cards", async () => {
|
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
|
||||||
selectAppointmentRow = { ...APPOINTMENT };
|
|
||||||
selectWaitlistRows = [
|
|
||||||
{
|
|
||||||
id: "11111111-1111-1111-1111-111111111111",
|
|
||||||
petId: "pet-1",
|
|
||||||
serviceId: "svc-1",
|
|
||||||
preferredDate: "2099-01-01",
|
|
||||||
preferredTime: "13:00:00",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }];
|
|
||||||
|
|
||||||
const res = await app.request("/portal/appointments", {
|
|
||||||
headers: { "X-Impersonation-Session-Id": SESSION_ID },
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
const waitlistCard = body.appointments.find(
|
|
||||||
(a: { status: string }) => a.status === "waitlisted",
|
|
||||||
);
|
|
||||||
expect(waitlistCard).toBeTruthy();
|
|
||||||
expect(waitlistCard.id).toBe("waitlist:11111111-1111-1111-1111-111111111111");
|
|
||||||
expect(waitlistCard.pet.name).toBe("Rex");
|
|
||||||
expect(waitlistCard.confirmationStatus).toBeNull();
|
|
||||||
// startTime is derived from preferredDate + preferredTime so the card sorts
|
|
||||||
// and classifies as Upcoming.
|
|
||||||
expect(waitlistCard.startTime).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits the waitlist section when the client has no active entries", async () => {
|
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
|
||||||
selectAppointmentRow = { ...APPOINTMENT };
|
|
||||||
selectWaitlistRows = [];
|
|
||||||
|
|
||||||
const res = await app.request("/portal/appointments", {
|
|
||||||
headers: { "X-Impersonation-Session-Id": SESSION_ID },
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.appointments.some((a: { status: string }) => a.status === "waitlisted")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// GRO-2342: GET /portal/appointments must populate the synthetic waitlist
|
|
||||||
// card's `service` object with the full service record (id + name) — same
|
|
||||||
// shape the appointments join returns — so the portal renders the real
|
|
||||||
// service name in place of the fallback "Service" label.
|
|
||||||
describe("GET /portal/appointments (waitlist service name — GRO-2342)", () => {
|
|
||||||
it("returns service {id, name} on the synthetic waitlist card", async () => {
|
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
|
||||||
selectAppointmentRow = { ...APPOINTMENT };
|
|
||||||
selectWaitlistRows = [
|
|
||||||
{
|
|
||||||
id: "22222222-2222-2222-2222-222222222222",
|
|
||||||
petId: "pet-1",
|
|
||||||
serviceId: "svc-1",
|
|
||||||
preferredDate: "2099-01-01",
|
|
||||||
preferredTime: "13:00:00",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }];
|
|
||||||
selectServiceRows = [{ id: "svc-1", name: "Full Groom" }];
|
|
||||||
|
|
||||||
const res = await app.request("/portal/appointments", {
|
|
||||||
headers: { "X-Impersonation-Session-Id": SESSION_ID },
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
const waitlistCard = body.appointments.find(
|
|
||||||
(a: { status: string }) => a.status === "waitlisted",
|
|
||||||
);
|
|
||||||
expect(waitlistCard).toBeTruthy();
|
|
||||||
expect(waitlistCard.service).toEqual({ id: "svc-1", name: "Full Groom" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns service {id, name} on the appointment card (same shape)", async () => {
|
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
|
||||||
selectAppointmentRow = { ...APPOINTMENT, serviceId: "svc-appt" };
|
|
||||||
selectServiceRows = [{ id: "svc-appt", name: "Bath & Brush" }];
|
|
||||||
|
|
||||||
const res = await app.request("/portal/appointments", {
|
|
||||||
headers: { "X-Impersonation-Session-Id": SESSION_ID },
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
const apptCard = body.appointments.find(
|
|
||||||
(a: { status: string }) => a.status === "scheduled",
|
|
||||||
);
|
|
||||||
expect(apptCard).toBeTruthy();
|
|
||||||
expect(apptCard.service).toEqual({ id: "svc-appt", name: "Bath & Brush" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PATCH /portal/appointments/:id/notes", () => {
|
describe("PATCH /portal/appointments/:id/notes", () => {
|
||||||
it("returns updated appointment with safe fields only", async () => {
|
it("returns updated appointment with safe fields only", async () => {
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
import { getAuth } from "../lib/auth.js";
|
|
||||||
|
|
||||||
const NEW_USER_EMAIL = "new-sso-user@example.com";
|
|
||||||
const NEW_USER_NAME = "New SSO User";
|
|
||||||
const NEW_USER_ID = "11111111-2222-3333-4444-555555555555";
|
|
||||||
|
|
||||||
const BETTER_AUTH_SESSION = {
|
|
||||||
user: {
|
|
||||||
id: "auth-user-new",
|
|
||||||
email: NEW_USER_EMAIL,
|
|
||||||
name: NEW_USER_NAME,
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
id: "ba-session-new",
|
|
||||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let mockGetAuth: ReturnType<typeof vi.fn>;
|
|
||||||
let mockGetSession: ReturnType<typeof vi.fn>;
|
|
||||||
let existingClientRow: Record<string, unknown> | null = null;
|
|
||||||
let insertedClientValues: Record<string, unknown> | null = null;
|
|
||||||
let insertShouldThrow: { code?: string } | 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 clients = new Proxy(
|
|
||||||
{ _name: "clients" },
|
|
||||||
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
getDb: () => ({
|
|
||||||
select: () => ({
|
|
||||||
from: (table: { _name: string }) => {
|
|
||||||
if (table._name === "clients") {
|
|
||||||
return makeChainable(existingClientRow ? [existingClientRow] : []);
|
|
||||||
}
|
|
||||||
return makeChainable([]);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
insert: (table: { _name: string }) => ({
|
|
||||||
values: (vals: Record<string, unknown>) => {
|
|
||||||
if (insertShouldThrow) {
|
|
||||||
const err = new Error("unique violation") as Error & { code?: string };
|
|
||||||
err.code = insertShouldThrow.code;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
returning: () => {
|
|
||||||
if (table._name === "clients") {
|
|
||||||
insertedClientValues = { id: NEW_USER_ID, ...vals };
|
|
||||||
return [insertedClientValues];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
clients,
|
|
||||||
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/clients-from-auth (GRO-2359)", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
existingClientRow = null;
|
|
||||||
insertedClientValues = null;
|
|
||||||
insertShouldThrow = null;
|
|
||||||
mockGetSession = vi.fn();
|
|
||||||
mockGetAuth = vi.fn(() => ({
|
|
||||||
api: {
|
|
||||||
getSession: mockGetSession,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mocked(getAuth).mockImplementation(mockGetAuth);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 401 when no Better Auth session is present", async () => {
|
|
||||||
mockGetSession.mockResolvedValue(null);
|
|
||||||
const res = await app.request("/portal/clients-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: "Test User" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.error).toBe("Unauthorized");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when body fails zod validation (empty name)", async () => {
|
|
||||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
|
||||||
const res = await app.request("/portal/clients-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: "" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a new client row bound to the auth user's email and returns 201", async () => {
|
|
||||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
|
||||||
const res = await app.request("/portal/clients-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: " New SSO User ",
|
|
||||||
phone: "555-1234",
|
|
||||||
address: "1 Main St",
|
|
||||||
notes: "test note",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
id: NEW_USER_ID,
|
|
||||||
name: "New SSO User",
|
|
||||||
email: NEW_USER_EMAIL,
|
|
||||||
});
|
|
||||||
// Trim must be applied to the persisted values.
|
|
||||||
expect(insertedClientValues).not.toBeNull();
|
|
||||||
expect((insertedClientValues as Record<string, unknown>).name).toBe("New SSO User");
|
|
||||||
expect((insertedClientValues as Record<string, unknown>).email).toBe(NEW_USER_EMAIL);
|
|
||||||
expect((insertedClientValues as Record<string, unknown>).phone).toBe("555-1234");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes empty optional fields to null on insert", async () => {
|
|
||||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
|
||||||
await app.request("/portal/clients-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: "Test", phone: "", address: " " }),
|
|
||||||
});
|
|
||||||
expect(insertedClientValues).not.toBeNull();
|
|
||||||
expect((insertedClientValues as Record<string, unknown>).phone).toBeNull();
|
|
||||||
expect((insertedClientValues as Record<string, unknown>).address).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 409 when a client row already exists for this email", async () => {
|
|
||||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
|
||||||
existingClientRow = { id: "existing-client-id", email: NEW_USER_EMAIL };
|
|
||||||
const res = await app.request("/portal/clients-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: "Test" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(409);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.error).toMatch(/already exists/i);
|
|
||||||
expect(insertedClientValues).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 409 on unique constraint race (23505)", async () => {
|
|
||||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
|
||||||
insertShouldThrow = { code: "23505" };
|
|
||||||
const res = await app.request("/portal/clients-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: "Test" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(409);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 503 when auth is not configured", async () => {
|
|
||||||
mockGetAuth.mockImplementation(() => {
|
|
||||||
throw new Error("Auth not initialized");
|
|
||||||
});
|
|
||||||
const res = await app.request("/portal/clients-from-auth", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: "Test" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(503);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
|
|
||||||
// Mock auth lib so getAuth() throws — non-bypass paths hit the 503 "not configured" guard.
|
|
||||||
vi.mock("../lib/auth.js", () => ({
|
|
||||||
getAuth: () => {
|
|
||||||
throw new Error("auth not configured");
|
|
||||||
},
|
|
||||||
initAuth: vi.fn(),
|
|
||||||
getActiveProviders: vi.fn(() => []),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("authMiddleware bypass: /api/readyz", () => {
|
|
||||||
it("serves /api/readyz without auth (bypass before auth check)", async () => {
|
|
||||||
// Ensure AUTH_DISABLED is not set so the bypass is exercised, not AUTH_DISABLED shortcut.
|
|
||||||
const prev = process.env.AUTH_DISABLED;
|
|
||||||
delete process.env.AUTH_DISABLED;
|
|
||||||
|
|
||||||
const { authMiddleware } = await import("../middleware/auth.js");
|
|
||||||
const app = new Hono();
|
|
||||||
app.use("/api/*", authMiddleware);
|
|
||||||
app.get("/api/readyz", (c) => c.json({ status: "ok" }, 200));
|
|
||||||
|
|
||||||
const res = await app.request("/api/readyz", { method: "GET" });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
|
|
||||||
if (prev !== undefined) process.env.AUTH_DISABLED = prev;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks non-whitelisted /api/* paths when auth is not configured", async () => {
|
|
||||||
const prev = process.env.AUTH_DISABLED;
|
|
||||||
delete process.env.AUTH_DISABLED;
|
|
||||||
|
|
||||||
const { authMiddleware } = await import("../middleware/auth.js");
|
|
||||||
const app = new Hono();
|
|
||||||
app.use("/api/*", authMiddleware);
|
|
||||||
app.get("/api/staff", (c) => c.json({ ok: true }, 200));
|
|
||||||
|
|
||||||
const res = await app.request("/api/staff", { method: "GET" });
|
|
||||||
expect(res.status).toBe(503);
|
|
||||||
|
|
||||||
if (prev !== undefined) process.env.AUTH_DISABLED = prev;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
|
|
||||||
// ─── Mock db module ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let selectImpl: () => Promise<unknown>;
|
|
||||||
|
|
||||||
vi.mock("@groombook/db", () => {
|
|
||||||
const staff = new Proxy(
|
|
||||||
{ _name: "staff" },
|
|
||||||
{
|
|
||||||
get(_target, prop) {
|
|
||||||
if (prop === "_name") return "staff";
|
|
||||||
return { table: "staff", column: prop };
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
getDb: () => ({
|
|
||||||
select: (_fields: unknown) => ({
|
|
||||||
from: (_table: unknown) => ({
|
|
||||||
limit: (_n: number) => selectImpl(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
staff,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Build test app ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function makeApp() {
|
|
||||||
// Import after mocks are in place
|
|
||||||
const { getDb, staff } = await import("@groombook/db");
|
|
||||||
|
|
||||||
const app = new Hono();
|
|
||||||
app.get("/api/readyz", async (c) => {
|
|
||||||
try {
|
|
||||||
await getDb().select({ id: staff.id }).from(staff).limit(1);
|
|
||||||
return c.json({ status: "ready" }, 200);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[readyz] DB check failed:", err);
|
|
||||||
return c.json({ status: "degraded", check: "db" }, 503);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("GET /api/readyz", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 200 {status:'ready'} when DB query succeeds", async () => {
|
|
||||||
selectImpl = () => Promise.resolve([{ id: "staff-1" }]);
|
|
||||||
|
|
||||||
const app = await makeApp();
|
|
||||||
const res = await app.request("/api/readyz", { method: "GET" });
|
|
||||||
const body = (await res.json()) as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(body.status).toBe("ready");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 503 {status:'degraded',check:'db'} when DB query throws", async () => {
|
|
||||||
selectImpl = () => Promise.reject(new Error("42P01: relation staff does not exist"));
|
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
|
|
||||||
const app = await makeApp();
|
|
||||||
const res = await app.request("/api/readyz", { method: "GET" });
|
|
||||||
const body = (await res.json()) as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(res.status).toBe(503);
|
|
||||||
expect(body.status).toBe("degraded");
|
|
||||||
expect(body.check).toBe("db");
|
|
||||||
|
|
||||||
// Raw SQL / driver error must NOT appear in the response body
|
|
||||||
expect(JSON.stringify(body)).not.toContain("42P01");
|
|
||||||
expect(JSON.stringify(body)).not.toContain("relation");
|
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,8 +4,6 @@ import {
|
|||||||
estimateLeg,
|
estimateLeg,
|
||||||
nearestNeighborOrder,
|
nearestNeighborOrder,
|
||||||
optimizeRoute,
|
optimizeRoute,
|
||||||
detectScheduleConflicts,
|
|
||||||
recomputeLegsForOrder,
|
|
||||||
MAX_STOPS_PER_ROUTE,
|
MAX_STOPS_PER_ROUTE,
|
||||||
type RouteStopInput,
|
type RouteStopInput,
|
||||||
} from "../services/routeOptimization.js";
|
} from "../services/routeOptimization.js";
|
||||||
@@ -184,152 +182,3 @@ describe("optimizeRoute — >25 stop chunking", () => {
|
|||||||
expect(new Set(r.stops.map((s) => s.appointmentId)).size).toBe(stops.length);
|
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+4
-37
@@ -3,7 +3,6 @@ import { Hono } from "hono";
|
|||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
|
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
|
||||||
import { enforceAuthCors } from "./lib/auth-cors.js";
|
|
||||||
import { clientsRouter } from "./routes/clients.js";
|
import { clientsRouter } from "./routes/clients.js";
|
||||||
import { petsRouter } from "./routes/pets.js";
|
import { petsRouter } from "./routes/pets.js";
|
||||||
import { servicesRouter } from "./routes/services.js";
|
import { servicesRouter } from "./routes/services.js";
|
||||||
@@ -65,17 +64,6 @@ app.use(
|
|||||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||||
// /api/health: used by Gateway HTTPRoute (/api/* → API pod)
|
// /api/health: used by Gateway HTTPRoute (/api/* → API pod)
|
||||||
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
||||||
// /api/readyz: DB-touching deep health check consumed by monitoring (not K8s probes)
|
|
||||||
// Distinct from /health so a dropped schema triggers an alert without cycling pods (GRO-2678)
|
|
||||||
app.get("/api/readyz", async (c) => {
|
|
||||||
try {
|
|
||||||
await getDb().select({ id: staff.id }).from(staff).limit(1);
|
|
||||||
return c.json({ status: "ready" }, 200);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[readyz] DB check failed:", err);
|
|
||||||
return c.json({ status: "degraded", check: "db" }, 503);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Public booking routes — no auth required, must be registered before auth middleware
|
// Public booking routes — no auth required, must be registered before auth middleware
|
||||||
app.route("/api/book", bookRouter);
|
app.route("/api/book", bookRouter);
|
||||||
@@ -212,10 +200,9 @@ api.use("*", resolveStaffMiddleware);
|
|||||||
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
||||||
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
||||||
const authRouter = new Hono();
|
const authRouter = new Hono();
|
||||||
authRouter.all("/*", async (c) => {
|
authRouter.all("/*", (c) => {
|
||||||
try {
|
try {
|
||||||
const res = await getAuth().handler(c.req.raw);
|
return getAuth().handler(c.req.raw);
|
||||||
return enforceAuthCors(c.req.header("origin"), TRUSTED_ORIGINS, res);
|
|
||||||
} catch {
|
} catch {
|
||||||
return c.json({ error: "Authentication not configured" }, 503);
|
return c.json({ error: "Authentication not configured" }, 503);
|
||||||
}
|
}
|
||||||
@@ -303,34 +290,14 @@ api.route("/search", searchRouter);
|
|||||||
api.route("/buffer-rules", bufferRulesRouter);
|
api.route("/buffer-rules", bufferRulesRouter);
|
||||||
api.route("/routes", routesRouter);
|
api.route("/routes", routesRouter);
|
||||||
|
|
||||||
// Start the HTTP server first so /health and public routes are available immediately.
|
|
||||||
// Auth initialization runs afterward with retry — a transient DB ECONNRESET at boot
|
|
||||||
// must not crash the process (GRO-2652). Auth routes return 503 until initAuth succeeds.
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
const server = serve({ fetch: app.fetch, port });
|
await initAuth();
|
||||||
console.log(`API server listening on port ${port}`);
|
console.log(`API server listening on port ${port}`);
|
||||||
|
const server = serve({ fetch: app.fetch, port });
|
||||||
|
|
||||||
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
||||||
startReminderScheduler();
|
startReminderScheduler();
|
||||||
|
|
||||||
let initAttempt = 0;
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
await initAuth();
|
|
||||||
break;
|
|
||||||
} catch (err) {
|
|
||||||
initAttempt++;
|
|
||||||
const delay = Math.min(2 ** initAttempt * 500, 30_000);
|
|
||||||
console.error(`[auth] initAuth attempt ${initAttempt} failed: ${err}`);
|
|
||||||
if (initAttempt >= 10) {
|
|
||||||
console.error("[auth] auth init permanently failed — auth endpoints will serve 503");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
console.error(`[auth] retrying in ${delay}ms`);
|
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function shutdown() {
|
function shutdown() {
|
||||||
console.log("Shutting down gracefully...");
|
console.log("Shutting down gracefully...");
|
||||||
// SIGTERM/SIGINT → server.close() → callback → process.exit(0)
|
// SIGTERM/SIGINT → server.close() → callback → process.exit(0)
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Enforces the trusted-origins CORS allowlist on a raw Response from Better Auth.
|
|
||||||
* Better Auth reflects the request Origin into Access-Control-Allow-Origin
|
|
||||||
* regardless of the trustedOrigins config, allowing credentialed cross-origin reads
|
|
||||||
* from arbitrary attacker origins. This wrapper strips CORS headers for any origin
|
|
||||||
* not in the allowlist. (GRO-2586)
|
|
||||||
*/
|
|
||||||
export function enforceAuthCors(
|
|
||||||
requestOrigin: string | undefined,
|
|
||||||
trustedOrigins: string[],
|
|
||||||
res: Response
|
|
||||||
): Response {
|
|
||||||
const headers = new Headers(res.headers);
|
|
||||||
if (requestOrigin && trustedOrigins.includes(requestOrigin)) {
|
|
||||||
headers.set("Access-Control-Allow-Origin", requestOrigin);
|
|
||||||
headers.set("Access-Control-Allow-Credentials", "true");
|
|
||||||
} else {
|
|
||||||
headers.delete("Access-Control-Allow-Origin");
|
|
||||||
headers.delete("Access-Control-Allow-Credentials");
|
|
||||||
}
|
|
||||||
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
||||||
}
|
|
||||||
+9
-31
@@ -118,34 +118,18 @@ export async function initAuth(): Promise<void> {
|
|||||||
updateAge: 60 * 60 * 24,
|
updateAge: 60 * 60 * 24,
|
||||||
cookieCache: { enabled: false },
|
cookieCache: { enabled: false },
|
||||||
},
|
},
|
||||||
trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173")
|
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||||
.split(",").map((s) => s.trim()).filter(Boolean),
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Try to load config from DB, with retry-with-backoff for transient ECONNRESET (GRO-2652).
|
// Step 1: Try to load config from DB
|
||||||
// A single connection reset during boot must not abort initialization.
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
let dbQueryRows: (typeof authProviderConfig.$inferSelect)[] = [];
|
const [dbConfig] = await db
|
||||||
let dbAttempt = 0;
|
.select()
|
||||||
while (true) {
|
.from(authProviderConfig)
|
||||||
try {
|
.where(eq(authProviderConfig.enabled, true))
|
||||||
dbQueryRows = await db
|
.limit(1);
|
||||||
.select()
|
|
||||||
.from(authProviderConfig)
|
|
||||||
.where(eq(authProviderConfig.enabled, true))
|
|
||||||
.limit(1);
|
|
||||||
break;
|
|
||||||
} catch (err) {
|
|
||||||
dbAttempt++;
|
|
||||||
if (dbAttempt >= 5) throw err;
|
|
||||||
const delay = Math.min(1000 * 2 ** (dbAttempt - 1), 8_000);
|
|
||||||
console.warn(`[auth] DB query attempt ${dbAttempt} failed (${err}), retrying in ${delay}ms`);
|
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const [dbConfig] = dbQueryRows;
|
|
||||||
|
|
||||||
let providerConfig: {
|
let providerConfig: {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
@@ -324,15 +308,9 @@ export async function initAuth(): Promise<void> {
|
|||||||
maxAge: 5 * 60, // 5 minutes
|
maxAge: 5 * 60, // 5 minutes
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173")
|
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||||
.split(",").map((s) => s.trim()).filter(Boolean),
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
try {
|
await authInitPromise;
|
||||||
await authInitPromise;
|
|
||||||
} catch (err) {
|
|
||||||
authInitPromise = null; // allow retry on next call
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
if (c.req.path.startsWith("/api/auth/") || c.req.path === "/api/health" || c.req.path === "/api/readyz") {
|
if (c.req.path.startsWith("/api/auth/") || c.req.path === "/api/health") {
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,32 +8,6 @@ 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.
|
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
|
||||||
* Must be applied to all portal routes.
|
* Must be applied to all portal routes.
|
||||||
@@ -42,12 +16,6 @@ const SSO_BRIDGE_REASON = "sso-bridge";
|
|||||||
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
||||||
* Returns 401 if session is invalid/missing/expired.
|
* Returns 401 if session is invalid/missing/expired.
|
||||||
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
|
* 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) => {
|
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
@@ -56,29 +24,16 @@ export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, nex
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const now = new Date();
|
|
||||||
const [session] = await db
|
const [session] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(impersonationSessions)
|
.from(impersonationSessions)
|
||||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!session || session.expiresAt <= now) {
|
if (!session || session.expiresAt <= new Date()) {
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
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("portalClientId", session.clientId);
|
||||||
c.set("portalSessionId", session.id);
|
c.set("portalSessionId", session.id);
|
||||||
await next();
|
await next();
|
||||||
|
|||||||
+1
-10
@@ -12,12 +12,6 @@ import {
|
|||||||
|
|
||||||
export const clientsRouter = new Hono<AppEnv>();
|
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;
|
type ClientRow = typeof clients.$inferSelect;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,15 +185,12 @@ clientsRouter.post("/:clientId/geocode", async (c) => {
|
|||||||
clientsRouter.post("/geocode-batch", async (c) => {
|
clientsRouter.post("/geocode-batch", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const limitRaw = c.req.query("limit");
|
const limitRaw = c.req.query("limit");
|
||||||
let limit = GEOCODE_BATCH_DEFAULT_LIMIT;
|
let limit = 50;
|
||||||
if (limitRaw !== undefined) {
|
if (limitRaw !== undefined) {
|
||||||
limit = Number(limitRaw);
|
limit = Number(limitRaw);
|
||||||
if (!Number.isFinite(limit) || limit <= 0) {
|
if (!Number.isFinite(limit) || limit <= 0) {
|
||||||
return c.json({ error: "limit must be a positive integer" }, 400);
|
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);
|
const summary = await geocodeUngeocodedClients(db, limit);
|
||||||
return c.json(summary);
|
return c.json(summary);
|
||||||
|
|||||||
+2
-26
@@ -57,23 +57,6 @@ const createPetSchema = z.object({
|
|||||||
customFields: z.record(z.string(), z.string()).optional(),
|
customFields: z.record(z.string(), z.string()).optional(),
|
||||||
petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
|
petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
|
||||||
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
|
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
|
||||||
// Extended pet profile fields (api/#39, GRO-1178).
|
|
||||||
// GRO-2172: these were missing from the schema, causing POST/PATCH to
|
|
||||||
// silently drop them even though migrations 0034/0036 and seed data
|
|
||||||
// populate them. GRO-1472 was the original UAT regression.
|
|
||||||
temperamentScore: z.number().int().min(1).max(5).optional(),
|
|
||||||
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
|
|
||||||
medicalAlerts: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
type: z.string().max(100),
|
|
||||||
description: z.string().max(1000),
|
|
||||||
severity: z.enum(["low", "medium", "high"]),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.max(50)
|
|
||||||
.optional(),
|
|
||||||
preferredCuts: z.array(z.string().max(200)).max(20).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
||||||
@@ -350,8 +333,7 @@ petsRouter.get("/:id/profile-summary", async (c) => {
|
|||||||
|
|
||||||
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
|
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } =
|
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
||||||
c.req.valid("json");
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(pets)
|
.insert(pets)
|
||||||
.values({
|
.values({
|
||||||
@@ -359,10 +341,6 @@ petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
|
|||||||
weightKg: weightKg?.toString(),
|
weightKg: weightKg?.toString(),
|
||||||
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
||||||
customFields: customFields ?? {},
|
customFields: customFields ?? {},
|
||||||
// GRO-2172: medicalAlerts shape from the API request is
|
|
||||||
// { type, description, severity } — the @groombook/types MedicalAlert
|
|
||||||
// has an optional server-generated `id`, so cast for the jsonb column.
|
|
||||||
medicalAlerts: medicalAlerts as never,
|
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
return c.json(row, 201);
|
return c.json(row, 201);
|
||||||
@@ -373,8 +351,7 @@ petsRouter.patch(
|
|||||||
zValidator("json", updatePetSchema),
|
zValidator("json", updatePetSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } =
|
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
||||||
c.req.valid("json");
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.update(pets)
|
.update(pets)
|
||||||
.set({
|
.set({
|
||||||
@@ -382,7 +359,6 @@ petsRouter.patch(
|
|||||||
weightKg: weightKg?.toString(),
|
weightKg: weightKg?.toString(),
|
||||||
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
||||||
...(customFields !== undefined ? { customFields } : {}),
|
...(customFields !== undefined ? { customFields } : {}),
|
||||||
medicalAlerts: medicalAlerts as never,
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(pets.id, c.req.param("id")))
|
.where(eq(pets.id, c.req.param("id")))
|
||||||
|
|||||||
+16
-196
@@ -1,9 +1,9 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { and, eq, inArray } from "@groombook/db";
|
import { eq, inArray } from "@groombook/db";
|
||||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||||
import { validatePortalSession, PORTAL_SESSION_IDLE_TTL_MS } from "../middleware/portalSession.js";
|
import { validatePortalSession } from "../middleware/portalSession.js";
|
||||||
import { portalAudit } from "../middleware/portalAudit.js";
|
import { portalAudit } from "../middleware/portalAudit.js";
|
||||||
import type { PortalEnv } from "../middleware/portalSession.js";
|
import type { PortalEnv } from "../middleware/portalSession.js";
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ portalRouter.post("/session-from-auth", async (c) => {
|
|||||||
staffId,
|
staffId,
|
||||||
clientId: client.id,
|
clientId: client.id,
|
||||||
reason: "sso-bridge",
|
reason: "sso-bridge",
|
||||||
expiresAt: new Date(Date.now() + PORTAL_SESSION_IDLE_TTL_MS),
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -147,114 +147,6 @@ portalRouter.post("/session-from-auth", async (c) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GRO-2359 — register a brand-new SSO user. The post-auth handler in the
|
|
||||||
// web portal redirects here when `session-from-auth` returns 404, so the
|
|
||||||
// OOBE can complete a customer record for the new user. Auth is via the
|
|
||||||
// Better Auth session (same shape as `session-from-auth`), so this is
|
|
||||||
// registered BEFORE the `validatePortalSession` middleware.
|
|
||||||
//
|
|
||||||
// Contract:
|
|
||||||
// POST /api/portal/clients-from-auth
|
|
||||||
// Body: { name: string; phone?: string|null; address?: string|null; notes?: string|null }
|
|
||||||
// 201: { id, name, email }
|
|
||||||
// 400: invalid body (zod failure)
|
|
||||||
// 401: no Better Auth session
|
|
||||||
// 409: a `clients` row already exists for this email (portal selection case)
|
|
||||||
// 500: insert failed
|
|
||||||
//
|
|
||||||
// We do NOT auto-link the user's auth account to the new client row; the
|
|
||||||
// existing `session-from-auth` endpoint re-resolves the row by email on the
|
|
||||||
// next call, so the OOBE's success path just navigates the user back to
|
|
||||||
// `/` and lets the bridge mint a portal session.
|
|
||||||
const createClientFromAuthSchema = z.object({
|
|
||||||
name: z.string().min(1).max(200),
|
|
||||||
phone: z.string().max(50).nullish(),
|
|
||||||
address: z.string().max(500).nullish(),
|
|
||||||
notes: z.string().max(2000).nullish(),
|
|
||||||
});
|
|
||||||
|
|
||||||
portalRouter.post(
|
|
||||||
"/clients-from-auth",
|
|
||||||
zValidator("json", createClientFromAuthSchema),
|
|
||||||
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 body = c.req.valid("json");
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Pre-check: if a client already exists for this email, return 409 so
|
|
||||||
// the OOBE can render the "portal selection" message (the user needs
|
|
||||||
// to contact their groomer to link the new SSO identity to the
|
|
||||||
// pre-existing customer record). We don't return the existing row to
|
|
||||||
// avoid leaking PII about other accounts.
|
|
||||||
const [existing] = await db
|
|
||||||
.select({ id: clients.id })
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.email, session.user.email))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
return c.json(
|
|
||||||
{ error: "A customer record with this email already exists" },
|
|
||||||
409,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let row;
|
|
||||||
try {
|
|
||||||
[row] = await db
|
|
||||||
.insert(clients)
|
|
||||||
.values({
|
|
||||||
name: body.name.trim(),
|
|
||||||
email: session.user.email,
|
|
||||||
phone: body.phone?.trim() || null,
|
|
||||||
address: body.address?.trim() || null,
|
|
||||||
notes: body.notes?.trim() || null,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
} catch (err) {
|
|
||||||
// Concurrent insert from a parallel OOBE submit — treat as 409.
|
|
||||||
if (
|
|
||||||
err instanceof Error &&
|
|
||||||
"code" in err &&
|
|
||||||
(err as { code?: string }).code === "23505"
|
|
||||||
) {
|
|
||||||
return c.json(
|
|
||||||
{ error: "A customer record with this email already exists" },
|
|
||||||
409,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
return c.json({ error: "Failed to create client" }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
|
||||||
email: row.email,
|
|
||||||
},
|
|
||||||
201,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Apply middleware to all portal routes
|
// Apply middleware to all portal routes
|
||||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||||
|
|
||||||
@@ -303,46 +195,14 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
.where(eq(appointments.clientId, clientId))
|
.where(eq(appointments.clientId, clientId))
|
||||||
.orderBy(appointments.startTime);
|
.orderBy(appointments.startTime);
|
||||||
|
|
||||||
// GRO-2319: surface the client's ACTIVE waitlist entries alongside their
|
const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null);
|
||||||
// appointments so the portal can render them as `waitlisted` cards in the
|
|
||||||
// Upcoming list. The `appointment_status` enum cannot represent `waitlisted`,
|
|
||||||
// so these are synthetic entries (status hard-set to `waitlisted`, id prefixed
|
|
||||||
// `waitlist:`) derived from `waitlist_entries`.
|
|
||||||
const waitlistRows = await db
|
|
||||||
.select({
|
|
||||||
id: waitlistEntries.id,
|
|
||||||
petId: waitlistEntries.petId,
|
|
||||||
serviceId: waitlistEntries.serviceId,
|
|
||||||
preferredDate: waitlistEntries.preferredDate,
|
|
||||||
preferredTime: waitlistEntries.preferredTime,
|
|
||||||
})
|
|
||||||
.from(waitlistEntries)
|
|
||||||
.where(
|
|
||||||
and(eq(waitlistEntries.clientId, clientId), eq(waitlistEntries.status, "active")),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pet lookups must cover both appointment and waitlist pets.
|
|
||||||
const petIds = [
|
|
||||||
...allAppts.map(a => a.petId).filter((id): id is string => id !== null),
|
|
||||||
...waitlistRows.map(w => w.petId),
|
|
||||||
];
|
|
||||||
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
|
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
|
||||||
// GRO-2342: services must be looked up for both appointment and waitlist cards
|
|
||||||
// so the portal can render `service.name` in place of the fallback "Service"
|
|
||||||
// label (CMPO sign-off on the GRO-2319 waitlist card explicitly excluded the
|
|
||||||
// service name; this follow-up closes the cosmetic gap).
|
|
||||||
const serviceIds = [
|
|
||||||
...allAppts.map(a => a.serviceId).filter((id): id is string => id !== null),
|
|
||||||
...waitlistRows.map(w => w.serviceId).filter((id): id is string => id !== null),
|
|
||||||
];
|
|
||||||
|
|
||||||
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
|
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
|
||||||
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
|
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
|
||||||
const serviceRows = serviceIds.length ? await db.select().from(services).where(inArray(services.id, serviceIds)) : [];
|
|
||||||
|
|
||||||
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
|
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
|
||||||
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
|
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
|
||||||
const serviceMap = Object.fromEntries(serviceRows.map(s => [s.id, s]));
|
|
||||||
|
|
||||||
const appts = allAppts.map(a => ({
|
const appts = allAppts.map(a => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
@@ -353,35 +213,11 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
customerNotes: a.customerNotes,
|
customerNotes: a.customerNotes,
|
||||||
notes: a.notes,
|
notes: a.notes,
|
||||||
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
|
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
|
||||||
service: a.serviceId ? { id: a.serviceId, name: serviceMap[a.serviceId]?.name } : null,
|
service: a.serviceId ? { id: a.serviceId } : null,
|
||||||
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Derive a display `startTime` from the entry's preferred date/time so the
|
return c.json({ appointments: appts });
|
||||||
// portal can sort/classify the synthetic card (an invalid combination simply
|
|
||||||
// yields a null startTime, which the portal tolerates). GRO-2342: also
|
|
||||||
// populate the synthetic card's `service` object with the full service
|
|
||||||
// record (id + name) — same shape the appointments join returns — so the
|
|
||||||
// portal renders the real service name in place of the fallback "Service"
|
|
||||||
// label.
|
|
||||||
const waitlistAppts = waitlistRows.map(w => {
|
|
||||||
const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`);
|
|
||||||
const startTime = Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
||||||
return {
|
|
||||||
id: `waitlist:${w.id}`,
|
|
||||||
startTime,
|
|
||||||
endTime: null,
|
|
||||||
status: "waitlisted" as const,
|
|
||||||
confirmationStatus: null,
|
|
||||||
customerNotes: null,
|
|
||||||
notes: null,
|
|
||||||
pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey },
|
|
||||||
service: w.serviceId ? { id: w.serviceId, name: serviceMap[w.serviceId]?.name } : null,
|
|
||||||
staff: null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json({ appointments: [...appts, ...waitlistAppts] });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.get("/pets", async (c) => {
|
portalRouter.get("/pets", async (c) => {
|
||||||
@@ -760,32 +596,16 @@ portalRouter.post(
|
|||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
const clientId = c.get("portalClientId");
|
||||||
|
|
||||||
let entry;
|
const [entry] = await db
|
||||||
try {
|
.insert(waitlistEntries)
|
||||||
[entry] = await db
|
.values({
|
||||||
.insert(waitlistEntries)
|
clientId,
|
||||||
.values({
|
petId: body.petId,
|
||||||
clientId,
|
serviceId: body.serviceId,
|
||||||
petId: body.petId,
|
preferredDate: body.preferredDate,
|
||||||
serviceId: body.serviceId,
|
preferredTime: normalizeTime(body.preferredTime),
|
||||||
preferredDate: body.preferredDate,
|
})
|
||||||
preferredTime: normalizeTime(body.preferredTime),
|
.returning();
|
||||||
})
|
|
||||||
.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);
|
return c.json(entry, 201);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-249
@@ -1,4 +1,4 @@
|
|||||||
import { Hono, type Context } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import {
|
import {
|
||||||
@@ -19,16 +19,8 @@ import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
|||||||
import {
|
import {
|
||||||
optimizeRoute,
|
optimizeRoute,
|
||||||
resolveRouteGoogleApiKey,
|
resolveRouteGoogleApiKey,
|
||||||
detectScheduleConflicts,
|
|
||||||
recomputeLegsForOrder,
|
|
||||||
type RouteStopInput,
|
type RouteStopInput,
|
||||||
type StopConflictFlags,
|
|
||||||
} from "../services/routeOptimization.js";
|
} from "../services/routeOptimization.js";
|
||||||
import {
|
|
||||||
buildNavigationUrl,
|
|
||||||
type NavigationPlatform,
|
|
||||||
type NavigationStop,
|
|
||||||
} from "../services/navigationExport.js";
|
|
||||||
|
|
||||||
export const routesRouter = new Hono<AppEnv>();
|
export const routesRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
@@ -42,11 +34,6 @@ const optimizeBodySchema = z.object({
|
|||||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
|
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 /
|
* 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
|
* manager authorization rule. Groomers may only act on their own route; if a
|
||||||
@@ -109,31 +96,6 @@ async function loadRouteStops(db: ReturnType<typeof getDb>, routeId: string) {
|
|||||||
.orderBy(asc(routeStops.stopOrder));
|
.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=
|
* GET /api/routes/daily?staffId=&date=
|
||||||
* Fetches (creating a draft if absent) the daily route for a groomer, with all
|
* Fetches (creating a draft if absent) the daily route for a groomer, with all
|
||||||
@@ -168,13 +130,7 @@ routesRouter.get("/daily", zValidator("query", dailyQuerySchema), async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stops = await loadRouteStops(db, route!.id);
|
const stops = await loadRouteStops(db, route!.id);
|
||||||
const annotated = annotateConflicts(stops);
|
return c.json({ route, stops });
|
||||||
return c.json({
|
|
||||||
route,
|
|
||||||
stops: annotated.stops,
|
|
||||||
hasConflicts: annotated.hasConflicts,
|
|
||||||
conflictCount: annotated.conflictCount,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -306,9 +262,7 @@ routesRouter.post(
|
|||||||
s.travelDistanceKmFromPrev == null
|
s.travelDistanceKmFromPrev == null
|
||||||
? null
|
? null
|
||||||
: s.travelDistanceKmFromPrev.toFixed(2),
|
: s.travelDistanceKmFromPrev.toFixed(2),
|
||||||
// Buffer applies between consecutive stops; the first stop has no
|
bufferMins,
|
||||||
// predecessor, so it carries no travel buffer.
|
|
||||||
bufferMins: i === 0 ? 0 : bufferMins,
|
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -317,12 +271,9 @@ routesRouter.post(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const stops = await loadRouteStops(db, route.id);
|
const stops = await loadRouteStops(db, route.id);
|
||||||
const annotated = annotateConflicts(stops);
|
|
||||||
return c.json({
|
return c.json({
|
||||||
route,
|
route,
|
||||||
stops: annotated.stops,
|
stops,
|
||||||
hasConflicts: annotated.hasConflicts,
|
|
||||||
conflictCount: annotated.conflictCount,
|
|
||||||
provider: optimized.provider,
|
provider: optimized.provider,
|
||||||
chunked: optimized.chunked,
|
chunked: optimized.chunked,
|
||||||
subRouteCount: optimized.subRouteCount,
|
subRouteCount: optimized.subRouteCount,
|
||||||
@@ -331,199 +282,3 @@ routesRouter.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH /api/routes/:routeId/reorder { stopOrder: string[] }
|
|
||||||
* Persists a manual stop order (array of routeStops.id, first-to-last), then
|
|
||||||
* re-runs the buffer logic: each leg's travel is re-estimated for the new
|
|
||||||
* adjacency, the default travel buffer is re-applied between consecutive stops,
|
|
||||||
* route totals are recomputed, and tight-schedule conflicts are re-flagged.
|
|
||||||
* Appointments are never moved. Auth: groomer (own route) or manager.
|
|
||||||
*/
|
|
||||||
routesRouter.patch(
|
|
||||||
"/:routeId/reorder",
|
|
||||||
zValidator("json", reorderBodySchema),
|
|
||||||
async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const routeId = c.req.param("routeId");
|
|
||||||
if (!z.string().uuid().safeParse(routeId).success) {
|
|
||||||
return c.json({ error: "routeId must be a UUID" }, 400);
|
|
||||||
}
|
|
||||||
const { stopOrder: newOrderIds } = c.req.valid("json");
|
|
||||||
|
|
||||||
const [route] = await db
|
|
||||||
.select()
|
|
||||||
.from(groomerRoutes)
|
|
||||||
.where(eq(groomerRoutes.id, routeId));
|
|
||||||
if (!route) {
|
|
||||||
return c.json({ error: "Route not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reuse the groomer-own / manager authorization rule against the route owner.
|
|
||||||
const resolved = resolveTargetStaffId(c.get("staff"), route.staffId);
|
|
||||||
if ("error" in resolved) {
|
|
||||||
return c.json({ error: resolved.error }, resolved.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await db
|
|
||||||
.select({
|
|
||||||
id: routeStops.id,
|
|
||||||
latitude: routeStops.latitude,
|
|
||||||
longitude: routeStops.longitude,
|
|
||||||
})
|
|
||||||
.from(routeStops)
|
|
||||||
.where(eq(routeStops.routeId, routeId));
|
|
||||||
|
|
||||||
// The new order must be an exact permutation of the route's current stops.
|
|
||||||
const existingIds = new Set(existing.map((s) => s.id));
|
|
||||||
if (newOrderIds.length !== existing.length) {
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
error: `stopOrder must list every stop exactly once (expected ${existing.length}, got ${newOrderIds.length})`,
|
|
||||||
},
|
|
||||||
400
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const seen = new Set<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")
|
|
||||||
);
|
|
||||||
|
|||||||
+3
-16
@@ -7,17 +7,6 @@ import { requireSuperUser } from "../middleware/rbac.js";
|
|||||||
|
|
||||||
export const settingsRouter = new Hono();
|
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
|
// GET /api/admin/settings — return current business settings
|
||||||
settingsRouter.get("/", async (c) => {
|
settingsRouter.get("/", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -25,10 +14,9 @@ settingsRouter.get("/", async (c) => {
|
|||||||
if (!row) {
|
if (!row) {
|
||||||
// Auto-create default settings if none exist
|
// Auto-create default settings if none exist
|
||||||
const [created] = await db.insert(businessSettings).values({}).returning();
|
const [created] = await db.insert(businessSettings).values({}).returning();
|
||||||
if (!created) throw new Error("Failed to create default settings");
|
return c.json(created);
|
||||||
return c.json(redactSettings(created));
|
|
||||||
}
|
}
|
||||||
return c.json(redactSettings(row));
|
return c.json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
const hexColorRegex = /^#[0-9a-fA-F]{6}$/;
|
const hexColorRegex = /^#[0-9a-fA-F]{6}$/;
|
||||||
@@ -65,8 +53,7 @@ settingsRouter.patch(
|
|||||||
.where(eq(businessSettings.id, settingsId))
|
.where(eq(businessSettings.id, settingsId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!updated) throw new Error("Failed to update settings");
|
return c.json(updated);
|
||||||
return c.json(redactSettings(updated));
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
@@ -411,103 +411,3 @@ export async function resolveRouteGoogleApiKey(
|
|||||||
const fromEnv = process.env.GOOGLE_MAPS_API_KEY?.trim();
|
const fromEnv = process.env.GOOGLE_MAPS_API_KEY?.trim();
|
||||||
return fromEnv ? fromEnv : null;
|
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user