Compare commits

..

1 Commits

Author SHA1 Message Date
Flea Flicker c26b40e299 fix(db): add extra_large to pet_size_category enum (GRO-1999)
GRO-1999: UAT seed-test-data job (image 2026.05.31-5390131) crashes with
22P02 'invalid input value for enum pet_size_category: "extra_large"'.
seed.ts petSizeCategoryPool and the drizzle schema both use
'extra_large', but migration 0031_buffer_rules.sql created the enum
with 'xlarge' instead — same migration drift pattern that GRO-1971
fixed for coat_type.

Add migration 0037_add_extra_large_to_pet_size_category.sql that
ALTERs the enum to add the missing 'extra_large' value (idempotent,
auto-commit because Postgres forbids ALTER TYPE ADD VALUE inside a
transaction block). Register idx 37 in the drizzle journal.

Mirror the UAT_PLAYBOOK entry from GRO-1971: TC-API-3.28 verifies the
pet_size_category enum has all 4 values used by seed.ts after the seed
job completes, so future enum drift is caught at the UAT gate.

Refs: GRO-1950 (UAT regression that surfaced this), GRO-1971
(pattern this fix mirrors), GRO-1979 (parallel fix landed on a
different branch via PR #124 — this is the GRO-1983 baseline equivalent).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 13:34:30 +00:00
48 changed files with 1424 additions and 7304 deletions
+3 -32
View File
@@ -33,11 +33,11 @@ jobs:
- name: Typecheck
run: |
pnpm run typecheck
pnpm --filter @groombook/api typecheck
pnpm --filter @groombook/db typecheck
- name: Lint
run: pnpm run lint
run: pnpm --filter @groombook/api lint
test:
name: Test
@@ -58,7 +58,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm run test
run: pnpm --filter @groombook/api test
docker:
name: Build & Push Docker Images
@@ -156,32 +156,3 @@ jobs:
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
- name: Smoke test seed image (blackhole npmjs.org)
run: |
set -euo pipefail
IMAGE="git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}"
docker pull "$IMAGE"
# GRO-1985: pnpm must be a real binary, not a Corepack shim, and must
# not try to reach registry.npmjs.org on invocation.
docker run --rm \
--add-host registry.npmjs.org:127.0.0.1 \
--entrypoint="" \
"$IMAGE" \
sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; pnpm --version'
echo "seed image: pnpm resolves to /usr/local/bin/pnpm and runs offline ✓"
- name: Smoke test reset image (blackhole npmjs.org)
run: |
set -euo pipefail
IMAGE="git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}"
docker pull "$IMAGE"
# GRO-1985: pnpm must be a real binary, not a Corepack shim, and must
# not try to reach registry.npmjs.org on invocation. Validates the
# hard requirement from the issue: reset runs offline.
docker run --rm \
--add-host registry.npmjs.org:127.0.0.1 \
--entrypoint="" \
"$IMAGE" \
sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; echo "HOME=$HOME"; pnpm --version'
echo "reset image: pnpm resolves to /usr/local/bin/pnpm, HOME=/tmp, runs offline ✓"
-11
View File
@@ -1,11 +0,0 @@
{
"mcpServers": {
"gitea": {
"type": "http",
"url": "https://git-mcp.farh.net/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_TOKEN}"
}
}
}
}
-54
View File
@@ -1,54 +0,0 @@
# AGENTS.md
This repository (`groombook/api`) is part of the GroomBook application stack. The
authoritative process, quality bar, and safety rules live in the shared
[`groombook/org`](https://git.farh.net/groombook/org) skills repository. Read
those first; this file is only a pointer.
## Authoritative skills
- **SDLC (branching, PRs, phases, handoffs):**
[`groombook/org/skills/sdlc/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/sdlc/SKILL.md)
- **Coding standards (priority ordering, PR discipline, tests, no-hardcoded-values, CalVer):**
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md)
- **Safety (no plaintext secrets, no direct `kubectl apply` to `groombook`, no self-merge, board approval for destructive actions):**
[`groombook/org/skills/safety/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/safety/SKILL.md)
For human contributors and humans reviewing agent work, see
[`CONTRIBUTING.md`](./CONTRIBUTING.md) in this repo for the phase-by-phase PR
flow and the `uat→main` merge-gate policy summary.
## Non-negotiable operational rules
These mirror the org skills; they are restated here so any agent landing in
this repo sees them without a cross-repo fetch.
- **All changes go through a PR.** Never push directly to `dev`, `uat`, or `main`.
- **Branch strategy:** `feature/<name>``dev``uat``main`. Engineers
always target `dev` first.
- **No self-merge contract.** The engineer who opened a PR clicks merge only
after the named reviewer (CI / QA / UAT / Security / CTO per phase)
approves. Issue-thread QA / UAT / security approvals do **not** clear the
Gitea `required_approvals` gate on `uat→main` — only a Gitea **Approve**
click from a member of the `approvals_whitelist_username` does. On this
repo that whitelist is `["gb_flea", "gb_dogfather"]` (engineer team).
Board-level accounts cannot give the Approve click by policy.
- **Always include `cc @cpfarhood`** at the bottom of every PR body for
board visibility (not as a reviewer).
- **Secrets in code are forbidden.** Use Bitnami Sealed Secrets; never commit
plaintext. See the `safety` skill.
- **Production (`groombook` namespace) is Flux-managed.** Never
`kubectl apply` directly. Infrastructure changes go through PRs in
`groombook/infra`.
## Local development
See the repo's own README, package scripts, and CI workflow. The
authoritative pipeline (Gitea Actions, image build, deploy hooks) is the
shared `groombook/infra` overlay; do not reimplement it here.
## When uncertain
If a task conflicts with the org skills, **the org skills win**. Open an
issue in `groombook/org` to propose a change rather than encoding a local
exception.
-117
View File
@@ -1,117 +0,0 @@
# Contributing to `groombook/api`
Thanks for contributing. This document is the human-facing companion to
[`AGENTS.md`](./AGENTS.md) and the authoritative
[`groombook/org`](https://git.farh.net/groombook/org) skills. The org skills
govern; this file is a quick-reference for the human/agent PR flow in this
repo.
## Branch strategy
Three long-lived branches; one PR per promotion step.
| Branch | Environment | Who merges | Prerequisites for merge |
|---------|-------------|-----------|-------------------------|
| `dev` | Dev | Engineer | CI passes |
| `uat` | UAT | Engineer | QA code review approval |
| `main` | Production | Engineer | UAT validation + CTO Gitea Approve when the `uat→main` merge-gate policy applies (see below) |
Engineers always target `dev` first. Feature branches: `<agent-name>/<short-description>`.
## Phase-by-phase PR flow
### Phase 1 — Dev
1. Branch from `dev`: `git checkout -b <name>/<short-description> origin/dev`.
2. Write code + tests. Run unit tests, type check, and lint locally (or rely on CI).
3. Open a PR against `dev`:
```bash
tea pr create --base dev --title "..." --body "..."
```
Include `cc @cpfarhood` at the bottom of the body for board visibility.
4. CI must pass. CI green → engineer self-merges.
5. CI builds and deploys to Dev automatically.
### Phase 2 — UAT promotion
1. Open a PR from `dev` to `uat`.
2. CI must pass.
3. **QA (Lint Roller)** reviews and approves on the Gitea PR.
4. QA approved → engineer self-merges.
5. CI builds and deploys to UAT automatically.
### Phase 3 — UAT regression + Security review
1. **UAT (Shedward Scissorhands)** runs full regression against UAT — every
feature, old and new, no exceptions.
2. **Security (Barkley Trimsworth)** reviews the changes.
3. Failures in either gate bounce back to Phase 1.
### Phase 4 — Production promotion (`uat → main`)
This is the gate the org PR
[`groombook/org#13`](https://git.farh.net/groombook/org/pulls/13) defines.
The full rule is in
[`groombook/org/skills/sdlc/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/sdlc/SKILL.md)
and
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md);
the summary is below.
**The CTO Gitea Approve click is NOT the default gate.** Once the four
pre-gates (QA, UAT deploy, UAT regression, security) are green, the engineer
self-merges.
**A CTO Gitea Approve click IS required** only for PRs in one of three
categories:
1. **Novel auth / session paths** — login, OIDC, OOBE, session middleware,
token issuance, password reset, MFA, new auth provider integrations.
Routine auth-gated UI (button styling, error messages, form layout) is
**not** in this category.
2. **Infra / prod-affecting merges** — deploys, infra manifests, secrets,
GitOps overlays, CI/CD, `main` branch protection, production
routing/ingress, prod state mutations. All Phase 5 infra overlay PRs in
`groombook/infra` require CTO Gitea Approve without exception.
3. **Risk-flagged merges** — `risk:cto-approve` label, or explicit CTO/CEO
sign-off request in the PR or issue thread.
The engineer opens the `uat→main` PR, classifies it against the three
categories above, and adds `cc @cpfarhood`. If the PR is in scope, the CTO
clicks Approve; once approved (and the four pre-gates are green), the
engineer merges.
### Phase 5 — Production deployment
A separate PR in `groombook/infra` bumps the overlay image tag for prod.
Handed to QA (Lint Roller) for review, then self-merged by the engineer.
## The four pre-gates (uat→main)
A `uat→main` PR is mergeable when **all four** are green:
1. **QA code review** — done on the dev→uat promotion PR.
2. **UAT deploy** — the UAT image built from the uat tip is live in UAT.
3. **UAT regression** — Shedward's full-feature UAT pass is green (no
pre-existing defects, no new defects).
4. **Security review** — Barkley's security code review is green.
Issue-thread QA / UAT / security approvals do **not** clear the Gitea
`required_approvals` gate. Only a Gitea **Approve** click from a member of
the `approvals_whitelist_username` for `main` clears it. In this repo that
whitelist is the engineer team (`gb_flea`, `gb_dogfather`).
## Style, tests, and quality bar
See
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md)
for the engineering priority ordering, test requirements, no-hardcoded-values
rules, CalVer versioning policy, and the `git.farh.net` container registry
policy.
## Safety
See
[`groombook/org/skills/safety/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/safety/SKILL.md)
for the non-negotiable rules: no plaintext secrets, no `kubectl apply` to
`groombook`, no self-merge, no direct `tofu` runs, board approval for
destructive actions, escalation protocol.
+1 -13
View File
@@ -3,12 +3,8 @@ FROM node:22-alpine AS base
# invocations of `pnpm` work without DNS access to registry.npmjs.org.
# The corepack shim delegates to corepack, which re-validates against
# npmjs.org on first use — that fails in air-gapped UAT seed/migrate/reset
# Jobs. GRO-1983 / GRO-1889 / GRO-1909 / GRO-1981 / GRO-1985.
# Jobs. GRO-1983 / GRO-1889 / GRO-1909.
RUN npm install -g pnpm@9.15.4
# Belt-and-braces: disable Corepack's download fallback so that even if a
# Corepack shim is somehow invoked at runtime, it will not try to fetch
# pnpm from registry.npmjs.org. Belt for the real-binary trousers. GRO-1985.
ENV COREPACK_ENABLE_DOWNLOAD_FALLBACK=0
WORKDIR /app
# Install deps
@@ -30,8 +26,6 @@ RUN pnpm --filter @groombook/types build && \
# Runtime
FROM node:22-alpine AS runner
RUN npm install -g pnpm@9.15.4
# Same defence-in-depth as base: no Corepack fallback. GRO-1985.
ENV COREPACK_ENABLE_DOWNLOAD_FALLBACK=0
WORKDIR /app
ENV NODE_ENV=production
@@ -52,18 +46,12 @@ CMD ["node", "dist/index.js"]
# Migrate stage — runs drizzle-kit migrate against the database
FROM builder AS migrate
# pnpm needs a writable HOME for any config/state it writes. With
# readOnlyRootFilesystem: true and runAsUser: 1000, /home/node is read-only.
# The job pods mount a writable emptyDir at /tmp; point HOME there. GRO-1985.
ENV HOME=/tmp
CMD ["pnpm", "--filter", "@groombook/db", "migrate"]
# Seed stage — populates the database with test data
FROM builder AS seed
ENV HOME=/tmp
CMD ["pnpm", "--filter", "@groombook/db", "seed"]
# Reset stage — drops all tables, re-runs migrations, and re-seeds
FROM builder AS reset
ENV HOME=/tmp
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
+2 -174
View File
@@ -19,45 +19,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
- OIDC authentication provider configured
- Seed data present (clients, pets, services, staff)
### Source of truth for UAT passwords (GRO-2000)
The `UAT_SUPER_PASSWORD` / `UAT_GROOMER_PASSWORD` / `UAT_TESTER_PASSWORD` / `UAT_CUSTOMER_PASSWORD` env vars the test orchestrator uses **must** be pulled from the live `seed-uat-passwords` Secret in the UAT cluster — never from a captured shell value, a previous run's `.env`, or a copy of the SealedSecret committed before the latest rotation.
**Canonical recipe** (works from any host with `kubectl` + cluster credentials):
```bash
SUPER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
-o jsonpath='{.data.super-password}' | base64 -d)
GROOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
-o jsonpath='{.data.groomer-password}' | base64 -d)
TESTER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
-o jsonpath='{.data.tester-password}' | base64 -d)
CUSTOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
-o jsonpath='{.data.customer-password}' | base64 -d)
```
**Why:** the Bitnami SealedSecret `apps/overlays/uat/ss-seed-uat-passwords.yaml` (in `groombook/infra`) is the single source of truth. The UAT `reset-demo-data` CronJob re-hashes these values into the `account` table on every run (idempotent — GRO-1977). A captured env var from a previous generation will not match the current hash, producing 401 `INVALID_EMAIL_OR_PASSWORD`. If the live login still 401s after pulling from the SealedSecret, the seed Job is stale — trigger `kubectl create job --from=cronjob/reset-demo-data -n groombook-uat manual-seed-$$` and retry.
**How to apply:** at the start of every UAT run that touches TC-API-1.4 / 1.5 / 1.6 / 1.7 / 3.18 / 3.21 / 3.23, refresh these four env vars from the cluster before issuing the sign-in request.
### rbac auto-provision for Better-Auth customers (GRO-2052)
> Applies to TC-API-3.16 / 3.19a / 3.19b / 3.19c (customer-as-owner profile-summary paths) and any future case where the test user authenticates via Better-Auth email/password and the route relies on `resolveStaffMiddleware` to resolve a `staff` row.
**Pre-condition (rbac auto-provision):** The test user must have a row in the Better-Auth `user` table (email/password sign-in creates this automatically — see TC-API-1.6 / 1.7). On first authenticated call, `resolveStaffMiddleware` (`./src/middleware/rbac.ts`) auto-provisions a `groomer` staff row keyed by `staff.user_id = user.id` (Better-Auth branch fires before the legacy OIDC `account` branch).
**Verify the auto-provision fired** by querying the DB after the first authenticated call:
```sql
SELECT user_id, role FROM staff WHERE user_id = '<test-user-id>';
```
Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the OIDC `account` branch and 403'd, or the user has no `user` row — fix the test sign-in path before re-running.
**Why this matters:** without the auto-provision branch, Better-Auth email/password customers (e.g. `uat-customer@groombook.dev`) have no `account` row for the OIDC providers, so `resolveStaffMiddleware` falls through to `403 "Forbidden: no staff record found for authenticated user"` *before* `pets.ts` can run the owner-bypass added in GRO-2013. The owner-bypass code is unreachable unless the auto-provision has fired. A green TC-API-3.19a therefore implicitly proves the auto-provision worked; if 3.19a fails with the pre-fix 403, the auto-provision branch is missing from the deployed `./src` tree (see [GRO-2052](/GRO/issues/GRO-2052)).
**How to apply:** for every run of TC-API-3.16 / 3.19a / 3.19b / 3.19c, sign in via TC-API-1.6 (email+password) first to guarantee the `user` row exists, then run the profile-summary call, then assert the `staff` row above before declaring pass.
## Test Cases
### 4.0 Health Check
@@ -120,25 +81,6 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the
| TC-API-2.5 | Disable client | PATCH /api/clients/{id} with status: "disabled" | 200 OK, client marked as disabled |
| TC-API-2.6 | Delete client | DELETE /api/clients/{id}?confirm=true | 200 OK, client deleted (if no appointments) |
#### Client Geocoding — Route Optimization (GRO-2154, Phase 1.3)
Geocoding turns a client's street address into `latitude`/`longitude` + `geocodedAt`. Provider is driven by `businessSettings.routeOptimizationProvider` (default Nominatim/OpenStreetMap, 1 req/sec; optional Google fallback). All explicit geocode endpoints are **manager-only**.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-2.7 | Geocode single client (success) | As **manager**, `POST /api/clients/{id}/geocode` for a client with a valid, real address (e.g. a seed client) | 200 OK; body `{ status: "geocoded", latitude, longitude, geocodedAt, formattedAddress, provider }`. Subsequent `GET /api/clients/{id}` shows the same non-null `latitude`/`longitude`/`geocodedAt` persisted |
| TC-API-2.8 | Geocode single client — no address | As manager, `POST /api/clients/{id}/geocode` for a client whose `address` is null/blank | 422; `{ status: "no_address", message: "...no address on file..." }` (clear, actionable) |
| TC-API-2.9 | Geocode single client — unresolvable/ambiguous address | As manager, set a nonsense address (e.g. `"asdkjhqweoui 99999"`) then `POST /api/clients/{id}/geocode` | 422; `{ status: "unresolved", message: "Address could not be resolved..." }` so groomers/managers know to correct it |
| TC-API-2.10 | Geocode single client — not found | As manager, `POST /api/clients/00000000-0000-0000-0000-000000000000/geocode` | 404 `{ error: "Not found" }` |
| TC-API-2.11 | Geocode endpoint is manager-only | As **groomer** or **receptionist**, `POST /api/clients/{id}/geocode` | 403 Forbidden (role not permitted) |
| TC-API-2.12 | Batch geocode un-geocoded clients | As manager, `POST /api/clients/geocode-batch?limit=10` on a DB with un-geocoded clients | 200 OK; body `{ provider, processed, geocoded, unresolved, errors, remaining, outcomes[] }`. `processed` ≤ 10; `remaining` reflects un-geocoded clients beyond this batch. Re-run while `remaining > 0` to finish (throttled to provider rate limit) |
| TC-API-2.13 | Batch geocode — invalid limit | As manager, `POST /api/clients/geocode-batch?limit=0` (or non-numeric) | 400 `{ error: "limit must be a positive integer" }` |
| TC-API-2.13a | Batch geocode — `?limit` cap enforced (GRO-2294) | As manager, `POST /api/clients/geocode-batch?limit=100000` on a DB with un-geocoded clients | 200 OK; the request is **clamped to the documented max of 500**`processed` ≤ 500 (never the raw 100000). A fractional `?limit` (e.g. `49.9`) is floored to `49`. Confirms a manager cannot hold one synchronous request open / accrue unbounded Google API cost via an oversized limit |
| TC-API-2.14 | Batch geocode — manager-only | As groomer/receptionist, `POST /api/clients/geocode-batch` | 403 Forbidden |
| TC-API-2.15 | Auto-geocode on create | As manager/receptionist, `POST /api/clients` with a valid `address` | 201 Created; response includes a `geocoding` object (`status: "geocoded"` for a resolvable address) and the persisted client carries `latitude`/`longitude`/`geocodedAt`. Creating without an address succeeds with no `geocoding` field |
| TC-API-2.16 | Auto-geocode on address update | As manager/receptionist, `PATCH /api/clients/{id}` changing `address` to a new valid value | 200 OK; response includes a `geocoding` object and refreshed coordinates. Patching unrelated fields (e.g. `name`) does NOT re-geocode (no `geocoding` field) |
| TC-API-2.17 | Clearing address drops coordinates | As manager/receptionist, `PATCH /api/clients/{id}` with `address: ""` | 200 OK; `latitude`/`longitude`/`geocodedAt` reset to null (no stale pin) |
### 4.3 Pet Management
| # | Scenario | Steps | Expected |
@@ -162,15 +104,6 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
| TC-API-3.17 | Get pet profile summary — groomer restricted | GET /api/pets/{id}/profile-summary as groomer with no pet linkage | 403 Forbidden |
| TC-API-3.18 | Get pet profile summary — visitCount returns full count | GET /api/pets/{id}/profile-summary with 2+ completed appointments | visitCount >= 2 (not capped at 1) |
| TC-API-3.19 | Get pet profile summary — upcomingAppointment excludes past | GET /api/pets/{id}/profile-summary with a past confirmed/scheduled appointment | upcomingAppointment is null (past appointments filtered by startTime >= now) |
| TC-API-3.19a | Get pet profile summary — customer owner-bypass (GRO-2013) | Sign in as `uat-customer@groombook.dev`; `POST /api/portal/session-from-auth`; then `GET /api/pets/{ownPetId}/profile-summary` with header `X-Impersonation-Session-Id: {sessionId}` for either of the customer's seeded pets (`c0000001-0000-0000-0000-000000000002` UAT Pup Alpha, `c0000001-0000-0000-0000-000000000003` UAT Pup Beta) | 200 OK, aggregated profile returned (owner-bypass: customer with valid portal session for pet's clientId is allowed even though rbac.ts auto-provisions them as a `groomer` staff row with no appointment linkage) |
| TC-API-3.19b | Get pet profile summary — customer cross-tenant blocked (GRO-2013) | Sign in as `uat-customer@groombook.dev`; reuse the customer's sessionId from TC-API-3.19a; `GET /api/pets/{otherClientPetId}/profile-summary` for a pet owned by a different client (`c0000002-...` or any non-customer pet) | 403 Forbidden (owner-bypass requires session.clientId === pet.clientId) |
| TC-API-3.19c | Get pet profile summary — customer without portal session header | Same as TC-API-3.19a but omit the `X-Impersonation-Session-Id` header | 403 Forbidden (no owner-bypass without valid portal session) |
| TC-API-3.19d | Get pet profile summary — owner-bypass writes audit row (GRO-2063) | Same setup as TC-API-3.19a (sign in as `uat-customer@groombook.dev`, establish a portal session for the customer's own clientId, call `GET /api/pets/{ownPetId}/profile-summary` with `X-Impersonation-Session-Id: {sessionId}` and a 200 OK response). Then call `GET /api/impersonation/sessions/{sessionId}/audit-log` and confirm there is exactly one entry with `action === "read_profile_summary"`, `pageVisited` matching the profile-summary path, and `metadata` containing `petId` and `actorStaffId` for the customer. Repeat TC-API-3.19b (cross-tenant attempt) and confirm NO new `read_profile_summary` row was written for the cross-tenant attempt. | 200 OK on the profile-summary call AND an audit log entry is present with the correct shape (defense-in-depth audit row; bypass attempts against other clients must NOT log) |
| TC-UAT-2 | Groomer accesses linked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000002/profile-summary` (UAT Pup Alpha — linked via deterministic completed appointment `a0000001-0000-0000-0000-000000000001`, service `b0000001-…-0001` "Bath & Brush", `startTime` ~7 days ago) | 200 OK, `recentGroomingHistory[]` non-empty (>=1 entry), `visitCount >= 1`, `upcomingAppointment` null (the seeded appointment is in the past) |
| TC-UAT-3 | Groomer blocked from unlinked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000003/profile-summary` (UAT Pup Beta — intentionally UNLINKED; no appointment row references this pet's clientId+groomerId combo) | 403 Forbidden (RBAC `groomer` role lacks the appointment-linkage grant for this pet). NOTE: if 404 is returned instead of 403, file a separate RBAC defect (not against the seed) — see GRO-2100 verification note |
| TC-API-3.29 | Get pet profile summary — unknown UUID returns 404 (GRO-2014) | GET /api/pets/00000000-0000-0000-0000-000000000001/profile-summary while authenticated (any role) | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014) |
| TC-API-3.30 | Get pet profile summary — malformed UUID returns 404 (GRO-2014) | GET /api/pets/not-a-uuid/profile-summary while authenticated | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014 — Postgres uuid cast failure) |
| TC-API-3.31 | Get pet profile summary — never empty-body 500 (GRO-2014) | GET /api/pets/{anyId}/profile-summary across the test sweep | No response has status 500 with an empty body. Any 500 must include a JSON body `{"error":"Internal Server Error"}` |
#### Seed Data Verification (GRO-1898)
@@ -187,7 +120,6 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
| TC-API-3.26 | Verify 25-35% medicalAlerts distribution | GET /api/pets (first 30 pets), count how many have non-empty medicalAlerts | Ratio is 25-35% (seed uses rand() < 0.3 for ~30% distribution) |
| TC-API-3.27 | Verify coat_type enum has all seed values | After UAT seed completes, inspect the coat_type enum on the UAT DB — it must contain: short, medium, long, double, wire, silky, curly, hairless | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; coat_type includes all 8 values used by seed.ts `coatTypePool` |
| TC-API-3.28 | Verify pet_size_category enum has all seed values | After UAT seed completes, inspect the pet_size_category enum on the UAT DB — it must contain: small, medium, large, extra_large | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; pet_size_category includes all 4 values used by seed.ts `petSizeCategoryPool` (regression for GRO-1999, mirrors TC-API-3.27) |
| TC-API-3.29 | Verify `reset-demo-data` CronJob does not fail with FK 23503 on `invoice_tip_splits` (GRO-2123) | Trigger the CronJob manually: `kubectl create job --from=cronjob/reset-demo-data verify-gro2123 -n groombook-uat`. Wait for pod to terminate. Inspect logs: `kubectl logs -n groombook-uat -l job-name=verify-gro2123` | Pod reaches `Completed` state; logs show `✓ Acquired seed advisory lock` and `✓ Released seed advisory lock` from `seed.ts`; no `PostgresError: … violates foreign key constraint "invoice_tip_splits_invoice_id_invoices_id_fk"` (code 23503); final counts unchanged (500 clients, ~4000 invoices) |
### 4.4 Appointment Scheduling
@@ -214,33 +146,6 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
| TC-API-5.4 | Update service | PATCH /api/services/{id} with updated fields | 200 OK, service updated |
| TC-API-5.5 | Delete service | DELETE /api/services/{id} | 200 OK, service deleted |
#### 4.5.1 Seed/Reset idempotency (GRO-2064)
Services seeding is now keyed on the deterministic `services.id` (not `name`) and
the reset path now `TRUNCATE`s `services` alongside the other dynamic tables.
This means:
- Running the seed Job twice in a row (no reset in between) converges to the
same catalogue — no `services_pkey` collision.
- A `pnpm reset` followed by `pnpm seed` (or a CronJob reset fire) leaves the
catalogue exactly matching `servicesDef` (10 rows, ids `b0000001-…-001`
`…-00a`), regardless of any stale rows that were present beforehand.
- Mixed `seedKnownUsers` + full `seed()` invocations are safe — the
`demoSvcs` subset (Bath & Brush, Full Groom Small/Medium, Nail Trim) is
keyed on ids `…-001`, `…-002`, `…-003`, `…-005` and the upsert target
is `services.id`, so the same-id / different-name collision that broke
GRO-2033 (id `…-004` = "Nail Trim" vs servicesDef `…-004` =
"Full Groom — Large") cannot recur.
**UAT regression** (verify after a new image is rolled out):
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-SEED-1 | Reset → seed converges | `kubectl -n groombook exec deploy/api -- pnpm reset && pnpm seed` | Seed completes 1/1, `services` count = 10, all ids match `servicesDef` |
| TC-SEED-2 | Idempotent re-seed | Re-run `pnpm seed` without reset | Seed completes 1/1, no `services_pkey` errors, `services` count still 10 |
| TC-SEED-3 | Catalogue matches servicesDef | `psql -c "SELECT id, name FROM services ORDER BY id"` | Rows `…-001``…-00a` with names "Bath & Brush"…"Sanitary Trim" exactly as in `servicesDef` |
| TC-SEED-4 | Demo subset coexists | Run `seedKnownUsers` then full `seed` | No collision, demo subset (4 services) ends up with the same rows the full seed would write |
### 4.6 Staff Management
| # | Scenario | Steps | Expected |
@@ -280,15 +185,6 @@ This means:
| TC-API-8.9 | SSO bridge — no Better Auth session | POST /api/portal/session-from-auth without Better Auth session cookie | 401 Unauthorized |
| TC-API-8.10 | SSO bridge — no matching client | POST /api/portal/session-from-auth with valid Better Auth session for a user with no client record | 404 Not Found, error "No client record found for this user" |
| TC-API-8.11 | SSO bridge — returned session works on portal routes | After TC-API-8.8, use returned sessionId as `X-Impersonation-Session-Id` header on GET /api/portal/me | 200 OK, client profile returned |
| TC-API-8.12 | Portal GET pets returns extended fields (GRO-2187) | Establish a portal session (TC-API-8.8), then `GET /api/portal/pets` with `X-Impersonation-Session-Id` | 200 OK; each pet includes `coatType`, `petSizeCategory`, `healthAlerts`, `preferredCuts`, `medicalAlerts` (in addition to id/name/breed/weight/birthDate/photoUrl/notes) |
| TC-API-8.13 | Portal pet update — owner success + persistence (GRO-2187, fixes [GRO-1480](/GRO/issues/GRO-1480) §5.23) | With a portal session for the pet's owner, `PATCH /api/portal/pets/{petId}` with body `{ "name": "...", "breed": "...", "weightKg": 18.25, "healthAlerts": "...", "coatType": "double", "petSizeCategory": "xlarge", "preferredCuts": ["teddy bear"], "medicalAlerts": [{"type":"allergy","description":"oatmeal","severity":"medium"}] }` | 200 OK; response reflects the update with `petSizeCategory: "extra_large"` (web `xlarge` → DB `extra_large`). A follow-up `GET /api/portal/pets` shows the persisted values |
| TC-API-8.14 | Portal pet update — non-owner blocked (GRO-2187) | `PATCH /api/portal/pets/{petId}` for a pet owned by a different client, using another client's portal session | 403 Forbidden (or 404 if pet id is unknown); no mutation persisted |
| TC-API-8.15 | Portal pet update — invalid enum rejected (GRO-2187) | `PATCH /api/portal/pets/{petId}` with `coatType: "fluffy"` or `petSizeCategory: "gigantic"` | 422 Unprocessable Entity; pet unchanged |
| 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
@@ -334,8 +230,8 @@ This means:
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the projection (GRO-2294, defense-in-depth); non-secret fields (`businessName`, colors, `routeOptimizationProvider`, etc.) are still present |
| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated. 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.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned |
| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated |
| TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored |
| TC-API-13.4 | View logo | GET /api/admin/settings/logo | 200 OK, logo image returned |
| TC-API-13.5 | Delete logo | DELETE /api/admin/settings/logo | 200 OK, logo removed |
@@ -366,74 +262,6 @@ This means:
| TC-API-15.6 | Reject missing required fields | POST /api/admin/buffer-rules with service only | 400 Bad Request, species and sizeCategory required |
| TC-API-15.7 | Booking uses buffer | Book appointment for pet with sizeCategory; verify duration reflects buffer | 201 Created, appointment duration includes buffer time |
### 4.16 Route Optimization — Route CRUD + Optimize (GRO-2155, Phase 2.1)
A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes`, with ordered `route_stops`. `POST /api/routes/optimize` pulls the day's non-cancelled appointments whose client is geocoded (GRO-2154), orders them (Google Directions `optimizeWaypoints` when a key is configured in `businessSettings.googleMapsApiKey`, else an offline nearest-neighbor heuristic), and persists `stopOrder`, `travelMinsFromPrev`, `travelDistanceKmFromPrev` plus route `totalTravelMins`/`totalDistanceKm`/`optimizedAt`. **Auth: manager (any groomer's route) or groomer (own route only); receptionists have no access.**
**Pre-condition (GRO-2225 — zero-touch; no manual PATCH/geocoding needed).** A fresh UAT reset+seed now provisions a deterministic route cohort, so §4.16 runs directly against seed data:
- **Groomer:** `uat-groomer@groombook.dev` (staffId `00000000-0000-0000-0000-000000000004`). Resolve its id via `GET /api/staff` or sign in as the groomer and omit `staffId`.
- **Date:** `2026-09-15` (fixed). On this date the groomer has **12** confirmed appointments: **10 pre-geocoded** clients clustered in the Seattle metro (multi-stop route) + **2 intentionally un-geocoded** clients (exercise the skip-and-surface path, TC-API-16.4). Cohort clients are named `Route Demo — …` (emails `route-client-NN@uat.groombook.dev`).
- **Receptionist (TC-API-16.9 403):** sign in as `uat-receptionist@groombook.dev` (password from the `seed-uat-passwords` secret, key `SEED_UAT_RECEPTIONIST_PASSWORD`) — a standing receptionist login; no hand-built session required.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-16.1 | Fetch daily route (auto-create draft) | As **manager**, `GET /api/routes/daily?staffId={groomerId}&date=YYYY-MM-DD` for a date with no existing route | 200 OK; body `{ route, stops }`. `route.status` is `"draft"`, `route.staffId`/`routeDate` match, `stops` is `[]`. Re-calling returns the same route row (no duplicate) |
| TC-API-16.2 | Optimize a multi-stop day | As manager, with ≥2 geocoded appointments for the groomer on the date, `POST /api/routes/optimize` body `{ "staffId": "{groomerId}", "date": "YYYY-MM-DD" }` | 200 OK; `route.status: "optimized"`, `optimizedAt` set, `totalTravelMins`/`totalDistanceKm` populated. `stops` ordered by `stopOrder` (1..N); first stop has `travelMinsFromPrev: null`, the rest positive. `provider` is `"nearest_neighbor"` (no Google key in UAT). The first stop carries `bufferMins: 0` (no predecessor); every later stop carries `bufferMins` = `businessSettings.defaultTravelBufferMins` (default 15). Response also includes `hasConflicts` / `conflictCount` and each stop a `conflict` object (GRO-2156, see §4.17) |
| TC-API-16.3 | Re-optimize replaces prior order | As manager, run TC-API-16.2 twice | Second call returns 200; stops fully replaced (no duplicate `route_stops`, `stopOrder` still contiguous 1..N), `optimizedAt` refreshed |
| TC-API-16.4 | Skips un-geocoded appointments | As manager, optimize a day where one appointment's client has no coordinates | 200 OK; that appointment is absent from `stops` and listed under `skipped[]` with `reason: "client address is not geocoded"`; a corresponding entry appears in `warnings[]` |
| TC-API-16.5 | Empty / single-stop day | As manager, optimize a date with 0 (or 1) geocoded appointments | 200 OK; `route.status: "optimized"`, `totalTravelMins: 0`, `totalDistanceKm: "0.00"`. For 1 stop, `stops` has one entry with `travelMinsFromPrev: null` |
| TC-API-16.6 | >25 stops chunked with warning | As manager, optimize a day with >25 geocoded appointments | 200 OK; `chunked: true`, `subRouteCount ≥ 2`, a `warnings[]` entry mentions sub-routes; all appointments appear exactly once with contiguous `stopOrder` |
| TC-API-16.7 | Groomer reads own route | As **groomer**, `GET /api/routes/daily?date=YYYY-MM-DD` (omit staffId, or pass own id) | 200 OK; route resolves to the groomer's own `staffId` |
| TC-API-16.8 | Groomer cannot access another's route | As groomer, `GET /api/routes/daily?staffId={otherGroomerId}&date=...` or `POST /api/routes/optimize` with another `staffId` | 403 Forbidden (`groomers may only access their own route`) |
| TC-API-16.9 | Receptionist denied | As **receptionist**, `GET /api/routes/daily?...` or `POST /api/routes/optimize` | 403 Forbidden (role not permitted) |
| TC-API-16.10 | Manager must supply staffId | As manager, `POST /api/routes/optimize` body `{ "date": "YYYY-MM-DD" }` (no staffId) | 400 `{ error: "staffId is required" }` |
| TC-API-16.11 | Invalid date rejected | `GET /api/routes/daily?staffId=...&date=06-08-2026` (wrong format) | 400 validation error (`date must be YYYY-MM-DD`) |
### 4.17 Route Optimization — Travel Buffer + Reorder (GRO-2156, Phase 2.2)
Builds on §4.16. After optimization each consecutive leg carries a travel `bufferMins` (= `businessSettings.defaultTravelBufferMins`, default 15; the first stop is `0`). The API derives a per-stop **`conflict`** object at read time on `GET /api/routes/daily`, `POST /api/routes/optimize`, and `PATCH /api/routes/:routeId/reorder`:
- `conflict.scheduleGapMins` — minutes between the previous appointment's `endTime` and this appointment's `startTime` (null for the first stop)
- `conflict.requiredGapMins``travelMinsFromPrev + bufferMins` (null for the first stop)
- `conflict.shortfallMins``requiredGapMins scheduleGapMins` (positive ⇒ tight)
- `conflict.hasConflict` — true when `shortfallMins > 0` ("tight schedule"); appointments are **never auto-moved**, only flagged
`PATCH /api/routes/:routeId/reorder` accepts `{ "stopOrder": ["<routeStopId>", …] }` (every current stop id, exactly once, first-to-last), persists the new `stopOrder`, re-estimates each leg's travel offline for the new adjacency, re-applies buffers, recomputes route totals, and returns the route with refreshed conflict flags. **Auth: manager (any route) or groomer (own route only).**
| ID | Scenario | Steps | Expected |
|----|----------|-------|----------|
| TC-API-17.1 | Conflict flags on optimize | As manager, optimize a day with ≥2 geocoded appointments whose times are close together | 200 OK; top-level `hasConflicts` (bool) + `conflictCount` (int). First stop `conflict.hasConflict:false` with null gap fields. A later stop whose `scheduleGapMins < travelMinsFromPrev + bufferMins` has `conflict.hasConflict:true` and positive `shortfallMins` |
| TC-API-17.2 | No false conflict on a roomy schedule | Optimize a day where appointment gaps comfortably exceed travel + buffer | 200 OK; `hasConflicts:false`, `conflictCount:0`, every `conflict.shortfallMins ≤ 0` |
| TC-API-17.3 | Reorder persists new order | As manager, take an optimized route, `PATCH /api/routes/{routeId}/reorder` with the stop ids in a new order | 200 OK; `stops` returned in the requested order with contiguous `stopOrder` 1..N; first stop `travelMinsFromPrev:null`/`bufferMins:0`, others recomputed; `route.totalTravelMins`/`totalDistanceKm` updated |
| TC-API-17.4 | Reorder re-flags conflicts | Reorder so a far-apart pair becomes adjacent | 200 OK; `conflict` flags recomputed for the new adjacency (`hasConflicts`/`conflictCount` reflect the new order) |
| TC-API-17.5 | Reorder validation — wrong stop set | `PATCH …/reorder` with a missing, extra, duplicate, or unknown stop id | 400 with an explanatory `error` (e.g. "must list every stop exactly once", "unknown stop id", "duplicate stop id") |
| TC-API-17.6 | Reorder unknown route | `PATCH /api/routes/{randomUuid}/reorder` with any body | 404 `{ error: "Route not found" }` |
| TC-API-17.7 | Reorder invalid routeId | `PATCH /api/routes/not-a-uuid/reorder` | 400 `{ error: "routeId must be a UUID" }` |
| TC-API-17.8 | Groomer cannot reorder another's route | As groomer, reorder a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) |
### 4.18 Route Optimization — Navigation Export (GRO-2157, Phase 2.3)
Builds on §4.16/§4.17. Two read-only endpoints turn an optimized route into a native-navigation deep-link URL the frontend opens on the groomer's phone:
- `GET /api/routes/:routeId/export/google-maps` → Google Maps URLs API link (`https://www.google.com/maps/dir/?api=1&travelmode=driving&origin=…&destination=…&waypoints=…`)
- `GET /api/routes/:routeId/export/apple-maps` → Apple Maps URL scheme (`maps://?saddr=…&daddr=<first>+to:<next>…&dirflg=d`)
Both use the stops' stored `latitude`/`longitude` in `stopOrder`: **origin = first stop, destination = last stop, the rest are ordered intermediate waypoints**. Each response body is `{ platform, url, stopCount, waypointCount }` where `waypointCount` = stops minus origin and destination. Waypoint limits are validated per platform: **Google Maps ≤ 9**, **Apple Maps ≤ 15** intermediate waypoints; over-limit routes return 400. **Auth: manager (any route) or groomer (own route only); receptionists have no access.**
| ID | Scenario | Steps | Expected |
|----|----------|-------|----------|
| TC-API-18.1 | Google Maps export of a multi-stop route | As manager, optimize a multi-stop day (§4.16), then `GET /api/routes/{routeId}/export/google-maps` | 200 OK; `platform:"google-maps"`, `url` starts `https://www.google.com/maps/dir/?api=1`, contains `travelmode=driving`, `origin`/`destination` are the first/last stop coords, `waypoints` lists the middle stops in order (pipe-separated). `stopCount` = total stops, `waypointCount` = `stopCount 2` |
| TC-API-18.2 | Apple Maps export of a multi-stop route | As manager, `GET /api/routes/{routeId}/export/apple-maps` for the same route | 200 OK; `platform:"apple-maps"`, `url` starts `maps://?saddr=`, `daddr` chains the remaining stops with `+to:`, ends `&dirflg=d`; `stopCount`/`waypointCount` as above |
| TC-API-18.3 | Single-stop route | Export a route (google-maps and apple-maps) that has exactly one stop | 200 OK; `waypointCount:0`. Google url has `destination` and no `waypoints=`; Apple url is `maps://?daddr=<coord>&dirflg=d` (no `saddr`) |
| TC-API-18.4 | Empty route rejected | Export a route with no stops (a fresh `draft` route) | 400 `{ error: "route has no stops to export" }` |
| TC-API-18.5 | Google waypoint limit | Export (google-maps) a route with >11 stops (>9 intermediate waypoints) | 400 with an `error` mentioning Google Maps' limit of 9 |
| TC-API-18.6 | Apple waypoint limit | Export (apple-maps) a route with >17 stops (>15 intermediate waypoints) | 400 with an `error` mentioning Apple Maps' limit of 15 |
| TC-API-18.7 | Unknown route | `GET /api/routes/{randomUuid}/export/google-maps` | 404 `{ error: "Route not found" }` |
| TC-API-18.8 | Invalid routeId | `GET /api/routes/not-a-uuid/export/apple-maps` | 400 `{ error: "routeId must be a UUID" }` |
| TC-API-18.9 | Groomer exports own route | As **groomer**, export a route owned by self | 200 OK; deep-link returned |
| TC-API-18.10 | Groomer cannot export another's route | As groomer, export a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) |
| TC-API-18.11 | Receptionist denied | As **receptionist**, export any route | 403 Forbidden (role not permitted) |
## Pass/Fail Criteria
**Pass:**
+2 -2
View File
@@ -12,8 +12,8 @@
"test": "vitest run",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:seed": "pnpm --filter @groombook/db seed",
"db:reset": "pnpm --filter @groombook/db reset",
"db:seed": "tsx src/db/seed.ts",
"db:reset": "tsx src/db/reset.ts && drizzle-kit migrate && tsx src/db/seed.ts",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
@@ -44,7 +44,6 @@ interface MockState {
groomingLogs: Record<string, unknown>[];
staffMembers: Record<string, unknown>[];
services: Record<string, unknown>[];
impersonationSessions: Record<string, unknown>[];
}
let mock: MockState;
@@ -169,19 +168,6 @@ function resetMock() {
{ id: "service-1", name: "Full Groom", description: null, basePriceCents: 6000, durationMinutes: 120, active: true, createdAt: new Date(), updatedAt: new Date() },
{ id: "service-2", name: "Bath & Brush", description: null, basePriceCents: 4000, durationMinutes: 60, active: true, createdAt: new Date(), updatedAt: new Date() },
],
impersonationSessions: [
{
id: "sess-owner",
staffId: "staff-groomer-id",
clientId: CLIENT_ID,
reason: "sso-bridge",
status: "active",
startedAt: new Date("2024-11-01"),
endedAt: null,
expiresAt: new Date("2099-01-01T00:00:00Z"),
createdAt: new Date("2024-11-01"),
},
],
};
}
@@ -191,7 +177,6 @@ vi.mock("../db/index.js", () => {
const groomingVisitLogs = new Proxy({ _name: "groomingVisitLogs" }, { get: (t, p) => p === "_name" ? "groomingVisitLogs" : {} });
const staff = new Proxy({ _name: "staff" }, { get: (t, p) => p === "_name" ? "staff" : {} });
const services = new Proxy({ _name: "services" }, { get: (t, p) => p === "_name" ? "services" : {} });
const impersonationSessions = new Proxy({ _name: "impersonationSessions" }, { get: (t, p) => p === "_name" ? "impersonationSessions" : {} });
// Tracks { [tableName]: { [alias]: SQLExpression } } for the current select() call
let selectedColumns: Record<string, Record<string, unknown>> = {};
@@ -263,7 +248,6 @@ vi.mock("../db/index.js", () => {
if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs);
if (name === "staff") return makeChainable(mock.staffMembers);
if (name === "services") return makeChainable(mock.services);
if (name === "impersonationSessions") return makeChainable(mock.impersonationSessions);
return makeChainable([]);
},
};
@@ -277,7 +261,6 @@ vi.mock("../db/index.js", () => {
groomingVisitLogs,
staff,
services,
impersonationSessions,
and: vi.fn((a: unknown, b: unknown) => [a, b]),
desc: vi.fn((c: unknown) => c),
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
@@ -416,102 +399,4 @@ describe("GET /:id/profile-summary — empty history", () => {
expect(body.recentGroomingHistory).toEqual([]);
expect(body.lastVisitDate).toBeNull();
});
});
describe("GET /:id/profile-summary — owner-bypass via X-Impersonation-Session-Id (GRO-2013)", () => {
beforeEach(resetMock);
// Simulates the rbac.ts auto-provisioned "groomer" that a customer gets on first login:
// role=groomer, no linkage to any appointment.
const CUSTOMER_STAFF: StaffRow = {
id: "staff-customer-id",
oidcSub: null,
userId: "user-customer-id",
role: "groomer",
isSuperUser: false,
name: "UAT Customer",
email: "uat-customer@groombook.dev",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
it("customer with valid portal session for pet's client returns 200 (owner-bypass)", async () => {
const app = makeApp(CUSTOMER_STAFF);
// Groomer has no appointment linkage — proves the bypass is via portal session, not linkage.
mock.appointments = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-owner" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe(PET_ID);
expect(body.name).toBe("Biscuit");
expect(body.clientId).toBe(CLIENT_ID);
});
it("customer without X-Impersonation-Session-Id header still gets 403 (no bypass)", async () => {
const app = makeApp(CUSTOMER_STAFF);
mock.appointments = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(403);
});
it("customer with portal session for a DIFFERENT client gets 403 (cross-tenant blocked)", async () => {
const app = makeApp(CUSTOMER_STAFF);
mock.appointments = [];
mock.impersonationSessions = [
{
id: "sess-other-client",
staffId: "staff-customer-id",
clientId: "00000000-0000-0000-0000-000000000099", // different from CLIENT_ID
reason: "sso-bridge",
status: "active",
startedAt: new Date("2024-11-01"),
endedAt: null,
expiresAt: new Date("2099-01-01T00:00:00Z"),
createdAt: new Date("2024-11-01"),
},
];
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-other-client" },
});
expect(res.status).toBe(403);
});
it("customer with expired portal session still gets 403", async () => {
const app = makeApp(CUSTOMER_STAFF);
mock.appointments = [];
mock.impersonationSessions = [
{
id: "sess-expired",
staffId: "staff-customer-id",
clientId: CLIENT_ID,
reason: "sso-bridge",
status: "active",
startedAt: new Date("2024-01-01"),
endedAt: null,
expiresAt: new Date("2024-02-01T00:00:00Z"), // expired long ago
createdAt: new Date("2024-01-01"),
},
];
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-expired" },
});
expect(res.status).toBe(403);
});
it("manager does NOT need the impersonation header (existing role check still works)", async () => {
const app = makeApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
});
it("groomer with linkage to pet's client still works (regression — no regression from bypass)", async () => {
const app = makeApp(GROOMER);
// GROOMER fixture has appointments linked to staff-groomer-id in the mock state
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
});
});
File diff suppressed because it is too large Load Diff
+1 -37
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, impersonationSessions, or, pets, appointments, staff, services, sql } from "../db/index.js";
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
import {
getPresignedUploadUrl,
@@ -307,38 +307,10 @@ async function groomerLinkageCheck(
return !!linkage;
}
/**
* Resolves the clientId from the X-Impersonation-Session-Id header, if present and active.
* Used by staff routes to allow a customer (auto-provisioned as a `groomer` staff row
* by rbac.ts) to access their own pet's data when they are the rightful owner.
*
* Returns null when the header is missing, the session is unknown/expired/ended, or the
* session exists but has no clientId — callers should treat null as "no owner-bypass".
*/
async function resolveImpersonationClientId(
db: ReturnType<typeof getDb>,
c: { req: { header: (name: string) => string | undefined } }
): Promise<string | null> {
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) return null;
const [session] = await db
.select({ clientId: impersonationSessions.clientId, status: impersonationSessions.status, expiresAt: impersonationSessions.expiresAt })
.from(impersonationSessions)
.where(eq(impersonationSessions.id, sessionId))
.limit(1);
if (!session) return null;
if (session.status !== "active") return null;
if (session.expiresAt <= new Date()) return null;
return session.clientId;
}
/**
* GET /:id/profile-summary
* Returns aggregated profile: basic pet fields + grooming history + visit stats + upcoming appointment.
* Groomer RBAC: same visibility rules as GET /:id.
* Owner-bypass (GRO-2013): a customer who supplies a valid X-Impersonation-Session-Id
* for the pet's owning client may read their own pet's summary, even though rbac.ts
* auto-provisions them as a `groomer` staff row with no appointment linkage.
*/
petsRouter.get("/:id/profile-summary", async (c) => {
const db = getDb();
@@ -349,15 +321,7 @@ petsRouter.get("/:id/profile-summary", async (c) => {
const [row] = await db.select().from(pets).where(eq(pets.id, petId));
if (!row) return c.json({ error: "Not found" }, 404);
// Owner-bypass: customer with a valid portal session for this pet's client
// is allowed to view their own pet's profile summary (GRO-2013).
let isOwner = false;
if (isGroomer) {
const ownerClientId = await resolveImpersonationClientId(db, c);
isOwner = !!ownerClientId && ownerClientId === row.clientId;
}
if (isGroomer && !isOwner) {
const hasLinkage = await groomerLinkageCheck(db, row.clientId, staffRow);
if (!hasLinkage) return c.json({ error: "Forbidden" }, 403);
}
@@ -1,5 +1,5 @@
-- Migration: 0037_add_extra_large_to_pet_size_category.sql
-- GRO-1979: Adds the 'extra_large' value to the pet_size_category enum.
-- GRO-1999: Adds the 'extra_large' value to the pet_size_category enum.
--
-- 0031_buffer_rules.sql created pet_size_category with values
-- ('small', 'medium', 'large', 'xlarge'), but seed.ts and the drizzle
@@ -1,4 +0,0 @@
-- GRO-1999: 0037 was skipped on existing DBs due to a below-high-water-mark
-- journal timestamp. Re-register extra_large with a monotonic timestamp so
-- the existing UAT/persistent DBs apply it. Idempotent.
ALTER TYPE "pet_size_category" ADD VALUE IF NOT EXISTS 'extra_large';
@@ -1,27 +0,0 @@
-- Migration: 0039_extend_pet_profile_columns_idempotent.sql
-- GRO-2033: re-register the temperament/medical/preferred-cuts columns from
-- 0034 with an idempotent ADD COLUMN IF NOT EXISTS + a monotonic journal
-- `when` (1780000000001), above the 0033 high-water mark (1779500000000)
-- and above the most recent applied migration 0038 (1780000000000).
--
-- 0034_extend_pet_profile_columns.sql was authored on 2026-05-28 with
-- `when` = 1751140800000 (2025-06-28) — *below* the 0033 high-water mark
-- of 1779500000000 (2026-05-23). drizzle-orm@0.38.4
-- (pg-core/dialect.js#migrate) only applies a migration when
-- `migration.folderMillis > lastDbMigration.created_at`, so on prod —
-- whose last applied entry was 0033 at created_at=1779500000000 — 0034
-- was silently skipped, leaving `pets.temperament_score` (and friends)
-- missing. The migrate Job still exits 0 ("migrations applied
-- successfully!") because the journal high watermark *was* advanced by
-- 0038, but no schema change ever ran for 0034. Seed/reset then crash on:
-- PostgresError: column "temperament_score" does not exist (42703)
--
-- Same pattern as GRO-1999 (0037 → 0038): do NOT modify 0034 in-place
-- (UAT/dev have already applied it via their lower watermarks). Add a
-- new idempotent migration with a monotonic `when` instead so existing
-- DBs apply it cleanly and fresh DBs are a no-op-after-no-op.
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "temperament_score" integer;
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "temperament_flags" jsonb DEFAULT '[]';
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "medical_alerts" jsonb DEFAULT '[]';
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "preferred_cuts" jsonb DEFAULT '[]';
@@ -1,26 +0,0 @@
-- Migration: 0040_register_missing_coat_type_values.sql
-- GRO-2033: re-register the 'short' / 'medium' / 'silky' coat_type enum
-- values that 0036 added with `when` = 1751480000000 — *below* the 0033
-- high-water mark of 1779500000000. drizzle-orm@0.38.4
-- (pg-core/dialect.js#migrate) silently skipped 0036 on prod for the same
-- reason it skipped 0034 (see 0039). 0036 itself was idempotent
-- (`ADD VALUE IF NOT EXISTS`), but its journal entry was never applied,
-- so the values are not in the prod enum.
--
-- Same pattern as GRO-1999 (0037 → 0038) and 0039: do NOT modify 0036 in
-- place. Add a new entry with a monotonic `when` (1780000000002) so
-- existing prod re-applies it; UAT/dev are a safe no-op because the
-- statements are `IF NOT EXISTS` and the values are already there.
--
-- Postgres restriction: `ALTER TYPE ... ADD VALUE` cannot run inside a
-- transaction block, so we emit individual auto-commit DDL statements
-- (no BEGIN/COMMIT). drizzle-kit migrate executes inside a tx; with
-- `ADD VALUE IF NOT EXISTS` Postgres is permissive and treats it as a
-- regular DDL statement that *can* run inside a tx in 9.6+ when no new
-- value is actually added. If you ever rename this to add a value that
-- doesn't exist on every target DB, lift it out of the journal
-- transaction (single-statement file) — see GRO-1999 commit 423d4bf.
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short';
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'medium';
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'silky';
@@ -1,66 +0,0 @@
-- Migration: 0041_route_optimization.sql
-- Route optimization schema: geocoding columns on clients, groomerRoutes +
-- routeStops tables, and route settings on business_settings.
-- Written idempotently so it is safe to re-run.
-- ─── Enums ────────────────────────────────────────────────────────────────────
DO $$ BEGIN
CREATE TYPE "route_status" AS ENUM ('draft', 'optimized', 'in_progress', 'completed');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- ─── Clients: geocoding columns ───────────────────────────────────────────────
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "latitude" double precision;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "longitude" double precision;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "geocoded_at" timestamp;
-- ─── Business settings: route optimization config ─────────────────────────────
ALTER TABLE "business_settings"
ADD COLUMN IF NOT EXISTS "default_travel_buffer_mins" integer NOT NULL DEFAULT 15;
ALTER TABLE "business_settings"
ADD COLUMN IF NOT EXISTS "route_optimization_provider" text DEFAULT 'nominatim';
-- Encrypted at rest at the application layer (AES-256-GCM).
ALTER TABLE "business_settings"
ADD COLUMN IF NOT EXISTS "google_maps_api_key" text;
-- ─── Groomer routes table ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "groomer_routes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"staff_id" uuid NOT NULL REFERENCES "staff"("id") ON DELETE CASCADE,
"route_date" date NOT NULL,
"status" "route_status" NOT NULL DEFAULT 'draft',
"total_travel_mins" integer,
"total_distance_km" numeric(8, 2),
"optimized_at" timestamp,
"created_at" timestamp NOT NULL DEFAULT now(),
"updated_at" timestamp NOT NULL DEFAULT now(),
CONSTRAINT "uq_groomer_routes_staff_date" UNIQUE ("staff_id", "route_date")
);
CREATE INDEX IF NOT EXISTS "idx_groomer_routes_staff_id"
ON "groomer_routes"("staff_id");
-- ─── Route stops table ────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "route_stops" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"route_id" uuid NOT NULL REFERENCES "groomer_routes"("id") ON DELETE CASCADE,
"appointment_id" uuid NOT NULL REFERENCES "appointments"("id") ON DELETE CASCADE,
"stop_order" integer NOT NULL,
"latitude" double precision NOT NULL,
"longitude" double precision NOT NULL,
"travel_mins_from_prev" integer,
"travel_distance_km_from_prev" numeric(8, 2),
"buffer_mins" integer NOT NULL DEFAULT 15,
"created_at" timestamp NOT NULL DEFAULT now(),
"updated_at" timestamp NOT NULL DEFAULT now(),
CONSTRAINT "uq_route_stops_route_appointment" UNIQUE ("route_id", "appointment_id"),
CONSTRAINT "uq_route_stops_route_order" UNIQUE ("route_id", "stop_order")
);
CREATE INDEX IF NOT EXISTS "idx_route_stops_route_id"
ON "route_stops"("route_id");
-28
View File
@@ -260,34 +260,6 @@
"when": 1751500000000,
"tag": "0037_add_extra_large_to_pet_size_category",
"breakpoints": true
},
{
"idx": 38,
"version": "7",
"when": 1780000000000,
"tag": "0038_register_extra_large_pet_size_category",
"breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1780000000001,
"tag": "0039_extend_pet_profile_columns_idempotent",
"breakpoints": true
},
{
"idx": 40,
"version": "7",
"when": 1780000000002,
"tag": "0040_register_missing_coat_type_values",
"breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1780000000003,
"tag": "0041_route_optimization",
"breakpoints": true
}
]
}
+3 -4
View File
@@ -18,10 +18,9 @@
"scripts": {
"build": "tsc --project .",
"generate": "drizzle-kit generate",
"wait-for-db": "node ./scripts/wait-for-db.mjs",
"migrate": "node ./scripts/wait-for-db.mjs && drizzle-kit migrate",
"seed": "node ./scripts/wait-for-db.mjs && tsx src/seed.ts",
"reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts",
"migrate": "drizzle-kit migrate",
"seed": "tsx src/seed.ts",
"reset": "tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts",
"studio": "drizzle-kit studio",
"typecheck": "tsc --noEmit"
},
-104
View File
@@ -1,104 +0,0 @@
#!/usr/bin/env node
// wait-for-db.mjs
//
// GRO-2163: wait for / retry DNS resolution of the database hostname derived
// from DATABASE_URL before invoking `drizzle-kit migrate`. The first attempt
// of a fresh migrate-schema pod occasionally hits a transient CoreDNS miss
// (EAI_AGAIN) on `groombook-postgres-rw.<ns>.svc`; with backoffLimit: 2 the
// retry pod usually wins, but three unlucky attempts in a row trips
// BackoffLimitExceeded. Resolving once here, with backoff, removes the dice
// roll at the source so the first attempt reliably succeeds.
//
// Mirrors the belt-and-braces pattern used in GRO-1985 (no Corepack
// download fallback): we don't try to outsmart CoreDNS, we just don't ask
// drizzle-kit to do the very first DNS lookup of a freshly-scheduled pod.
//
// Configuration (env):
// WAIT_FOR_DB_MAX_ATTEMPTS default 12 (~30s of total wait at default backoff)
// WAIT_FOR_DB_BASE_DELAY_MS default 500
// WAIT_FOR_DB_MAX_DELAY_MS default 5000
// WAIT_FOR_DB_SKIP default unset; set to "1" to skip (debug only)
//
// On success: exit 0. On exhaustion: exit 1 so the Job's backoff is
// preserved (we don't want to silently mask a real outage by giving up
// after 30s and letting drizzle-kit fail with a less-actionable error).
import { setTimeout as delay } from "node:timers/promises";
import dns from "node:dns/promises";
const MAX_ATTEMPTS = Number(process.env.WAIT_FOR_DB_MAX_ATTEMPTS ?? 12);
const BASE_DELAY_MS = Number(process.env.WAIT_FOR_DB_BASE_DELAY_MS ?? 500);
const MAX_DELAY_MS = Number(process.env.WAIT_FOR_DB_MAX_DELAY_MS ?? 5000);
function parseHost(databaseUrl) {
try {
return new URL(databaseUrl).hostname || null;
} catch {
return null;
}
}
async function resolveOnce(host) {
const start = Date.now();
const result = await dns.lookup(host);
return { address: result.address, ms: Date.now() - start };
}
async function main() {
if (process.env.WAIT_FOR_DB_SKIP === "1") {
console.log("[wait-for-db] WAIT_FOR_DB_SKIP=1, skipping");
return;
}
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
// Don't gate the migrate on a misconfigured env — let drizzle-kit fail
// loudly with its own clear error.
console.warn("[wait-for-db] DATABASE_URL not set; skipping");
return;
}
const host = parseHost(databaseUrl);
if (!host) {
console.warn(`[wait-for-db] could not parse hostname from DATABASE_URL; skipping`);
return;
}
console.log(
`[wait-for-db] host=${host} max_attempts=${MAX_ATTEMPTS} ` +
`base_delay_ms=${BASE_DELAY_MS} max_delay_ms=${MAX_DELAY_MS}`,
);
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const { address, ms } = await resolveOnce(host);
console.log(`[wait-for-db] ok attempt=${attempt} host=${host} -> ${address} (${ms}ms)`);
return;
} catch (err) {
const code = err?.code ?? "UNKNOWN";
const transient = code === "EAI_AGAIN" || code === "ENOTFOUND" || code === "EAI_NODATA";
if (!transient) {
// Hard error (e.g. invalid hostname): surface and let drizzle-kit fail
// with a real error rather than spinning.
console.error(`[wait-for-db] non-transient DNS error attempt=${attempt} code=${code}: ${err.message}`);
process.exit(1);
}
if (attempt === MAX_ATTEMPTS) {
console.error(
`[wait-for-db] exhausted attempts=${MAX_ATTEMPTS} host=${host} last_code=${code}; exiting 1`,
);
process.exit(1);
}
const backoff = Math.min(
MAX_DELAY_MS,
BASE_DELAY_MS * 2 ** (attempt - 1) + Math.floor(Math.random() * BASE_DELAY_MS),
);
console.log(
`[wait-for-db] transient attempt=${attempt} code=${code} retry_in_ms=${backoff}`,
);
await delay(backoff);
}
}
}
main().catch((err) => {
console.error(`[wait-for-db] fatal: ${err?.message ?? err}`);
process.exit(1);
});
-3
View File
@@ -78,9 +78,6 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
stripeCustomerId: null,
status: "active",
disabledAt: null,
latitude: null,
longitude: null,
geocodedAt: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
...overrides,
-82
View File
@@ -1,7 +1,5 @@
import {
boolean,
date,
doublePrecision,
index,
integer,
jsonb,
@@ -142,10 +140,6 @@ export const clients = pgTable(
stripeCustomerId: text("stripe_customer_id"),
status: clientStatusEnum("status").notNull().default("active"),
disabledAt: timestamp("disabled_at"),
// Geocoded coordinates for route optimization; null until geocoded.
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),
geocodedAt: timestamp("geocoded_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
@@ -561,16 +555,6 @@ export const businessSettings = pgTable("business_settings", {
accentColor: text("accent_color").notNull().default("#8b7355"),
messagingPhoneNumber: text("messaging_phone_number"),
telnyxMessagingProfileId: text("telnyx_messaging_profile_id"),
// Route optimization settings.
defaultTravelBufferMins: integer("default_travel_buffer_mins")
.notNull()
.default(15),
routeOptimizationProvider: text("route_optimization_provider").default(
"nominatim"
),
// Encrypted at rest at the application layer (AES-256-GCM), mirroring
// the handling of authProviderConfigs.clientSecret.
googleMapsApiKey: text("google_maps_api_key"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
@@ -674,69 +658,3 @@ export const bufferRules = pgTable(
index("idx_buffer_rules_service_id").on(t.serviceId),
]
);
// ─── Route Optimization ───────────────────────────────────────────────────────
export const routeStatusEnum = pgEnum("route_status", [
"draft",
"optimized",
"in_progress",
"completed",
]);
// A groomer's optimized route for a single day. One row per (staff, date).
export const groomerRoutes = pgTable(
"groomer_routes",
{
id: uuid("id").primaryKey().defaultRandom(),
staffId: uuid("staff_id")
.notNull()
.references(() => staff.id, { onDelete: "cascade" }),
routeDate: date("route_date", { mode: "string" }).notNull(),
status: routeStatusEnum("status").notNull().default("draft"),
// Populated once the route is optimized.
totalTravelMins: integer("total_travel_mins"),
totalDistanceKm: numeric("total_distance_km", { precision: 8, scale: 2 }),
optimizedAt: timestamp("optimized_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
// One route per groomer per day.
unique("uq_groomer_routes_staff_date").on(t.staffId, t.routeDate),
index("idx_groomer_routes_staff_id").on(t.staffId),
]
);
// An ordered stop within a groomer's route, tied to an appointment.
export const routeStops = pgTable(
"route_stops",
{
id: uuid("id").primaryKey().defaultRandom(),
routeId: uuid("route_id")
.notNull()
.references(() => groomerRoutes.id, { onDelete: "cascade" }),
appointmentId: uuid("appointment_id")
.notNull()
.references(() => appointments.id, { onDelete: "cascade" }),
stopOrder: integer("stop_order").notNull(),
latitude: doublePrecision("latitude").notNull(),
longitude: doublePrecision("longitude").notNull(),
// Null for the first stop in the route.
travelMinsFromPrev: integer("travel_mins_from_prev"),
travelDistanceKmFromPrev: numeric("travel_distance_km_from_prev", {
precision: 8,
scale: 2,
}),
bufferMins: integer("buffer_mins").notNull().default(15),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
// An appointment appears at most once per route.
unique("uq_route_stops_route_appointment").on(t.routeId, t.appointmentId),
// Stop order is unique within a route.
unique("uq_route_stops_route_order").on(t.routeId, t.stopOrder),
index("idx_route_stops_route_id").on(t.routeId),
]
);
+17 -663
View File
@@ -401,9 +401,7 @@ const servicesDef = [
*
* In seedKnownUsers() this replaces the inline UAT-staff block.
*/
async function seedUatStaffAccounts(
db: ReturnType<typeof drizzle>,
): Promise<string | null> {
async function seedUatStaffAccounts(db: ReturnType<typeof drizzle>) {
// ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ──
const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB;
if (uatSuperOidcSub) {
@@ -456,36 +454,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) ──
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) ?? [];
@@ -525,8 +493,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-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null },
{ email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" },
// GRO-2225: standing receptionist login for the route-optimization 403 path (TC-API-16.9).
{ email: "uat-receptionist@groombook.dev", name: "UAT Receptionist", passwordEnv: "SEED_UAT_RECEPTIONIST_PASSWORD", staffEmail: "uat-receptionist@groombook.dev" },
];
for (const acct of uatPasswordAccounts) {
@@ -702,507 +668,6 @@ async function seedUatStaffAccounts(
console.log(`✓ Created UAT pet '${pet.name}' with extended fields`);
}
}
// ── GRO-2100: deterministic uat-groomer ↔ pet linkage ───────────────────────
// The UAT groomer (`uat-groomer@groombook.dev`, staffId 00000000-0000-0000-0000-000000000004)
// needs at least one linked pet/appointment or GRO-1987 TC-UAT-2/3 cannot run
// (the pet profile-summary endpoint returns 404 instead of 200/403).
//
// We deterministically link the UAT groomer to the UAT customer's first pet
// ("UAT Pup Alpha") and leave the second pet ("UAT Pup Beta") UNLINKED so
// TC-UAT-2 (200) and TC-UAT-3 (403) can both hardcode the stable petIds.
//
// The linkage call itself is performed by the caller AFTER the `services`
// catalogue has been seeded (this helper runs before services exist,
// which previously caused the linkage to be silently skipped on every
// reset). GRO-2100 follow-up.
return uatCustomerClientId;
}
/**
* GRO-2100: create a deterministic completed appointment linking the UAT groomer
* to "UAT Pup Alpha" (c0000001-0000-0000-0000-000000000002). "UAT Pup Beta"
* (c0000001-0000-0000-0000-000000000003) is intentionally left UNLINKED so
* GRO-1987 TC-UAT-3 can verify the 403 forbidden response.
*
* Idempotent: the deterministic appointment id (`a0000001-…-0001`) is the
* upsert key, so re-running the seed on every reset-demo-data CronJob
* (hourly per apps/overlays/uat/reset-cronjob.yaml) is safe.
*/
async function seedUatGroomerLinkage(
db: ReturnType<typeof drizzle>,
customerClientId: string | null,
): Promise<void> {
const uatGroomerEmail = "uat-groomer@groombook.dev";
const LINKED_PET_ID = "c0000001-0000-0000-0000-000000000002"; // UAT Pup Alpha
const APPT_ID = "a0000001-0000-0000-0000-000000000001";
// Skip silently if the UAT Customer client wasn't created (non-UAT seed
// profile, e.g. seedKnownUsers() in an env without the UAT personas).
if (!customerClientId) {
return;
}
// Only run if the UAT groomer staff record actually exists — dev/test seeds
// that don't set SEED_UAT_STAFF_OIDC_SUB should not crash.
const [uatGroomerStaff] = await db
.select({ id: schema.staff.id })
.from(schema.staff)
.where(eq(schema.staff.email, uatGroomerEmail))
.limit(1);
if (!uatGroomerStaff) {
return;
}
// Skip if this exact appointment already exists (idempotent on re-seed).
const [existing] = await db
.select({ id: schema.appointments.id })
.from(schema.appointments)
.where(eq(schema.appointments.id, APPT_ID))
.limit(1);
if (existing) {
console.log(`✓ GRO-2100: uat-groomer linkage appointment already exists — skipping`);
return;
}
// Skip if the linked pet hasn't been seeded yet (defensive: caller should
// ensure pets exist; if the helper is re-ordered later we don't want to
// crash here).
const [linkedPet] = await db
.select({ id: schema.pets.id })
.from(schema.pets)
.where(eq(schema.pets.id, LINKED_PET_ID))
.limit(1);
if (!linkedPet) {
console.warn(`⚠ GRO-2100: UAT Pup Alpha (${LINKED_PET_ID}) not found — skipping uat-groomer linkage`);
return;
}
// The "Bath & Brush" service id is stable across the reset; falls back to
// any active service if it has not been seeded yet (e.g. seedKnownUsers
// runs in isolation).
const BATH_AND_BRUSH_ID = "b0000001-0000-0000-0000-000000000001";
const [bathService] = await db
.select({ id: schema.services.id })
.from(schema.services)
.where(eq(schema.services.id, BATH_AND_BRUSH_ID))
.limit(1);
let serviceId: string;
if (bathService) {
serviceId = bathService.id;
} else {
const [fallback] = await db
.select({ id: schema.services.id })
.from(schema.services)
.where(eq(schema.services.active, true))
.limit(1);
if (!fallback) {
console.warn(`⚠ GRO-2100: no active services found — skipping uat-groomer linkage`);
return;
}
serviceId = fallback.id;
}
// Schedule the completed appointment 7 days ago so the profile-summary's
// "recentGroomingHistory" window (last 10) reliably includes it.
const startTime = new Date();
startTime.setDate(startTime.getDate() - 7);
startTime.setHours(10, 0, 0, 0);
const endTime = new Date(startTime.getTime() + 45 * 60 * 1000);
await db.insert(schema.appointments).values({
id: APPT_ID,
clientId: customerClientId,
petId: LINKED_PET_ID,
serviceId,
staffId: uatGroomerStaff.id,
batherStaffId: null,
status: "completed",
startTime,
endTime,
notes: "GRO-2100: deterministic uat-groomer linkage for TC-UAT-2/3.",
priceCents: null,
confirmationStatus: "confirmed",
});
console.log(
`✓ GRO-2100: linked uat-groomer (${uatGroomerStaff.id}) → UAT Pup Alpha (${LINKED_PET_ID}) via appointment ${APPT_ID}`,
);
}
// ── 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) ───────────────────────────────────────
@@ -1280,44 +745,27 @@ async function seedKnownUsers() {
// ── UAT staff accounts + Better Auth credentials (shared impl) ──────────────
// Extracted into seedUatStaffAccounts() so it runs in both seedKnownUsers()
// and the full seed() UAT branch.
const uatCustomerClientId = await seedUatStaffAccounts(db);
await seedUatStaffAccounts(db);
// ── Services: idempotent upsert keyed on `id` ─────────────────────────────
// GRO-2064: previously keyed on `services.name` while writing a
// deterministic `id`. If a stale row existed with the same `id` but a
// different `name`, PostgreSQL raised `services_pkey` (id collision)
// before the name-targeted ON CONFLICT could fire. Switch the conflict
// target to `services.id` so deterministic ids always win; pair with
// `TRUNCATE services … CASCADE` above so each reset rebuilds the
// catalogue from `servicesDef` cleanly. GRO-2033 close-out.
// Id↔name map MUST stay in sync with `servicesDef` (the canonical source
// of truth in the main `seed()` function).
// ── Services: idempotent upsert using name as unique key ─────────────────────
// UNIQUE constraint on services.name (migration 0020) must exist first.
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
const demoSvcs = [
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
{ id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 },
{ id: "b0000001-0000-0000-0000-000000000005", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
];
for (const svc of demoSvcs) {
await db.insert(schema.services)
.values({ ...svc, active: true })
.onConflictDoUpdate({
target: schema.services.id,
set: { name: svc.name, description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
target: schema.services.name,
set: { description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
});
}
console.log(`✓ Seeded ${demoSvcs.length} services`);
// GRO-2100: deterministic uat-groomer ↔ UAT Pup Alpha linkage. Must run
// AFTER services are seeded (this helper looks up an active service id
// to attach to the appointment; on a fresh reset there are none yet at
// the time seedUatStaffAccounts() returns).
await seedUatGroomerLinkage(db, uatCustomerClientId);
// 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 ──
const [existingClient] = await db
.select()
@@ -1387,63 +835,6 @@ async function seedKnownUsers() {
// ── Main seed ────────────────────────────────────────────────────────────────
// ── GRO-2123: serialize reset+seed with a Postgres advisory lock ────────
// The reset-demo-data CronJob runs on an hourly schedule. With
// concurrencyPolicy=Replace, a new pod can start while the previous one
// is still mid-seed; the new pod's TRUNCATE then deletes rows the old pod
// is still inserting, producing FK 23503 errors non-deterministically
// (see GRO-2123: invoice_tip_splits → invoices).
//
// We hold a session-level advisory lock for the full duration of the
// seed so that overlapping invocations block then proceed in order —
// not skip. The key is a stable 32-bit constant so it can be referenced
// from runbooks without ambiguity and binds to the single-argument
// `pg_advisory_lock(int)` form, which postgres-js serializes as a plain
// number (no bigint type plumbing required).
const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable
/**
* Reserve a dedicated connection from `pool`, take the seed advisory lock
* on it, run `fn`, and release the lock + connection in a try/finally.
*
* CRITICAL: with postgres-js connection pooling, a session-level
* `pg_advisory_lock(KEY)` acquired on one pooled connection and released
* on a *different* one is a no-op (the lock is bound to the session /
* pg-backend that took it). We therefore reserve a dedicated connection
* for the lock and release it from the same reserved connection. The
* seed work itself still runs on the pooled connections.
*/
async function withSeedAdvisoryLock<T>(
pool: ReturnType<typeof postgres>,
fn: () => Promise<T>,
): Promise<T> {
const lockConnection = await pool.reserve();
let lockHeld = false;
try {
await lockConnection`SELECT pg_advisory_lock(${SEED_ADVISORY_LOCK_KEY})`;
lockHeld = true;
console.log(`✓ Acquired seed advisory lock (key=${SEED_ADVISORY_LOCK_KEY})`);
const result = await fn();
await lockConnection`SELECT pg_advisory_unlock(${SEED_ADVISORY_LOCK_KEY})`;
lockHeld = false;
console.log(`✓ Released seed advisory lock`);
return result;
} finally {
if (lockHeld) {
try {
await lockConnection`SELECT pg_advisory_unlock(${SEED_ADVISORY_LOCK_KEY})`;
} catch (err) {
console.error("Failed to release seed advisory lock during cleanup:", err);
}
}
try {
lockConnection.release();
} catch (err) {
console.error("Failed to release reserved lock connection:", err);
}
}
}
async function seed() {
const url = process.env.DATABASE_URL;
if (!url) {
@@ -1461,22 +852,6 @@ async function seed() {
const client = postgres(url, { max: 5 });
const db = drizzle(client, { schema });
// GRO-2123: hold the seed advisory lock for the full body of runSeedBody.
// See the withSeedAdvisoryLock comment for why a reserved connection is
// required (postgres-js pooling would silently drop the lock otherwise).
await withSeedAdvisoryLock(client, async () => {
return await runSeedBody(client, db, profile, cfg);
});
await client.end();
}
async function runSeedBody(
client: ReturnType<typeof postgres>,
db: ReturnType<typeof drizzle>,
profile: SeedProfile,
cfg: ProfileConfig,
): Promise<void> {
console.log(`Seeding Groom Book database (profile: ${profile})...\n`);
// ── Staff ──
@@ -1493,13 +868,7 @@ async function runSeedBody(
({ id: uuid(), name: `Bather ${i + 1}`, email: `bather${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false })
);
// GRO-2064: also TRUNCATE `services` so each reset rebuilds the catalogue
// from `servicesDef` (deterministic IDs + UNIQUE(name)). Stale service rows
// (e.g. a prior `seedKnownUsers` run that wrote a different `name` for the
// same `id`) would otherwise cause the deterministic upsert to PK-collide
// on `services.id` — see CTO review on infra PR #605 (rev #4230). TRUNCATE
// CASCADE handles appointments/invoices FKs to services.id.
await db.execute(sql`TRUNCATE services, impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
await db.execute(sql`TRUNCATE impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`);
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
for (const s of allStaff) {
@@ -1547,14 +916,12 @@ async function runSeedBody(
// ── UAT staff accounts + Better Auth credentials (shared impl) ──────────────
// Seeds deterministic UAT staff with numeric OIDC subs and Better Auth credentials.
// Must run AFTER random staff are created so upserts land correctly.
const uatCustomerClientId = await seedUatStaffAccounts(db);
await seedUatStaffAccounts(db);
// ── Services ──
// GRO-2064: key the upsert on `services.id` (not `name`) so deterministic
// ids always win, and rely on the TRUNCATE above to clear stale rows before
// the catalogue is rebuilt. The previous name-targeted upsert failed with
// `services_pkey` when a prior run had left a row with the same id but a
// different name (CTO review on infra PR #605, rev #4230).
// Upsert services using name as unique key. With deterministic IDs in
// servicesDef and TRUNCATE clearing downstream tables first, this is
// idempotent: first run inserts, subsequent runs update existing rows.
const serviceIds: string[] = [];
for (const s of servicesDef) {
serviceIds.push(s.id);
@@ -1568,27 +935,12 @@ async function runSeedBody(
active: true,
})
.onConflictDoUpdate({
target: schema.services.id,
set: { name: s.name, description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true },
target: schema.services.name,
set: { description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true },
});
}
console.log(`✓ Created ${servicesDef.length} services`);
// GRO-2100: deterministic uat-groomer ↔ UAT Pup Alpha linkage. Must run
// AFTER services are seeded (this helper looks up an active service id
// to attach to the appointment; on a fresh reset there are none yet at
// the time seedUatStaffAccounts() returns).
await seedUatGroomerLinkage(db, uatCustomerClientId);
// 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 ──
const now = new Date();
const appointmentsBackDate = new Date(now);
@@ -2107,6 +1459,8 @@ async function runSeedBody(
}
console.log(`✓ Created ${visitLogCount} grooming visit logs`);
console.log("\nSeed complete!");
await client.end();
}
seed().catch((err) => {
-192
View File
@@ -1,192 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import {
geocodeClient,
geocodeUngeocodedClients,
resolveClientGeocodingProvider,
} from "../services/clientGeocoding.js";
import {
NominatimGeocodingProvider,
type GeocodeResult,
type GeocodingProvider,
} from "../services/geocoding.js";
// ─── Fakes ──────────────────────────────────────────────────────────────────
/** Fake provider with a scripted geocode behaviour and a call log. */
function fakeProvider(
impl: (address: string) => Promise<GeocodeResult | null>
): GeocodingProvider & { calls: string[] } {
const calls: string[] = [];
return {
name: "nominatim",
minRequestIntervalMs: 0,
calls,
geocode: (address: string) => {
calls.push(address);
return impl(address);
},
};
}
const okResult = (lat: number, lng: number): GeocodeResult => ({
latitude: lat,
longitude: lng,
formattedAddress: "1 Main St, Anytown",
provider: "nominatim",
});
/**
* Minimal db double recording update() set-values. `select()` chains return the
* preloaded `selectQueue` shift()ed per call so different statements get
* different rows (used by geocodeUngeocodedClients: count, then rows).
*/
function fakeDb(selectQueue: unknown[][]) {
const updates: Record<string, unknown>[] = [];
const queue = [...selectQueue];
const chain = () => {
const rows = queue.shift() ?? [];
const proxy: Record<string, unknown> = {};
for (const k of ["from", "where", "orderBy", "limit"]) {
proxy[k] = () => proxy;
}
// Make the chain awaitable / iterable as the resolved rows.
(proxy as { then: unknown }).then = (resolve: (v: unknown) => void) =>
resolve(rows);
(proxy as { [Symbol.iterator]: unknown })[Symbol.iterator] = () =>
(rows as unknown[])[Symbol.iterator]();
return proxy;
};
const db = {
select: () => chain(),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => {
updates.push(vals);
return { returning: async () => [] };
},
}),
}),
updates,
};
return db as unknown as Parameters<typeof geocodeClient>[0] & {
updates: Record<string, unknown>[];
};
}
const clientRow = (over: Record<string, unknown> = {}) =>
({
id: "client-1",
name: "Alice",
email: "a@example.com",
address: "1 Main St",
latitude: null,
longitude: null,
geocodedAt: null,
...over,
}) as unknown as Parameters<typeof geocodeClient>[1];
// ─── geocodeClient ────────────────────────────────────────────────────────────
describe("geocodeClient", () => {
it("persists coordinates and returns a geocoded outcome", async () => {
const db = fakeDb([]);
const provider = fakeProvider(async () => okResult(40.1, -74.2));
const outcome = await geocodeClient(db, clientRow(), provider);
expect(outcome.status).toBe("geocoded");
expect(outcome.latitude).toBe(40.1);
expect(outcome.longitude).toBe(-74.2);
expect(outcome.geocodedAt).toBeTruthy();
expect(db.updates).toHaveLength(1);
expect(db.updates[0]!.latitude).toBe(40.1);
expect(db.updates[0]!.longitude).toBe(-74.2);
expect(db.updates[0]!.geocodedAt).toBeInstanceOf(Date);
});
it("returns no_address and does not persist when address is blank", async () => {
const db = fakeDb([]);
const provider = fakeProvider(async () => okResult(0, 0));
const outcome = await geocodeClient(db, clientRow({ address: " " }), provider);
expect(outcome.status).toBe("no_address");
expect(provider.calls).toHaveLength(0);
expect(db.updates).toHaveLength(0);
});
it("returns unresolved when the provider finds no match", async () => {
const db = fakeDb([]);
const provider = fakeProvider(async () => null);
const outcome = await geocodeClient(db, clientRow(), provider);
expect(outcome.status).toBe("unresolved");
expect(outcome.message).toMatch(/could not be resolved/i);
expect(db.updates).toHaveLength(0);
});
it("returns error (without throwing) when the provider fails", async () => {
const db = fakeDb([]);
const provider = fakeProvider(async () => {
throw new Error("quota exceeded");
});
const outcome = await geocodeClient(db, clientRow(), provider);
expect(outcome.status).toBe("error");
expect(outcome.message).toMatch(/quota exceeded/);
expect(db.updates).toHaveLength(0);
});
});
// ─── geocodeUngeocodedClients ─────────────────────────────────────────────────
describe("geocodeUngeocodedClients", () => {
it("geocodes candidates, tallies outcomes, and reports remaining", async () => {
// First select() = count query, second select() = candidate rows.
const db = fakeDb([
[{ count: 5 }],
[
clientRow({ id: "c1", address: "1 Main St" }),
clientRow({ id: "c2", address: "2 Oak Ave" }),
clientRow({ id: "c3", address: "" }), // no_address
],
]);
const provider = fakeProvider(async (addr) =>
addr === "2 Oak Ave" ? null : okResult(1, 2)
);
const summary = await geocodeUngeocodedClients(db, 50, provider);
expect(summary.processed).toBe(3);
expect(summary.geocoded).toBe(1);
expect(summary.unresolved).toBe(1); // "2 Oak Ave"
expect(summary.remaining).toBe(2); // 5 total - 3 processed
expect(summary.provider).toBe("nominatim");
expect(db.updates).toHaveLength(1); // only the successful one persisted
});
it("clamps the limit to the 1..500 range", async () => {
const db = fakeDb([[{ count: 0 }], []]);
const provider = fakeProvider(async () => okResult(1, 2));
const summary = await geocodeUngeocodedClients(db, 0, provider);
expect(summary.processed).toBe(0);
expect(summary.remaining).toBe(0);
});
});
// ─── resolveClientGeocodingProvider ───────────────────────────────────────────
describe("resolveClientGeocodingProvider", () => {
it("defaults to Nominatim when no settings row exists", async () => {
const db = fakeDb([[]]); // businessSettings select -> empty
const provider = await resolveClientGeocodingProvider(db);
expect(provider).toBeInstanceOf(NominatimGeocodingProvider);
expect(provider.name).toBe("nominatim");
});
it("defaults to Nominatim when provider is unset on settings", async () => {
const db = fakeDb([[{ routeOptimizationProvider: null, googleMapsApiKey: null }]]);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const provider = await resolveClientGeocodingProvider(db);
expect(provider.name).toBe("nominatim");
warn.mockRestore();
});
});
-89
View File
@@ -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();
});
});
-313
View File
@@ -1,313 +0,0 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
NominatimGeocodingProvider,
GoogleGeocodingProvider,
resolveGeocodingProvider,
geocodeBatch,
type FetchLike,
} from "../services/geocoding.js";
/** Builds a fake fetch returning a single JSON body, recording the called URLs. */
function fakeFetch(
body: unknown,
init: { ok?: boolean; status?: number; statusText?: string } = {}
): { fetchImpl: FetchLike; calls: string[] } {
const calls: string[] = [];
const fetchImpl: FetchLike = async (url) => {
calls.push(url);
return {
ok: init.ok ?? true,
status: init.status ?? 200,
statusText: init.statusText ?? "OK",
json: async () => body,
};
};
return { fetchImpl, calls };
}
/** Virtual clock whose `sleep` advances `now`, so throttle timing is deterministic. */
function fakeClock() {
const state = { t: 0 };
const sleeps: number[] = [];
return {
now: () => state.t,
sleep: async (ms: number) => {
sleeps.push(ms);
state.t += ms;
},
sleeps,
};
}
const NOMINATIM_ROW = {
lat: "40.7128",
lon: "-74.0060",
display_name: "New York, NY, USA",
};
describe("NominatimGeocodingProvider", () => {
it("parses the top match into a GeocodeResult", async () => {
const { fetchImpl, calls } = fakeFetch([NOMINATIM_ROW]);
const provider = new NominatimGeocodingProvider({ fetchImpl });
const result = await provider.geocode("123 Main St");
expect(result).toEqual({
latitude: 40.7128,
longitude: -74.006,
formattedAddress: "New York, NY, USA",
provider: "nominatim",
});
expect(calls).toHaveLength(1);
expect(calls[0]).toContain("/search");
expect(calls[0]).toContain("q=123+Main+St");
expect(calls[0]).toContain("format=jsonv2");
expect(calls[0]).toContain("limit=1");
});
it("returns null for an empty result set", async () => {
const { fetchImpl } = fakeFetch([]);
const provider = new NominatimGeocodingProvider({ fetchImpl });
expect(await provider.geocode("nowhere at all")).toBeNull();
});
it("returns null for a blank address without calling fetch", async () => {
const { fetchImpl, calls } = fakeFetch([NOMINATIM_ROW]);
const provider = new NominatimGeocodingProvider({ fetchImpl });
expect(await provider.geocode(" ")).toBeNull();
expect(calls).toHaveLength(0);
});
it("throws on a non-OK HTTP response", async () => {
const { fetchImpl } = fakeFetch("rate limited", {
ok: false,
status: 429,
statusText: "Too Many Requests",
});
const provider = new NominatimGeocodingProvider({ fetchImpl });
await expect(provider.geocode("123 Main St")).rejects.toThrow(
/Nominatim geocoding failed: 429/
);
});
it("sends the configured User-Agent and honors a custom base URL", async () => {
const calls: Array<{ url: string; headers?: Record<string, string> }> = [];
const fetchImpl: FetchLike = async (url, opts) => {
calls.push({ url, headers: opts?.headers });
return { ok: true, status: 200, statusText: "OK", json: async () => [NOMINATIM_ROW] };
};
const provider = new NominatimGeocodingProvider({
fetchImpl,
baseUrl: "https://nominatim.example.com/",
userAgent: "TestAgent/9.9",
});
await provider.geocode("123 Main St");
expect(calls[0]!.url).toContain("https://nominatim.example.com/search");
expect(calls[0]!.headers?.["User-Agent"]).toBe("TestAgent/9.9");
});
it("throttles to ~1 req/sec across consecutive calls (first call not delayed)", async () => {
const clock = fakeClock();
const { fetchImpl } = fakeFetch([NOMINATIM_ROW]);
const provider = new NominatimGeocodingProvider({
fetchImpl,
minRequestIntervalMs: 1000,
now: clock.now,
sleep: clock.sleep,
});
await provider.geocode("a");
await provider.geocode("b");
await provider.geocode("c");
// First request immediate; each subsequent waits the full interval.
expect(clock.sleeps).toEqual([1000, 1000]);
});
});
describe("GoogleGeocodingProvider", () => {
const GOOGLE_OK = {
status: "OK",
results: [
{
formatted_address: "1600 Amphitheatre Pkwy, Mountain View, CA",
geometry: { location: { lat: 37.4224, lng: -122.0842 } },
},
],
};
it("parses the first result into a GeocodeResult", async () => {
const { fetchImpl, calls } = fakeFetch(GOOGLE_OK);
const provider = new GoogleGeocodingProvider("test-key", { fetchImpl });
const result = await provider.geocode("1600 Amphitheatre Pkwy");
expect(result).toEqual({
latitude: 37.4224,
longitude: -122.0842,
formattedAddress: "1600 Amphitheatre Pkwy, Mountain View, CA",
provider: "google",
});
expect(calls[0]).toContain("key=test-key");
expect(calls[0]).toContain("address=1600+Amphitheatre+Pkwy");
});
it("returns null on ZERO_RESULTS", async () => {
const { fetchImpl } = fakeFetch({ status: "ZERO_RESULTS", results: [] });
const provider = new GoogleGeocodingProvider("test-key", { fetchImpl });
expect(await provider.geocode("nowhere")).toBeNull();
});
it("throws on an API error status with the error message", async () => {
const { fetchImpl } = fakeFetch({
status: "REQUEST_DENIED",
error_message: "The provided API key is invalid.",
});
const provider = new GoogleGeocodingProvider("bad-key", { fetchImpl });
await expect(provider.geocode("123 Main St")).rejects.toThrow(
/Google geocoding error: REQUEST_DENIED: The provided API key is invalid\./
);
});
it("returns null for a blank address without calling fetch", async () => {
const { fetchImpl, calls } = fakeFetch(GOOGLE_OK);
const provider = new GoogleGeocodingProvider("test-key", { fetchImpl });
expect(await provider.geocode("")).toBeNull();
expect(calls).toHaveLength(0);
});
it("rejects construction with an empty API key", () => {
expect(() => new GoogleGeocodingProvider("")).toThrow(/non-empty API key/);
});
});
describe("resolveGeocodingProvider", () => {
const originalEnv = process.env.GOOGLE_MAPS_API_KEY;
afterEach(() => {
if (originalEnv === undefined) delete process.env.GOOGLE_MAPS_API_KEY;
else process.env.GOOGLE_MAPS_API_KEY = originalEnv;
});
it("defaults to Nominatim when provider is unset", () => {
const provider = resolveGeocodingProvider(null);
expect(provider.name).toBe("nominatim");
});
it("returns Nominatim for an explicit nominatim setting", () => {
const provider = resolveGeocodingProvider({
routeOptimizationProvider: "nominatim",
});
expect(provider.name).toBe("nominatim");
});
it("returns Google and decrypts the stored key when provider is google", () => {
const decrypt = vi.fn().mockReturnValue("decrypted-google-key");
const provider = resolveGeocodingProvider(
{ routeOptimizationProvider: "google", googleMapsApiKey: "enc:abc" },
{ decrypt }
);
expect(provider.name).toBe("google");
expect(decrypt).toHaveBeenCalledWith("enc:abc");
});
it("falls back to Nominatim (with a warning) when google has no usable key", () => {
delete process.env.GOOGLE_MAPS_API_KEY;
const warn = vi.fn();
const provider = resolveGeocodingProvider(
{ routeOptimizationProvider: "google", googleMapsApiKey: null },
{ warn }
);
expect(provider.name).toBe("nominatim");
expect(warn).toHaveBeenCalledOnce();
});
it("falls back to Nominatim (with a warning) when decryption fails", () => {
const decrypt = vi.fn().mockImplementation(() => {
throw new Error("bad ciphertext");
});
const warn = vi.fn();
delete process.env.GOOGLE_MAPS_API_KEY;
const provider = resolveGeocodingProvider(
{ routeOptimizationProvider: "google", googleMapsApiKey: "enc:corrupt" },
{ decrypt, warn }
);
expect(provider.name).toBe("nominatim");
expect(warn).toHaveBeenCalled();
});
it("uses GOOGLE_MAPS_API_KEY env var as a fallback key source", () => {
process.env.GOOGLE_MAPS_API_KEY = "env-key";
const provider = resolveGeocodingProvider({
routeOptimizationProvider: "google",
});
expect(provider.name).toBe("google");
});
});
describe("geocodeBatch", () => {
it("geocodes items in order and preserves keys", async () => {
const { fetchImpl } = fakeFetch([NOMINATIM_ROW]);
const provider = new NominatimGeocodingProvider({
fetchImpl,
minRequestIntervalMs: 0,
});
const outcomes = await geocodeBatch(
[
{ key: "c1", address: "123 Main St" },
{ key: "c2", address: "456 Oak Ave" },
],
provider
);
expect(outcomes.map((o) => o.key)).toEqual(["c1", "c2"]);
expect(outcomes[0]!.result?.latitude).toBe(40.7128);
expect(outcomes[1]!.error).toBeUndefined();
});
it("captures per-item errors and continues the batch", async () => {
let call = 0;
const fetchImpl: FetchLike = async () => {
call += 1;
if (call === 1) {
return { ok: false, status: 500, statusText: "Server Error", json: async () => "" };
}
return { ok: true, status: 200, statusText: "OK", json: async () => [NOMINATIM_ROW] };
};
const provider = new NominatimGeocodingProvider({ fetchImpl, minRequestIntervalMs: 0 });
const outcomes = await geocodeBatch(
[
{ key: 1, address: "bad" },
{ key: 2, address: "good" },
],
provider
);
expect(outcomes[0]!.result).toBeNull();
expect(outcomes[0]!.error).toMatch(/500/);
expect(outcomes[1]!.result?.latitude).toBe(40.7128);
});
it("reports progress for each completed item", async () => {
const { fetchImpl } = fakeFetch([NOMINATIM_ROW]);
const provider = new NominatimGeocodingProvider({ fetchImpl, minRequestIntervalMs: 0 });
const progress: Array<[number, number]> = [];
await geocodeBatch(
[
{ key: "a", address: "1 St" },
{ key: "b", address: "2 St" },
],
provider,
{ onProgress: (completed, total) => progress.push([completed, total]) }
);
expect(progress).toEqual([
[1, 2],
[2, 2],
]);
});
});
-140
View File
@@ -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");
});
});
-631
View File
@@ -1,631 +0,0 @@
/**
* Pet Profile Summary Tests
*
* Covers GET /api/pets/:id/profile-summary in the deployed tree (root src/).
*
* Two suites share one mock harness:
*
* 1. GRO-2013 owner-bypass (the deployed-tree port of #135):
* A customer who is auto-provisioned as a `groomer` staff row by rbac.ts
* (with no appointment linkage) may still read their own pet's summary
* when they supply a valid X-Impersonation-Session-Id whose clientId
* matches the pet's clientId.
*
* 2. GRO-2014 error handling (deployed tree):
* - Empty-body 500 must never escape the route — the onError handler
* converts unhandled errors into a structured JSON 500.
* - Malformed UUIDs must return 404 (not 500 via a Postgres uuid cast).
* - Missing staff context must return 401 (not TypeError on staffRow.id).
* - Pet not found must return 404.
* - Groomer with no appointment linkage must return 403.
* - Manager and groomer with linkage must receive the summary body.
*
* Deployed tree handler: src/routes/pets.ts. The mock queries the
* `appointments` table (the live schema) for visit history, not
* `groomingVisitLogs`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
// ─── Staff fixtures ──────────────────────────────────────────────────────────
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
userId: null,
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const GROOMER: StaffRow = {
...MANAGER,
id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub",
role: "groomer",
isSuperUser: false,
name: "Groomer Gary",
email: "groomer@example.com",
};
/**
* Mirrors the auto-provisioned "groomer" staff row rbac.ts creates for an
* OIDC user (e.g. uat-customer@groombook.dev) on first login: role=groomer,
* no appointment linkage.
*/
const CUSTOMER_STAFF: StaffRow = {
...MANAGER,
id: "staff-customer-id",
oidcSub: null,
userId: "user-customer-id",
role: "groomer",
name: "UAT Customer",
email: "uat-customer@groombook.dev",
};
// ─── Mutable mock state ─────────────────────────────────────────────────────
const CLIENT_ID = "c0000001-0000-0000-0000-000000000001";
const PET_ID = "c0000001-0000-0000-0000-000000000002";
const OTHER_CLIENT_PET_ID = "c0000002-0000-0000-0000-000000000099";
const UNKNOWN_PET_UUID = "00000000-0000-0000-0000-000000000001";
const futureDate = () => new Date(Date.now() + 30 * 60_000);
const pastDate = () => new Date(Date.now() - 5 * 60_000);
function makePet(overrides: Record<string, unknown> = {}) {
return {
id: PET_ID,
clientId: CLIENT_ID,
name: "Biscuit",
species: "dog",
breed: "Golden Retriever",
weightKg: "30.00",
dateOfBirth: null,
healthAlerts: null,
groomingNotes: null,
cutStyle: null,
shampooPreference: null,
specialCareNotes: null,
customFields: {},
petSizeCategory: "large",
coatType: "double",
photoKey: null,
photoUploadedAt: null,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
...overrides,
};
}
function makeAppointment(overrides: Record<string, unknown> = {}) {
return {
id: "appt-1",
clientId: CLIENT_ID,
petId: PET_ID,
serviceId: "service-1",
staffId: GROOMER.id,
batherStaffId: null,
status: "completed",
startTime: new Date("2024-06-01T09:00:00Z"),
endTime: new Date("2024-06-01T11:00:00Z"),
notes: null,
priceCents: 6000,
seriesId: null,
seriesIndex: null,
groupId: null,
confirmationStatus: "confirmed",
confirmedAt: null,
cancelledAt: null,
confirmationToken: null,
customerNotes: null,
createdAt: new Date("2024-05-15"),
updatedAt: new Date("2024-05-15"),
...overrides,
};
}
function makeSession(overrides: Record<string, unknown> = {}) {
return {
id: "sess-owner",
staffId: CUSTOMER_STAFF.id,
clientId: CLIENT_ID,
reason: "sso-bridge",
status: "active",
startedAt: new Date(),
endedAt: null,
expiresAt: futureDate(),
createdAt: new Date(),
...overrides,
};
}
// ─── DB mock state ──────────────────────────────────────────────────────────
let petsTable: Record<string, unknown>[];
let appointmentsTable: Record<string, unknown>[];
let sessionsTable: Record<string, unknown>[];
// selectQueue: queries resolve in FIFO order. Each .from(table) result
// returns a chain that resolves to the next queued row set on a terminal
// call (.where()/.orderBy()/.limit()).
//
// A queued entry of `{ table: "pets", rows: null, throw: "..." }` tells the
// mock to throw instead of returning rows — used by the GRO-2014 "JSON
// envelope on downstream error" test. Any other queued entry with `rows`
// resolves to those rows. An entry with `rows: []` returns an empty array
// (no rows, no throw).
let selectQueue: Array<{
table: string;
rows: unknown[] | null;
throw?: string;
}> = [];
// Captured `db.insert(table).values(vals)` calls. Mirrors the pattern from
// src/__tests__/impersonation.test.ts so the GRO-2063 audit row assertions
// can inspect what the route tried to write without needing a real DB.
let insertCapture: Array<{ table: string; vals: Record<string, unknown> }> = [];
function enqueue(table: string, rows: unknown[] = []) {
selectQueue.push({ table, rows });
}
function enqueueThrow(table: string, message: string) {
selectQueue.push({ table, rows: null, throw: message });
}
function resetMock() {
petsTable = [makePet()];
appointmentsTable = [makeAppointment()];
sessionsTable = [makeSession()];
selectQueue = [];
insertCapture = [];
}
// ─── Module mocks ───────────────────────────────────────────────────────────
vi.mock("@groombook/db", () => {
function makeTable(name: string) {
return new Proxy(
{ _name: name },
{
get(target, prop) {
if (prop === "_name") return name;
if (prop === "$inferSelect") return {};
return { table: name, column: prop };
},
}
);
}
function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) {
const queryString = _strings[0];
return {
queryChunks: [queryString],
as: (alias: string) => ({
queryChunks: [queryString],
fieldAlias: alias,
getSQL() { return this.queryChunks; },
}),
};
}
function takeQueuedRows(tableName: string): unknown[] {
const next = selectQueue.shift();
if (next && next.table === tableName) {
if (next.throw) {
throw new Error(next.throw);
}
return next.rows ?? [];
}
return [];
}
// Wrap a finalised result in a Proxy that exposes chainable methods
// and the resolved rows. Each call to a chainable method (where/orderBy/
// limit/...) returns the SAME rows so the route's natural await on the
// chain resolves to the queued data.
function wrapRows(rows: unknown[]): unknown {
return new Proxy(rows, {
get(target, prop: string | symbol) {
if (prop === "where" || prop === "orderBy" || prop === "limit"
|| prop === "leftJoin" || prop === "innerJoin" || prop === "from") {
return () => wrapRows(rows);
}
if (prop === "then") {
return (onFulfilled?: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) =>
Promise.resolve(rows).then(onFulfilled, onRejected);
}
if (prop === Symbol.iterator) {
return function* () { for (const v of target) yield v; };
}
if (prop === Symbol.asyncIterator) {
return async function* () { for (const v of target) yield v; };
}
// @ts-expect-error proxy access
return target[prop];
},
});
}
return {
getDb: () => ({
select: (_cols?: Record<string, unknown>) => ({
from: (table: { _name?: string }) => wrapRows(takeQueuedRows(table._name ?? "")),
}),
insert: (table: { _name?: string }) => ({
values: (vals: Record<string, unknown>) => {
insertCapture.push({ table: table._name ?? "unknown", vals });
return { returning: () => [{}] };
},
}),
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
}),
pets: makeTable("pets"),
appointments: makeTable("appointments"),
staff: makeTable("staff"),
services: makeTable("services"),
impersonationSessions: makeTable("impersonationSessions"),
impersonationAuditLogs: makeTable("impersonation_audit_logs"),
and: vi.fn((..._args: unknown[]) => ({})),
desc: vi.fn((c: unknown) => c),
eq: vi.fn((_a: unknown, _b: unknown) => ({})),
exists: vi.fn(() => true),
or: vi.fn((..._args: unknown[]) => ({})),
sql: sqlMock,
};
});
vi.mock("../lib/s3.js", () => ({
getPresignedUploadUrl: vi.fn(),
getPresignedGetUrl: vi.fn(),
deleteObject: vi.fn(),
}));
// ─── Import after mocks are set up ──────────────────────────────────────────
const { petsRouter } = await import("../routes/pets.js");
// ─── App builder ────────────────────────────────────────────────────────────
function buildApp(staffRow: StaffRow | null) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
if (staffRow) {
c.set("jwtPayload", { sub: staffRow.oidcSub ?? staffRow.userId ?? "" });
c.set("staff", staffRow);
}
await next();
});
app.route("/pets", petsRouter);
return app;
}
// ─── Reset before each test ─────────────────────────────────────────────────
beforeEach(() => {
resetMock();
vi.clearAllMocks();
});
// ─── GRO-2014 error-handling suite ──────────────────────────────────────────
describe("GET /:id/profile-summary — GRO-2014 error handling", () => {
it("returns 404 (not 500) for a malformed UUID path param", async () => {
const app = buildApp(MANAGER);
const res = await app.request("/pets/not-a-uuid/profile-summary");
expect(res.status).toBe(404);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Not found");
});
it("returns 401 when staff context is missing (defense in depth)", async () => {
const app = buildApp(null);
const res = await app.request(`/pets/${UNKNOWN_PET_UUID}/profile-summary`);
expect(res.status).toBe(401);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Unauthorized");
});
it("returns 404 when authenticated and pet does not exist", async () => {
enqueue("pets", []);
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${UNKNOWN_PET_UUID}/profile-summary`);
expect(res.status).toBe(404);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Not found");
});
it("returns 403 when groomer has no appointment linkage to the pet's client", async () => {
enqueue("pets", petsTable);
enqueue("appointments", []); // linkage check returns empty → 403
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(403);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Forbidden");
});
it("returns 200 with summary for a manager (no groomer linkage check)", async () => {
enqueue("pets", petsTable);
enqueue("appointments", appointmentsTable); // history
enqueue("appointments", [{ count: 1 }]); // visit count
enqueue("appointments", []); // upcoming (none)
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body.id).toBe(PET_ID);
expect(body.name).toBe("Biscuit");
expect(body.visitCount).toBe(1);
expect(body.upcomingAppointment).toBeNull();
expect(body.recentGroomingHistory).toBeInstanceOf(Array);
});
it("returns 200 with summary for a groomer with appointment linkage", async () => {
enqueue("pets", petsTable);
enqueue("appointments", [{ id: "appt-1" }]); // linkage found
enqueue("appointments", appointmentsTable); // history
enqueue("appointments", [{ count: 1 }]); // visit count
enqueue("appointments", []); // upcoming
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body.id).toBe(PET_ID);
});
it("returns a JSON envelope (not empty body) when a downstream query throws", async () => {
enqueueThrow("pets", "simulated postgres uuid cast failure");
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(500);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Internal Server Error");
});
});
// ─── GRO-2013 owner-bypass suite ────────────────────────────────────────────
describe("GET /:id/profile-summary — owner-bypass (GRO-2013)", () => {
it("returns 404 when the pet does not exist", async () => {
enqueue("pets", []);
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(404);
});
it("returns 200 with aggregated profile for a manager", async () => {
enqueue("pets", petsTable);
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe(PET_ID);
expect(body.name).toBe("Biscuit");
expect(body.recentGroomingHistory).toBeInstanceOf(Array);
expect(body.visitCount).toBe(1);
expect(body.upcomingAppointment).toBeNull();
});
it("returns 200 for a groomer with appointment linkage to the pet's client", async () => {
enqueue("pets", petsTable);
enqueue("appointments", [{ id: "appt-1" }]); // linkage found
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
});
it("returns 403 for a groomer with no appointment linkage and no bypass header", async () => {
enqueue("pets", petsTable);
enqueue("appointments", []); // no linkage
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(403);
});
it("customer-as-groomer with valid active session for pet's client returns 200", async () => {
enqueue("pets", petsTable);
enqueue("impersonationSessions", sessionsTable); // active session found
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-owner" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe(PET_ID);
});
it("customer-as-groomer with no header still gets 403 (no bypass)", async () => {
enqueue("pets", petsTable);
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(403);
});
it("customer-as-groomer with session for a DIFFERENT client gets 403 (cross-tenant blocked)", async () => {
// Session exists but clientId !== pet.clientId → bypass does not apply
// → falls through to groomer linkage check → no linkage → 403
enqueue("pets", petsTable);
enqueue("impersonationSessions", [
makeSession({
id: "sess-other-client",
clientId: "c0000000-0000-0000-0000-000000000099", // different from CLIENT_ID
}),
]);
enqueue("appointments", []); // no linkage → 403
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-other-client" },
});
expect(res.status).toBe(403);
});
it("customer-as-groomer with expired session still gets 403", async () => {
enqueue("pets", petsTable);
enqueue("impersonationSessions", [
makeSession({ id: "sess-expired", expiresAt: pastDate() }),
]);
enqueue("appointments", []); // no linkage → 403
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-expired" },
});
expect(res.status).toBe(403);
});
it("customer-as-groomer with ended (status != active) session still gets 403", async () => {
enqueue("pets", petsTable);
enqueue("impersonationSessions", [
makeSession({ id: "sess-ended", status: "ended" }),
]);
enqueue("appointments", []); // no linkage → 403
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-ended" },
});
expect(res.status).toBe(403);
});
it("customer-as-groomer with unknown session id still gets 403", async () => {
enqueue("pets", petsTable);
enqueue("impersonationSessions", []); // session not found
enqueue("appointments", []); // no linkage → 403
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-unknown" },
});
expect(res.status).toBe(403);
});
it("manager does NOT need the impersonation header (existing role check still works)", async () => {
enqueue("pets", petsTable);
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
});
it("groomer with linkage to pet's client still works (regression — no regression from bypass)", async () => {
enqueue("pets", petsTable);
enqueue("appointments", [{ id: "appt-1" }]); // linkage found
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
});
it("owner-bypass: customer cannot view another client's pet (cross-tenant block)", async () => {
// The customer has a valid session for CLIENT_ID, but the pet belongs
// to a different client → isOwner=false → falls through to groomer
// linkage check → 403.
enqueue("pets", [
makePet({ id: OTHER_CLIENT_PET_ID, clientId: "c0000002-0000-0000-0000-000000000002" }),
]);
enqueue("impersonationSessions", sessionsTable); // valid session, but for CLIENT_ID
enqueue("appointments", []); // no linkage → 403
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${OTHER_CLIENT_PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-owner" },
});
expect(res.status).toBe(403);
});
});
// ─── GRO-2063 owner-bypass audit write ──────────────────────────────────────
describe("GET /:id/profile-summary — owner-bypass audit row (GRO-2063)", () => {
it("writes exactly one audit row on the owner-bypass success path", async () => {
enqueue("pets", petsTable);
enqueue("impersonationSessions", sessionsTable); // valid active session for CLIENT_ID
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-owner" },
});
expect(res.status).toBe(200);
const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
expect(auditInserts).toHaveLength(1);
const vals = auditInserts[0]!.vals;
expect(vals.action).toBe("read_profile_summary");
expect(vals.sessionId).toBe("sess-owner");
expect(vals.pageVisited).toBe(`/pets/${PET_ID}/profile-summary`);
expect(vals.metadata).toEqual({
petId: PET_ID,
actorStaffId: CUSTOMER_STAFF.id,
});
});
it("does NOT write an audit row on the normal groomer-linkage success path", async () => {
// GROOMER is a "real" groomer with appointment linkage, NOT the
// auto-provisioned customer-as-groomer. No impersonation header is
// present, so the owner-bypass branch never executes.
enqueue("pets", petsTable);
enqueue("appointments", [{ id: "appt-1" }]); // linkage found
enqueue("appointments", appointmentsTable);
enqueue("appointments", [{ count: 1 }]);
enqueue("appointments", []);
const app = buildApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
expect(auditInserts).toHaveLength(0);
});
it("does NOT write an audit row when the owner-bypass attempt is denied (cross-tenant)", async () => {
// Customer has a valid session but it points at a different client.
// isOwner=false, falls through to groomer linkage check, returns 403.
enqueue("pets", [
makePet({ id: OTHER_CLIENT_PET_ID, clientId: "c0000002-0000-0000-0000-000000000002" }),
]);
enqueue("impersonationSessions", sessionsTable); // session is for CLIENT_ID
enqueue("appointments", []); // no linkage → 403
const app = buildApp(CUSTOMER_STAFF);
const res = await app.request(`/pets/${OTHER_CLIENT_PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-owner" },
});
expect(res.status).toBe(403);
const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
expect(auditInserts).toHaveLength(0);
});
});
-130
View File
@@ -39,19 +39,11 @@ const APPOINTMENT = {
let selectSessionRow: Record<string, unknown> | null = null;
let selectAppointmentRow: Record<string, unknown> | null = null;
let selectWaitlistRows: Record<string, unknown>[] = [];
let selectPetRows: Record<string, unknown>[] = [];
let selectStaffRows: Record<string, unknown>[] = [];
let selectServiceRows: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
function resetMock() {
selectSessionRow = null;
selectAppointmentRow = null;
selectWaitlistRows = [];
selectPetRows = [];
selectStaffRows = [];
selectServiceRows = [];
updatedValues = [];
}
@@ -80,13 +72,6 @@ vi.mock("@groombook/db", () => {
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
const mkTable = (name: string) =>
new Proxy({ _name: name }, { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) });
const waitlistEntries = mkTable("waitlistEntries");
const pets = mkTable("pets");
const staff = mkTable("staff");
const services = mkTable("services");
return {
getDb: () => ({
select: () => ({
@@ -97,18 +82,6 @@ vi.mock("@groombook/db", () => {
if (table._name === "appointments") {
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
}
if (table._name === "waitlistEntries") {
return makeChainable(selectWaitlistRows);
}
if (table._name === "pets") {
return makeChainable(selectPetRows);
}
if (table._name === "staff") {
return makeChainable(selectStaffRows);
}
if (table._name === "services") {
return makeChainable(selectServiceRows);
}
return makeChainable([]);
},
}),
@@ -129,13 +102,8 @@ vi.mock("@groombook/db", () => {
}),
impersonationSessions,
appointments,
waitlistEntries,
pets,
staff,
services,
eq: vi.fn(),
and: vi.fn(),
inArray: vi.fn(),
};
});
@@ -157,104 +125,6 @@ function jsonPatch(path: string, body: unknown, headers?: Record<string, string>
beforeEach(() => resetMock());
// GRO-2319 item 2: the portal Upcoming list renders active waitlist entries as
// synthetic `waitlisted` cards, so GET /portal/appointments must surface them.
describe("GET /portal/appointments (waitlist surfacing — GRO-2319)", () => {
it("returns active waitlist entries as synthetic waitlisted cards", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
selectWaitlistRows = [
{
id: "11111111-1111-1111-1111-111111111111",
petId: "pet-1",
serviceId: "svc-1",
preferredDate: "2099-01-01",
preferredTime: "13:00:00",
},
];
selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }];
const res = await app.request("/portal/appointments", {
headers: { "X-Impersonation-Session-Id": SESSION_ID },
});
expect(res.status).toBe(200);
const body = await res.json();
const waitlistCard = body.appointments.find(
(a: { status: string }) => a.status === "waitlisted",
);
expect(waitlistCard).toBeTruthy();
expect(waitlistCard.id).toBe("waitlist:11111111-1111-1111-1111-111111111111");
expect(waitlistCard.pet.name).toBe("Rex");
expect(waitlistCard.confirmationStatus).toBeNull();
// startTime is derived from preferredDate + preferredTime so the card sorts
// and classifies as Upcoming.
expect(waitlistCard.startTime).toBeTruthy();
});
it("omits the waitlist section when the client has no active entries", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
selectWaitlistRows = [];
const res = await app.request("/portal/appointments", {
headers: { "X-Impersonation-Session-Id": SESSION_ID },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.appointments.some((a: { status: string }) => a.status === "waitlisted")).toBe(false);
});
});
// GRO-2342: GET /portal/appointments must populate the synthetic waitlist
// card's `service` object with the full service record (id + name) — same
// shape the appointments join returns — so the portal renders the real
// service name in place of the fallback "Service" label.
describe("GET /portal/appointments (waitlist service name — GRO-2342)", () => {
it("returns service {id, name} on the synthetic waitlist card", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
selectWaitlistRows = [
{
id: "22222222-2222-2222-2222-222222222222",
petId: "pet-1",
serviceId: "svc-1",
preferredDate: "2099-01-01",
preferredTime: "13:00:00",
},
];
selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }];
selectServiceRows = [{ id: "svc-1", name: "Full Groom" }];
const res = await app.request("/portal/appointments", {
headers: { "X-Impersonation-Session-Id": SESSION_ID },
});
expect(res.status).toBe(200);
const body = await res.json();
const waitlistCard = body.appointments.find(
(a: { status: string }) => a.status === "waitlisted",
);
expect(waitlistCard).toBeTruthy();
expect(waitlistCard.service).toEqual({ id: "svc-1", name: "Full Groom" });
});
it("returns service {id, name} on the appointment card (same shape)", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, serviceId: "svc-appt" };
selectServiceRows = [{ id: "svc-appt", name: "Bath & Brush" }];
const res = await app.request("/portal/appointments", {
headers: { "X-Impersonation-Session-Id": SESSION_ID },
});
expect(res.status).toBe(200);
const body = await res.json();
const apptCard = body.appointments.find(
(a: { status: string }) => a.status === "scheduled",
);
expect(apptCard).toBeTruthy();
expect(apptCard.service).toEqual({ id: "svc-appt", name: "Bath & Brush" });
});
});
describe("PATCH /portal/appointments/:id/notes", () => {
it("returns updated appointment with safe fields only", async () => {
selectSessionRow = ACTIVE_SESSION;
-201
View File
@@ -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);
});
});
-336
View File
@@ -1,336 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
const OTHER_CLIENT_ID = "550e8400-e29b-41d4-a716-446655440099";
const PET_ID = "880e8400-e29b-41d4-a716-446655440004";
const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003";
const futureDate = () => new Date(Date.now() + 30 * 60 * 1000);
const ACTIVE_SESSION = {
id: SESSION_ID,
clientId: CLIENT_ID,
status: "active" as const,
expiresAt: futureDate(),
createdAt: new Date(),
};
// A persisted pet owned by CLIENT_ID. weightKg is a string because the column is
// numeric (Drizzle serialises numeric to string).
const PET = {
id: PET_ID,
clientId: CLIENT_ID,
name: "Rex",
species: "dog",
breed: "Labrador",
weightKg: "12.50",
dateOfBirth: null,
healthAlerts: null,
groomingNotes: null,
coatType: null,
petSizeCategory: null,
preferredCuts: [],
medicalAlerts: [],
photoKey: null,
};
let selectSessionRow: Record<string, unknown> | null = null;
let selectPetRow: Record<string, unknown> | null = null;
let updatedValues: Record<string, unknown>[] = [];
function resetMock() {
selectSessionRow = null;
selectPetRow = null;
updatedValues = [];
}
vi.mock("@groombook/db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit") {
return () => chain;
}
// @ts-expect-error proxy
return target[prop];
},
});
return chain;
}
function tableProxy(name: string) {
return new Proxy(
{ _name: name },
{ get: (t, p) => (p === "_name" ? name : { table: name, column: p }) }
);
}
const impersonationSessions = tableProxy("impersonationSessions");
const pets = tableProxy("pets");
return {
getDb: () => ({
select: () => ({
from: (table: { _name: string }) => {
if (table._name === "impersonationSessions") {
return makeChainable(selectSessionRow ? [selectSessionRow] : []);
}
if (table._name === "pets") {
return makeChainable(selectPetRow ? [selectPetRow] : []);
}
return makeChainable([]);
},
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => ({
returning: () => {
if (selectPetRow) {
updatedValues.push(vals);
return [{ ...selectPetRow, ...vals }];
}
return [];
},
}),
}),
}),
// portalAudit inserts an audit row after the handler; make it a no-op so
// the middleware does not log a swallowed error during tests.
insert: () => ({ values: () => ({ returning: () => [] }) }),
}),
impersonationSessions,
pets,
// Other tables imported by the portal router but unused in these tests.
appointments: tableProxy("appointments"),
waitlistEntries: tableProxy("waitlistEntries"),
clients: tableProxy("clients"),
services: tableProxy("services"),
staff: tableProxy("staff"),
invoices: tableProxy("invoices"),
invoiceLineItems: tableProxy("invoiceLineItems"),
impersonationAuditLogs: tableProxy("impersonationAuditLogs"),
eq: vi.fn(),
and: vi.fn(),
inArray: vi.fn(),
};
});
const { portalRouter } = await import("../routes/portal.js");
const app = new Hono();
app.route("/portal", portalRouter);
function jsonPatch(path: string, body: unknown, headers?: Record<string, string>) {
return app.request(path, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify(body),
});
}
beforeEach(() => resetMock());
describe("PATCH /portal/pets/:petId", () => {
it("updates an owned pet and persists the mapped columns (200)", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = PET;
// Mirrors the groombook/web PetForm payload: it spreads the GET-shaped pet
// (weight, notes, birthDate, photoUrl) and adds the form's edited keys
// (weightKg, healthAlerts, coatType, …). "xlarge" must map to "extra_large".
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{
id: PET_ID,
name: "Rex Updated",
breed: "Golden Retriever",
weight: "12.50",
weightKg: 18.25,
notes: "old grooming notes",
healthAlerts: "Allergic to oatmeal shampoo",
photoUrl: "pets/rex.jpg",
coatType: "double",
petSizeCategory: "xlarge",
preferredCuts: ["teddy bear", "puppy cut"],
medicalAlerts: [
{ id: "a1", type: "allergy", description: "oatmeal", severity: "medium" },
],
},
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.name).toBe("Rex Updated");
expect(body.petSizeCategory).toBe("extra_large");
expect(body.coatType).toBe("double");
const persisted = updatedValues[0]!;
expect(persisted.name).toBe("Rex Updated");
expect(persisted.breed).toBe("Golden Retriever");
// weightKg (form key) wins over weight (GET key) and is stored as a string.
expect(persisted.weightKg).toBe("18.25");
expect(persisted.groomingNotes).toBe("old grooming notes");
expect(persisted.healthAlerts).toBe("Allergic to oatmeal shampoo");
// photoKey is NOT writable via portal PATCH (GRO-2187 S3 key-hijack fix):
// the web form round-trips the GET-shaped photoUrl, but the server must not
// persist it. Photo changes go through the key-validated upload flow.
expect(persisted.photoKey).toBeUndefined();
expect(persisted.coatType).toBe("double");
expect(persisted.petSizeCategory).toBe("extra_large");
expect(persisted.preferredCuts).toEqual(["teddy bear", "puppy cut"]);
expect(persisted.medicalAlerts).toEqual([
{ id: "a1", type: "allergy", description: "oatmeal", severity: "medium" },
]);
expect(persisted.updatedAt).toBeInstanceOf(Date);
});
// GRO-2187 security regression: a portal customer must not be able to set the
// S3 object key. photoKey is consumed server-side by getPresignedGetUrl /
// deleteObject; the upload path guards keys with a pets/{petId}/ prefix, and the
// portal PATCH must not offer a bypass. A foreign/arbitrary photoUrl is accepted
// (Zod strips the unknown key) but must leave photoKey untouched.
it("does not mutate photoKey when a foreign photoUrl is supplied (200)", async () => {
selectSessionRow = ACTIVE_SESSION;
const ownKey = `pets/${PET_ID}/original.jpg`;
selectPetRow = { ...PET, photoKey: ownKey };
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{
name: "Rex",
// attacker-chosen key pointing at another tenant's object
photoUrl: "pets/00000000-0000-0000-0000-0000000000ff/victim-secret.jpg",
},
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
const persisted = updatedValues[0]!;
// The attacker-supplied key never reaches the update payload.
expect(persisted.photoKey).toBeUndefined();
// And the stored key is unchanged from the pet's own value.
const body = await res.json();
expect(body.photoUrl).toBe(ownKey);
});
// The length/array caps live in the Zod schema, so violations are rejected by
// zValidator with 400 (in-handler enum checks are what return 422).
it("returns 400 when a medicalAlert description exceeds the length cap", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = PET;
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{
medicalAlerts: [
{ type: "allergy", description: "x".repeat(2001), severity: "low" },
],
},
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(400);
expect(updatedValues).toHaveLength(0);
});
it("falls back to the weight key when weightKg is absent", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = PET;
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{ weight: "9.75" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
expect(updatedValues[0]!.weightKg).toBe("9.75");
});
it("returns 403 when the pet belongs to a different client", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = { ...PET, clientId: OTHER_CLIENT_ID };
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{ name: "Hacker" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(403);
expect(updatedValues).toHaveLength(0);
});
it("returns 404 when the pet does not exist", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = null;
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{ name: "Ghost" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(404);
});
it("returns 404 for a malformed (non-UUID) petId without hitting the db (GRO-2203)", async () => {
selectSessionRow = ACTIVE_SESSION;
// A non-UUID petId previously reached `where(eq(pets.id, ...))` and made
// Postgres throw "invalid input syntax for type uuid" → unhandled 500.
// It must now short-circuit to 404 before any select/update.
selectPetRow = PET;
const res = await jsonPatch(
`/portal/pets/not-a-uuid`,
{ coatType: "short" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(404);
expect(updatedValues).toHaveLength(0);
});
it("returns 422 for an invalid coatType", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = PET;
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{ coatType: "fluffy" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
expect(updatedValues).toHaveLength(0);
});
it("returns 422 for an invalid petSizeCategory", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = PET;
const res = await jsonPatch(
`/portal/pets/${PET_ID}`,
{ petSizeCategory: "gigantic" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
expect(updatedValues).toHaveLength(0);
});
it("returns 401 without an impersonation session header", async () => {
selectSessionRow = ACTIVE_SESSION;
selectPetRow = PET;
const res = await jsonPatch(`/portal/pets/${PET_ID}`, { name: "NoAuth" });
expect(res.status).toBe(401);
});
});
-188
View File
@@ -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);
});
});
+25 -214
View File
@@ -43,103 +43,42 @@ const GROOMER: StaffRow = {
// ─── Mock DB ──────────────────────────────────────────────────────────────────
// staffLookupResult drives every `from(staff)` query that doesn't go through
// the dev-mode `.limit()` shortcut. Tests that want to simulate "no staff row"
// leave it null.
let staffLookupResult: StaffRow | null = null;
// managerFallbackResult is only consumed by the dev-mode `from(staff).limit(1)`
// path (looking up the first manager when AUTH_DISABLED=true and no header).
let managerFallbackResult: StaffRow | null = MANAGER;
// userLookupResult drives `from(user).limit(1)` for the Better-Auth user
// auto-provision branch (GRO-2052). Tests that simulate "no Better-Auth user"
// leave it null.
type UserRow = { id: string; name: string | null; email: string | null };
let userLookupResult: UserRow | null = null;
// accountLookupResult drives `from(account).limit(1)` for the legacy OIDC
// auto-provision branch. Null means "no OIDC account row".
let accountLookupResult: { id: string } | null = null;
// insertReturningResult drives `insert(staff).values(...).returning()` for
// any auto-provision branch that actually creates a staff record. Null means
// the INSERT returned no rows (simulating a DB failure).
let insertReturningResult: StaffRow | null = null;
vi.mock("@groombook/db", () => {
function tableMarker(name: string) {
return new Proxy(
{ _name: name },
{
get(_target, prop) {
if (prop === "_name") return name;
if (prop === "$inferSelect") return {};
return { table: name, column: prop };
},
}
);
}
const staff = tableMarker("staff");
const user = tableMarker("user");
const account = tableMarker("account");
function lookupFor(tableName: string) {
if (tableName === "user") return userLookupResult;
if (tableName === "account") return accountLookupResult;
return staffLookupResult;
}
const staff = new Proxy(
{ _name: "staff" },
{
get(target, prop) {
if (prop === "_name") return "staff";
if (prop === "$inferSelect") return {};
return { table: "staff", column: prop };
},
}
);
return {
getDb: () => ({
select: (_columns?: unknown) => ({
from: (table: { _name?: string }) => {
const name = table?._name ?? "staff";
return {
where: () => ({
limit: () => {
// The user / account auto-provision branches always call
// `.limit(1)`; route to the per-table lookup state.
if (name === "user")
return userLookupResult ? [userLookupResult] : [];
if (name === "account")
return accountLookupResult ? [accountLookupResult] : [];
// dev-mode `from(staff).limit(1)` falls back to the first
// manager when AUTH_DISABLED is set with no header.
return managerFallbackResult ? [managerFallbackResult] : [];
},
[Symbol.iterator]: function* () {
const row = lookupFor(name);
if (row) yield row;
},
0: lookupFor(name),
length: lookupFor(name) ? 1 : 0,
}),
};
},
}),
insert: (_table: unknown) => ({
values: (_v: unknown) => ({
returning: () =>
insertReturningResult ? [insertReturningResult] : [],
}),
}),
update: (_table: unknown) => ({
set: (_v: unknown) => ({
where: () => Promise.resolve(undefined),
select: () => ({
from: () => ({
where: () => ({
limit: () => {
// dev mode fallback to first manager
return managerFallbackResult ? [managerFallbackResult] : [];
},
[Symbol.iterator]: function* () {
if (staffLookupResult) yield staffLookupResult;
},
0: staffLookupResult,
length: staffLookupResult ? 1 : 0,
}),
}),
}),
}),
staff,
user,
account,
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
and: vi.fn((..._clauses: unknown[]) => ({})),
sql: Object.assign(
vi.fn((..._tpl: unknown[]) => ({})),
{ raw: vi.fn(() => ({})) }
),
};
});
@@ -148,25 +87,16 @@ vi.mock("@groombook/db", () => {
function resetMocks() {
staffLookupResult = null;
managerFallbackResult = MANAGER;
userLookupResult = null;
accountLookupResult = null;
insertReturningResult = null;
}
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
function buildApp(
middleware: MiddlewareHandler<AppEnv>,
handler?: (c: Context<AppEnv>) => Response | Promise<Response>,
jwtOverride?: Partial<{ sub: string; email: string; name: string }>
handler?: (c: Context<AppEnv>) => Response | Promise<Response>
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
const defaultSub = staffLookupResult?.userId ?? "unknown-sub";
c.set("jwtPayload", {
sub: jwtOverride?.sub ?? defaultSub,
...(jwtOverride?.email !== undefined ? { email: jwtOverride.email } : {}),
...(jwtOverride?.name !== undefined ? { name: jwtOverride.name } : {}),
});
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
await next();
});
app.use("*", middleware);
@@ -274,125 +204,6 @@ describe("resolveStaffMiddleware", () => {
});
});
// ─── Auto-provision branches (GRO-2052) ───────────────────────────────────────
//
// Each branch creates a staff row on first authenticated request when no row
// exists yet. The Better-Auth branch (user table) is the primary path for
// email/password customers; the OIDC branch (account table) is a fallback for
// legacy authentik/google/github sessions.
describe("resolveStaffMiddleware — auto-provision", () => {
const PROVISIONED: StaffRow = {
...MANAGER,
id: "staff-provisioned-id",
oidcSub: null,
userId: "ba-user-customer",
role: "groomer",
isSuperUser: false,
name: "UAT Customer",
email: "uat-customer@groombook.dev",
};
it("Better-Auth: creates a groomer staff row when user exists but no staff record (GRO-2052)", async () => {
// No existing staff row, no OIDC account row, but a Better-Auth user row.
staffLookupResult = null;
userLookupResult = {
id: "ba-user-customer",
name: "UAT Customer",
email: "uat-customer@groombook.dev",
};
accountLookupResult = null;
insertReturningResult = PROVISIONED;
let capturedStaff: StaffRow | null = null;
const app = buildApp(
resolveStaffMiddleware,
(c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
},
{ sub: "ba-user-customer", email: "uat-customer@groombook.dev" }
);
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff).not.toBeNull();
expect(capturedStaff!.role).toBe("groomer");
expect(capturedStaff!.userId).toBe("ba-user-customer");
});
it("Better-Auth: returns 500 if INSERT yields no row", async () => {
staffLookupResult = null;
userLookupResult = {
id: "ba-user-customer",
name: "UAT Customer",
email: "uat-customer@groombook.dev",
};
insertReturningResult = null; // simulate INSERT … RETURNING returning []
const app = buildApp(resolveStaffMiddleware, undefined, {
sub: "ba-user-customer",
email: "uat-customer@groombook.dev",
});
const res = await app.request("/test");
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toMatch(/auto-provision failed/i);
});
it("Better-Auth branch runs before OIDC branch (does not require jwt.email)", async () => {
// A Better-Auth user row alone is sufficient: jwt.email is intentionally
// absent. The pre-GRO-2052 code only auto-provisioned inside `if (jwt.email)`.
staffLookupResult = null;
userLookupResult = {
id: "ba-user-customer",
name: "UAT Customer",
email: "uat-customer@groombook.dev",
};
insertReturningResult = PROVISIONED;
const app = buildApp(resolveStaffMiddleware, undefined, {
sub: "ba-user-customer",
});
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("OIDC fallback: still provisions when user row is missing but account row exists", async () => {
// No staff row, no Better-Auth user, but an OIDC account row.
staffLookupResult = null;
userLookupResult = null;
accountLookupResult = { id: "oidc-account-id" };
insertReturningResult = { ...PROVISIONED, userId: "oidc-sub" };
const app = buildApp(resolveStaffMiddleware, undefined, {
sub: "oidc-sub",
email: "oidc-user@example.com",
});
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("falls through to 403 when neither Better-Auth user nor OIDC account row exists", async () => {
staffLookupResult = null;
userLookupResult = null;
accountLookupResult = null;
const app = buildApp(resolveStaffMiddleware, undefined, {
sub: "ghost-sub",
email: "ghost@example.com",
});
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/no staff record/i);
});
});
// ─── requireRole tests ────────────────────────────────────────────────────────
describe("requireRole", () => {
-335
View File
@@ -1,335 +0,0 @@
import { describe, it, expect } from "vitest";
import {
haversineKm,
estimateLeg,
nearestNeighborOrder,
optimizeRoute,
detectScheduleConflicts,
recomputeLegsForOrder,
MAX_STOPS_PER_ROUTE,
type RouteStopInput,
} from "../services/routeOptimization.js";
import type { FetchLike } from "../services/geocoding.js";
/** Builds a fake fetch returning a single JSON body, recording called URLs. */
function fakeFetch(
body: unknown,
init: { ok?: boolean; status?: number; statusText?: string } = {}
): { fetchImpl: FetchLike; calls: string[] } {
const calls: string[] = [];
const fetchImpl: FetchLike = async (url) => {
calls.push(url);
return {
ok: init.ok ?? true,
status: init.status ?? 200,
statusText: init.statusText ?? "OK",
json: async () => body,
};
};
return { fetchImpl, calls };
}
function stop(appointmentId: string, lat: number, lng: number): RouteStopInput {
return { appointmentId, latitude: lat, longitude: lng };
}
describe("haversineKm", () => {
it("is zero for the same point", () => {
expect(haversineKm({ latitude: 40, longitude: -74 }, { latitude: 40, longitude: -74 })).toBe(0);
});
it("approximates 1 degree of latitude as ~111km", () => {
const d = haversineKm({ latitude: 0, longitude: 0 }, { latitude: 1, longitude: 0 });
expect(d).toBeGreaterThan(110);
expect(d).toBeLessThan(112);
});
});
describe("estimateLeg", () => {
it("applies the circuity factor and average speed", () => {
const a = { latitude: 0, longitude: 0 };
const b = { latitude: 0, longitude: 1 };
const { distanceKm, mins } = estimateLeg(a, b);
// ~111km straight * 1.3 circuity ≈ 144.6km; at 40km/h ≈ 217 min
expect(distanceKm).toBeGreaterThan(140);
expect(distanceKm).toBeLessThan(150);
expect(mins).toBeGreaterThan(200);
expect(mins).toBeLessThan(230);
expect(Number.isInteger(mins)).toBe(true);
});
});
describe("nearestNeighborOrder", () => {
it("returns trivial order for 0 or 1 points", () => {
expect(nearestNeighborOrder([])).toEqual([]);
expect(nearestNeighborOrder([{ latitude: 1, longitude: 1 }])).toEqual([0]);
});
it("greedily visits the nearest unvisited point", () => {
// Points on a line; scrambled input order.
const points = [
{ latitude: 0, longitude: 0 }, // 0 (start)
{ latitude: 0, longitude: 5 }, // 1 (far)
{ latitude: 0, longitude: 1 }, // 2
{ latitude: 0, longitude: 2 }, // 3
];
expect(nearestNeighborOrder(points, 0)).toEqual([0, 2, 3, 1]);
});
});
describe("optimizeRoute — nearest-neighbor fallback (no API key)", () => {
it("returns an empty route for no stops", async () => {
const r = await optimizeRoute([]);
expect(r.stops).toHaveLength(0);
expect(r.totalTravelMins).toBe(0);
expect(r.totalDistanceKm).toBe(0);
expect(r.provider).toBe("nearest_neighbor");
expect(r.chunked).toBe(false);
});
it("handles a single stop with null travel-from-prev", async () => {
const r = await optimizeRoute([stop("a", 40, -74)]);
expect(r.stops).toHaveLength(1);
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
expect(r.stops[0]!.travelDistanceKmFromPrev).toBeNull();
expect(r.totalTravelMins).toBe(0);
});
it("orders multiple stops greedily and sums totals", async () => {
const stops = [
stop("start", 0, 0),
stop("far", 0, 5),
stop("near1", 0, 1),
stop("near2", 0, 2),
];
const r = await optimizeRoute(stops);
expect(r.provider).toBe("nearest_neighbor");
expect(r.stops.map((s) => s.appointmentId)).toEqual([
"start",
"near1",
"near2",
"far",
]);
// First stop has no inbound leg.
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
// Remaining stops have positive travel.
for (const s of r.stops.slice(1)) {
expect(s.travelMinsFromPrev!).toBeGreaterThan(0);
expect(s.travelDistanceKmFromPrev!).toBeGreaterThan(0);
}
const summed = r.stops.reduce((acc, s) => acc + (s.travelMinsFromPrev ?? 0), 0);
expect(r.totalTravelMins).toBe(summed);
});
});
describe("optimizeRoute — Google Directions path", () => {
it("uses optimized waypoint order and real leg metrics, dropping the return leg", async () => {
const stops = [stop("A", 0, 0), stop("B", 0, 1), stop("C", 0, 2)];
// waypoints = [B, C]; optimizer reorders them to [C, B] (waypoint_order [1,0]).
// legs: A->C, C->B, B->A(return). The return leg must be dropped.
const { fetchImpl, calls } = fakeFetch({
status: "OK",
routes: [
{
waypoint_order: [1, 0],
legs: [
{ distance: { value: 2000 }, duration: { value: 600 } }, // A->C
{ distance: { value: 1000 }, duration: { value: 300 } }, // C->B
{ distance: { value: 3000 }, duration: { value: 900 } }, // B->A (return, dropped)
],
},
],
});
const r = await optimizeRoute(stops, { googleApiKey: "key", fetchImpl });
expect(r.provider).toBe("google");
expect(r.stops.map((s) => s.appointmentId)).toEqual(["A", "C", "B"]);
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
expect(r.stops[1]!.travelDistanceKmFromPrev).toBe(2); // 2000m -> 2km
expect(r.stops[1]!.travelMinsFromPrev).toBe(10); // 600s -> 10min
expect(r.stops[2]!.travelDistanceKmFromPrev).toBe(1); // 1000m
expect(r.stops[2]!.travelMinsFromPrev).toBe(5); // 300s
expect(r.totalDistanceKm).toBe(3);
expect(r.totalTravelMins).toBe(15);
expect(decodeURIComponent(calls[0]!)).toContain("optimize:true");
});
it("falls back to the heuristic when Google returns a non-OK status", async () => {
const stops = [stop("A", 0, 0), stop("B", 0, 1), stop("C", 0, 2)];
const { fetchImpl } = fakeFetch({ status: "REQUEST_DENIED", error_message: "bad key" });
const r = await optimizeRoute(stops, { googleApiKey: "key", fetchImpl });
// Provider label reflects the chosen strategy (google requested) but a
// warning records the degradation and stops are still ordered.
expect(r.stops).toHaveLength(3);
expect(r.warnings.some((w) => w.includes("offline heuristic"))).toBe(true);
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
});
});
describe("optimizeRoute — >25 stop chunking", () => {
it("splits into sub-routes with a warning and continuous stop ordering", async () => {
const stops: RouteStopInput[] = [];
for (let i = 0; i < MAX_STOPS_PER_ROUTE + 5; i++) {
stops.push(stop(`s${i}`, 0, i * 0.1));
}
const r = await optimizeRoute(stops);
expect(r.chunked).toBe(true);
expect(r.subRouteCount).toBe(2);
expect(r.warnings.some((w) => w.includes("sub-routes"))).toBe(true);
expect(r.stops).toHaveLength(MAX_STOPS_PER_ROUTE + 5);
// Only the very first stop of the whole route lacks an inbound leg.
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
expect(r.stops.slice(1).every((s) => s.travelMinsFromPrev !== null)).toBe(true);
// All appointment ids preserved exactly once.
expect(new Set(r.stops.map((s) => s.appointmentId)).size).toBe(stops.length);
});
});
describe("detectScheduleConflicts", () => {
const at = (iso: string) => new Date(iso);
it("returns no conflict and null gaps for an empty or single-stop route", () => {
expect(detectScheduleConflicts([])).toEqual([]);
const one = detectScheduleConflicts([
{
appointmentStartTime: at("2026-06-08T09:00:00Z"),
appointmentEndTime: at("2026-06-08T10:00:00Z"),
travelMinsFromPrev: null,
bufferMins: 15,
},
]);
expect(one).toEqual([
{
hasConflict: false,
scheduleGapMins: null,
requiredGapMins: null,
shortfallMins: null,
},
]);
});
it("flags a tight schedule when gap < travel + buffer", () => {
// Stop 1 ends 10:00, stop 2 starts 10:20 → 20min gap. Travel 15 + buffer 15
// = 30 required → shortfall 10 → conflict.
const flags = detectScheduleConflicts([
{
appointmentStartTime: at("2026-06-08T09:00:00Z"),
appointmentEndTime: at("2026-06-08T10:00:00Z"),
travelMinsFromPrev: null,
bufferMins: 0,
},
{
appointmentStartTime: at("2026-06-08T10:20:00Z"),
appointmentEndTime: at("2026-06-08T11:00:00Z"),
travelMinsFromPrev: 15,
bufferMins: 15,
},
]);
expect(flags[0]!.hasConflict).toBe(false);
expect(flags[1]).toEqual({
hasConflict: true,
scheduleGapMins: 20,
requiredGapMins: 30,
shortfallMins: 10,
});
});
it("does not flag when the gap comfortably covers travel + buffer", () => {
// 90min gap, 15 travel + 15 buffer = 30 required → 60 slack → no conflict.
const flags = detectScheduleConflicts([
{
appointmentStartTime: at("2026-06-08T09:00:00Z"),
appointmentEndTime: at("2026-06-08T10:00:00Z"),
travelMinsFromPrev: null,
bufferMins: 0,
},
{
appointmentStartTime: at("2026-06-08T11:30:00Z"),
appointmentEndTime: at("2026-06-08T12:00:00Z"),
travelMinsFromPrev: 15,
bufferMins: 15,
},
]);
expect(flags[1]).toEqual({
hasConflict: false,
scheduleGapMins: 90,
requiredGapMins: 30,
shortfallMins: -60,
});
});
it("treats a null travelMinsFromPrev as zero travel", () => {
const flags = detectScheduleConflicts([
{
appointmentStartTime: at("2026-06-08T09:00:00Z"),
appointmentEndTime: at("2026-06-08T10:00:00Z"),
travelMinsFromPrev: null,
bufferMins: 0,
},
{
appointmentStartTime: at("2026-06-08T10:05:00Z"),
appointmentEndTime: at("2026-06-08T11:00:00Z"),
travelMinsFromPrev: null,
bufferMins: 15,
},
]);
// 5min gap vs 0 travel + 15 buffer = 15 required → conflict, shortfall 10.
expect(flags[1]!.hasConflict).toBe(true);
expect(flags[1]!.requiredGapMins).toBe(15);
expect(flags[1]!.shortfallMins).toBe(10);
});
it("flags overlapping appointments (negative gap) as conflicts", () => {
const flags = detectScheduleConflicts([
{
appointmentStartTime: at("2026-06-08T09:00:00Z"),
appointmentEndTime: at("2026-06-08T10:00:00Z"),
travelMinsFromPrev: null,
bufferMins: 0,
},
{
appointmentStartTime: at("2026-06-08T09:30:00Z"),
appointmentEndTime: at("2026-06-08T10:30:00Z"),
travelMinsFromPrev: 10,
bufferMins: 15,
},
]);
expect(flags[1]!.scheduleGapMins).toBe(-30);
expect(flags[1]!.hasConflict).toBe(true);
expect(flags[1]!.shortfallMins).toBe(55);
});
});
describe("recomputeLegsForOrder", () => {
it("returns null travel for an empty or single-point order", () => {
expect(recomputeLegsForOrder([])).toEqual([]);
expect(recomputeLegsForOrder([{ latitude: 40, longitude: -74 }])).toEqual([
{ travelMinsFromPrev: null, travelDistanceKmFromPrev: null },
]);
});
it("estimates each leg for the fixed given order without reordering", () => {
const pts = [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 1 },
{ latitude: 0, longitude: 2 },
];
const legs = recomputeLegsForOrder(pts);
expect(legs).toHaveLength(3);
expect(legs[0]).toEqual({
travelMinsFromPrev: null,
travelDistanceKmFromPrev: null,
});
// Each leg equals estimateLeg between adjacent points (no optimization).
const e01 = estimateLeg(pts[0]!, pts[1]!);
const e12 = estimateLeg(pts[1]!, pts[2]!);
expect(legs[1]).toEqual({
travelMinsFromPrev: e01.mins,
travelDistanceKmFromPrev: e01.distanceKm,
});
expect(legs[2]).toEqual({
travelMinsFromPrev: e12.mins,
travelDistanceKmFromPrev: e12.distanceKm,
});
});
});
-145
View File
@@ -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");
});
});
-70
View File
@@ -184,66 +184,6 @@ describe("POST /portal/waitlist", () => {
expect(insertedValues).toHaveLength(1);
});
it("normalizes HH:MM:SS preferredTime and returns 201 (GRO-2211)", async () => {
selectSessionRow = ACTIVE_SESSION;
const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3,
serviceId: VALID_UUID_4,
preferredDate: "2026-03-25",
preferredTime: "10:00:00",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(201);
expect(insertedValues[0]?.preferredTime).toBe("10:00:00");
});
it("normalizes HH:MM preferredTime to HH:MM:SS before insert (GRO-2211)", async () => {
selectSessionRow = ACTIVE_SESSION;
const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3,
serviceId: VALID_UUID_4,
preferredDate: "2026-03-25",
preferredTime: "10:00",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(201);
expect(insertedValues[0]?.preferredTime).toBe("10:00:00");
});
it("returns 400 (not 500) for a full ISO datetime preferredTime (GRO-2211)", async () => {
selectSessionRow = ACTIVE_SESSION;
const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3,
serviceId: VALID_UUID_4,
preferredDate: "2026-03-25",
preferredTime: "2026-06-09T10:00:00.000Z",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(400);
expect(insertedValues).toHaveLength(0);
});
it("returns 400 for a malformed preferredDate (GRO-2211)", async () => {
selectSessionRow = ACTIVE_SESSION;
const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3,
serviceId: VALID_UUID_4,
preferredDate: "03/25/2026",
preferredTime: "10:00",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(400);
expect(insertedValues).toHaveLength(0);
});
it("returns 400 for an out-of-range preferredTime (GRO-2211)", async () => {
selectSessionRow = ACTIVE_SESSION;
const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3,
serviceId: VALID_UUID_4,
preferredDate: "2026-03-25",
preferredTime: "25:99",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(400);
expect(insertedValues).toHaveLength(0);
});
it("returns 401 without session", async () => {
const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3,
@@ -318,16 +258,6 @@ describe("PATCH /portal/waitlist/:id", () => {
expect(updatedValues[0]?.status).toBe("cancelled");
});
it("returns 400 (not 500) for a full ISO datetime preferredTime on update (GRO-2211)", async () => {
selectSessionRow = ACTIVE_SESSION;
selectRows = [WAITLIST_ENTRY];
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
preferredTime: "2026-06-09T10:00:00.000Z",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(400);
expect(updatedValues).toHaveLength(0);
});
it("returns 401 without session", async () => {
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
status: "cancelled",
-15
View File
@@ -20,7 +20,6 @@ import { settingsRouter } from "./routes/settings.js";
import { authProviderRouter } from "./routes/authProvider.js";
import { searchRouter } from "./routes/search.js";
import { bufferRulesRouter } from "./routes/buffer-rules.js";
import { routesRouter } from "./routes/routes.js";
import { getObject } from "./lib/s3.js";
import { calendarRouter } from "./routes/calendar.js";
import { setupRouter } from "./routes/setup.js";
@@ -221,10 +220,6 @@ api.use("/reports/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager", "groomer"));
api.use("/impersonation/*", requireRole("manager"));
// Route optimization: manager (any groomer's route) or groomer (own route only,
// enforced in-handler). Receptionists have no access. (GRO-2155)
api.use("/routes/*", requireRole("manager", "groomer"));
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
api.use("/appointment-groups/*", requireRole("manager", "receptionist"));
api.use("/grooming-logs/*", requireRole("manager", "receptionist"));
@@ -240,15 +235,6 @@ api.on(
requireRole("manager", "receptionist", "groomer")
);
// Route-optimization geocoding endpoints are manager-only (GRO-2154), stricter
// than the general client write guard below. Registered FIRST so receptionists
// are rejected here before the manager+receptionist guard can admit them.
api.on(
["POST"],
["/clients/geocode-batch", "/clients/:clientId/geocode"],
requireRole("manager")
);
// Clients, appointments: all roles may read; only manager + receptionist may write
api.on(
["POST", "PUT", "PATCH", "DELETE"],
@@ -288,7 +274,6 @@ api.route("/admin/auth-provider", authProviderRouter);
api.route("/admin/seed", adminSeedRouter);
api.route("/search", searchRouter);
api.route("/buffer-rules", bufferRulesRouter);
api.route("/routes", routesRouter);
const port = Number(process.env.PORT ?? 3000);
await initAuth();
+1 -46
View File
@@ -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.
* 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().
* Returns 401 if session is invalid/missing/expired.
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
*
* Sliding expiration (GRO-2234): for SSO-bridge sessions, each successful request
* extends `expiresAt` to `now + PORTAL_SESSION_IDLE_TTL_MS`, bounded by
* `startedAt + PORTAL_SESSION_MAX_LIFETIME_MS`. Staff-initiated impersonation
* sessions (any other `reason`) are left untouched, preserving their existing
* console-enforced timeout behavior.
*/
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
const sessionId = c.req.header("X-Impersonation-Session-Id");
@@ -56,29 +24,16 @@ export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, nex
}
const db = getDb();
const now = new Date();
const [session] = await db
.select()
.from(impersonationSessions)
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
.limit(1);
if (!session || session.expiresAt <= now) {
if (!session || session.expiresAt <= new Date()) {
return c.json({ error: "Unauthorized" }, 401);
}
// Sliding expiration for SSO-bridge portal sessions only (GRO-2234).
if (session.reason === SSO_BRIDGE_REASON) {
const maxExpiry = session.startedAt.getTime() + PORTAL_SESSION_MAX_LIFETIME_MS;
const slidExpiry = Math.min(now.getTime() + PORTAL_SESSION_IDLE_TTL_MS, maxExpiry);
if (slidExpiry - session.expiresAt.getTime() >= PORTAL_SESSION_SLIDE_THRESHOLD_MS) {
await db
.update(impersonationSessions)
.set({ expiresAt: new Date(slidExpiry) })
.where(eq(impersonationSessions.id, session.id));
}
}
c.set("portalClientId", session.clientId);
c.set("portalSessionId", session.id);
await next();
+2 -43
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff, account, user } from "@groombook/db";
import { and, eq, getDb, sql, staff, account } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
@@ -111,49 +111,8 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
}
}
// Auto-provision for Better-Auth users (GRO-2052): the user signed in via
// Better-Auth (email/password, magic link, etc.), so a row exists in `user`
// for jwt.sub but no `account` provider row is required. Create a minimal
// groomer staff record on first login. This is the primary auto-provision
// path; the OIDC branch below remains as a fallback for legacy accounts
// that exist in `account` but not in `user`.
const [userRow] = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, jwt.sub))
.limit(1);
if (userRow) {
const emailPrefix = userRow.email ? userRow.email.split("@")[0] : "Unknown";
const name = userRow.name?.trim() || jwt.name?.trim() || emailPrefix;
const [newStaff] = await db
.insert(staff)
.values({
userId: jwt.sub,
email: userRow.email ?? jwt.email ?? "",
name,
role: "groomer",
isSuperUser: false,
active: true,
} as Parameters<typeof db.insert>[0] extends { values: infer V } ? V : never)
.returning()!;
if (!newStaff) {
return c.json({ error: "Forbidden: auto-provision failed" }, 500);
}
console.log(
`[rbac] auto-provisioned staff record for Better-Auth user: ${jwt.sub} -> staff:${newStaff.id} (${name})`
);
c.set("staff", newStaff);
await next();
return;
}
// Auto-provision for OIDC users: check if jwt.sub has an OAuth/OIDC account
// (e.g. authentik). If so, create a groomer staff record on the fly. This
// is kept for backward compatibility with legacy OIDC sessions whose user
// row may not yet exist in the Better-Auth `user` table.
// (e.g. authentik). If so, create a groomer staff record on the fly.
if (jwt.email) {
const [oidcAccount] = await db
.select({ id: account.id })
+3 -130
View File
@@ -3,67 +3,9 @@ import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import {
geocodeClient,
geocodeUngeocodedClients,
resolveClientGeocodingProvider,
type ClientGeocodeOutcome,
} from "../services/clientGeocoding.js";
export const clientsRouter = new Hono<AppEnv>();
// Batch-geocode bounds (GRO-2294): default 50, hard cap 500. The cap bounds how
// long one synchronous request stays open and the per-request external API cost
// when routeOptimizationProvider = "google".
const GEOCODE_BATCH_DEFAULT_LIMIT = 50;
const GEOCODE_BATCH_MAX_LIMIT = 500;
type ClientRow = typeof clients.$inferSelect;
/**
* Best-effort auto-geocode of a freshly created/updated client (GRO-2154).
* Never throws: a flaky geocoding backend must not break client mutations.
* Returns the (possibly coordinate-enriched) row plus a structured outcome the
* caller surfaces under a `geocoding` field so ambiguous addresses are visible.
*/
async function autoGeocodeClient(
db: ReturnType<typeof getDb>,
row: ClientRow
): Promise<{ row: ClientRow; outcome: ClientGeocodeOutcome }> {
try {
const provider = await resolveClientGeocodingProvider(db);
const outcome = await geocodeClient(db, row, provider);
const enriched =
outcome.status === "geocoded"
? {
...row,
latitude: outcome.latitude,
longitude: outcome.longitude,
geocodedAt: outcome.geocodedAt
? new Date(outcome.geocodedAt)
: row.geocodedAt,
}
: row;
return { row: enriched, outcome };
} catch (err) {
return {
row,
outcome: {
clientId: row.id,
status: "error",
message: `Auto-geocode failed: ${
err instanceof Error ? err.message : String(err)
}`,
latitude: null,
longitude: null,
geocodedAt: null,
formattedAddress: null,
provider: null,
},
};
}
}
const createClientSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().email(),
@@ -149,62 +91,9 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
const db = getDb();
const body = c.req.valid("json");
const [row] = await db.insert(clients).values(body).returning();
if (!row) return c.json({ error: "Failed to create client" }, 500);
// Auto-geocode on create when an address is supplied (GRO-2154). Best-effort:
// the client is created regardless; the `geocoding` field surfaces failures.
if (body.address && body.address.trim()) {
const { row: enriched, outcome } = await autoGeocodeClient(db, row);
return c.json({ ...enriched, geocoding: outcome }, 201);
}
return c.json(row, 201);
});
// Geocode a single client's address and persist coordinates (manager-only;
// enforced by the route guard in index.ts).
clientsRouter.post("/:clientId/geocode", async (c) => {
const db = getDb();
const clientId = c.req.param("clientId");
const [client] = await db
.select()
.from(clients)
.where(eq(clients.id, clientId));
if (!client) return c.json({ error: "Not found" }, 404);
const provider = await resolveClientGeocodingProvider(db);
const outcome = await geocodeClient(db, client, provider);
// Map outcome to an HTTP status so the result is unambiguous to the caller:
// geocoded -> 200, provider error -> 502, no_address/unresolved -> 422.
const status =
outcome.status === "geocoded"
? 200
: outcome.status === "error"
? 502
: 422;
return c.json(outcome, status);
});
// Batch-geocode un-geocoded clients with provider-rate-limited throttling
// (manager-only). Processes up to ?limit clients (default 50, max 500) per call;
// re-invoke while `remaining` > 0 to finish large datasets.
clientsRouter.post("/geocode-batch", async (c) => {
const db = getDb();
const limitRaw = c.req.query("limit");
let limit = GEOCODE_BATCH_DEFAULT_LIMIT;
if (limitRaw !== undefined) {
limit = Number(limitRaw);
if (!Number.isFinite(limit) || limit <= 0) {
return c.json({ error: "limit must be a positive integer" }, 400);
}
// Clamp to the documented maximum to bound synchronous request duration
// and (for the Google provider) per-request external API cost.
limit = Math.min(Math.floor(limit), GEOCODE_BATCH_MAX_LIMIT);
}
const summary = await geocodeUngeocodedClients(db, limit);
return c.json(summary);
});
// Update a client (including status changes)
const patchClientSchema = createClientSchema.partial().extend({
status: z.enum(["active", "disabled"]).optional(),
@@ -234,29 +123,13 @@ clientsRouter.patch(
}
delete setValues.smsOptOut;
// Auto-geocode on address change (GRO-2154). If the address was cleared,
// drop any stale coordinates so a disabled/blank address never keeps a pin.
const addressProvided = Object.prototype.hasOwnProperty.call(body, "address");
const trimmedAddress =
typeof body.address === "string" ? body.address.trim() : undefined;
if (addressProvided && !trimmedAddress) {
setValues.latitude = null;
setValues.longitude = null;
setValues.geocodedAt = null;
}
const [updated] = await db
const [row] = await db
.update(clients)
.set(setValues)
.where(eq(clients.id, c.req.param("id")))
.returning();
if (!updated) return c.json({ error: "Not found" }, 404);
if (addressProvided && trimmedAddress) {
const { row: enriched, outcome } = await autoGeocodeClient(db, updated);
return c.json({ ...enriched, geocoding: outcome });
}
return c.json(updated);
if (!row) return c.json({ error: "Not found" }, 404);
return c.json(row);
}
);
+4 -150
View File
@@ -7,8 +7,6 @@ import {
eq,
exists,
getDb,
impersonationAuditLogs,
impersonationSessions,
or,
pets,
appointments,
@@ -25,23 +23,6 @@ import {
export const petsRouter = new Hono<AppEnv>();
// Convert Zod validation errors from 422 to 400 and ensure any thrown error
// returns a structured JSON body rather than Hono's default empty-body 500.
// GRO-2014: profile-summary previously bubbled unhandled errors and produced
// an empty-body 500. Mirror the onError pattern already used in invoices.ts
// and reports.ts so every error has a JSON envelope.
petsRouter.onError((err, c) => {
if (err instanceof z.ZodError) {
return c.json({ error: "Validation failed", issues: err.issues }, 400);
}
console.error("[pets] unhandled error", err);
return c.json({ error: "Internal Server Error" }, 500);
});
// UUID format used by all pet routes — guards path params against malformed
// values before they hit Drizzle / Postgres uuid columns (which would throw).
const uuidSchema = z.string().uuid();
const createPetSchema = z.object({
clientId: z.string().uuid(),
name: z.string().min(1).max(200),
@@ -57,23 +38,6 @@ const createPetSchema = z.object({
customFields: z.record(z.string(), z.string()).optional(),
petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
// Extended pet profile fields (api/#39, GRO-1178).
// GRO-2172: these were missing from the schema, causing POST/PATCH to
// silently drop them even though migrations 0034/0036 and seed data
// populate them. GRO-1472 was the original UAT regression.
temperamentScore: z.number().int().min(1).max(5).optional(),
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
medicalAlerts: z
.array(
z.object({
type: z.string().max(100),
description: z.string().max(1000),
severity: z.enum(["low", "medium", "high"]),
})
)
.max(50)
.optional(),
preferredCuts: z.array(z.string().max(200)).max(20).optional(),
});
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
@@ -145,121 +109,18 @@ petsRouter.get("/:id", async (c) => {
return c.json(row);
});
/**
* Resolves the clientId from the X-Impersonation-Session-Id header, if present and active.
* Used by staff routes to allow a customer (auto-provisioned as a `groomer` staff row
* by rbac.ts) to access their own pet's data when they are the rightful owner.
*
* Returns null when the header is missing, the session is unknown/expired/ended, or the
* session exists but has no clientId — callers should treat null as "no owner-bypass".
*/
async function resolveImpersonationClientId(
db: ReturnType<typeof getDb>,
c: { req: { header: (name: string) => string | undefined } }
): Promise<string | null> {
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) return null;
const [session] = await db
.select({
clientId: impersonationSessions.clientId,
status: impersonationSessions.status,
expiresAt: impersonationSessions.expiresAt,
})
.from(impersonationSessions)
.where(eq(impersonationSessions.id, sessionId))
.limit(1);
if (!session) return null;
if (session.status !== "active") return null;
if (session.expiresAt <= new Date()) return null;
return session.clientId;
}
/**
* Defense-in-depth audit write for the staff-side owner-bypass path in
* GET /pets/:id/profile-summary. Mirrors the failure-isolation pattern in
* src/middleware/portalAudit.ts: errors are logged but never thrown, so a
* misbehaving audit insert cannot turn a working read into a 500.
*
* Called only when the owner-bypass actually fires (i.e. the requester is a
* groomer-role staff row with no appointment linkage, but supplies a valid
* X-Impersonation-Session-Id whose clientId matches the pet's owner). The
* `petId` and `actorStaffId` are written inside `metadata` because the
* impersonation_audit_logs schema has no first-class columns for them and
* adding a migration is out of scope.
*/
async function writeOwnerBypassAudit(
db: ReturnType<typeof getDb>,
args: {
sessionId: string;
petId: string;
actorStaffId: string;
pageVisited: string;
}
): Promise<void> {
try {
await db.insert(impersonationAuditLogs).values({
sessionId: args.sessionId,
action: "read_profile_summary",
pageVisited: args.pageVisited,
metadata: { petId: args.petId, actorStaffId: args.actorStaffId },
});
} catch (err) {
console.error("[pets] failed to write owner-bypass audit log:", err);
}
}
petsRouter.get("/:id/profile-summary", async (c) => {
const db = getDb();
const petId = c.req.param("id");
// GRO-2014: validate UUID format before hitting Postgres. Passing a non-UUID
// string to a uuid column makes the driver throw, which previously surfaced
// as an empty-body 500 to clients.
const parsedId = uuidSchema.safeParse(petId);
if (!parsedId.success) {
return c.json({ error: "Not found" }, 404);
}
// Defense in depth: resolveStaffMiddleware should always populate `staff`
// for protected routes (or short-circuit with 401/403 of its own). Guard
// anyway so a misconfigured route mount can't trigger a TypeError on
// staffRow.id when the linkage check runs.
const staffRow = c.get("staff");
if (!staffRow) {
return c.json({ error: "Unauthorized" }, 401);
}
const isGroomer = staffRow.role === "groomer";
const isGroomer = staffRow?.role === "groomer";
// Fetch the pet
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
if (!pet) return c.json({ error: "Not found" }, 404);
// Owner-bypass (GRO-2013): a customer who supplies a valid
// X-Impersonation-Session-Id for the pet's owning client may read their
// own pet's summary, even though rbac.ts auto-provisions them as a
// `groomer` staff row with no appointment linkage.
let isOwner = false;
if (isGroomer) {
const headerSessionId = c.req.header("X-Impersonation-Session-Id");
const ownerClientId = await resolveImpersonationClientId(db, c);
isOwner = !!ownerClientId && ownerClientId === pet.clientId;
if (isOwner && headerSessionId) {
// GRO-2063: defense-in-depth audit row. Only fires when the bypass
// is actually granted; never on the normal groomer-linkage path,
// 403/404/401, or when the header is absent. Failure is swallowed
// (try/catch inside writeOwnerBypassAudit) so this can never turn a
// working read into a 500.
await writeOwnerBypassAudit(db, {
sessionId: headerSessionId,
petId: pet.id,
actorStaffId: staffRow.id,
pageVisited: c.req.path,
});
}
}
// Groomer RBAC: check appointment linkage to this pet's client
if (isGroomer && !isOwner) {
if (isGroomer) {
const [linkage] = await db
.select({ id: appointments.id })
.from(appointments)
@@ -350,8 +211,7 @@ petsRouter.get("/:id/profile-summary", async (c) => {
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
const db = getDb();
const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } =
c.req.valid("json");
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
const [row] = await db
.insert(pets)
.values({
@@ -359,10 +219,6 @@ petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
weightKg: weightKg?.toString(),
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
customFields: customFields ?? {},
// GRO-2172: medicalAlerts shape from the API request is
// { type, description, severity } — the @groombook/types MedicalAlert
// has an optional server-generated `id`, so cast for the jsonb column.
medicalAlerts: medicalAlerts as never,
})
.returning();
return c.json(row, 201);
@@ -373,8 +229,7 @@ petsRouter.patch(
zValidator("json", updatePetSchema),
async (c) => {
const db = getDb();
const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } =
c.req.valid("json");
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
const [row] = await db
.update(pets)
.set({
@@ -382,7 +237,6 @@ petsRouter.patch(
weightKg: weightKg?.toString(),
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
...(customFields !== undefined ? { customFields } : {}),
medicalAlerts: medicalAlerts as never,
updatedAt: new Date(),
})
.where(eq(pets.id, c.req.param("id")))
+22 -375
View File
@@ -1,9 +1,9 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
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 { validatePortalSession, PORTAL_SESSION_IDLE_TTL_MS } from "../middleware/portalSession.js";
import { validatePortalSession } from "../middleware/portalSession.js";
import { portalAudit } from "../middleware/portalAudit.js";
import type { PortalEnv } from "../middleware/portalSession.js";
@@ -129,7 +129,7 @@ portalRouter.post("/session-from-auth", async (c) => {
staffId,
clientId: client.id,
reason: "sso-bridge",
expiresAt: new Date(Date.now() + PORTAL_SESSION_IDLE_TTL_MS),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
})
.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
portalRouter.use("/*", validatePortalSession, portalAudit);
@@ -303,46 +195,14 @@ portalRouter.get("/appointments", async (c) => {
.where(eq(appointments.clientId, clientId))
.orderBy(appointments.startTime);
// GRO-2319: surface the client's ACTIVE waitlist entries alongside their
// appointments so the portal can render them as `waitlisted` cards in the
// Upcoming list. The `appointment_status` enum cannot represent `waitlisted`,
// so these are synthetic entries (status hard-set to `waitlisted`, id prefixed
// `waitlist:`) derived from `waitlist_entries`.
const waitlistRows = await db
.select({
id: waitlistEntries.id,
petId: waitlistEntries.petId,
serviceId: waitlistEntries.serviceId,
preferredDate: waitlistEntries.preferredDate,
preferredTime: waitlistEntries.preferredTime,
})
.from(waitlistEntries)
.where(
and(eq(waitlistEntries.clientId, clientId), eq(waitlistEntries.status, "active")),
);
// Pet lookups must cover both appointment and waitlist pets.
const petIds = [
...allAppts.map(a => a.petId).filter((id): id is string => id !== null),
...waitlistRows.map(w => w.petId),
];
const petIds = allAppts.map(a => a.petId).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 staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
const serviceRows = serviceIds.length ? await db.select().from(services).where(inArray(services.id, serviceIds)) : [];
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
const serviceMap = Object.fromEntries(serviceRows.map(s => [s.id, s]));
const appts = allAppts.map(a => ({
id: a.id,
@@ -353,35 +213,11 @@ portalRouter.get("/appointments", async (c) => {
customerNotes: a.customerNotes,
notes: a.notes,
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
service: a.serviceId ? { id: a.serviceId, 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,
}));
// Derive a display `startTime` from the entry's preferred date/time so the
// portal can sort/classify the synthetic card (an invalid combination simply
// yields a null startTime, which the portal tolerates). GRO-2342: also
// populate the synthetic card's `service` object with the full service
// record (id + name) — same shape the appointments join returns — so the
// portal renders the real service name in place of the fallback "Service"
// label.
const waitlistAppts = waitlistRows.map(w => {
const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`);
const startTime = Number.isNaN(parsed.getTime()) ? null : parsed;
return {
id: `waitlist:${w.id}`,
startTime,
endTime: null,
status: "waitlisted" as const,
confirmationStatus: null,
customerNotes: null,
notes: null,
pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey },
service: w.serviceId ? { id: w.serviceId, name: serviceMap[w.serviceId]?.name } : null,
staff: null,
};
});
return c.json({ appointments: [...appts, ...waitlistAppts] });
return c.json({ appointments: appts });
});
portalRouter.get("/pets", async (c) => {
@@ -389,166 +225,9 @@ portalRouter.get("/pets", async (c) => {
const clientId = c.get("portalClientId");
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
return c.json(clientPets.map(p => ({
id: p.id,
name: p.name,
breed: p.breed,
weight: p.weightKg,
birthDate: p.dateOfBirth,
photoUrl: p.photoKey,
notes: p.groomingNotes,
coatType: p.coatType,
petSizeCategory: p.petSizeCategory,
healthAlerts: p.healthAlerts,
preferredCuts: p.preferredCuts,
medicalAlerts: p.medicalAlerts,
})));
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes })));
});
// ─── Customer-facing pet update ───────────────────────────────────────────────
//
// The customer portal pet-profile form (groombook/web) saves edits via
// PATCH /api/portal/pets/:petId. The web payload mixes the keys returned by
// GET /portal/pets (weight, birthDate, photoUrl, notes) with the form's own
// edited keys (weightKg, healthAlerts, coatType, …), so we accept both spellings
// and map each to its `pets` column. Ownership is enforced exactly like the
// appointment-notes handler: 404 if the pet does not exist, 403 if it belongs to
// another client.
// Allowed enum values mirror packages/db/src/schema.ts coatTypeEnum /
// petSizeCategoryEnum. Kept as plain string lists so an invalid value can be
// rejected with 422 in-handler (zValidator failures would surface as 400).
const PORTAL_COAT_TYPES: readonly string[] = ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"];
const PORTAL_PET_SIZES: readonly string[] = ["small", "medium", "large", "extra_large"];
// The web size dropdown emits "xlarge"; the DB enum value is "extra_large".
const PORTAL_PET_SIZE_ALIASES: Record<string, string> = { xlarge: "extra_large" };
const portalMedicalAlertSchema = z.object({
id: z.string().optional(),
type: z.string().max(2000),
description: z.string().max(2000),
severity: z.enum(["low", "medium", "high"]),
});
const portalPetUpdateSchema = z.object({
name: z.string().min(1).max(200).optional(),
breed: z.string().max(200).nullable().optional(),
// weightKg is the form's edited key; weight is the GET-shaped key. Accept both.
weightKg: z.union([z.number(), z.string()]).nullable().optional(),
weight: z.union([z.number(), z.string()]).nullable().optional(),
birthDate: z.string().nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
healthAlerts: z.string().max(2000).nullable().optional(),
// photoUrl/photoKey are intentionally NOT writable here: photoKey is a trusted
// S3 object key consumed server-side (getPresignedGetUrl / deleteObject), and the
// upload path (pets.ts) already enforces a pets/{petId}/ prefix guard against key
// hijacking. Photo changes go through the dedicated upload + /photo/confirm flow.
// The web form round-trips the GET-shaped photoUrl; Zod strips it as an unknown key.
// coatType / petSizeCategory validated in-handler so bad values return 422.
coatType: z.string().nullable().optional(),
petSizeCategory: z.string().nullable().optional(),
preferredCuts: z.array(z.string().max(2000)).max(50).nullable().optional(),
medicalAlerts: z.array(portalMedicalAlertSchema).max(50).nullable().optional(),
});
portalRouter.patch(
"/pets/:petId",
zValidator("json", portalPetUpdateSchema),
async (c) => {
const db = getDb();
const petId = c.req.param("petId");
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
// GRO-2203: validate UUID format before hitting Postgres. Passing a non-UUID
// string to a uuid column makes the driver throw ("invalid input syntax for
// type uuid"), which previously surfaced as an unhandled 500. Mirror the
// GRO-2014 fix in pets.ts and treat a malformed id as Not found.
if (!z.string().uuid().safeParse(petId).success) {
return c.json({ error: "Not found" }, 404);
}
const [pet] = await db
.select()
.from(pets)
.where(eq(pets.id, petId))
.limit(1);
if (!pet) {
return c.json({ error: "Not found" }, 404);
}
if (pet.clientId !== clientId) {
return c.json({ error: "Forbidden" }, 403);
}
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (body.name !== undefined) updateData.name = body.name;
if (body.breed !== undefined) updateData.breed = body.breed;
if (body.weightKg !== undefined || body.weight !== undefined) {
const w = body.weightKg ?? body.weight;
updateData.weightKg = w === null || w === undefined ? null : String(w);
}
if (body.birthDate !== undefined) {
updateData.dateOfBirth = body.birthDate ? new Date(body.birthDate) : null;
}
if (body.notes !== undefined) updateData.groomingNotes = body.notes;
if (body.healthAlerts !== undefined) updateData.healthAlerts = body.healthAlerts;
// photoKey is intentionally not writable here — see portalPetUpdateSchema note.
// Photo changes go through the key-validated upload + /photo/confirm flow.
if (body.coatType !== undefined) {
if (body.coatType !== null && !PORTAL_COAT_TYPES.includes(body.coatType)) {
return c.json({ error: "Invalid coatType" }, 422);
}
updateData.coatType = body.coatType;
}
if (body.petSizeCategory !== undefined) {
let size: string | null = body.petSizeCategory;
if (size !== null) {
size = PORTAL_PET_SIZE_ALIASES[size] ?? size;
if (!PORTAL_PET_SIZES.includes(size)) {
return c.json({ error: "Invalid petSizeCategory" }, 422);
}
}
updateData.petSizeCategory = size;
}
if (body.preferredCuts !== undefined) updateData.preferredCuts = body.preferredCuts ?? [];
if (body.medicalAlerts !== undefined) updateData.medicalAlerts = body.medicalAlerts ?? [];
const [updated] = await db
.update(pets)
.set(updateData)
.where(eq(pets.id, petId))
.returning();
if (!updated) {
return c.json({ error: "Not found" }, 404);
}
return c.json({
id: updated.id,
name: updated.name,
breed: updated.breed,
weight: updated.weightKg,
birthDate: updated.dateOfBirth,
photoUrl: updated.photoKey,
notes: updated.groomingNotes,
coatType: updated.coatType,
petSizeCategory: updated.petSizeCategory,
healthAlerts: updated.healthAlerts,
preferredCuts: updated.preferredCuts,
medicalAlerts: updated.medicalAlerts,
});
}
);
portalRouter.get("/invoices", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
@@ -723,33 +402,17 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
// ─── Client-facing waitlist routes ────────────────────────────────────────────
// Postgres `date` / `time` columns reject arbitrary strings (e.g. a full ISO
// datetime), throwing a DateTimeParseError that surfaces as an unhandled 500.
// Constrain client input here so malformed values are rejected with a 400 by
// zValidator before they ever reach the DB (GRO-2211 defense-in-depth).
const preferredDateSchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "preferredDate must be YYYY-MM-DD");
const preferredTimeSchema = z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/, "preferredTime must be HH:MM or HH:MM:SS");
// Normalize HH:MM → HH:MM:SS so it matches the Postgres `time` column format.
function normalizeTime(value: string): string {
return value.length === 5 ? `${value}:00` : value;
}
const createWaitlistEntrySchema = z.object({
petId: z.string().uuid(),
serviceId: z.string().uuid(),
preferredDate: preferredDateSchema,
preferredTime: preferredTimeSchema,
preferredDate: z.string(),
preferredTime: z.string(),
});
const updateWaitlistEntrySchema = z.object({
status: z.literal("cancelled").optional(),
preferredDate: preferredDateSchema.optional(),
preferredTime: preferredTimeSchema.optional(),
preferredDate: z.string().optional(),
preferredTime: z.string().optional(),
});
portalRouter.post(
@@ -760,32 +423,16 @@ portalRouter.post(
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
let entry;
try {
[entry] = await db
.insert(waitlistEntries)
.values({
clientId,
petId: body.petId,
serviceId: body.serviceId,
preferredDate: body.preferredDate,
preferredTime: normalizeTime(body.preferredTime),
})
.returning();
} catch (err) {
// An exact duplicate active waitlist entry violates the partial unique
// index idx_waitlist_active_unique (client_id, pet_id, service_id,
// preferred_date, preferred_time WHERE status='active'). postgres-js
// surfaces this as SQLSTATE 23505 — return a friendly 409 rather than a
// generic 500 (GRO-2235). Unrelated errors still surface as 500.
if ((err as { code?: string })?.code === "23505") {
return c.json(
{ error: "You already have a booking for this pet at that date and time." },
409
);
}
throw err;
}
const [entry] = await db
.insert(waitlistEntries)
.values({
clientId,
petId: body.petId,
serviceId: body.serviceId,
preferredDate: body.preferredDate,
preferredTime: body.preferredTime,
})
.returning();
return c.json(entry, 201);
}
@@ -814,7 +461,7 @@ portalRouter.patch(
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (body.status !== undefined) updateData.status = body.status;
if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate;
if (body.preferredTime !== undefined) updateData.preferredTime = normalizeTime(body.preferredTime);
if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime;
const [updated] = await db
.update(waitlistEntries)
-529
View File
@@ -1,529 +0,0 @@
import { Hono, type Context } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import {
and,
asc,
eq,
gte,
lt,
ne,
getDb,
appointments,
businessSettings,
clients,
groomerRoutes,
routeStops,
} from "@groombook/db";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
import {
optimizeRoute,
resolveRouteGoogleApiKey,
detectScheduleConflicts,
recomputeLegsForOrder,
type RouteStopInput,
type StopConflictFlags,
} from "../services/routeOptimization.js";
import {
buildNavigationUrl,
type NavigationPlatform,
type NavigationStop,
} from "../services/navigationExport.js";
export const routesRouter = new Hono<AppEnv>();
const dailyQuerySchema = z.object({
staffId: z.string().uuid().optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
});
const optimizeBodySchema = z.object({
staffId: z.string().uuid().optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
});
const reorderBodySchema = z.object({
// New visiting order expressed as routeStops.id values, first-to-last.
stopOrder: z.array(z.string().uuid()).min(1),
});
/**
* Resolves the target staffId for the request and enforces the groomer-own /
* manager authorization rule. Groomers may only act on their own route; if a
* groomer omits staffId it defaults to their own. Returns either the resolved
* id or an error tuple the caller turns into a JSON response.
*/
function resolveTargetStaffId(
staffRow: StaffRow | undefined,
requestedStaffId: string | undefined
): { staffId: string } | { error: string; status: 400 | 403 } {
const isGroomer = staffRow?.role === "groomer";
if (isGroomer) {
if (requestedStaffId && requestedStaffId !== staffRow.id) {
return {
error: "Forbidden: groomers may only access their own route",
status: 403,
};
}
return { staffId: staffRow.id };
}
// Manager: staffId is required (no implicit self — managers plan others' days).
if (!requestedStaffId) {
return { error: "staffId is required", status: 400 };
}
return { staffId: requestedStaffId };
}
/** Day window [date 00:00:00Z, nextDay 00:00:00Z) for filtering appointments. */
function dayBounds(date: string): { start: Date; end: Date } {
const start = new Date(`${date}T00:00:00.000Z`);
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000);
return { start, end };
}
/** Loads a route's persisted stops, enriched with appointment + client detail. */
async function loadRouteStops(db: ReturnType<typeof getDb>, routeId: string) {
return db
.select({
id: routeStops.id,
appointmentId: routeStops.appointmentId,
stopOrder: routeStops.stopOrder,
latitude: routeStops.latitude,
longitude: routeStops.longitude,
travelMinsFromPrev: routeStops.travelMinsFromPrev,
travelDistanceKmFromPrev: routeStops.travelDistanceKmFromPrev,
bufferMins: routeStops.bufferMins,
appointmentStartTime: appointments.startTime,
appointmentEndTime: appointments.endTime,
appointmentStatus: appointments.status,
clientId: clients.id,
clientName: clients.name,
clientAddress: clients.address,
})
.from(routeStops)
.innerJoin(appointments, eq(routeStops.appointmentId, appointments.id))
.innerJoin(clients, eq(appointments.clientId, clients.id))
.where(eq(routeStops.routeId, routeId))
.orderBy(asc(routeStops.stopOrder));
}
type LoadedRouteStop = Awaited<ReturnType<typeof loadRouteStops>>[number];
/**
* Annotates persisted stops with "tight schedule" conflict flags for the
* frontend. Conflicts are derived at read time from the live appointment times,
* persisted travel estimates and buffers — never auto-resolved by moving stops.
*/
function annotateConflicts(stops: LoadedRouteStop[]): {
stops: Array<LoadedRouteStop & { conflict: StopConflictFlags }>;
hasConflicts: boolean;
conflictCount: number;
} {
const flags = detectScheduleConflicts(
stops.map((s) => ({
appointmentStartTime: s.appointmentStartTime,
appointmentEndTime: s.appointmentEndTime,
travelMinsFromPrev: s.travelMinsFromPrev,
bufferMins: s.bufferMins,
}))
);
const annotated = stops.map((s, i) => ({ ...s, conflict: flags[i]! }));
const conflictCount = flags.filter((f) => f.hasConflict).length;
return { stops: annotated, hasConflicts: conflictCount > 0, conflictCount };
}
/**
* GET /api/routes/daily?staffId=&date=
* Fetches (creating a draft if absent) the daily route for a groomer, with all
* persisted stops. Auth: groomer (own) or manager.
*/
routesRouter.get("/daily", zValidator("query", dailyQuerySchema), async (c) => {
const db = getDb();
const { staffId: requestedStaffId, date } = c.req.valid("query");
const resolved = resolveTargetStaffId(c.get("staff"), requestedStaffId);
if ("error" in resolved) {
return c.json({ error: resolved.error }, resolved.status);
}
const staffId = resolved.staffId;
let [route] = await db
.select()
.from(groomerRoutes)
.where(
and(
eq(groomerRoutes.staffId, staffId),
eq(groomerRoutes.routeDate, date)
)
);
if (!route) {
// Create a draft route so the day is addressable before optimization.
[route] = await db
.insert(groomerRoutes)
.values({ staffId, routeDate: date, status: "draft" })
.returning();
}
const stops = await loadRouteStops(db, route!.id);
const annotated = annotateConflicts(stops);
return c.json({
route,
stops: annotated.stops,
hasConflicts: annotated.hasConflicts,
conflictCount: annotated.conflictCount,
});
});
/**
* POST /api/routes/optimize { staffId, date }
* Generates or re-optimizes the daily route: pulls the day's geocoded
* appointments, optimizes the visiting order (Google Directions when a key is
* configured, else nearest-neighbor), and persists the ordered stops + totals.
* Auth: groomer (own) or manager.
*/
routesRouter.post(
"/optimize",
zValidator("json", optimizeBodySchema),
async (c) => {
const db = getDb();
const { staffId: requestedStaffId, date } = c.req.valid("json");
const resolved = resolveTargetStaffId(c.get("staff"), requestedStaffId);
if ("error" in resolved) {
return c.json({ error: resolved.error }, resolved.status);
}
const staffId = resolved.staffId;
const { start, end } = dayBounds(date);
// Pull the day's non-cancelled appointments for this groomer, joined to the
// client coordinates. Ordered by start time so the earliest booking anchors
// the route.
const dayAppointments = await db
.select({
appointmentId: appointments.id,
startTime: appointments.startTime,
clientId: clients.id,
clientName: clients.name,
latitude: clients.latitude,
longitude: clients.longitude,
})
.from(appointments)
.innerJoin(clients, eq(appointments.clientId, clients.id))
.where(
and(
eq(appointments.staffId, staffId),
gte(appointments.startTime, start),
lt(appointments.startTime, end),
ne(appointments.status, "cancelled")
)
)
.orderBy(asc(appointments.startTime));
const stopInputs: RouteStopInput[] = [];
const skipped: Array<{ appointmentId: string; clientName: string; reason: string }> =
[];
for (const appt of dayAppointments) {
if (appt.latitude == null || appt.longitude == null) {
skipped.push({
appointmentId: appt.appointmentId,
clientName: appt.clientName,
reason: "client address is not geocoded",
});
continue;
}
stopInputs.push({
appointmentId: appt.appointmentId,
latitude: appt.latitude,
longitude: appt.longitude,
});
}
const [settings] = await db.select().from(businessSettings).limit(1);
const bufferMins = settings?.defaultTravelBufferMins ?? 15;
const googleApiKey = await resolveRouteGoogleApiKey(db);
const optimized = await optimizeRoute(stopInputs, { googleApiKey });
const warnings = [...optimized.warnings];
if (skipped.length > 0) {
warnings.push(
`${skipped.length} appointment(s) were skipped because the client address is not geocoded.`
);
}
const now = new Date();
const route = await db.transaction(async (tx) => {
// Upsert the route row for (staffId, date).
const [existing] = await tx
.select()
.from(groomerRoutes)
.where(
and(
eq(groomerRoutes.staffId, staffId),
eq(groomerRoutes.routeDate, date)
)
);
const [routeRow] = existing
? await tx
.update(groomerRoutes)
.set({
status: "optimized",
totalTravelMins: optimized.totalTravelMins,
totalDistanceKm: optimized.totalDistanceKm.toFixed(2),
optimizedAt: now,
updatedAt: now,
})
.where(eq(groomerRoutes.id, existing.id))
.returning()
: await tx
.insert(groomerRoutes)
.values({
staffId,
routeDate: date,
status: "optimized",
totalTravelMins: optimized.totalTravelMins,
totalDistanceKm: optimized.totalDistanceKm.toFixed(2),
optimizedAt: now,
})
.returning();
// Replace stops: clear prior ordering, insert the freshly optimized one.
await tx.delete(routeStops).where(eq(routeStops.routeId, routeRow!.id));
if (optimized.stops.length > 0) {
await tx.insert(routeStops).values(
optimized.stops.map((s, i) => ({
routeId: routeRow!.id,
appointmentId: s.appointmentId,
stopOrder: i + 1,
latitude: s.latitude,
longitude: s.longitude,
travelMinsFromPrev: s.travelMinsFromPrev,
travelDistanceKmFromPrev:
s.travelDistanceKmFromPrev == null
? null
: s.travelDistanceKmFromPrev.toFixed(2),
// Buffer applies between consecutive stops; the first stop has no
// predecessor, so it carries no travel buffer.
bufferMins: i === 0 ? 0 : bufferMins,
}))
);
}
return routeRow!;
});
const stops = await loadRouteStops(db, route.id);
const annotated = annotateConflicts(stops);
return c.json({
route,
stops: annotated.stops,
hasConflicts: annotated.hasConflicts,
conflictCount: annotated.conflictCount,
provider: optimized.provider,
chunked: optimized.chunked,
subRouteCount: optimized.subRouteCount,
skipped,
warnings,
});
}
);
/**
* PATCH /api/routes/:routeId/reorder { stopOrder: string[] }
* Persists a manual stop order (array of routeStops.id, first-to-last), then
* re-runs the buffer logic: each leg's travel is re-estimated for the new
* adjacency, the default travel buffer is re-applied between consecutive stops,
* route totals are recomputed, and tight-schedule conflicts are re-flagged.
* Appointments are never moved. Auth: groomer (own route) or manager.
*/
routesRouter.patch(
"/:routeId/reorder",
zValidator("json", reorderBodySchema),
async (c) => {
const db = getDb();
const routeId = c.req.param("routeId");
if (!z.string().uuid().safeParse(routeId).success) {
return c.json({ error: "routeId must be a UUID" }, 400);
}
const { stopOrder: newOrderIds } = c.req.valid("json");
const [route] = await db
.select()
.from(groomerRoutes)
.where(eq(groomerRoutes.id, routeId));
if (!route) {
return c.json({ error: "Route not found" }, 404);
}
// Reuse the groomer-own / manager authorization rule against the route owner.
const resolved = resolveTargetStaffId(c.get("staff"), route.staffId);
if ("error" in resolved) {
return c.json({ error: resolved.error }, resolved.status);
}
const existing = await db
.select({
id: routeStops.id,
latitude: routeStops.latitude,
longitude: routeStops.longitude,
})
.from(routeStops)
.where(eq(routeStops.routeId, routeId));
// The new order must be an exact permutation of the route's current stops.
const existingIds = new Set(existing.map((s) => s.id));
if (newOrderIds.length !== existing.length) {
return c.json(
{
error: `stopOrder must list every stop exactly once (expected ${existing.length}, got ${newOrderIds.length})`,
},
400
);
}
const seen = new Set<string>();
for (const id of newOrderIds) {
if (!existingIds.has(id)) {
return c.json({ error: `unknown stop id: ${id}` }, 400);
}
if (seen.has(id)) {
return c.json({ error: `duplicate stop id: ${id}` }, 400);
}
seen.add(id);
}
const [settings] = await db.select().from(businessSettings).limit(1);
const bufferMins = settings?.defaultTravelBufferMins ?? 15;
const byId = new Map(existing.map((s) => [s.id, s]));
const legs = recomputeLegsForOrder(
newOrderIds.map((id) => {
const s = byId.get(id)!;
return { latitude: s.latitude, longitude: s.longitude };
})
);
const totalTravelMins = legs.reduce(
(sum, l) => sum + (l.travelMinsFromPrev ?? 0),
0
);
const totalDistanceKm =
Math.round(
legs.reduce((sum, l) => sum + (l.travelDistanceKmFromPrev ?? 0), 0) * 100
) / 100;
const now = new Date();
await db.transaction(async (tx) => {
// Two-pass update: park stopOrder in a non-colliding negative range first
// so the unique(routeId, stopOrder) constraint never trips mid-reorder.
for (let i = 0; i < newOrderIds.length; i++) {
await tx
.update(routeStops)
.set({ stopOrder: -(i + 1), updatedAt: now })
.where(eq(routeStops.id, newOrderIds[i]!));
}
for (let i = 0; i < newOrderIds.length; i++) {
const leg = legs[i]!;
await tx
.update(routeStops)
.set({
stopOrder: i + 1,
travelMinsFromPrev: leg.travelMinsFromPrev,
travelDistanceKmFromPrev:
leg.travelDistanceKmFromPrev == null
? null
: leg.travelDistanceKmFromPrev.toFixed(2),
bufferMins: i === 0 ? 0 : bufferMins,
updatedAt: now,
})
.where(eq(routeStops.id, newOrderIds[i]!));
}
await tx
.update(groomerRoutes)
.set({
totalTravelMins,
totalDistanceKm: totalDistanceKm.toFixed(2),
updatedAt: now,
})
.where(eq(groomerRoutes.id, routeId));
});
const [updatedRoute] = await db
.select()
.from(groomerRoutes)
.where(eq(groomerRoutes.id, routeId));
const stops = await loadRouteStops(db, routeId);
const annotated = annotateConflicts(stops);
return c.json({
route: updatedRoute,
stops: annotated.stops,
hasConflicts: annotated.hasConflicts,
conflictCount: annotated.conflictCount,
});
}
);
/**
* GET /:routeId/export/:platform — build a native-navigation deep-link URL for an
* optimized route. Origin = first stop, destination = last stop, the rest carried
* as ordered intermediate waypoints. Waypoint count is validated against the
* platform's limit. Auth: manager (any route) or groomer (own route only).
*/
async function handleNavigationExport(
c: Context<AppEnv>,
platform: NavigationPlatform
) {
const db = getDb();
const routeId = c.req.param("routeId");
if (!routeId || !z.string().uuid().safeParse(routeId).success) {
return c.json({ error: "routeId must be a UUID" }, 400);
}
const [route] = await db
.select()
.from(groomerRoutes)
.where(eq(groomerRoutes.id, routeId));
if (!route) {
return c.json({ error: "Route not found" }, 404);
}
// Reuse the groomer-own / manager authorization rule against the route owner.
const resolved = resolveTargetStaffId(c.get("staff"), route.staffId);
if ("error" in resolved) {
return c.json({ error: resolved.error }, resolved.status);
}
const stops = await loadRouteStops(db, routeId);
if (stops.length === 0) {
return c.json({ error: "route has no stops to export" }, 400);
}
const navStops: NavigationStop[] = stops.map((s) => ({
latitude: s.latitude,
longitude: s.longitude,
label: s.clientName,
}));
const result = buildNavigationUrl(platform, navStops);
if ("error" in result) {
return c.json({ error: result.error }, result.status);
}
return c.json({
platform: result.platform,
url: result.url,
stopCount: result.stopCount,
waypointCount: result.waypointCount,
});
}
routesRouter.get("/:routeId/export/google-maps", (c) =>
handleNavigationExport(c, "google-maps")
);
routesRouter.get("/:routeId/export/apple-maps", (c) =>
handleNavigationExport(c, "apple-maps")
);
+3 -16
View File
@@ -7,17 +7,6 @@ import { requireSuperUser } from "../middleware/rbac.js";
export const settingsRouter = new Hono();
type BusinessSettingsRow = typeof businessSettings.$inferSelect;
// Strip the encrypted googleMapsApiKey ciphertext from settings responses
// (GRO-2294, defense-in-depth). The secret is never needed client-side; it is
// only written via the dedicated provider-config endpoint.
function redactSettings(row: BusinessSettingsRow) {
const rest: Partial<BusinessSettingsRow> = { ...row };
delete rest.googleMapsApiKey;
return rest;
}
// GET /api/admin/settings — return current business settings
settingsRouter.get("/", async (c) => {
const db = getDb();
@@ -25,10 +14,9 @@ settingsRouter.get("/", async (c) => {
if (!row) {
// Auto-create default settings if none exist
const [created] = await db.insert(businessSettings).values({}).returning();
if (!created) throw new Error("Failed to create default settings");
return c.json(redactSettings(created));
return c.json(created);
}
return c.json(redactSettings(row));
return c.json(row);
});
const hexColorRegex = /^#[0-9a-fA-F]{6}$/;
@@ -65,8 +53,7 @@ settingsRouter.patch(
.where(eq(businessSettings.id, settingsId))
.returning();
if (!updated) throw new Error("Failed to update settings");
return c.json(redactSettings(updated));
return c.json(updated);
}
);
-212
View File
@@ -1,212 +0,0 @@
import {
getDb,
businessSettings,
clients,
and,
eq,
isNull,
sql,
} from "@groombook/db";
import {
resolveGeocodingProvider,
type GeocodingProvider,
type GeocodeResult,
} from "./geocoding.js";
/**
* Client geocoding orchestration (GRO-2154, Phase 1.3 of Route Optimization).
*
* Bridges the provider-agnostic {@link GeocodingProvider} layer (GRO-2153) and
* the `clients` table: resolves the configured provider from `businessSettings`,
* geocodes a client's address, and persists `latitude`/`longitude`/`geocodedAt`.
*
* Outcomes are returned as structured {@link ClientGeocodeOutcome} values so that
* callers (the geocode endpoints and the auto-geocode create/update hook) can
* surface clear, actionable feedback — groomers need to know when an address is
* ambiguous or unresolvable, not just that "something failed".
*/
type Db = ReturnType<typeof getDb>;
type ClientRow = typeof clients.$inferSelect;
/** Status of a single client geocode attempt. */
export type ClientGeocodeStatus =
/** Coordinates resolved and persisted. */
| "geocoded"
/** Client has no (non-blank) address on file — nothing to geocode. */
| "no_address"
/** Provider returned no match; the address is ambiguous or unrecognized. */
| "unresolved"
/** Provider call failed (transport, quota, bad key). Coordinates unchanged. */
| "error";
/** Structured, UI-surfaceable result of geocoding one client. */
export interface ClientGeocodeOutcome {
clientId: string;
status: ClientGeocodeStatus;
/** Human-readable explanation, safe to show to managers/groomers. */
message: string;
latitude: number | null;
longitude: number | null;
geocodedAt: string | null;
/** Provider-normalized address, when a match was found. */
formattedAddress: string | null;
/** Provider that produced (or attempted) this result. */
provider: string | null;
}
/**
* Builds the geocoding provider for the current business settings. A single
* provider instance should be reused across a batch so its internal rate limiter
* throttles the whole run (e.g. Nominatim's 1 req/sec policy).
*/
export async function resolveClientGeocodingProvider(
db: Db
): Promise<GeocodingProvider> {
const [settings] = await db.select().from(businessSettings).limit(1);
return resolveGeocodingProvider(settings ?? null);
}
/**
* Geocodes a single client row through the given provider and persists the
* result on success. Never throws on provider failure — transport/quota errors
* are captured as an `"error"` outcome so callers (especially the create/update
* auto-geocode hook) are not broken by a flaky geocoding backend.
*/
export async function geocodeClient(
db: Db,
client: ClientRow,
provider: GeocodingProvider
): Promise<ClientGeocodeOutcome> {
const base = {
clientId: client.id,
latitude: null as number | null,
longitude: null as number | null,
geocodedAt: null as string | null,
formattedAddress: null as string | null,
provider: provider.name as string | null,
};
const address = client.address?.trim();
if (!address) {
return {
...base,
status: "no_address",
message: "Client has no address on file, so it cannot be geocoded.",
};
}
let result: GeocodeResult | null;
try {
result = await provider.geocode(address);
} catch (err) {
return {
...base,
status: "error",
message: `Geocoding provider (${provider.name}) failed: ${
err instanceof Error ? err.message : String(err)
}`,
};
}
if (!result) {
return {
...base,
status: "unresolved",
message: `Address could not be resolved to a location: "${address}". Please verify or correct the address.`,
};
}
const geocodedAt = new Date();
await db
.update(clients)
.set({
latitude: result.latitude,
longitude: result.longitude,
geocodedAt,
updatedAt: geocodedAt,
})
.where(eq(clients.id, client.id));
return {
clientId: client.id,
status: "geocoded",
message: `Geocoded via ${result.provider} to ${result.latitude}, ${result.longitude}.`,
latitude: result.latitude,
longitude: result.longitude,
geocodedAt: geocodedAt.toISOString(),
formattedAddress: result.formattedAddress,
provider: result.provider,
};
}
/** Summary returned by {@link geocodeUngeocodedClients}. */
export interface BatchGeocodeSummary {
provider: string;
/** Number of clients processed in this invocation. */
processed: number;
geocoded: number;
unresolved: number;
errors: number;
/** Un-geocoded clients with an address that were NOT processed (over `limit`). */
remaining: number;
/** Per-client outcomes for everything processed this invocation. */
outcomes: ClientGeocodeOutcome[];
}
/**
* Batch-geocodes clients that have an address but no `geocodedAt` yet, throttled
* by the active provider's rate limiter.
*
* Because Nominatim allows only ~1 req/sec, geocoding every un-geocoded client in
* a single HTTP request would risk timeouts on large datasets. Each invocation
* therefore processes at most `limit` clients (default 50, clamped 1..500) and
* reports `remaining`; managers re-run until `remaining` is 0.
*/
export async function geocodeUngeocodedClients(
db: Db,
limit = 50,
injectedProvider?: GeocodingProvider
): Promise<BatchGeocodeSummary> {
const effectiveLimit = Math.min(Math.max(Math.trunc(limit) || 0, 1), 500);
const provider =
injectedProvider ?? (await resolveClientGeocodingProvider(db));
// Un-geocoded = geocodedAt IS NULL with a non-blank address.
const candidateFilter = and(
isNull(clients.geocodedAt),
sql`${clients.address} IS NOT NULL AND length(trim(${clients.address})) > 0`
);
const countRows = await db
.select({ count: sql<number>`count(*)::int` })
.from(clients)
.where(candidateFilter);
const totalRemaining = countRows[0]?.count ?? 0;
const rows = await db
.select()
.from(clients)
.where(candidateFilter)
.orderBy(clients.createdAt)
.limit(effectiveLimit);
const outcomes: ClientGeocodeOutcome[] = [];
for (const row of rows) {
outcomes.push(await geocodeClient(db, row, provider));
}
const geocoded = outcomes.filter((o) => o.status === "geocoded").length;
const unresolved = outcomes.filter((o) => o.status === "unresolved").length;
const errors = outcomes.filter((o) => o.status === "error").length;
return {
provider: provider.name,
processed: outcomes.length,
geocoded,
unresolved,
errors,
remaining: Math.max(totalRemaining - outcomes.length, 0),
outcomes,
};
}
-419
View File
@@ -1,419 +0,0 @@
import { decryptSecret } from "@groombook/db";
/**
* Abstracted geocoding service layer (GRO-2153, Phase 1.2 of Route Optimization).
*
* Provides a provider-agnostic interface for turning a street address into
* latitude/longitude coordinates, with two concrete implementations:
*
* - {@link NominatimGeocodingProvider} — OpenStreetMap Nominatim (default, free,
* self-hostable). Enforces the public Nominatim usage policy of at most one
* request per second.
* - {@link GoogleGeocodingProvider} — Google Geocoding API (optional fallback,
* requires an API key stored encrypted at rest in `businessSettings`).
*
* Provider selection is driven by `businessSettings.routeOptimizationProvider`
* via {@link resolveGeocodingProvider}. A {@link geocodeBatch} helper geocodes a
* list of addresses while respecting the active provider's rate limit.
*/
/** Identifier for a supported geocoding backend. Mirrors `route_optimization_provider`. */
export type GeocodingProviderName = "nominatim" | "google";
/** Successful geocoding result in WGS84 decimal degrees. */
export interface GeocodeResult {
latitude: number;
longitude: number;
/** Provider-normalized display address, when available. */
formattedAddress: string | null;
/** Which provider produced this result. */
provider: GeocodingProviderName;
}
/** Abstract geocoding provider contract. */
export interface GeocodingProvider {
/** Stable provider identifier. */
readonly name: GeocodingProviderName;
/**
* Minimum milliseconds to leave between consecutive requests. Used both by the
* provider's internal rate limiter and by {@link geocodeBatch} so callers do
* not need to know provider-specific limits.
*/
readonly minRequestIntervalMs: number;
/**
* Geocode a single address.
* @returns the top match, or `null` when the address is blank or unresolvable.
* @throws on transport failures or provider-level errors (e.g. quota, bad key).
*/
geocode(address: string): Promise<GeocodeResult | null>;
}
// --- Constants -------------------------------------------------------------
/** Nominatim usage policy: at most 1 request per second. */
const NOMINATIM_RATE_LIMIT_MS = 1000;
const DEFAULT_NOMINATIM_BASE_URL = "https://nominatim.openstreetmap.org";
/** Nominatim requires a descriptive User-Agent identifying the application. */
const DEFAULT_NOMINATIM_USER_AGENT = "GroomBook/1.0 (+https://groombook.app route-optimization)";
const GOOGLE_GEOCODE_BASE_URL = "https://maps.googleapis.com/maps/api/geocode/json";
/**
* Google permits a high request rate; a small floor keeps batch traffic polite
* without throttling interactive single calls.
*/
const GOOGLE_RATE_LIMIT_MS = 20;
// --- Injectable primitives (for testability) -------------------------------
/** Minimal `fetch` shape this module depends on. Defaults to the global `fetch`. */
export type FetchLike = (
input: string,
init?: { headers?: Record<string, string> }
) => Promise<{
ok: boolean;
status: number;
statusText: string;
json(): Promise<unknown>;
}>;
type NowFn = () => number;
type SleepFn = (ms: number) => Promise<void>;
const defaultSleep: SleepFn = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms));
const defaultFetch: FetchLike = (input, init) =>
(globalThis.fetch as unknown as FetchLike)(input, init);
/**
* Serializes async tasks and guarantees at least `intervalMs` between the start
* of consecutive tasks. A failing task never wedges the queue.
*/
class RateLimiter {
// Negative infinity so the very first task never waits.
private last = Number.NEGATIVE_INFINITY;
private chain: Promise<unknown> = Promise.resolve();
constructor(
private readonly intervalMs: number,
private readonly now: NowFn = Date.now,
private readonly sleep: SleepFn = defaultSleep
) {}
run<T>(task: () => Promise<T>): Promise<T> {
const result = this.chain.then(async () => {
if (this.intervalMs > 0) {
const elapsed = this.now() - this.last;
const wait = this.intervalMs - elapsed;
if (wait > 0) await this.sleep(wait);
}
this.last = this.now();
return task();
});
// Keep the queue alive regardless of whether this task resolves or rejects.
this.chain = result.then(
() => undefined,
() => undefined
);
return result;
}
}
function normalizeAddress(address: string): string {
return address.trim();
}
// --- Nominatim provider ----------------------------------------------------
interface NominatimSearchRow {
lat?: string;
lon?: string;
display_name?: string;
}
export interface NominatimProviderOptions {
/** Override the Nominatim instance base URL (e.g. a self-hosted mirror). */
baseUrl?: string;
/** User-Agent header identifying this application, per Nominatim policy. */
userAgent?: string;
/** Minimum spacing between requests; defaults to the 1 req/sec policy. */
minRequestIntervalMs?: number;
fetchImpl?: FetchLike;
now?: NowFn;
sleep?: SleepFn;
}
export class NominatimGeocodingProvider implements GeocodingProvider {
readonly name = "nominatim" as const;
readonly minRequestIntervalMs: number;
private readonly baseUrl: string;
private readonly userAgent: string;
private readonly fetchImpl: FetchLike;
private readonly limiter: RateLimiter;
constructor(options: NominatimProviderOptions = {}) {
this.baseUrl = (options.baseUrl ?? DEFAULT_NOMINATIM_BASE_URL).replace(/\/+$/, "");
this.userAgent = options.userAgent ?? DEFAULT_NOMINATIM_USER_AGENT;
this.minRequestIntervalMs = options.minRequestIntervalMs ?? NOMINATIM_RATE_LIMIT_MS;
this.fetchImpl = options.fetchImpl ?? defaultFetch;
this.limiter = new RateLimiter(this.minRequestIntervalMs, options.now, options.sleep);
}
async geocode(address: string): Promise<GeocodeResult | null> {
const query = normalizeAddress(address);
if (!query) return null;
return this.limiter.run(async () => {
const url = new URL(`${this.baseUrl}/search`);
url.searchParams.set("q", query);
url.searchParams.set("format", "jsonv2");
url.searchParams.set("limit", "1");
const res = await this.fetchImpl(url.toString(), {
headers: { "User-Agent": this.userAgent },
});
if (!res.ok) {
throw new Error(
`Nominatim geocoding failed: ${res.status} ${res.statusText}`
);
}
const body = (await res.json()) as NominatimSearchRow[];
if (!Array.isArray(body) || body.length === 0) return null;
const top = body[0]!;
const latitude = Number(top.lat);
const longitude = Number(top.lon);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
return {
latitude,
longitude,
formattedAddress: top.display_name ?? null,
provider: this.name,
};
});
}
}
// --- Google provider -------------------------------------------------------
interface GoogleGeocodeResponse {
status: string;
error_message?: string;
results?: Array<{
formatted_address?: string;
geometry?: { location?: { lat?: number; lng?: number } };
}>;
}
export interface GoogleProviderOptions {
baseUrl?: string;
minRequestIntervalMs?: number;
fetchImpl?: FetchLike;
now?: NowFn;
sleep?: SleepFn;
}
export class GoogleGeocodingProvider implements GeocodingProvider {
readonly name = "google" as const;
readonly minRequestIntervalMs: number;
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly fetchImpl: FetchLike;
private readonly limiter: RateLimiter;
constructor(apiKey: string, options: GoogleProviderOptions = {}) {
if (!apiKey) {
throw new Error("GoogleGeocodingProvider requires a non-empty API key");
}
this.apiKey = apiKey;
this.baseUrl = options.baseUrl ?? GOOGLE_GEOCODE_BASE_URL;
this.minRequestIntervalMs = options.minRequestIntervalMs ?? GOOGLE_RATE_LIMIT_MS;
this.fetchImpl = options.fetchImpl ?? defaultFetch;
this.limiter = new RateLimiter(this.minRequestIntervalMs, options.now, options.sleep);
}
async geocode(address: string): Promise<GeocodeResult | null> {
const query = normalizeAddress(address);
if (!query) return null;
return this.limiter.run(async () => {
const url = new URL(this.baseUrl);
url.searchParams.set("address", query);
url.searchParams.set("key", this.apiKey);
const res = await this.fetchImpl(url.toString());
if (!res.ok) {
throw new Error(
`Google geocoding failed: ${res.status} ${res.statusText}`
);
}
const body = (await res.json()) as GoogleGeocodeResponse;
if (body.status === "ZERO_RESULTS") return null;
if (body.status !== "OK") {
const detail = body.error_message ? `: ${body.error_message}` : "";
throw new Error(`Google geocoding error: ${body.status}${detail}`);
}
const top = body.results?.[0];
const location = top?.geometry?.location;
const latitude = location?.lat;
const longitude = location?.lng;
if (
typeof latitude !== "number" ||
typeof longitude !== "number" ||
!Number.isFinite(latitude) ||
!Number.isFinite(longitude)
) {
return null;
}
return {
latitude,
longitude,
formattedAddress: top?.formatted_address ?? null,
provider: this.name,
};
});
}
}
// --- Provider selection ----------------------------------------------------
/** Subset of `businessSettings` relevant to geocoding provider selection. */
export interface GeocodingSettings {
routeOptimizationProvider?: string | null;
/** Google API key, encrypted at rest (AES-256-GCM via `encryptSecret`). */
googleMapsApiKey?: string | null;
}
export interface ResolveProviderOptions {
/** Decryption function for the stored Google key. Defaults to `decryptSecret`. */
decrypt?: (ciphertext: string) => string;
/** Options forwarded to the constructed provider (base URL, fetch, timing). */
nominatim?: NominatimProviderOptions;
google?: GoogleProviderOptions;
/** Sink for non-fatal selection warnings. Defaults to `console.warn`. */
warn?: (message: string) => void;
}
/**
* Resolves the Google API key from settings (decrypting the at-rest value) or,
* as a development convenience, from the `GOOGLE_MAPS_API_KEY` env var.
* Returns `null` when no usable key is available.
*/
function resolveGoogleApiKey(
settings: GeocodingSettings,
decrypt: (ciphertext: string) => string,
warn: (message: string) => void
): string | null {
const stored = settings.googleMapsApiKey?.trim();
if (stored) {
try {
const decrypted = decrypt(stored).trim();
if (decrypted) return decrypted;
} catch (err) {
warn(
`Failed to decrypt googleMapsApiKey; falling back to Nominatim: ${
err instanceof Error ? err.message : String(err)
}`
);
return null;
}
}
const fromEnv = process.env.GOOGLE_MAPS_API_KEY?.trim();
return fromEnv ? fromEnv : null;
}
/**
* Selects a geocoding provider based on `businessSettings.routeOptimizationProvider`.
*
* - `"google"` returns a {@link GoogleGeocodingProvider} when a usable key exists,
* otherwise warns and falls back to Nominatim.
* - Any other value (including `null`/`undefined`) returns a
* {@link NominatimGeocodingProvider}.
*/
export function resolveGeocodingProvider(
settings: GeocodingSettings | null | undefined,
options: ResolveProviderOptions = {}
): GeocodingProvider {
const warn = options.warn ?? ((message: string) => console.warn(message));
const decrypt = options.decrypt ?? decryptSecret;
const requested = settings?.routeOptimizationProvider ?? "nominatim";
if (requested === "google") {
const apiKey = resolveGoogleApiKey(settings ?? {}, decrypt, warn);
if (apiKey) {
return new GoogleGeocodingProvider(apiKey, options.google);
}
warn(
"routeOptimizationProvider is 'google' but no usable API key was found; falling back to Nominatim"
);
}
return new NominatimGeocodingProvider(options.nominatim);
}
// --- Batch geocoding -------------------------------------------------------
/** Input item for {@link geocodeBatch}: a caller-defined key and its address. */
export interface BatchGeocodeItem<K> {
key: K;
address: string;
}
/** Per-item outcome from {@link geocodeBatch}. */
export interface BatchGeocodeOutcome<K> {
key: K;
address: string;
/** Resolved coordinates, or `null` when unresolvable. */
result: GeocodeResult | null;
/** Present when the geocode call threw; the batch continues past errors. */
error?: string;
}
export interface GeocodeBatchOptions<K> {
/** Invoked after each item completes; useful for progress reporting. */
onProgress?: (
completed: number,
total: number,
outcome: BatchGeocodeOutcome<K>
) => void;
}
/**
* Geocodes a list of addresses sequentially through the given provider. The
* provider's internal rate limiter enforces throttling (e.g. Nominatim's
* 1 req/sec), so addresses are processed one at a time and individual failures
* are captured per item rather than aborting the whole batch.
*/
export async function geocodeBatch<K>(
items: ReadonlyArray<BatchGeocodeItem<K>>,
provider: GeocodingProvider,
options: GeocodeBatchOptions<K> = {}
): Promise<Array<BatchGeocodeOutcome<K>>> {
const outcomes: Array<BatchGeocodeOutcome<K>> = [];
for (const item of items) {
let outcome: BatchGeocodeOutcome<K>;
try {
const result = await provider.geocode(item.address);
outcome = { key: item.key, address: item.address, result };
} catch (err) {
outcome = {
key: item.key,
address: item.address,
result: null,
error: err instanceof Error ? err.message : String(err),
};
}
outcomes.push(outcome);
options.onProgress?.(outcomes.length, items.length, outcome);
}
return outcomes;
}
-155
View File
@@ -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 };
-513
View File
@@ -1,513 +0,0 @@
import { businessSettings, decryptSecret, type Db } from "@groombook/db";
import type { FetchLike } from "./geocoding.js";
/**
* Route optimization service (GRO-2155, Phase 2.1 of Route Optimization).
*
* Given a groomer's geocoded stops for a day, produces an optimized visiting
* order plus per-leg and total travel estimates. Two strategies:
*
* - {@link optimizeWithGoogle}: Google Maps Directions API with
* `optimizeWaypoints: true` (real road durations/distances), used when a
* Google Maps API key is configured.
* - {@link nearestNeighborOrder}: an offline nearest-neighbor TSP heuristic over
* great-circle distance, used as the default free / no-API-key fallback.
*
* Both strategies share the same public {@link optimizeRoute} orchestrator,
* which also handles the >25-stop edge case by chunking into sub-routes (the
* Google Directions waypoint cap) and surfacing a warning.
*/
/** Google Directions allows origin + destination + up to 23 waypoints = 25
* points per request. We cap a sub-route at 25 stops and chunk beyond that. */
export const MAX_STOPS_PER_ROUTE = 25;
/** Average driving speed (km/h) used to convert distance into travel minutes in
* the offline heuristic. Tuned for mixed urban/suburban mobile-groomer routes. */
export const AVG_SPEED_KMH = 40;
/** Multiplier applied to great-circle distance to approximate real road
* distance in the offline heuristic (straight-line underestimates driving). */
export const ROAD_CIRCUITY_FACTOR = 1.3;
const EARTH_RADIUS_KM = 6371;
/** A geocoded stop to be ordered. `appointmentId` ties it back to the schedule. */
export interface RouteStopInput {
appointmentId: string;
latitude: number;
longitude: number;
}
/** A single stop in the optimized order, with travel from the previous stop. */
export interface OptimizedStop {
appointmentId: string;
latitude: number;
longitude: number;
/** Null for the first stop of the whole route. */
travelMinsFromPrev: number | null;
/** Null for the first stop of the whole route. Kilometres, 2-dp. */
travelDistanceKmFromPrev: number | null;
}
export type RouteOptimizationProvider = "google" | "nearest_neighbor";
export interface OptimizedRoute {
provider: RouteOptimizationProvider;
stops: OptimizedStop[];
totalTravelMins: number;
/** Kilometres, rounded to 2 decimal places. */
totalDistanceKm: number;
/** True when the route was split into multiple sub-routes (>25 stops). */
chunked: boolean;
subRouteCount: number;
/** Non-fatal advisories for the caller to surface to the user. */
warnings: string[];
}
export interface OptimizeRouteOptions {
/** Google Maps API key. When absent, the nearest-neighbor heuristic is used. */
googleApiKey?: string | null;
/** Injectable fetch for testing the Google path. Defaults to global fetch. */
fetchImpl?: FetchLike;
}
const defaultFetch: FetchLike = (input, init) =>
(globalThis.fetch as unknown as FetchLike)(input, init);
// ─── Geometry helpers ───────────────────────────────────────────────────────
function toRadians(deg: number): number {
return (deg * Math.PI) / 180;
}
/** Great-circle distance between two coordinates, in kilometres. */
export function haversineKm(
a: { latitude: number; longitude: number },
b: { latitude: number; longitude: number }
): number {
const dLat = toRadians(b.latitude - a.latitude);
const dLon = toRadians(b.longitude - a.longitude);
const lat1 = toRadians(a.latitude);
const lat2 = toRadians(b.latitude);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
}
/** Round to 2 decimal places, returning a finite number. */
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
/**
* Estimate a road travel leg from the great-circle distance between two points.
* Applies a circuity factor for distance and a fixed average speed for time.
*/
export function estimateLeg(
a: { latitude: number; longitude: number },
b: { latitude: number; longitude: number }
): { distanceKm: number; mins: number } {
const straight = haversineKm(a, b);
const distanceKm = straight * ROAD_CIRCUITY_FACTOR;
const mins = (distanceKm / AVG_SPEED_KMH) * 60;
return { distanceKm: round2(distanceKm), mins: Math.round(mins) };
}
// ─── Nearest-neighbor heuristic ─────────────────────────────────────────────
/**
* Orders points greedily: start at `startIndex`, then repeatedly visit the
* nearest unvisited point (great-circle distance). Returns indices into the
* input array in visiting order. Deterministic ties broken by lowest index.
*/
export function nearestNeighborOrder(
points: Array<{ latitude: number; longitude: number }>,
startIndex = 0
): number[] {
const n = points.length;
if (n <= 1) return points.map((_, i) => i);
const visited = new Array<boolean>(n).fill(false);
const order: number[] = [startIndex];
visited[startIndex] = true;
let current = startIndex;
for (let step = 1; step < n; step++) {
let best = -1;
let bestDist = Infinity;
for (let j = 0; j < n; j++) {
if (visited[j]) continue;
const d = haversineKm(points[current]!, points[j]!);
if (d < bestDist) {
bestDist = d;
best = j;
}
}
visited[best] = true;
order.push(best);
current = best;
}
return order;
}
/** Orders one chunk (<= MAX_STOPS_PER_ROUTE) via nearest-neighbor. */
function optimizeChunkNearestNeighbor(
stops: RouteStopInput[]
): RouteStopInput[] {
const order = nearestNeighborOrder(stops, 0);
return order.map((i) => stops[i]!);
}
// ─── Google Directions ──────────────────────────────────────────────────────
const GOOGLE_DIRECTIONS_URL =
"https://maps.googleapis.com/maps/api/directions/json";
interface GoogleDirectionsResponse {
status: string;
error_message?: string;
routes?: Array<{
waypoint_order?: number[];
legs?: Array<{
duration?: { value?: number };
distance?: { value?: number };
}>;
}>;
}
/**
* Orders one chunk via the Google Directions API with `optimizeWaypoints=true`.
*
* The first stop is fixed as both origin and destination (a closed tour); the
* remaining stops are passed as optimizable waypoints. We keep the optimized
* forward order and drop the final return-to-origin leg, yielding an open route
* whose per-leg durations/distances come from real road data.
*/
async function optimizeChunkGoogle(
stops: RouteStopInput[],
apiKey: string,
fetchImpl: FetchLike
): Promise<{ stops: RouteStopInput[]; legsMeters: number[]; legsSeconds: number[] }> {
if (stops.length <= 1) {
return { stops: [...stops], legsMeters: [], legsSeconds: [] };
}
const origin = stops[0]!;
const waypoints = stops.slice(1);
const url = new URL(GOOGLE_DIRECTIONS_URL);
url.searchParams.set("origin", `${origin.latitude},${origin.longitude}`);
url.searchParams.set("destination", `${origin.latitude},${origin.longitude}`);
url.searchParams.set(
"waypoints",
"optimize:true|" +
waypoints.map((w) => `${w.latitude},${w.longitude}`).join("|")
);
url.searchParams.set("key", apiKey);
const res = await fetchImpl(url.toString());
if (!res.ok) {
throw new Error(
`Google Directions request failed: ${res.status} ${res.statusText}`
);
}
const body = (await res.json()) as GoogleDirectionsResponse;
if (body.status !== "OK" || !body.routes || body.routes.length === 0) {
throw new Error(
`Google Directions returned status ${body.status}${
body.error_message ? `: ${body.error_message}` : ""
}`
);
}
const route = body.routes[0]!;
const waypointOrder = route.waypoint_order ?? waypoints.map((_, i) => i);
const legs = route.legs ?? [];
// Ordered stops: origin first, then waypoints in the optimized order.
const orderedStops: RouteStopInput[] = [
origin,
...waypointOrder.map((i) => waypoints[i]!),
];
// legs[k] is the travel into orderedStops[k+1]. Drop the trailing return leg
// (orderedStops.length-1 legs describe the open route).
const legsMeters: number[] = [];
const legsSeconds: number[] = [];
for (let k = 0; k < orderedStops.length - 1; k++) {
const leg = legs[k];
legsMeters.push(leg?.distance?.value ?? 0);
legsSeconds.push(leg?.duration?.value ?? 0);
}
return { stops: orderedStops, legsMeters, legsSeconds };
}
// ─── Orchestration ──────────────────────────────────────────────────────────
function chunk<T>(items: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) {
out.push(items.slice(i, i + size));
}
return out;
}
/**
* Optimizes a full day's stops into a single visiting order with travel
* metrics. Uses Google Directions when `googleApiKey` is provided, otherwise the
* offline nearest-neighbor heuristic. Routes longer than
* {@link MAX_STOPS_PER_ROUTE} stops are split into sub-routes and a warning is
* emitted; sub-routes are stitched end-to-end, with the boundary leg estimated
* from great-circle distance.
*/
export async function optimizeRoute(
inputStops: RouteStopInput[],
options: OptimizeRouteOptions = {}
): Promise<OptimizedRoute> {
const fetchImpl = options.fetchImpl ?? defaultFetch;
const useGoogle = Boolean(options.googleApiKey);
const provider: RouteOptimizationProvider = useGoogle
? "google"
: "nearest_neighbor";
const warnings: string[] = [];
if (inputStops.length === 0) {
return {
provider,
stops: [],
totalTravelMins: 0,
totalDistanceKm: 0,
chunked: false,
subRouteCount: 0,
warnings,
};
}
const chunks = chunk(inputStops, MAX_STOPS_PER_ROUTE);
const chunked = chunks.length > 1;
if (chunked) {
warnings.push(
`Route has ${inputStops.length} stops, exceeding the ${MAX_STOPS_PER_ROUTE}-stop optimization limit. Split into ${chunks.length} sub-routes; review the order at sub-route boundaries.`
);
}
const ordered: OptimizedStop[] = [];
let prev: RouteStopInput | null = null;
for (const group of chunks) {
let groupStops: RouteStopInput[];
let legDistanceKm: (i: number) => number;
let legMins: (i: number) => number;
if (useGoogle) {
try {
const result = await optimizeChunkGoogle(
group,
options.googleApiKey!,
fetchImpl
);
groupStops = result.stops;
legDistanceKm = (i) => round2(result.legsMeters[i]! / 1000);
legMins = (i) => Math.round(result.legsSeconds[i]! / 60);
} catch (err) {
// Google failed mid-optimization — degrade to the offline heuristic for
// this run rather than failing the whole request.
warnings.push(
`Google Directions unavailable; used offline heuristic: ${
err instanceof Error ? err.message : String(err)
}`
);
groupStops = optimizeChunkNearestNeighbor(group);
legDistanceKm = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).distanceKm;
legMins = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).mins;
}
} else {
groupStops = optimizeChunkNearestNeighbor(group);
legDistanceKm = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).distanceKm;
legMins = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).mins;
}
for (let i = 0; i < groupStops.length; i++) {
const stop = groupStops[i]!;
if (prev === null) {
// Very first stop of the whole route.
ordered.push({
appointmentId: stop.appointmentId,
latitude: stop.latitude,
longitude: stop.longitude,
travelMinsFromPrev: null,
travelDistanceKmFromPrev: null,
});
} else if (i === 0) {
// First stop of a non-initial chunk: estimate the boundary leg.
const est = estimateLeg(prev, stop);
ordered.push({
appointmentId: stop.appointmentId,
latitude: stop.latitude,
longitude: stop.longitude,
travelMinsFromPrev: est.mins,
travelDistanceKmFromPrev: est.distanceKm,
});
} else {
ordered.push({
appointmentId: stop.appointmentId,
latitude: stop.latitude,
longitude: stop.longitude,
travelMinsFromPrev: legMins(i - 1),
travelDistanceKmFromPrev: legDistanceKm(i - 1),
});
}
prev = stop;
}
}
const totalTravelMins = ordered.reduce(
(sum, s) => sum + (s.travelMinsFromPrev ?? 0),
0
);
const totalDistanceKm = round2(
ordered.reduce((sum, s) => sum + (s.travelDistanceKmFromPrev ?? 0), 0)
);
return {
provider,
stops: ordered,
totalTravelMins,
totalDistanceKm,
chunked,
subRouteCount: chunks.length,
warnings,
};
}
// ─── Google API key resolution ──────────────────────────────────────────────
/**
* Resolves the Google Maps API key for route optimization from
* `businessSettings.googleMapsApiKey` (decrypted at rest) or, as a development
* convenience, the `GOOGLE_MAPS_API_KEY` env var. Returns `null` when no usable
* key exists, in which case callers fall back to the offline heuristic.
*/
export async function resolveRouteGoogleApiKey(
db: Db,
decrypt: (ciphertext: string) => string = decryptSecret
): Promise<string | null> {
const [settings] = await db.select().from(businessSettings).limit(1);
const stored = settings?.googleMapsApiKey?.trim();
if (stored) {
try {
const decrypted = decrypt(stored).trim();
if (decrypted) return decrypted;
} catch (err) {
console.warn(
`Failed to decrypt googleMapsApiKey for route optimization; using offline heuristic: ${
err instanceof Error ? err.message : String(err)
}`
);
}
}
const fromEnv = process.env.GOOGLE_MAPS_API_KEY?.trim();
return fromEnv ? fromEnv : null;
}
// ─── Travel buffer & schedule-conflict logic (GRO-2156, Phase 2.2) ───────────
/** A single stop's timing inputs for schedule-conflict detection. */
export interface ScheduleStopTiming {
/** Scheduled appointment start. */
appointmentStartTime: Date;
/** Scheduled appointment end. */
appointmentEndTime: Date;
/** Travel minutes into this stop from the previous one (null for first). */
travelMinsFromPrev: number | null;
/** Configured buffer minutes before this stop. */
bufferMins: number;
}
/** Conflict annotation for one stop, surfaced for the frontend to display. */
export interface StopConflictFlags {
/** True when the schedule gap is too tight for travel + buffer. */
hasConflict: boolean;
/** Minutes between the previous appointment's end and this one's start.
* Null for the first stop (no predecessor). */
scheduleGapMins: number | null;
/** travelMinsFromPrev + bufferMins. Null for the first stop. */
requiredGapMins: number | null;
/** requiredGapMins scheduleGapMins; positive when the schedule is tight.
* Null for the first stop. */
shortfallMins: number | null;
}
const MS_PER_MIN = 60_000;
/**
* Detects "tight schedule" conflicts between consecutive stops, in visiting
* order. A conflict exists when the real gap between the previous appointment's
* end and this appointment's start is smaller than the time needed to travel
* plus the configured buffer (`travelMinsFromPrev + bufferMins`).
*
* This only *flags* conflicts — appointments are never moved. The first stop
* has no predecessor and is therefore always conflict-free.
*/
export function detectScheduleConflicts(
stops: ScheduleStopTiming[]
): StopConflictFlags[] {
return stops.map((s, i) => {
if (i === 0) {
return {
hasConflict: false,
scheduleGapMins: null,
requiredGapMins: null,
shortfallMins: null,
};
}
const prev = stops[i - 1]!;
const scheduleGapMins = Math.round(
(s.appointmentStartTime.getTime() - prev.appointmentEndTime.getTime()) /
MS_PER_MIN
);
const requiredGapMins = (s.travelMinsFromPrev ?? 0) + s.bufferMins;
const shortfallMins = requiredGapMins - scheduleGapMins;
return {
hasConflict: shortfallMins > 0,
scheduleGapMins,
requiredGapMins,
shortfallMins,
};
});
}
/** A coordinate used when recomputing legs for a fixed (manually chosen) order. */
export interface OrderedPoint {
latitude: number;
longitude: number;
}
/** Recomputed per-leg travel for a fixed stop order. */
export interface RecomputedLeg {
/** Null for the first stop. */
travelMinsFromPrev: number | null;
/** Null for the first stop. Kilometres, 2-dp. */
travelDistanceKmFromPrev: number | null;
}
/**
* Recomputes per-leg travel estimates for a *fixed* visiting order (e.g. after a
* manual reorder). Unlike {@link optimizeRoute} this does not reorder anything —
* it walks the given order and estimates each leg offline via {@link estimateLeg}
* so a manual drag does not consume Google Directions quota.
*/
export function recomputeLegsForOrder(points: OrderedPoint[]): RecomputedLeg[] {
return points.map((p, i) => {
if (i === 0) {
return { travelMinsFromPrev: null, travelDistanceKmFromPrev: null };
}
const est = estimateLeg(points[i - 1]!, p);
return {
travelMinsFromPrev: est.mins,
travelDistanceKmFromPrev: est.distanceKm,
};
});
}
View File