Compare commits
157 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 337c0e2733 | |||
| 423d4bf72d | |||
| a9bac033fd | |||
| 5fab813215 | |||
| 84d923a707 | |||
| 944a4e161f | |||
| f262c19561 | |||
| 17d261fa94 | |||
| e5fe005986 | |||
| b15a53a19b | |||
| 97da5f332e | |||
| 1faa7945c6 | |||
| b928acf5d6 | |||
| 5390131a6a | |||
| dd220598ca | |||
| 8cce9c4d35 | |||
| bec7b014be | |||
| 01cff9006a | |||
| f80f781b23 | |||
| c99e2980a1 | |||
| 5ec9e9a8fd | |||
| e9aef5719f | |||
| c588c94dcb | |||
| e00cdc1321 | |||
| 1891b9c523 | |||
| a5bd9c915c | |||
| 0ab16b82e0 | |||
| 981a257d2d | |||
| a14bb5e17d | |||
| 280c699d0d | |||
| 5d6bc06295 | |||
| 53677b1420 | |||
| 0a3eb8a282 | |||
| b5f964c1ff | |||
| 86a6e3245c | |||
| aee82efbac | |||
| 4cc0676d52 | |||
| dff0e17a63 | |||
| 612c0467a1 | |||
| 543d9560ec | |||
| 17b44e3b00 | |||
| 2e0d63f7f6 | |||
| 7bdb92999a | |||
| b96b6c06fc | |||
| fa67b75b76 | |||
| 7e329ff72f | |||
| b050fb9a5f | |||
| 63ed91e5f3 | |||
| 45b3d4343d | |||
| 32156e9a45 | |||
| ed3d7df1c9 | |||
| 9622b109d0 | |||
| a25b2fe281 | |||
| de33edd7c6 | |||
| 3b9e82adff | |||
| 385ed10211 | |||
| 8e8a87767c | |||
| b796d36aed | |||
| d9ba6045ad | |||
| 2f17b1ab85 | |||
| b83a793de4 | |||
| a610ef9d39 | |||
| 2a0b3cf3d3 | |||
| cf3d30f19e | |||
| 0625961adf | |||
| 78762b5278 | |||
| b61d899f81 | |||
| 38047d5ea3 | |||
| fbcaedf155 | |||
| 7cfb24d542 | |||
| b0d9e5816f | |||
| 7a0662541d | |||
| 5e78df85f1 | |||
| aa9670d4dc | |||
| 0a2259b67f | |||
| cc09a8e1e8 | |||
| 74da042d13 | |||
| ad1b210de1 | |||
| e5f16a5fe5 | |||
| a03771f7e7 | |||
| baeff6c4f5 | |||
| 040ff4a253 | |||
| a1466b44c9 | |||
| 8d9a9d8dba | |||
| b486c44a82 | |||
| 8c62ce2368 | |||
| b5a08a2c7e | |||
| 06d72b5baf | |||
| 2380698128 | |||
| 33aa63b10f | |||
| e26d960046 | |||
| 4e8c66f3ca | |||
| 00c6a36021 | |||
| ea28095434 | |||
| 3b9c72c2c4 | |||
| 49f70eb74b | |||
| f4561b539f | |||
| 62dfc7776b | |||
| d847343090 | |||
| 68df697cf3 | |||
| 174d1c667b | |||
| 190c39f905 | |||
| 9fe6e15012 | |||
| 002e6575ba | |||
| 122d32d635 | |||
| f9c679b392 | |||
| ce0739b3ba | |||
| 3609087980 | |||
| 7b2b533c16 | |||
| 55894c6ff2 | |||
| d458f93600 | |||
| f55c74983f | |||
| 8bdab69288 | |||
| 634e9d03e1 | |||
| 8f7104c3a0 | |||
| dab9bfab71 | |||
| 974dade8f7 | |||
| 70bc946a0d | |||
| 3eaefb4911 | |||
| 40422a14f0 | |||
| 9462915a66 | |||
| 46f134a294 | |||
| 4086b6f5c0 | |||
| d7d98791c7 | |||
| 3bec5d095a | |||
| 3fddf80fac | |||
| 1ea319e122 | |||
| da913d600f | |||
| ce9fcfb362 | |||
| 59893908e2 | |||
| 2b78fcf731 | |||
| c9e176f08c | |||
| 0cab1522cf | |||
| ff6f8471d5 | |||
| 2a27e8bee2 | |||
| d6f7ade7bd | |||
| 00dadac0a1 | |||
| 9692476202 | |||
| 44da26820b | |||
| 21981fbdc4 | |||
| 85fc803548 | |||
| 6045024150 | |||
| 6a3c1aa65e | |||
| df5e413930 | |||
| 490ab06e8c | |||
| 609f86b927 | |||
| 7cb5fda3e3 | |||
| 9f2809e89b | |||
| 76540cea0d | |||
| d83210e7e2 | |||
| 5c9cac7a28 | |||
| fad99dc032 | |||
| 247570abc8 | |||
| 4f5ec60961 | |||
| 39ffdccac7 | |||
| 1ff0d4230c | |||
| be5e9d8fc7 |
@@ -0,0 +1 @@
|
||||
GRO-1757 direct push CI trigger - 2026-05-26T00:15:41Z
|
||||
+68
-9
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
branches: [main, dev, uat]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
branches: [main, dev, uat]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -25,17 +25,19 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
run: |
|
||||
pnpm --filter @groombook/api typecheck
|
||||
pnpm --filter @groombook/db typecheck
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
run: pnpm --filter @groombook/api lint
|
||||
|
||||
test:
|
||||
name: Test
|
||||
@@ -49,17 +51,17 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
run: pnpm --filter @groombook/api test
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Image
|
||||
name: Build & Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-typecheck, test]
|
||||
steps:
|
||||
@@ -78,6 +80,8 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -89,11 +93,66 @@ jobs:
|
||||
- name: Build and push API image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
provenance: false
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: runner
|
||||
push: true
|
||||
tags: |
|
||||
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
||||
|
||||
- name: Build and push Migrate image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
provenance: false
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: migrate
|
||||
push: true
|
||||
tags: |
|
||||
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
|
||||
|
||||
- name: Smoke test migrate image (blackhole npmjs.org)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}"
|
||||
docker pull "$IMAGE"
|
||||
docker run --rm \
|
||||
--add-host registry.npmjs.org:127.0.0.1 \
|
||||
--entrypoint="" \
|
||||
"$IMAGE" \
|
||||
pnpm --version
|
||||
|
||||
- name: Build and push Seed image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
provenance: false
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: seed
|
||||
push: true
|
||||
tags: |
|
||||
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:seed
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max
|
||||
|
||||
- name: Build and push Reset image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
provenance: false
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: reset
|
||||
push: true
|
||||
tags: |
|
||||
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
+13
-9
@@ -1,5 +1,10 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
FROM node:22-alpine AS base
|
||||
# Install pnpm as a real binary via npm (not corepack shim) so runtime
|
||||
# invocations of `pnpm` work without DNS access to registry.npmjs.org.
|
||||
# The corepack shim delegates to corepack, which re-validates against
|
||||
# npmjs.org on first use — that fails in air-gapped UAT seed/migrate/reset
|
||||
# Jobs. GRO-1983 / GRO-1889 / GRO-1909.
|
||||
RUN npm install -g pnpm@9.15.4
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps
|
||||
@@ -11,17 +16,16 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build
|
||||
FROM deps AS builder
|
||||
RUN mkdir -p /home/node/.cache/node/corepack
|
||||
COPY packages/ packages/
|
||||
COPY src/ src/
|
||||
COPY tsconfig.json ./
|
||||
RUN pnpm --filter @groombook/types build && \
|
||||
pnpm --filter @groombook/db build && \
|
||||
pnpm --filter @groombook/api build
|
||||
pnpm build
|
||||
|
||||
# Runtime
|
||||
FROM node:20-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
FROM node:22-alpine AS runner
|
||||
RUN npm install -g pnpm@9.15.4
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -42,12 +46,12 @@ CMD ["node", "dist/index.js"]
|
||||
|
||||
# Migrate stage — runs drizzle-kit migrate against the database
|
||||
FROM builder AS migrate
|
||||
CMD ["pnpm", "db:migrate"]
|
||||
CMD ["pnpm", "--filter", "@groombook/db", "migrate"]
|
||||
|
||||
# Seed stage — populates the database with test data
|
||||
FROM builder AS seed
|
||||
CMD ["pnpm", "db:seed"]
|
||||
CMD ["pnpm", "--filter", "@groombook/db", "seed"]
|
||||
|
||||
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
||||
FROM builder AS reset
|
||||
CMD ["pnpm", "db:reset"]
|
||||
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
|
||||
|
||||
@@ -19,8 +19,37 @@ 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.
|
||||
|
||||
## Test Cases
|
||||
|
||||
### 4.0 Health Check
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-0.1 | Unauthenticated health check | GET /api/health | 200 OK, `{"status":"ok"}` |
|
||||
|
||||
> **Note (GRO-1544):** Health endpoint registered on `api` basePath before auth middleware at `/api/health`. The old path `/health` was incorrect (routed to web pod via HTTPRoute `/*` rule).
|
||||
|
||||
### 4.1 Authentication
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
@@ -34,6 +63,34 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
||||
| TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned |
|
||||
| TC-API-1.10 | Auto-provision on first OIDC login | First login as a Better-Auth user with no existing staff record | 200 OK, access granted; groomer staff record auto-created with name/email from user table |
|
||||
|
||||
> **Note (GRO-1977):** Seed credential provisioning is idempotent — re-running the seed with updated `SEED_UAT_*_PASSWORD` env vars rotates stored credential hashes. TC-API-1.4 through TC-API-1.7 now return 200 for all 4 UAT personas (previously returned 401 due to frozen-hash bug).
|
||||
| TC-API-1.11 | Existing staff unaffected by OIDC login | Login as uat-groomer@groombook.dev (email+password), then GET /api/staff to find that record | 200 OK, staff record unchanged — no duplicate created, original role and isSuperUser preserved |
|
||||
| TC-API-1.12 | Auto-provisioned role and superUser flags | After TC-API-1.10, GET /api/staff and inspect the auto-created record | role = "groomer", isSuperUser = false, active = true |
|
||||
| TC-API-1.13 | Name fallback — user.name present | Auto-provision where Better-Auth user has name set | Staff name = user.name value from user table |
|
||||
| TC-API-1.14 | Name fallback — no name, email present | Auto-provision where Better-Auth user has name = null, email = "test@example.com" | Staff name = "test" (email prefix before @) |
|
||||
| TC-API-1.15 | Name fallback — no name, no email | Auto-provision where Better-Auth user has name = null, email = null | Staff name = "Unknown" |
|
||||
| TC-API-1.16 | OIDC login — Terraform-provisioned user | Initiate OIDC login as any UAT persona (uat-super, uat-groomer, uat-customer, uat-tester), complete authentik callback | 200 OK, session created — no account_not_linked error |
|
||||
|
||||
#### SSO Login Journey (Authentik OIDC end-to-end)
|
||||
|
||||
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|
||||
|---|----------|-------|---------------|---------------|
|
||||
| TC-API-1.17 | SSO redirect to Authentik | Navigate to app → sign-in page shown → click "Sign in with SSO" | Redirected to Authentik at auth.farh.net | 403 error, redirect loop, no SSO button |
|
||||
| TC-API-1.18 | Authenticate with valid OIDC credentials | At Authentik login page, enter valid credentials and authenticate | Redirected back to app with valid session | Redirect loop, 403, missing session cookie |
|
||||
| TC-API-1.19 | SSO user auto-provisioned as groomer | Complete SSO login as a user with no pre-existing staff record | 200 response; groomer staff record auto-created; session active | 403 Forbidden, staff record not created |
|
||||
| TC-API-1.20 | Existing staff record resolves correctly | Complete SSO login as uat-groomer (pre-existing staff) | 200 OK, correct staff identity resolved, no duplicate record created | 403, duplicate record, wrong staff data |
|
||||
| TC-API-1.21 | SSO session grants dashboard access | After TC-API-1.18 SSO login, GET /api/staff/me | 200 OK, valid staff record returned, correct role displayed | 401/403, missing session, wrong identity |
|
||||
|
||||
#### OOBE Flow Post-Login
|
||||
|
||||
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|
||||
|---|----------|-------|---------------|---------------|
|
||||
| TC-API-1.22 | Fresh DB reports needsSetup | On a fresh DB (no super user), GET /api/setup/status | needsSetup: true returned | needsSetup: false when it should be true |
|
||||
| TC-API-1.23 | Configure OIDC via auth-provider endpoint | POST /api/setup/auth-provider with valid OIDC config | 200 OK, auth provider configured, no 403 | 403, setup blocked, invalid config rejected |
|
||||
| TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints |
|
||||
| TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false |
|
||||
| TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
|
||||
|
||||
### 4.2 Client Management
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
@@ -64,6 +121,26 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
||||
| TC-API-3.13 | Reject too many temperamentFlags | POST /api/pets with 21 temperamentFlags | 400 Bad Request, max 20 flags enforced |
|
||||
| TC-API-3.14 | Reject too many preferredCuts | POST /api/pets with 21 preferredCuts | 400 Bad Request, max 20 cuts enforced |
|
||||
| TC-API-3.15 | Reject too many medicalAlerts | POST /api/pets with 51 medicalAlerts | 400 Bad Request, max 50 alerts enforced |
|
||||
| TC-API-3.16 | Get pet profile summary | GET /api/pets/{id}/profile-summary | 200 OK, aggregated profile with grooming history, visit count, upcoming appointment |
|
||||
| TC-API-3.17 | Get pet profile summary — groomer restricted | GET /api/pets/{id}/profile-summary as groomer with no pet linkage | 403 Forbidden |
|
||||
| TC-API-3.18 | Get pet profile summary — visitCount returns full count | GET /api/pets/{id}/profile-summary with 2+ completed appointments | visitCount >= 2 (not capped at 1) |
|
||||
| TC-API-3.19 | Get pet profile summary — upcomingAppointment excludes past | GET /api/pets/{id}/profile-summary with a past confirmed/scheduled appointment | upcomingAppointment is null (past appointments filtered by startTime >= now) |
|
||||
|
||||
#### Seed Data Verification (GRO-1898)
|
||||
|
||||
> As of PR #98, UAT seed data populates all 5 extended profile fields for every pet, including the 5 deterministic UAT test client pets (Alpha, Bravo, Charlie, Delta, Echo). This enables manual verification of extended profile rendering without requiring a DB reset.
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-3.20 | GET /api/clients returns seed data | GET /api/clients | 200 OK, array with 1+ clients (UAT seed creates 500 + 5 deterministic UAT clients) |
|
||||
| TC-API-3.21 | GET /api/pets/{id} returns extended fields for seed pet | Pick any pet ID from UAT test clients (uat-alpha through uat-echo pet names: TestBuddy, TestMax, TestCooper, TestRocky, TestDuke) and GET /api/pets/{id} | 200 OK; coatType, temperamentScore, temperamentFlags, medicalAlerts, preferredCuts all non-null |
|
||||
| TC-API-3.22 | Verify medicalAlerts shape | GET /api/pets/{id} for any pet with non-empty medicalAlerts | medicalAlerts is an array; each entry has type, description, severity |
|
||||
| TC-API-3.23 | Verify UAT test pet Charlie has behavioral alert | GET /api/pets/{id} where name = "TestCooper" (pet for uat-charlie@groombook.dev) | medicalAlerts includes an entry with type: "behavioral", severity: "low" or "high" |
|
||||
| TC-API-3.24 | Verify UAT test pet Delta has skin alert | GET /api/pets/{id} where name = "TestRocky" (pet for uat-delta@groombook.dev) | medicalAlerts includes an entry with type: "skin" |
|
||||
| TC-API-3.25 | Verify 30+ total pets in UAT DB | GET /api/pets then count total | 30+ pets returned (UAT seed creates 500 random-pool + 5 UAT test clients + 2 UAT customer = 507 total) |
|
||||
| TC-API-3.26 | Verify 25-35% medicalAlerts distribution | GET /api/pets (first 30 pets), count how many have non-empty medicalAlerts | Ratio is 25-35% (seed uses rand() < 0.3 for ~30% distribution) |
|
||||
| TC-API-3.27 | Verify coat_type enum has all seed values | After UAT seed completes, inspect the coat_type enum on the UAT DB — it must contain: short, medium, long, double, wire, silky, curly, hairless | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; coat_type includes all 8 values used by seed.ts `coatTypePool` |
|
||||
| TC-API-3.28 | Verify pet_size_category enum has all seed values | After UAT seed completes, inspect the pet_size_category enum on the UAT DB — it must contain: small, medium, large, extra_large | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; pet_size_category includes all 4 values used by seed.ts `petSizeCategoryPool` (regression for GRO-1999, mirrors TC-API-3.27) |
|
||||
|
||||
### 4.4 Appointment Scheduling
|
||||
|
||||
@@ -125,6 +202,10 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
||||
| TC-API-8.5 | Add waitlist entry | POST /api/portal/waitlist with pet and service | 201 Created, waitlist entry created |
|
||||
| TC-API-8.6 | View portal invoices | GET /api/portal/invoices | 200 OK, list of client's invoices returned |
|
||||
| TC-API-8.7 | Pay multiple invoices | POST /api/portal/invoices/pay-multiple with invoice IDs | 200 OK, payment intent created |
|
||||
| TC-API-8.8 | SSO bridge — valid Better Auth session | POST /api/portal/session-from-auth with valid Better Auth session cookie (authenticated SSO user with matching client email) | 201 Created, `{sessionId, clientId, clientName}` returned |
|
||||
| TC-API-8.9 | SSO bridge — no Better Auth session | POST /api/portal/session-from-auth without Better Auth session cookie | 401 Unauthorized |
|
||||
| TC-API-8.10 | SSO bridge — no matching client | POST /api/portal/session-from-auth with valid Better Auth session for a user with no client record | 404 Not Found, error "No client record found for this user" |
|
||||
| TC-API-8.11 | SSO bridge — returned session works on portal routes | After TC-API-8.8, use returned sessionId as `X-Impersonation-Session-Id` header on GET /api/portal/me | 200 OK, client profile returned |
|
||||
|
||||
### 4.9 Waitlist
|
||||
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
import { petsRouter } from "../routes/pets.js";
|
||||
|
||||
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
||||
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: null,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const GROOMER: StaffRow = {
|
||||
id: "staff-groomer-id",
|
||||
oidcSub: "oidc-groomer-sub",
|
||||
userId: null,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
name: "Groomer McGroome",
|
||||
email: "groomer@example.com",
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// ─── Mutable mock state ───────────────────────────────────────────────────────
|
||||
|
||||
const CLIENT_ID = "client-uuid-summary";
|
||||
const PET_ID = "pet-uuid-summary";
|
||||
|
||||
interface MockState {
|
||||
pets: Record<string, unknown>[];
|
||||
appointments: Record<string, unknown>[];
|
||||
groomingLogs: Record<string, unknown>[];
|
||||
staffMembers: Record<string, unknown>[];
|
||||
services: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
let mock: MockState;
|
||||
|
||||
function resetMock() {
|
||||
mock = {
|
||||
pets: [{
|
||||
id: PET_ID,
|
||||
clientId: CLIENT_ID,
|
||||
name: "Biscuit",
|
||||
species: "dog",
|
||||
breed: "Golden Retriever",
|
||||
weightKg: "30.00",
|
||||
dateOfBirth: null,
|
||||
healthAlerts: null,
|
||||
groomingNotes: null,
|
||||
cutStyle: null,
|
||||
shampooPreference: null,
|
||||
specialCareNotes: null,
|
||||
customFields: {},
|
||||
photoKey: null,
|
||||
photoUploadedAt: null,
|
||||
image: null,
|
||||
coatType: "double",
|
||||
temperamentScore: 3,
|
||||
temperamentFlags: ["gentle"],
|
||||
medicalAlerts: [],
|
||||
preferredCuts: ["puppy cut"],
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
}],
|
||||
appointments: [
|
||||
{
|
||||
id: "appt-completed-1",
|
||||
clientId: CLIENT_ID,
|
||||
petId: PET_ID,
|
||||
serviceId: "service-1",
|
||||
staffId: "staff-groomer-id",
|
||||
batherStaffId: null,
|
||||
status: "completed",
|
||||
startTime: new Date("2024-06-01T09:00:00Z"),
|
||||
endTime: new Date("2024-06-01T11:00:00Z"),
|
||||
notes: null,
|
||||
priceCents: 6000,
|
||||
seriesId: null,
|
||||
seriesIndex: null,
|
||||
groupId: null,
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: null,
|
||||
cancelledAt: null,
|
||||
confirmationToken: null,
|
||||
customerNotes: null,
|
||||
createdAt: new Date("2024-05-15"),
|
||||
updatedAt: new Date("2024-05-15"),
|
||||
},
|
||||
{
|
||||
id: "appt-upcoming-1",
|
||||
clientId: CLIENT_ID,
|
||||
petId: PET_ID,
|
||||
serviceId: "service-2",
|
||||
staffId: "staff-groomer-id",
|
||||
batherStaffId: null,
|
||||
status: "confirmed",
|
||||
startTime: new Date("2024-12-01T09:00:00Z"),
|
||||
endTime: new Date("2024-12-01T11:00:00Z"),
|
||||
notes: null,
|
||||
priceCents: 6500,
|
||||
seriesId: null,
|
||||
seriesIndex: null,
|
||||
groupId: null,
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: null,
|
||||
cancelledAt: null,
|
||||
confirmationToken: null,
|
||||
customerNotes: null,
|
||||
createdAt: new Date("2024-11-01"),
|
||||
updatedAt: new Date("2024-11-01"),
|
||||
},
|
||||
],
|
||||
groomingLogs: [
|
||||
{
|
||||
id: "log-1",
|
||||
petId: PET_ID,
|
||||
appointmentId: "appt-completed-1",
|
||||
staffId: "staff-groomer-id",
|
||||
cutStyle: "puppy cut",
|
||||
productsUsed: "oatmeal shampoo",
|
||||
notes: "Trimmed nails",
|
||||
groomedAt: new Date("2024-06-01T10:00:00Z"),
|
||||
createdAt: new Date("2024-06-01T10:00:00Z"),
|
||||
},
|
||||
],
|
||||
staffMembers: [
|
||||
{
|
||||
id: "staff-groomer-id",
|
||||
name: "Groomer McGroome",
|
||||
email: "groomer@example.com",
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
oidcSub: "oidc-groomer-sub",
|
||||
userId: null,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "staff-manager-id",
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: null,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
services: [
|
||||
{ id: "service-1", name: "Full Groom", description: null, basePriceCents: 6000, durationMinutes: 120, active: true, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: "service-2", name: "Bath & Brush", description: null, basePriceCents: 4000, durationMinutes: 60, active: true, createdAt: new Date(), updatedAt: new Date() },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../db/index.js", () => {
|
||||
const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} });
|
||||
const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} });
|
||||
const groomingVisitLogs = new Proxy({ _name: "groomingVisitLogs" }, { get: (t, p) => p === "_name" ? "groomingVisitLogs" : {} });
|
||||
const staff = new Proxy({ _name: "staff" }, { get: (t, p) => p === "_name" ? "staff" : {} });
|
||||
const services = new Proxy({ _name: "services" }, { get: (t, p) => p === "_name" ? "services" : {} });
|
||||
|
||||
// Tracks { [tableName]: { [alias]: SQLExpression } } for the current select() call
|
||||
let selectedColumns: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
function makeChainable(rows: unknown[]) {
|
||||
const arr = rows as unknown[];
|
||||
return new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "leftJoin" || prop === "from") {
|
||||
return () => makeChainable(target);
|
||||
}
|
||||
if (prop === Symbol.iterator) {
|
||||
return function* () { for (const v of target) yield v; };
|
||||
}
|
||||
if (prop === Symbol.asyncIterator) {
|
||||
return async function* () { for (const v of target) yield v; };
|
||||
}
|
||||
// @ts-expect-error proxy
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// sql mock: returns an object with .as() so drizzle's select() can alias it
|
||||
function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) {
|
||||
const queryString = _strings[0];
|
||||
const asFn = (alias: string) => ({
|
||||
sql: { queryChunks: [_strings[0]] },
|
||||
fieldAlias: alias,
|
||||
getSQL() { return this.sql; },
|
||||
});
|
||||
return { queryChunks: [queryString], as: asFn };
|
||||
}
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: (cols?: Record<string, unknown>) => {
|
||||
selectedColumns = {};
|
||||
if (cols) {
|
||||
// Inspect cols to find sql-aliased expressions and their aliases
|
||||
for (const [alias, expr] of Object.entries(cols)) {
|
||||
if (expr && typeof expr === "object" && "as" in expr && typeof (expr as Record<string, unknown>).as === "function") {
|
||||
const aliased = (expr as { as: (a: string) => { fieldAlias: string; sql: unknown } }).as(alias);
|
||||
// Detect count(*) queries
|
||||
if (typeof aliased.sql === "object" && aliased.sql !== null && "queryChunks" in (aliased.sql as Record<string, unknown>) && String((aliased.sql as { queryChunks?: unknown[] }).queryChunks).includes("count")) {
|
||||
// Store count query intent — we'll resolve it in from()
|
||||
if (!selectedColumns["appointments"]) selectedColumns["appointments"] = {};
|
||||
selectedColumns["appointments"][alias] = { _isCountQuery: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
from: (table: unknown) => {
|
||||
const name = (table as { _name?: string })._name;
|
||||
const tableCols = selectedColumns[name] || {};
|
||||
// If this table has a count query, return computed count result
|
||||
const countQueryEntry = Object.entries(tableCols).find(([, v]) =>
|
||||
typeof v === "object" && v !== null && "_isCountQuery" in v
|
||||
);
|
||||
if (countQueryEntry) {
|
||||
const [countAlias] = countQueryEntry;
|
||||
const count = (name === "appointments" ? mock.appointments : [])
|
||||
.filter((row: Record<string, unknown>) => row.status === "completed").length;
|
||||
return makeChainable([{ [countAlias]: count }]);
|
||||
}
|
||||
if (name === "pets") return makeChainable(mock.pets);
|
||||
if (name === "appointments") return makeChainable(mock.appointments);
|
||||
if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs);
|
||||
if (name === "staff") return makeChainable(mock.staffMembers);
|
||||
if (name === "services") return makeChainable(mock.services);
|
||||
return makeChainable([]);
|
||||
},
|
||||
};
|
||||
},
|
||||
insert: () => ({ values: () => ({ returning: () => [{}] }) }),
|
||||
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
|
||||
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
|
||||
}),
|
||||
pets,
|
||||
appointments,
|
||||
groomingVisitLogs,
|
||||
staff,
|
||||
services,
|
||||
and: vi.fn((a: unknown, b: unknown) => [a, b]),
|
||||
desc: vi.fn((c: unknown) => c),
|
||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||
exists: vi.fn(() => true),
|
||||
gte: vi.fn((a: unknown, b: unknown) => ({ col: a, val: b })),
|
||||
or: vi.fn((a: unknown, b: unknown) => [a, b]),
|
||||
sql: sqlMock,
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeApp(staff: StaffRow = MANAGER) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("staff", staff);
|
||||
await next();
|
||||
});
|
||||
return app.route("/pets", petsRouter);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /:id/profile-summary", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("returns 404 for non-existent pet", async () => {
|
||||
const app = makeApp();
|
||||
mock.pets = [];
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 403 for groomer with no pet linkage", async () => {
|
||||
const app = makeApp(GROOMER);
|
||||
// Groomer has no linkage to this pet's client — clear appointments
|
||||
mock.appointments = [];
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns complete aggregated profile for manager", async () => {
|
||||
const app = makeApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.id).toBe(PET_ID);
|
||||
expect(body.name).toBe("Biscuit");
|
||||
expect(body.species).toBe("dog");
|
||||
expect(body.recentGroomingHistory).toBeInstanceOf(Array);
|
||||
expect(body.lastVisitDate).toBeTruthy();
|
||||
expect(body.visitCount).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("groomer with pet linkage returns 200", async () => {
|
||||
const app = makeApp(GROOMER);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("recentGroomingHistory is limited to 10 entries", async () => {
|
||||
const app = makeApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.recentGroomingHistory.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it("returns null upcomingAppointment when none scheduled", async () => {
|
||||
const app = makeApp(MANAGER);
|
||||
mock.appointments = [];
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.upcomingAppointment).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /:id/profile-summary — visitCount", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("returns visitCount >= 2 when pet has 2+ completed appointments", async () => {
|
||||
const app = makeApp(MANAGER);
|
||||
// Add a second completed appointment
|
||||
mock.appointments = [
|
||||
...mock.appointments,
|
||||
{
|
||||
id: "appt-completed-2",
|
||||
clientId: CLIENT_ID,
|
||||
petId: PET_ID,
|
||||
serviceId: "service-1",
|
||||
staffId: "staff-groomer-id",
|
||||
batherStaffId: null,
|
||||
status: "completed",
|
||||
startTime: new Date("2024-07-01T09:00:00Z"),
|
||||
endTime: new Date("2024-07-01T11:00:00Z"),
|
||||
notes: null,
|
||||
priceCents: 6000,
|
||||
seriesId: null,
|
||||
seriesIndex: null,
|
||||
groupId: null,
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: null,
|
||||
cancelledAt: null,
|
||||
confirmationToken: null,
|
||||
customerNotes: null,
|
||||
createdAt: new Date("2024-06-15"),
|
||||
updatedAt: new Date("2024-06-15"),
|
||||
},
|
||||
];
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.visitCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("returns visitCount = 0 when no completed appointments", async () => {
|
||||
const app = makeApp(MANAGER);
|
||||
mock.appointments = mock.appointments.map((a) => ({ ...a, status: "cancelled" }));
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.visitCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /:id/profile-summary — empty history", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("returns empty history array when no grooming logs", async () => {
|
||||
const app = makeApp(MANAGER);
|
||||
mock.groomingLogs = [];
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.recentGroomingHistory).toEqual([]);
|
||||
expect(body.lastVisitDate).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
import { petsRouter } from "../routes/pets.js";
|
||||
import { and, eq, exists, or } from "../db/index.js";
|
||||
|
||||
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -134,7 +135,7 @@ function makeDeleteChainable(): unknown {
|
||||
}
|
||||
if (prop === "returning") {
|
||||
return () => {
|
||||
const row = petRows[0];
|
||||
const row = petRows[0]!;
|
||||
deletedId = row.id as string;
|
||||
return [row];
|
||||
};
|
||||
@@ -164,10 +165,10 @@ vi.mock("../db", async (importOriginal) => {
|
||||
}),
|
||||
pets,
|
||||
appointments,
|
||||
and: (...conds: unknown[]) => conds,
|
||||
eq: (col: unknown, val: unknown) => ({ col, val }),
|
||||
exists: (q: unknown) => q,
|
||||
or: (...conds: unknown[]) => conds,
|
||||
and: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
or: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -67,6 +67,11 @@ vi.mock("../db", () => {
|
||||
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
|
||||
);
|
||||
|
||||
const impersonationAuditLogs = new Proxy(
|
||||
{ _name: "impersonationAuditLogs" },
|
||||
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
|
||||
);
|
||||
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
@@ -99,8 +104,12 @@ vi.mock("../db", () => {
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: () => ({ returning: () => [{}] }),
|
||||
}),
|
||||
}),
|
||||
impersonationSessions,
|
||||
impersonationAuditLogs,
|
||||
appointments,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
|
||||
@@ -67,6 +67,7 @@ let dbAccounts: AccountRow[] = [];
|
||||
let dbStaff: StaffRow[] = [];
|
||||
let insertedUsers: UserRow[] = [];
|
||||
let insertedAccounts: AccountRow[] = [];
|
||||
let updatedAccounts: Array<{ id: string; password: string }> = [];
|
||||
let updatedStaff: Array<{ id: string; userId: string }> = [];
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
@@ -77,6 +78,7 @@ function resetMock() {
|
||||
dbStaff = [];
|
||||
insertedUsers = [];
|
||||
insertedAccounts = [];
|
||||
updatedAccounts = [];
|
||||
updatedStaff = [];
|
||||
process.env = { ...originalEnv };
|
||||
}
|
||||
@@ -173,7 +175,11 @@ async function seedUatCredentials(
|
||||
);
|
||||
|
||||
if (existingAccount) {
|
||||
// skip — already has credential account
|
||||
// Idempotent update: re-hash the current env password and update the stored hash.
|
||||
const { hashPassword } = await import("better-auth/crypto");
|
||||
const passwordHash = await hashPassword(password);
|
||||
existingAccount.password = passwordHash;
|
||||
updatedAccounts.push({ id: existingAccount.id, password: passwordHash });
|
||||
} else {
|
||||
// Use Better-Auth's hashPassword so test helper matches production seed.ts
|
||||
const { hashPassword } = await import("better-auth/crypto");
|
||||
@@ -312,9 +318,9 @@ describe("seedUatCredentials — credential provisioning logic", () => {
|
||||
expect(updatedStaff).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── AC-5: idempotent — skips when user already exists ───────────────────────
|
||||
// ── AC-5: idempotent — does not insert duplicate records ───────────────────
|
||||
|
||||
it("AC-5: re-running does not duplicate user or account records (idempotent)", async () => {
|
||||
it("AC-5: re-running does not insert duplicate user or account records", async () => {
|
||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
||||
|
||||
const preExistingUsers: UserRow[] = [
|
||||
@@ -330,25 +336,96 @@ describe("seedUatCredentials — credential provisioning logic", () => {
|
||||
},
|
||||
];
|
||||
|
||||
// First call — nothing inserted (user + account pre-exist)
|
||||
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
|
||||
users: preExistingUsers,
|
||||
accounts: preExistingAccounts,
|
||||
staff: [],
|
||||
});
|
||||
|
||||
// No inserts — user and account already exist
|
||||
expect(insertedUsers).toHaveLength(0);
|
||||
expect(insertedAccounts).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── AC-5b: password rotation on re-seed ─────────────────────────────────────
|
||||
|
||||
it("AC-5b: re-running with a new password updates the stored credential hash", async () => {
|
||||
const OLD_PASSWORD = "old-password-abc";
|
||||
const NEW_PASSWORD = "new-password-xyz";
|
||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = NEW_PASSWORD;
|
||||
|
||||
const preExistingUsers: UserRow[] = [
|
||||
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
|
||||
];
|
||||
const preExistingAccounts: AccountRow[] = [
|
||||
{
|
||||
id: "pre-existing-acct",
|
||||
accountId: "pre-existing-user",
|
||||
providerId: "credential",
|
||||
userId: "pre-existing-user",
|
||||
password: await hashPassword(OLD_PASSWORD),
|
||||
},
|
||||
];
|
||||
|
||||
// Second call — still nothing inserted
|
||||
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
|
||||
users: preExistingUsers,
|
||||
accounts: preExistingAccounts,
|
||||
staff: [],
|
||||
});
|
||||
|
||||
// No new records inserted
|
||||
expect(insertedUsers).toHaveLength(0);
|
||||
expect(insertedAccounts).toHaveLength(0);
|
||||
// Password WAS updated to the new env value
|
||||
expect(updatedAccounts).toHaveLength(1);
|
||||
expect(updatedAccounts[0]!.id).toBe("pre-existing-acct");
|
||||
// New hash is valid Better-Auth format (salt:key, each hex)
|
||||
const newHashParts = updatedAccounts[0]!.password.split(":");
|
||||
expect(Buffer.from(newHashParts[0]!, "hex")).toHaveLength(16);
|
||||
expect(Buffer.from(newHashParts[1]!, "hex")).toHaveLength(64);
|
||||
});
|
||||
|
||||
// ── AC-8: existing account password IS updated (not frozen at first-seed) ──
|
||||
|
||||
it("AC-8: re-seeding with a changed password env var updates the stored hash", async () => {
|
||||
const ORIGINAL_PASSWORD = "original-password";
|
||||
const ROTATED_PASSWORD = "rotated-password-456";
|
||||
|
||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = ROTATED_PASSWORD;
|
||||
|
||||
const preExistingUsers: UserRow[] = [
|
||||
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
|
||||
];
|
||||
// Account was created with the original password on first seed
|
||||
const originalHash = await hashPassword(ORIGINAL_PASSWORD);
|
||||
const preExistingAccounts: AccountRow[] = [
|
||||
{
|
||||
id: "pre-existing-acct",
|
||||
accountId: "pre-existing-user",
|
||||
providerId: "credential",
|
||||
userId: "pre-existing-user",
|
||||
password: originalHash,
|
||||
},
|
||||
];
|
||||
|
||||
// Re-seed with the rotated password env var
|
||||
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
|
||||
users: preExistingUsers,
|
||||
accounts: preExistingAccounts,
|
||||
staff: [],
|
||||
});
|
||||
|
||||
// No new user or account created
|
||||
expect(insertedUsers).toHaveLength(0);
|
||||
expect(insertedAccounts).toHaveLength(0);
|
||||
|
||||
// The pre-existing account's password WAS updated (not frozen at first-seed).
|
||||
// hashPassword uses a random salt so we verify by format + that it is a new,
|
||||
// different valid hash from the original.
|
||||
const updatedAcct = preExistingAccounts[0]!;
|
||||
expect(updatedAcct.password).toBeDefined();
|
||||
expect(updatedAcct.password).toMatch(/^[a-f0-9]{32}:[a-f0-9]{128}$/);
|
||||
expect(updatedAcct.password).not.toBe(originalHash); // it actually changed
|
||||
});
|
||||
|
||||
// ── AC-6: missing env var skips with warning ────────────────────────────────
|
||||
|
||||
+82
-8
@@ -20,6 +20,7 @@ import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import * as schema from "./schema.js";
|
||||
import type { MedicalAlert, MedicalAlertSeverity } from "./schema.js";
|
||||
|
||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||
|
||||
@@ -252,6 +253,38 @@ const appointmentNotes = [
|
||||
"Client running late, pushed start by 15min",
|
||||
];
|
||||
|
||||
const temperamentScores = [3, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9];
|
||||
|
||||
const temperamentFlags = [
|
||||
[], ["anxious"], ["friendly"], ["nippy"], ["anxious", "sensitive"],
|
||||
["friendly", "calm"], ["nippy", "territorial"], ["calm"], ["sensitive"],
|
||||
["friendly", "nippy"], ["anxious", "territorial"],
|
||||
];
|
||||
|
||||
const medicalAlertsList = [
|
||||
[] as MedicalAlert[],
|
||||
[] as MedicalAlert[],
|
||||
[{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }],
|
||||
[{ type: "ear", description: "Ear infection prone — dry ears thoroughly", severity: "medium" as MedicalAlertSeverity }],
|
||||
[{ type: "mobility", description: "Hip dysplasia — handle with care", severity: "high" as MedicalAlertSeverity }],
|
||||
[{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }],
|
||||
[{ type: "medical", description: "Seizure history — avoid stress triggers", severity: "high" as MedicalAlertSeverity }],
|
||||
[{ type: "skin", description: "Skin allergies — use hypoallergenic products only", severity: "medium" as MedicalAlertSeverity }],
|
||||
[{ type: "behavioral", description: "Aggressive when nails trimmed — muzzle required", severity: "high" as MedicalAlertSeverity }],
|
||||
[{ type: "cardiac", description: "Heart murmur — monitor during grooming", severity: "high" as MedicalAlertSeverity }],
|
||||
[{ type: "dietary", description: "Diabetic — owner brings treats", severity: "medium" as MedicalAlertSeverity }],
|
||||
];
|
||||
|
||||
const preferredCutsList = [
|
||||
[], ["Puppy Cut"], ["Teddy Bear Cut"], ["Breed Standard"],
|
||||
["Puppy Cut", "Sanitary Trim"], ["Full Groom"], ["Lion Cut"],
|
||||
["Kennel Cut", "Face & Feet Trim"], ["Teddy Bear Cut", "Sanitary Trim"],
|
||||
["Breed Standard", "Sanitary Trim"], ["Summer Shave"],
|
||||
["Puppy Cut", "Face & Feet Trim", "Sanitary Trim"],
|
||||
];
|
||||
|
||||
const coatTypes: string[] = ["short", "medium", "long", "curly", "wire", "double", "silky"];
|
||||
|
||||
const visitLogNotes = [
|
||||
null, null,
|
||||
"Coat in great condition",
|
||||
@@ -561,7 +594,15 @@ async function seedKnownUsers() {
|
||||
.limit(1);
|
||||
|
||||
if (existingAccount) {
|
||||
console.log(`✓ Credential account for '${acct.email}' already exists — skipping`);
|
||||
// Re-hash and update the password so that re-seeding rotates credentials
|
||||
// when the env var changes (e.g. after a password rotation). Previously
|
||||
// this branch skipped entirely, freezing the hash at first-seed.
|
||||
const { hashPassword } = await import("better-auth/crypto");
|
||||
const passwordHash = await hashPassword(password);
|
||||
await db.update(schema.account)
|
||||
.set({ password: passwordHash })
|
||||
.where(eq(schema.account.id, existingAccount.id));
|
||||
console.log(`✓ Updated credential account password for '${acct.email}'`);
|
||||
} else {
|
||||
// Use Better-Auth's own hashPassword to guarantee parameter/encoding match.
|
||||
// better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64, salt as 16-byte random
|
||||
@@ -872,6 +913,11 @@ async function seed() {
|
||||
cutStyle: pick(cutStyles),
|
||||
shampooPreference: pick(shampoos),
|
||||
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
|
||||
coatType: pick(coatTypes),
|
||||
temperamentScore: pick(temperamentScores),
|
||||
temperamentFlags: pick(temperamentFlags),
|
||||
medicalAlerts: pick(medicalAlertsList),
|
||||
preferredCuts: pick(preferredCutsList),
|
||||
customFields: {},
|
||||
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
|
||||
});
|
||||
@@ -907,6 +953,11 @@ async function seed() {
|
||||
cutStyle: pet.cutStyle,
|
||||
shampooPreference: pet.shampooPreference,
|
||||
specialCareNotes: pet.specialCareNotes,
|
||||
coatType: pet.coatType,
|
||||
temperamentScore: pet.temperamentScore,
|
||||
temperamentFlags: pet.temperamentFlags,
|
||||
medicalAlerts: pet.medicalAlerts,
|
||||
preferredCuts: pet.preferredCuts,
|
||||
customFields: pet.customFields,
|
||||
image: pet.image,
|
||||
},
|
||||
@@ -929,13 +980,18 @@ async function seed() {
|
||||
petId: string;
|
||||
petName: string;
|
||||
petBreed: string;
|
||||
petCoatType: string;
|
||||
petTemperamentScore: number;
|
||||
petTemperamentFlags: string[];
|
||||
petMedicalAlerts: MedicalAlert[];
|
||||
petPreferredCuts: string[];
|
||||
}
|
||||
const uatClients: UatClient[] = [
|
||||
{ id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever" },
|
||||
{ id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever" },
|
||||
{ id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle" },
|
||||
{ id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog" },
|
||||
{ id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle" },
|
||||
{ id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever", petCoatType: "double", petTemperamentScore: 7, petTemperamentFlags: ["calm", "friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Breed Standard"] },
|
||||
{ id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever", petCoatType: "short", petTemperamentScore: 8, petTemperamentFlags: ["friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Bath & Brush", "Sanitary Trim"] },
|
||||
{ id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle", petCoatType: "curly", petTemperamentScore: 9, petTemperamentFlags: ["calm"], petMedicalAlerts: [{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }], petPreferredCuts: ["Teddy Bear Cut"] },
|
||||
{ id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog", petCoatType: "short", petTemperamentScore: 6, petTemperamentFlags: ["nippy"], petMedicalAlerts: [{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }], petPreferredCuts: ["Puppy Cut"] },
|
||||
{ id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle", petCoatType: "short", petTemperamentScore: 7, petTemperamentFlags: ["friendly", "energetic"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Full Groom", "Nail Trim"] },
|
||||
];
|
||||
|
||||
for (const uc of uatClients) {
|
||||
@@ -943,8 +999,26 @@ async function seed() {
|
||||
.values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address })
|
||||
.onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
|
||||
await db.insert(schema.pets)
|
||||
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) })
|
||||
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } });
|
||||
.values({
|
||||
id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed,
|
||||
weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"),
|
||||
coatType: uc.petCoatType,
|
||||
temperamentScore: uc.petTemperamentScore,
|
||||
temperamentFlags: uc.petTemperamentFlags,
|
||||
medicalAlerts: uc.petMedicalAlerts,
|
||||
preferredCuts: uc.petPreferredCuts,
|
||||
image: pick(demoPetImages),
|
||||
})
|
||||
.onConflictDoUpdate({ target: schema.pets.id, set: {
|
||||
clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed,
|
||||
weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"),
|
||||
coatType: uc.petCoatType,
|
||||
temperamentScore: uc.petTemperamentScore,
|
||||
temperamentFlags: uc.petTemperamentFlags,
|
||||
medicalAlerts: uc.petMedicalAlerts,
|
||||
preferredCuts: uc.petPreferredCuts,
|
||||
image: pick(demoPetImages),
|
||||
} });
|
||||
// Create one completed appointment for this client
|
||||
const apptId = uuid();
|
||||
const svcIdx = 0;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { getDb, businessSettings, eq, staff } from "./db/index.js";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
|
||||
import { devRouter } from "./routes/dev.js";
|
||||
import { bufferRulesRouter } from "./routes/buffer-rules.js";
|
||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||
import { startReminderScheduler } from "./services/reminders.js";
|
||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||
@@ -211,6 +212,7 @@ api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
|
||||
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
|
||||
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
||||
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
||||
api.use("/buffer-rules/*", requireRole("manager"));
|
||||
api.use("/admin/settings/*", requireSuperUser());
|
||||
api.use("/reports/*", requireRole("manager"));
|
||||
api.use("/invoices/*", requireRole("manager", "groomer"));
|
||||
@@ -268,6 +270,7 @@ api.route("/impersonation", impersonationRouter);
|
||||
api.route("/admin/settings", settingsRouter);
|
||||
api.route("/admin/auth-provider", authProviderRouter);
|
||||
api.route("/admin/seed", adminSeedRouter);
|
||||
api.route("/buffer-rules", bufferRulesRouter);
|
||||
api.route("/search", searchRouter);
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
|
||||
@@ -36,6 +36,19 @@ const DEMO_PET = {
|
||||
weightKg: "30.00",
|
||||
};
|
||||
|
||||
const UAT_CLIENT = {
|
||||
name: "UAT Customer",
|
||||
email: "uat-customer@groombook.dev",
|
||||
phone: "555-0100",
|
||||
address: "1 UAT Lane, Test City, CA 90210",
|
||||
status: "active" as const,
|
||||
};
|
||||
|
||||
const UAT_PETS = [
|
||||
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly" as const, weightKg: "20.00" },
|
||||
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "smooth" as const, weightKg: "30.00" },
|
||||
];
|
||||
|
||||
const DEMO_SERVICES = [
|
||||
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
|
||||
@@ -43,7 +56,7 @@ const DEMO_SERVICES = [
|
||||
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
|
||||
];
|
||||
|
||||
adminSeedRouter.post("/seed", async (c) => {
|
||||
adminSeedRouter.post("/", async (c) => {
|
||||
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
return c.json(
|
||||
@@ -128,6 +141,51 @@ adminSeedRouter.post("/seed", async (c) => {
|
||||
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
|
||||
}
|
||||
|
||||
// ── Client: UAT Customer ──────────────────────────────────────────────────
|
||||
const [existingUatClient] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.email, UAT_CLIENT.email));
|
||||
|
||||
let uatClientId: string;
|
||||
if (existingUatClient) {
|
||||
uatClientId = existingUatClient.id;
|
||||
results.push(`Client '${UAT_CLIENT.name}' already exists (id: ${uatClientId})`);
|
||||
} else {
|
||||
const [created] = await db.insert(clients).values(UAT_CLIENT).returning();
|
||||
uatClientId = created!.id;
|
||||
results.push(`Created client '${UAT_CLIENT.name}' (id: ${uatClientId})`);
|
||||
}
|
||||
|
||||
// ── Pets: UAT Customer's Pets ─────────────────────────────────────────────
|
||||
const existingUatPets = await db
|
||||
.select()
|
||||
.from(pets)
|
||||
.where(eq(pets.clientId, uatClientId));
|
||||
|
||||
for (const uatPet of UAT_PETS) {
|
||||
const existingPet = existingUatPets.find(
|
||||
(p) => p.name === uatPet.name && p.species === uatPet.species
|
||||
);
|
||||
if (existingPet) {
|
||||
results.push(`Pet '${uatPet.name}' already exists for UAT Customer (id: ${existingPet.id})`);
|
||||
} else {
|
||||
const [created] = await db
|
||||
.insert(pets)
|
||||
.values({
|
||||
clientId: uatClientId,
|
||||
name: uatPet.name,
|
||||
species: uatPet.species,
|
||||
breed: uatPet.breed,
|
||||
coatType: uatPet.coatType,
|
||||
weightKg: uatPet.weightKg,
|
||||
dateOfBirth: new Date("2019-01-01T00:00:00Z"),
|
||||
})
|
||||
.returning();
|
||||
results.push(`Created pet '${uatPet.name}' for UAT Customer (id: ${created!.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Seed complete",
|
||||
details: results,
|
||||
@@ -136,4 +194,4 @@ adminSeedRouter.post("/seed", async (c) => {
|
||||
staffOidcSub: KNOWN_STAFF.oidcSub,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, getDb, isNull } from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import { bufferRules, services } from "../db/index.js";
|
||||
|
||||
export const bufferRulesRouter = new Hono<AppEnv>();
|
||||
|
||||
const createBufferRuleSchema = z.object({
|
||||
serviceId: z.string().uuid(),
|
||||
sizeCategory: z
|
||||
.enum(["small", "medium", "large", "extra_large"])
|
||||
.optional(),
|
||||
coatType: z
|
||||
.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"])
|
||||
.optional(),
|
||||
bufferMinutes: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const updateBufferRuleSchema = z.object({
|
||||
bufferMinutes: z.number().int().positive(),
|
||||
});
|
||||
|
||||
// GET / — list all buffer rules, optionally filtered by serviceId
|
||||
bufferRulesRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const serviceId = c.req.query("serviceId");
|
||||
|
||||
const conditions = [];
|
||||
if (serviceId) conditions.push(eq(bufferRules.serviceId, serviceId));
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: bufferRules.id,
|
||||
serviceId: bufferRules.serviceId,
|
||||
sizeCategory: bufferRules.sizeCategory,
|
||||
coatType: bufferRules.coatType,
|
||||
bufferMinutes: bufferRules.bufferMinutes,
|
||||
createdAt: bufferRules.createdAt,
|
||||
updatedAt: bufferRules.updatedAt,
|
||||
serviceName: services.name,
|
||||
})
|
||||
.from(bufferRules)
|
||||
.innerJoin(services, eq(bufferRules.serviceId, services.id))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(bufferRules.createdAt);
|
||||
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
// POST / — create a buffer rule
|
||||
bufferRulesRouter.post(
|
||||
"/",
|
||||
zValidator("json", createBufferRuleSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// Validate serviceId exists
|
||||
const [svc] = await db
|
||||
.select({ id: services.id })
|
||||
.from(services)
|
||||
.where(eq(services.id, body.serviceId));
|
||||
if (!svc) return c.json({ error: "Service not found" }, 404);
|
||||
|
||||
// Check for duplicate (service + size + coat)
|
||||
const [existing] = await db
|
||||
.select({ id: bufferRules.id })
|
||||
.from(bufferRules)
|
||||
.where(
|
||||
and(
|
||||
eq(bufferRules.serviceId, body.serviceId),
|
||||
body.sizeCategory !== undefined
|
||||
? eq(bufferRules.sizeCategory, body.sizeCategory)
|
||||
: isNull(bufferRules.sizeCategory),
|
||||
body.coatType !== undefined
|
||||
? eq(bufferRules.coatType, body.coatType)
|
||||
: isNull(bufferRules.coatType)
|
||||
)
|
||||
);
|
||||
if (existing) return c.json({ error: "Duplicate rule for this service+size+coat combination" }, 409);
|
||||
|
||||
const [row] = await db
|
||||
.insert(bufferRules)
|
||||
.values({
|
||||
serviceId: body.serviceId,
|
||||
sizeCategory: body.sizeCategory ?? null,
|
||||
coatType: body.coatType ?? null,
|
||||
bufferMinutes: body.bufferMinutes,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(row, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /:id — update bufferMinutes only
|
||||
bufferRulesRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateBufferRuleSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.update(bufferRules)
|
||||
.set({ bufferMinutes: body.bufferMinutes, updatedAt: new Date() })
|
||||
.where(eq(bufferRules.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /:id — delete a buffer rule
|
||||
bufferRulesRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.delete(bufferRules)
|
||||
.where(eq(bufferRules.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
+132
-1
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, pets, appointments } from "../db/index.js";
|
||||
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import {
|
||||
getPresignedUploadUrl,
|
||||
@@ -24,6 +24,7 @@ const createPetSchema = z.object({
|
||||
shampooPreference: z.string().max(500).optional(),
|
||||
specialCareNotes: z.string().max(2000).optional(),
|
||||
customFields: z.record(z.string(), z.string()).optional(),
|
||||
sizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
|
||||
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
|
||||
temperamentScore: z.number().int().min(1).max(5).optional(),
|
||||
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
|
||||
@@ -282,3 +283,133 @@ petsRouter.get("/:petId/photo", async (c) => {
|
||||
const url = await getPresignedGetUrl(pet.photoKey);
|
||||
return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt });
|
||||
});
|
||||
|
||||
// ─── Profile Summary ───────────────────────────────────────────────────────────
|
||||
|
||||
async function groomerLinkageCheck(
|
||||
db: ReturnType<typeof getDb>,
|
||||
clientId: string,
|
||||
staffRow: NonNullable<AppEnv["Variables"]["staff"]>
|
||||
): Promise<boolean> {
|
||||
const [linkage] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, clientId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return !!linkage;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /:id/profile-summary
|
||||
* Returns aggregated profile: basic pet fields + grooming history + visit stats + upcoming appointment.
|
||||
* Groomer RBAC: same visibility rules as GET /:id.
|
||||
*/
|
||||
petsRouter.get("/:id/profile-summary", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [row] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const hasLinkage = await groomerLinkageCheck(db, row.clientId, staffRow);
|
||||
if (!hasLinkage) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
// Recent grooming history: last 10, with staff name join
|
||||
const historyRows = await db
|
||||
.select({
|
||||
id: groomingVisitLogs.id,
|
||||
petId: groomingVisitLogs.petId,
|
||||
appointmentId: groomingVisitLogs.appointmentId,
|
||||
staffId: groomingVisitLogs.staffId,
|
||||
staffName: staff.name,
|
||||
cutStyle: groomingVisitLogs.cutStyle,
|
||||
productsUsed: groomingVisitLogs.productsUsed,
|
||||
notes: groomingVisitLogs.notes,
|
||||
groomedAt: groomingVisitLogs.groomedAt,
|
||||
createdAt: groomingVisitLogs.createdAt,
|
||||
})
|
||||
.from(groomingVisitLogs)
|
||||
.leftJoin(staff, eq(staff.id, groomingVisitLogs.staffId))
|
||||
.where(eq(groomingVisitLogs.petId, petId))
|
||||
.orderBy(desc(groomingVisitLogs.groomedAt))
|
||||
.limit(10);
|
||||
|
||||
const recentGroomingHistory = historyRows.map((r) => ({
|
||||
id: r.id,
|
||||
petId: r.petId,
|
||||
appointmentId: r.appointmentId,
|
||||
staffId: r.staffId,
|
||||
staffName: r.staffName,
|
||||
cutStyle: r.cutStyle,
|
||||
productsUsed: r.productsUsed,
|
||||
notes: r.notes,
|
||||
groomedAt: r.groomedAt?.toISOString() ?? null,
|
||||
createdAt: r.createdAt?.toISOString() ?? null,
|
||||
}));
|
||||
|
||||
const lastVisitDate = historyRows[0]?.groomedAt?.toISOString() ?? null;
|
||||
|
||||
// Completed appointment count for this pet
|
||||
const [{ count: visitCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(appointments)
|
||||
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")));
|
||||
|
||||
// Upcoming appointment: next scheduled or confirmed
|
||||
const [nextAppt] = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
serviceId: appointments.serviceId,
|
||||
staffId: appointments.staffId,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
status: appointments.status,
|
||||
serviceName: services.name,
|
||||
staffName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.leftJoin(services, eq(services.id, appointments.serviceId))
|
||||
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, petId),
|
||||
or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed")),
|
||||
gte(appointments.startTime, new Date())
|
||||
)
|
||||
)
|
||||
.orderBy(appointments.startTime)
|
||||
.limit(1);
|
||||
|
||||
const upcomingAppointment = nextAppt
|
||||
? {
|
||||
id: nextAppt.id,
|
||||
serviceId: nextAppt.serviceId,
|
||||
serviceName: nextAppt.serviceName,
|
||||
staffId: nextAppt.staffId,
|
||||
staffName: nextAppt.staffName,
|
||||
startTime: nextAppt.startTime?.toISOString() ?? null,
|
||||
endTime: nextAppt.endTime?.toISOString() ?? null,
|
||||
status: nextAppt.status,
|
||||
}
|
||||
: null;
|
||||
|
||||
return c.json({
|
||||
...row,
|
||||
recentGroomingHistory,
|
||||
lastVisitDate,
|
||||
visitCount,
|
||||
upcomingAppointment,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,9 @@ const createServiceSchema = z.object({
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const updateServiceSchema = createServiceSchema.partial();
|
||||
const updateServiceSchema = createServiceSchema.partial().extend({
|
||||
defaultBufferMinutes: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
servicesRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
|
||||
@@ -26,6 +26,19 @@ export interface Client {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ─── Medical Alerts ────────────────────────────────────────────────────────────
|
||||
|
||||
export type AlertSeverity = "low" | "medium" | "high";
|
||||
|
||||
export interface MedicalAlert {
|
||||
type: string;
|
||||
description: string;
|
||||
severity: AlertSeverity;
|
||||
}
|
||||
|
||||
// ─── Pet Profile Summary ────────────────────────────────────────────────────
|
||||
|
||||
export type CoatType = "short" | "medium" | "long" | "double" | "wire" | "silky" | "curly" | "hairless";
|
||||
export interface Pet {
|
||||
id: string;
|
||||
clientId: string;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc --project .",
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
CREATE TYPE "pet_size_category" AS ENUM ('small', 'medium', 'large', 'xlarge');
|
||||
CREATE TYPE "coat_type" AS ENUM ('smooth', 'double', 'wire', 'curly', 'long', 'hairless');
|
||||
|
||||
-- ─── Alter pets columns to use new enums ─────────────────────────────────────
|
||||
-- ─── Add columns to pets if missing, then cast to enums ──────────────────────
|
||||
|
||||
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "coat_type" text;
|
||||
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "pet_size_category" text;
|
||||
ALTER TABLE "pets" ALTER COLUMN "coat_type" TYPE "coat_type" USING "coat_type"::text::"coat_type";
|
||||
ALTER TABLE "pets" ALTER COLUMN "pet_size_category" TYPE "pet_size_category" USING "pet_size_category"::text::"pet_size_category";
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- no-op: journal entry exists but no schema change was needed
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Migration: 0033_add_services_default_buffer_minutes.sql
|
||||
-- Adds missing default_buffer_minutes column to services table.
|
||||
-- 0031_buffer_rules was applied to the DB but its journal entry was missing,
|
||||
-- so this ensures idempotent column addition for fresh DB restores.
|
||||
|
||||
ALTER TABLE "services" ADD COLUMN IF NOT EXISTS "default_buffer_minutes" integer DEFAULT 0 NOT NULL;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Migration: 0034_extend_pet_profile_columns.sql
|
||||
-- GRO-1850: Adds temperament_score, temperament_flags, medical_alerts,
|
||||
-- and preferred_cuts columns to the pets table.
|
||||
|
||||
ALTER TABLE "pets" ADD COLUMN "temperament_score" integer;
|
||||
ALTER TABLE "pets" ADD COLUMN "temperament_flags" jsonb DEFAULT '[]';
|
||||
ALTER TABLE "pets" ADD COLUMN "medical_alerts" jsonb DEFAULT '[]';
|
||||
ALTER TABLE "pets" ADD COLUMN "preferred_cuts" jsonb DEFAULT '[]';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration: 0035_add_missing_coat_type_values.sql
|
||||
-- Adds missing values to coat_type enum that seed.ts requires but which were
|
||||
-- omitted from the 0031_buffer_rules.sql CREATE TYPE statement (migration drift).
|
||||
-- 0031 created: 'smooth', 'double', 'wire', 'curly', 'long', 'hairless'
|
||||
-- Missing (from schema.ts coatTypeEnum): 'short', 'medium', 'silky'
|
||||
|
||||
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short';
|
||||
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'medium';
|
||||
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'silky';
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Migration: 0035_add_short_to_coat_type_enum.sql
|
||||
-- GRO-1953: Adds missing "short" value to the coat_type enum so that seed data
|
||||
-- (which uses coatTypePool including "short") can be inserted without error.
|
||||
--
|
||||
-- The seed file defines coatTypePool as:
|
||||
-- ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]
|
||||
-- but migration 0031 created the enum without "short", causing:
|
||||
-- PostgresError: invalid input value for enum coat_type: "short"
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration: 0036_add_missing_coat_type_values.sql
|
||||
-- Adds missing values to coat_type enum that seed.ts requires but which were
|
||||
-- omitted from the 0031_buffer_rules.sql CREATE TYPE statement (migration drift).
|
||||
-- 0031 created: 'smooth', 'double', 'wire', 'curly', 'long', 'hairless'
|
||||
-- Missing (from schema.ts coatTypeEnum): 'short', 'medium', 'silky'
|
||||
|
||||
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short';
|
||||
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'medium';
|
||||
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'silky';
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Migration: 0037_add_extra_large_to_pet_size_category.sql
|
||||
-- GRO-1979: Adds the 'extra_large' value to the pet_size_category enum.
|
||||
--
|
||||
-- 0031_buffer_rules.sql created pet_size_category with values
|
||||
-- ('small', 'medium', 'large', 'xlarge'), but seed.ts and the drizzle
|
||||
-- schema (PetSizeCategory type) both use 'extra_large' — a mismatch that
|
||||
-- caused the UAT seed job to fail with:
|
||||
-- invalid input value for enum pet_size_category: "extra_large"
|
||||
--
|
||||
-- 0035/0036 (GRO-1971) registered 'short'/'medium'/'silky' in coat_type.
|
||||
-- This migration is the pet_size_category counterpart: register
|
||||
-- 'extra_large' so seed.ts can write the value the schema declares.
|
||||
--
|
||||
-- Postgres restriction: ALTER TYPE ADD VALUE cannot run inside a
|
||||
-- transaction block. The drizzle migrate runner does not wrap
|
||||
-- individual statements in an explicit transaction, so this applies
|
||||
-- as a single auto-commit DDL.
|
||||
|
||||
ALTER TYPE "pet_size_category" ADD VALUE IF NOT EXISTS 'extra_large';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- GRO-1999: 0037 was skipped on existing DBs due to a below-high-water-mark
|
||||
-- journal timestamp. Re-register extra_large with a monotonic timestamp so
|
||||
-- the existing UAT/persistent DBs apply it. Idempotent.
|
||||
ALTER TYPE "pet_size_category" ADD VALUE IF NOT EXISTS 'extra_large';
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"id": "0033_add_services_default_buffer_minutes",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"authProviderConfig": {
|
||||
"name": "auth_provider_config",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
|
||||
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
|
||||
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
|
||||
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
|
||||
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
|
||||
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
|
||||
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
|
||||
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {}
|
||||
},
|
||||
"businessSettings": {
|
||||
"name": "business_settings",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
|
||||
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
|
||||
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
|
||||
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
|
||||
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
|
||||
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"name": { "name": "name", "type": "text", "isNullable": false },
|
||||
"email": { "name": "email", "type": "text", "isNullable": true },
|
||||
"phone": { "name": "phone", "type": "text", "isNullable": true },
|
||||
"address": { "name": "address", "type": "text", "isNullable": true },
|
||||
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
||||
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
|
||||
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
|
||||
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
|
||||
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
|
||||
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
|
||||
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
|
||||
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
|
||||
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
|
||||
},
|
||||
"invoices": {
|
||||
"name": "invoices",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
|
||||
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
|
||||
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
|
||||
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
|
||||
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
|
||||
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
|
||||
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
|
||||
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
|
||||
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
|
||||
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
|
||||
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
|
||||
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
|
||||
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
|
||||
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
|
||||
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
|
||||
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
|
||||
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
|
||||
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
|
||||
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
|
||||
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
|
||||
},
|
||||
"nativeEnums": {}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
{
|
||||
"id": "0034_extend_pet_profile_columns",
|
||||
"prevId": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.pets": {
|
||||
"name": "pets",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"species": {
|
||||
"name": "species",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"breed": {
|
||||
"name": "breed",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"weight_kg": {
|
||||
"name": "weight_kg",
|
||||
"type": "numeric(5, 2)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"date_of_birth": {
|
||||
"name": "date_of_birth",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"health_alerts": {
|
||||
"name": "health_alerts",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"grooming_notes": {
|
||||
"name": "grooming_notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cut_style": {
|
||||
"name": "cut_style",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"shampoo_preference": {
|
||||
"name": "shampoo_preference",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"special_care_notes": {
|
||||
"name": "special_care_notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"coat_type": {
|
||||
"name": "coat_type",
|
||||
"type": "coat_type",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pet_size_category": {
|
||||
"name": "pet_size_category",
|
||||
"type": "pet_size_category",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"temperament_score": {
|
||||
"name": "temperament_score",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"temperament_flags": {
|
||||
"name": "temperament_flags",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"medical_alerts": {
|
||||
"name": "medical_alerts",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"preferred_cuts": {
|
||||
"name": "preferred_cuts",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"custom_fields": {
|
||||
"name": "custom_fields",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"photo_key": {
|
||||
"name": "photo_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"photo_uploaded_at": {
|
||||
"name": "photo_uploaded_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"pets_client_id_clients_id_fk": {
|
||||
"name": "pets_client_id_clients_id_fk",
|
||||
"tableFrom": "pets",
|
||||
"tableTo": "clients",
|
||||
"columnsFrom": [
|
||||
"client_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"coat_type": {
|
||||
"name": "coat_type",
|
||||
"values": [
|
||||
"short",
|
||||
"medium",
|
||||
"long",
|
||||
"wire",
|
||||
"double",
|
||||
"hairless",
|
||||
"curly"
|
||||
]
|
||||
},
|
||||
"pet_size_category": {
|
||||
"name": "pet_size_category",
|
||||
"values": [
|
||||
"small",
|
||||
"medium",
|
||||
"large",
|
||||
"extra_large"
|
||||
]
|
||||
}
|
||||
},
|
||||
"nativeEnums": {}
|
||||
}
|
||||
@@ -218,6 +218,55 @@
|
||||
"when": 1775828067192,
|
||||
"tag": "0030_messaging",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "7",
|
||||
"when": 1775860800000,
|
||||
"tag": "0031_buffer_rules",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1775894400000,
|
||||
"tag": "0032_staff_read_at",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 33,
|
||||
"version": "7",
|
||||
"when": 1779500000000,
|
||||
"tag": "0033_add_services_default_buffer_minutes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "7",
|
||||
"when": 1751140800000,
|
||||
"tag": "0034_extend_pet_profile_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "7",
|
||||
"when": 1751480000000,
|
||||
"tag": "0036_add_missing_coat_type_values",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 37,
|
||||
"version": "7",
|
||||
"when": 1751500000000,
|
||||
"tag": "0037_add_extra_large_to_pet_size_category",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 38,
|
||||
"version": "7",
|
||||
"when": 1780000000000,
|
||||
"tag": "0038_register_extra_large_pet_size_category",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,8 +105,10 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
|
||||
photoKey: null,
|
||||
photoUploadedAt: null,
|
||||
image: null,
|
||||
coatType: null,
|
||||
petSizeCategory: null,
|
||||
temperamentScore: null,
|
||||
temperamentFlags: [],
|
||||
medicalAlerts: [],
|
||||
preferredCuts: [],
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
};
|
||||
@@ -121,7 +123,7 @@ export function buildService(overrides: Partial<ServiceRow> = {}): ServiceRow {
|
||||
description: "A grooming service",
|
||||
basePriceCents: 6500,
|
||||
durationMinutes: 60,
|
||||
defaultBufferMinutes: null,
|
||||
defaultBufferMinutes: 0,
|
||||
active: true,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
|
||||
@@ -12,7 +12,7 @@ export function getDb() {
|
||||
if (_db) return _db;
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("DATABASE_URL is not set");
|
||||
const client = postgres(url, { max: 10 });
|
||||
const client = postgres(url, { max: 10, connect_timeout: 5 });
|
||||
_db = drizzle(client, { schema });
|
||||
return _db;
|
||||
}
|
||||
|
||||
+57
-39
@@ -11,6 +11,7 @@ import {
|
||||
unique,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { MedicalAlert } from "@groombook/types";
|
||||
|
||||
// ─── Enums ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -48,22 +49,6 @@ export const clientStatusEnum = pgEnum("client_status", [
|
||||
"disabled",
|
||||
]);
|
||||
|
||||
export const petSizeCategoryEnum = pgEnum("pet_size_category", [
|
||||
"small",
|
||||
"medium",
|
||||
"large",
|
||||
"xlarge",
|
||||
]);
|
||||
|
||||
export const coatTypeEnum = pgEnum("coat_type", [
|
||||
"smooth",
|
||||
"double",
|
||||
"wire",
|
||||
"curly",
|
||||
"long",
|
||||
"hairless",
|
||||
]);
|
||||
|
||||
// ─── Better-Auth Tables ──────────────────────────────────────────────────────
|
||||
|
||||
export const user = pgTable("user", {
|
||||
@@ -116,6 +101,26 @@ export const verification = pgTable("verification", {
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ─── Pet enums ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const petSizeCategoryEnum = pgEnum("pet_size_category", [
|
||||
"small",
|
||||
"medium",
|
||||
"large",
|
||||
"extra_large",
|
||||
]);
|
||||
|
||||
export const coatTypeEnum = pgEnum("coat_type", [
|
||||
"short",
|
||||
"medium",
|
||||
"long",
|
||||
"double",
|
||||
"wire",
|
||||
"silky",
|
||||
"curly",
|
||||
"hairless",
|
||||
]);
|
||||
|
||||
// ─── Tables ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const clients = pgTable(
|
||||
@@ -160,6 +165,10 @@ export const pets = pgTable(
|
||||
specialCareNotes: text("special_care_notes"),
|
||||
coatType: coatTypeEnum("coat_type"),
|
||||
petSizeCategory: petSizeCategoryEnum("pet_size_category"),
|
||||
temperamentScore: integer("temperament_score"),
|
||||
temperamentFlags: jsonb("temperament_flags").$type<string[]>().default([]),
|
||||
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().default([]),
|
||||
preferredCuts: jsonb("preferred_cuts").$type<string[]>().default([]),
|
||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
||||
photoKey: text("photo_key"),
|
||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||
@@ -176,34 +185,12 @@ export const services = pgTable("services", {
|
||||
description: text("description"),
|
||||
basePriceCents: integer("base_price_cents").notNull(),
|
||||
durationMinutes: integer("duration_minutes").notNull(),
|
||||
defaultBufferMinutes: integer("default_buffer_minutes"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
defaultBufferMinutes: integer("default_buffer_minutes").notNull().default(0),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const bufferRules = pgTable(
|
||||
"buffer_rules",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
serviceId: uuid("service_id")
|
||||
.notNull()
|
||||
.references(() => services.id, { onDelete: "cascade" }),
|
||||
sizeCategory: petSizeCategoryEnum("size_category"),
|
||||
coatType: coatTypeEnum("coat_type"),
|
||||
bufferMinutes: integer("buffer_minutes").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
unique("uq_buffer_rules_service_size_coat").on(
|
||||
t.serviceId,
|
||||
t.sizeCategory,
|
||||
t.coatType
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
export const staff = pgTable("staff", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
@@ -640,3 +627,34 @@ export const authProviderConfig = pgTable("auth_provider_config", {
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ─── Buffer Rules ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Buffer time rules per service + pet size/coat combination.
|
||||
// Covers service-level defaults and pet-specific overrides.
|
||||
export const bufferRules = pgTable(
|
||||
"buffer_rules",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
serviceId: uuid("service_id")
|
||||
.notNull()
|
||||
.references(() => services.id, { onDelete: "cascade" }),
|
||||
// null sizeCategory means "any size" (wildcard)
|
||||
sizeCategory: petSizeCategoryEnum("size_category"),
|
||||
// null coatType means "any coat type" (wildcard)
|
||||
coatType: coatTypeEnum("coat_type"),
|
||||
// minutes to add to the service duration for this size/coat combo
|
||||
bufferMinutes: integer("buffer_minutes").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// One rule per unique (service, size, coat) combination
|
||||
unique("uq_buffer_rules_service_size_coat").on(
|
||||
t.serviceId,
|
||||
t.sizeCategory,
|
||||
t.coatType
|
||||
),
|
||||
index("idx_buffer_rules_service_id").on(t.serviceId),
|
||||
]
|
||||
);
|
||||
|
||||
+331
-95
@@ -20,6 +20,7 @@ import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import * as schema from "./schema.js";
|
||||
import type { MedicalAlert } from "@groombook/types";
|
||||
|
||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||
|
||||
@@ -243,6 +244,59 @@ const groomingNotes = [
|
||||
"Previous clipper burn — be gentle on belly",
|
||||
];
|
||||
|
||||
// ── Extended pet profile pools ─────────────────────────────────────────────────
|
||||
|
||||
const temperamentFlagPool: string[] = [
|
||||
"friendly",
|
||||
"anxious-with-strangers",
|
||||
"good-with-kids",
|
||||
"leash-reactive",
|
||||
"vocal",
|
||||
"high-energy",
|
||||
"calm-on-table",
|
||||
"treat-motivated",
|
||||
];
|
||||
|
||||
const medicalAlertPool: MedicalAlert[] = [
|
||||
{ id: "", type: "allergies", description: "Seasonal allergies — monitor skin", severity: "low" },
|
||||
{ id: "", type: "allergies", description: "Chicken allergy — avoid poultry-based treats", severity: "high" },
|
||||
{ id: "", type: "joint", description: "Hip dysplasia — handle with care", severity: "medium" },
|
||||
{ id: "", type: "joint", description: "Arthritis — anti-inflammatory medication on file", severity: "medium" },
|
||||
{ id: "", type: "dental", description: "Dental disease — extractions in history", severity: "medium" },
|
||||
{ id: "", type: "dental", description: "Baby teeth retained — vet monitor", severity: "low" },
|
||||
{ id: "", type: "heart", description: "Heart murmur grade II — avoid stress", severity: "high" },
|
||||
{ id: "", type: "heart", description: "Murmur cleared by vet last year", severity: "low" },
|
||||
{ id: "", type: "other", description: "Eye ulcer history — be careful around face", severity: "medium" },
|
||||
{ id: "", type: "other", description: "Seizure history — avoid flashing lights", severity: "high" },
|
||||
{ id: "", type: "other", description: "Luxating patella — short walks only", severity: "medium" },
|
||||
{ id: "", type: "other", description: "Ear infections — dry thoroughly after bath", severity: "low" },
|
||||
{ id: "", type: "behavioral", description: "Anxiety — calm environment preferred", severity: "low" },
|
||||
{ id: "", type: "behavioral", description: "Fear-based aggression — approach with caution", severity: "high" },
|
||||
{ id: "", type: "skin", description: "Contact dermatitis — avoid harsh chemicals", severity: "medium" },
|
||||
{ id: "", type: "skin", description: "Hot spots — monitor and report any worsening", severity: "high" },
|
||||
];
|
||||
|
||||
const preferredCutPool: string[] = [
|
||||
"Puppy Cut",
|
||||
"Teddy Bear Cut",
|
||||
"Lion Cut",
|
||||
"Breed Standard",
|
||||
"Summer Shave",
|
||||
"Kennel Cut",
|
||||
"Lamb Cut",
|
||||
"Continental Clip",
|
||||
"Sporting Clip",
|
||||
"Sanitary Trim",
|
||||
"Face & Feet Trim",
|
||||
"Full Groom",
|
||||
];
|
||||
|
||||
type CoatType = (typeof schema.coatTypeEnum.enumValues)[number];
|
||||
type PetSizeCategory = (typeof schema.petSizeCategoryEnum.enumValues)[number];
|
||||
|
||||
const coatTypePool: CoatType[] = ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"];
|
||||
const petSizeCategoryPool: PetSizeCategory[] = ["small", "medium", "large", "extra_large"];
|
||||
|
||||
const appointmentNotes = [
|
||||
null, null, null, null,
|
||||
"Client requested extra brushing",
|
||||
@@ -335,78 +389,19 @@ const servicesDef = [
|
||||
{ id: "b0000001-0000-0000-0000-00000000000a", name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 },
|
||||
];
|
||||
|
||||
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
|
||||
// ── UAT staff account seeding (shared between seed paths) ─────────────────────
|
||||
|
||||
/**
|
||||
* Seeds only the minimal known users for prod/demo environments.
|
||||
* Creates: Demo Manager staff + Demo Client + Demo Dog + basic services.
|
||||
* Idempotent: skips creation if records already exist.
|
||||
* Seeds or upserts the deterministic UAT staff accounts with numeric OIDC subs
|
||||
* from SEED_UAT_*_OIDC_SUB / SEED_UAT_GROOMER_OIDC_SUBS env vars.
|
||||
*
|
||||
* In the full seed path this must run AFTER random staff are created so the
|
||||
* deterministic upserts land on the correct rows (groomers referenced by the
|
||||
* UAT test-client appointment logic use groomers[0] etc.).
|
||||
*
|
||||
* In seedKnownUsers() this replaces the inline UAT-staff block.
|
||||
*/
|
||||
async function seedKnownUsers() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("DATABASE_URL is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = postgres(url, { max: 5 });
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
console.log("Seeding known users (prod/demo mode)...\n");
|
||||
|
||||
const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
||||
const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002";
|
||||
const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003";
|
||||
|
||||
// ── Staff: Demo Manager ──
|
||||
const [existingStaff] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, "demo-manager@groombook.dev"))
|
||||
.limit(1);
|
||||
|
||||
if (existingStaff) {
|
||||
console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`);
|
||||
} else {
|
||||
await db.insert(schema.staff).values({
|
||||
id: KNOWN_STAFF_ID,
|
||||
name: "Demo Manager",
|
||||
email: "demo-manager@groombook.dev",
|
||||
oidcSub: "demo-manager-001",
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
});
|
||||
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
|
||||
}
|
||||
|
||||
// ── Staff: SEED_ADMIN_EMAIL admin ──
|
||||
const adminEmail = process.env.SEED_ADMIN_EMAIL;
|
||||
if (adminEmail) {
|
||||
const adminName = process.env.SEED_ADMIN_NAME ?? "Admin";
|
||||
const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002";
|
||||
const [existingAdmin] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, adminEmail))
|
||||
.limit(1);
|
||||
|
||||
if (existingAdmin) {
|
||||
console.log(`✓ Staff admin '${existingAdmin.name}' already exists — skipping`);
|
||||
} else {
|
||||
await db.insert(schema.staff).values({
|
||||
id: ADMIN_STAFF_ID,
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
oidcSub: adminEmail,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
});
|
||||
console.log(`✓ Created staff admin '${adminName}' (${adminEmail})`);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -574,6 +569,184 @@ async function seedKnownUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Client: UAT Customer ─────────────────────────────────────────────────────
|
||||
// Only uat-customer is a real end-user who needs a clients row.
|
||||
// uat-groomer and uat-super are staff — they have staff records, not client records.
|
||||
const UAT_CUSTOMER_ID = "c0000001-0000-0000-0000-000000000001";
|
||||
const [uatCustomerRow] = await db
|
||||
.select()
|
||||
.from(schema.clients)
|
||||
.where(eq(schema.clients.email, "uat-customer@groombook.dev"))
|
||||
.limit(1);
|
||||
|
||||
let uatCustomerClientId: string;
|
||||
if (uatCustomerRow) {
|
||||
uatCustomerClientId = uatCustomerRow.id;
|
||||
console.log(`✓ UAT Customer client record already exists — skipping`);
|
||||
} else {
|
||||
const [created] = await db
|
||||
.insert(schema.clients)
|
||||
.values({
|
||||
id: UAT_CUSTOMER_ID,
|
||||
email: "uat-customer@groombook.dev",
|
||||
name: "UAT Customer",
|
||||
phone: "555-0102",
|
||||
address: "1 UAT Lane, Test City, CA 90210",
|
||||
})
|
||||
.returning();
|
||||
uatCustomerClientId = created!.id;
|
||||
console.log(`✓ Created client 'UAT Customer' for SSO bridge`);
|
||||
}
|
||||
|
||||
// ── Pets: UAT Customer's dogs ────────────────────────────────────────────────
|
||||
const uatCustomerPets = [
|
||||
{ id: "c0000001-0000-0000-0000-000000000002", name: "UAT Pup Alpha", species: "Dog", breed: "Beagle", weight: "12.00", dob: "2022-03-10", image: "/demo-pets/dog-beagle.png" },
|
||||
{ id: "c0000001-0000-0000-0000-000000000003", name: "UAT Pup Beta", species: "Dog", breed: "Labrador", weight: "28.00", dob: "2021-07-22", image: "/demo-pets/dog-labrador.png" },
|
||||
];
|
||||
for (const pet of uatCustomerPets) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(schema.pets)
|
||||
.where(eq(schema.pets.id, pet.id))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
// Upsert so extended fields are always populated on re-runs
|
||||
await db.insert(schema.pets)
|
||||
.values({
|
||||
id: pet.id,
|
||||
clientId: uatCustomerClientId,
|
||||
name: pet.name,
|
||||
species: pet.species,
|
||||
breed: pet.breed,
|
||||
weightKg: pet.weight,
|
||||
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
|
||||
image: pet.image,
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: [],
|
||||
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||
coatType: pick(coatTypePool),
|
||||
petSizeCategory: pick(petSizeCategoryPool),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.pets.id,
|
||||
set: {
|
||||
clientId: uatCustomerClientId,
|
||||
name: pet.name,
|
||||
species: pet.species,
|
||||
breed: pet.breed,
|
||||
weightKg: pet.weight,
|
||||
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
|
||||
image: pet.image,
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: [],
|
||||
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||
coatType: pick(coatTypePool),
|
||||
petSizeCategory: pick(petSizeCategoryPool),
|
||||
},
|
||||
});
|
||||
console.log(`✓ Upserted UAT pet '${pet.name}' with extended fields`);
|
||||
} else {
|
||||
await db.insert(schema.pets).values({
|
||||
id: pet.id,
|
||||
clientId: uatCustomerClientId,
|
||||
name: pet.name,
|
||||
species: pet.species,
|
||||
breed: pet.breed,
|
||||
weightKg: pet.weight,
|
||||
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
|
||||
image: pet.image,
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: [],
|
||||
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||
coatType: pick(coatTypePool),
|
||||
petSizeCategory: pick(petSizeCategoryPool),
|
||||
});
|
||||
console.log(`✓ Created UAT pet '${pet.name}' with extended fields`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seeds only the minimal known users for prod/demo environments.
|
||||
* Creates: Demo Manager staff + Demo Client + Demo Dog + basic services.
|
||||
* Idempotent: skips creation if records already exist.
|
||||
*/
|
||||
async function seedKnownUsers() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("DATABASE_URL is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = postgres(url, { max: 5 });
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
console.log("Seeding known users (prod/demo mode)...\n");
|
||||
|
||||
const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
||||
const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002";
|
||||
const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003";
|
||||
|
||||
// ── Staff: Demo Manager ──
|
||||
const [existingStaff] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, "demo-manager@groombook.dev"))
|
||||
.limit(1);
|
||||
|
||||
if (existingStaff) {
|
||||
console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`);
|
||||
} else {
|
||||
await db.insert(schema.staff).values({
|
||||
id: KNOWN_STAFF_ID,
|
||||
name: "Demo Manager",
|
||||
email: "demo-manager@groombook.dev",
|
||||
oidcSub: "demo-manager-001",
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
});
|
||||
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
|
||||
}
|
||||
|
||||
// ── Staff: SEED_ADMIN_EMAIL admin ──
|
||||
const adminEmail = process.env.SEED_ADMIN_EMAIL;
|
||||
if (adminEmail) {
|
||||
const adminName = process.env.SEED_ADMIN_NAME ?? "Admin";
|
||||
const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002";
|
||||
const [existingAdmin] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, adminEmail))
|
||||
.limit(1);
|
||||
|
||||
if (existingAdmin) {
|
||||
console.log(`✓ Staff admin '${existingAdmin.name}' already exists — skipping`);
|
||||
} else {
|
||||
await db.insert(schema.staff).values({
|
||||
id: ADMIN_STAFF_ID,
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
oidcSub: adminEmail,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
});
|
||||
console.log(`✓ Created staff admin '${adminName}' (${adminEmail})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── UAT staff accounts + Better Auth credentials (shared impl) ──────────────
|
||||
// Extracted into seedUatStaffAccounts() so it runs in both seedKnownUsers()
|
||||
// and the full seed() UAT branch.
|
||||
await seedUatStaffAccounts(db);
|
||||
|
||||
// ── 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.
|
||||
@@ -740,30 +913,10 @@ async function seed() {
|
||||
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
||||
}
|
||||
|
||||
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
||||
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
||||
for (let i = 0; i < groomerCount; i++) {
|
||||
const email = groomerEmails[i]!;
|
||||
const name = groomerNames[i]!;
|
||||
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
||||
await db.insert(schema.staff)
|
||||
.values({
|
||||
id: staffId,
|
||||
name,
|
||||
email,
|
||||
oidcSub: email,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.staff.email,
|
||||
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
|
||||
});
|
||||
console.log(`✓ Upserted groomer '${name}' (${email})`);
|
||||
}
|
||||
// ── UAT staff accounts + Better Auth credentials (shared impl) ──────────────
|
||||
// Seeds deterministic UAT staff with numeric OIDC subs and Better Auth credentials.
|
||||
// Must run AFTER random staff are created so upserts land correctly.
|
||||
await seedUatStaffAccounts(db);
|
||||
|
||||
// ── Services ──
|
||||
// Upsert services using name as unique key. With deterministic IDs in
|
||||
@@ -853,6 +1006,19 @@ async function seed() {
|
||||
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
|
||||
customFields: {},
|
||||
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: (() => {
|
||||
// ~30% of random-pool pets have alerts — lands squarely in the 25–35% AC band
|
||||
if (rand() < 0.3) {
|
||||
const count = rand() < 0.7 ? 1 : 2;
|
||||
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
return [];
|
||||
})(),
|
||||
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||
coatType: pick(coatTypePool),
|
||||
petSizeCategory: pick(petSizeCategoryPool),
|
||||
});
|
||||
|
||||
petRecords.push({ id: petId, clientId });
|
||||
@@ -888,6 +1054,12 @@ async function seed() {
|
||||
specialCareNotes: pet.specialCareNotes,
|
||||
customFields: pet.customFields,
|
||||
image: pet.image,
|
||||
temperamentScore: pet.temperamentScore,
|
||||
temperamentFlags: pet.temperamentFlags,
|
||||
medicalAlerts: pet.medicalAlerts,
|
||||
preferredCuts: pet.preferredCuts,
|
||||
coatType: pet.coatType,
|
||||
petSizeCategory: pet.petSizeCategory,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -922,8 +1094,72 @@ async function seed() {
|
||||
.values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address })
|
||||
.onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
|
||||
await db.insert(schema.pets)
|
||||
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) })
|
||||
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } });
|
||||
.values({
|
||||
id: uc.petId,
|
||||
clientId: uc.id,
|
||||
name: uc.petName,
|
||||
species: "Dog",
|
||||
breed: uc.petBreed,
|
||||
weightKg: "25.00",
|
||||
dateOfBirth: new Date("2021-03-15T00:00:00Z"),
|
||||
image: pick(demoPetImages),
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: (() => {
|
||||
// TestCooper always has a behavioral alert; TestRocky always has a skin alert.
|
||||
// All other UAT test pets follow the 30% random distribution.
|
||||
// Deterministic alerts on 2 of 507 pets (~0.4%) do not meaningfully shift
|
||||
// the overall distribution from the 25-35% target band.
|
||||
if (uc.petName === "TestCooper") {
|
||||
return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
if (uc.petName === "TestRocky") {
|
||||
return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
if (rand() < 0.3) {
|
||||
const count = rand() < 0.7 ? 1 : 2;
|
||||
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
return [];
|
||||
})(),
|
||||
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||
coatType: pick(coatTypePool),
|
||||
petSizeCategory: pick(petSizeCategoryPool),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.pets.id,
|
||||
set: {
|
||||
clientId: uc.id,
|
||||
name: uc.petName,
|
||||
species: "Dog",
|
||||
breed: uc.petBreed,
|
||||
weightKg: "25.00",
|
||||
dateOfBirth: new Date("2021-03-15T00:00:00Z"),
|
||||
image: pick(demoPetImages),
|
||||
temperamentScore: randInt(1, 5),
|
||||
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
|
||||
medicalAlerts: (() => {
|
||||
// TestCooper always has a behavioral alert; TestRocky always has a skin alert.
|
||||
// All other UAT test pets follow the 30% random distribution.
|
||||
// Deterministic alerts on 2 of 507 pets (~0.4%) do not meaningfully shift
|
||||
// the overall distribution from the 25-35% target band.
|
||||
if (uc.petName === "TestCooper") {
|
||||
return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
if (uc.petName === "TestRocky") {
|
||||
return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
if (rand() < 0.3) {
|
||||
const count = rand() < 0.7 ? 1 : 2;
|
||||
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
|
||||
}
|
||||
return [];
|
||||
})(),
|
||||
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
|
||||
coatType: pick(coatTypePool),
|
||||
petSizeCategory: pick(petSizeCategoryPool),
|
||||
},
|
||||
});
|
||||
// Create one completed appointment for this client
|
||||
const apptId = uuid();
|
||||
const svcIdx = 0;
|
||||
|
||||
@@ -225,3 +225,34 @@ export interface MedicalAlert {
|
||||
}
|
||||
|
||||
export type CoatType = "smooth" | "double" | "curly" | "wire" | "long" | "hairless";
|
||||
|
||||
export interface GroomingHistoryEntry {
|
||||
id: string;
|
||||
petId: string;
|
||||
appointmentId: string | null;
|
||||
staffId: string | null;
|
||||
staffName: string | null;
|
||||
cutStyle: string | null;
|
||||
productsUsed: string | null;
|
||||
notes: string | null;
|
||||
groomedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UpcomingAppointment {
|
||||
id: string;
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
staffId: string | null;
|
||||
staffName: string | null;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status: AppointmentStatus;
|
||||
}
|
||||
|
||||
export interface PetProfileSummary extends Pet {
|
||||
recentGroomingHistory: GroomingHistoryEntry[];
|
||||
lastVisitDate: string | null;
|
||||
visitCount: number;
|
||||
upcomingAppointment: UpcomingAppointment | null;
|
||||
}
|
||||
|
||||
Generated
+19
-2
@@ -28,7 +28,7 @@ importers:
|
||||
version: 0.7.6(hono@4.12.18)(zod@4.4.3)
|
||||
better-auth:
|
||||
specifier: ^1.5.6
|
||||
version: 1.6.10(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0))
|
||||
version: 1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0))
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.4
|
||||
version: 0.38.4(kysely@0.28.17)(postgres@3.4.9)
|
||||
@@ -84,6 +84,9 @@ importers:
|
||||
|
||||
packages/db:
|
||||
dependencies:
|
||||
better-auth:
|
||||
specifier: ^1.5.6
|
||||
version: 1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0))
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.4
|
||||
version: 0.38.4(kysely@0.28.17)(postgres@3.4.9)
|
||||
@@ -967,66 +970,79 @@ packages:
|
||||
resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.60.3':
|
||||
resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.60.3':
|
||||
resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==}
|
||||
@@ -3927,7 +3943,7 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
better-auth@1.6.10(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)):
|
||||
better-auth@1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
|
||||
'@better-auth/drizzle-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))
|
||||
@@ -3947,6 +3963,7 @@ snapshots:
|
||||
nanostores: 1.3.0
|
||||
zod: 4.4.3
|
||||
optionalDependencies:
|
||||
drizzle-kit: 0.30.6
|
||||
drizzle-orm: 0.38.4(kysely@0.28.17)(postgres@3.4.9)
|
||||
vitest: 3.2.4(@types/node@22.19.18)(tsx@4.21.0)
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { getAuth } from "../lib/auth.js";
|
||||
|
||||
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const CLIENT_EMAIL = "alice@example.com";
|
||||
const CLIENT_NAME = "Alice Smith";
|
||||
|
||||
const UAT_CUSTOMER_ID = "c0000001-0000-0000-0000-000000000001";
|
||||
const UAT_CUSTOMER_EMAIL = "uat-customer@groombook.dev";
|
||||
const UAT_CUSTOMER_NAME = "UAT Customer";
|
||||
|
||||
const BETTER_AUTH_SESSION = {
|
||||
user: {
|
||||
id: "auth-user-001",
|
||||
email: CLIENT_EMAIL,
|
||||
name: CLIENT_NAME,
|
||||
},
|
||||
session: {
|
||||
id: "ba-session-001",
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_CLIENT = {
|
||||
id: CLIENT_ID,
|
||||
email: CLIENT_EMAIL,
|
||||
name: CLIENT_NAME,
|
||||
};
|
||||
|
||||
let mockGetAuth: ReturnType<typeof vi.fn>;
|
||||
let mockGetSession: ReturnType<typeof vi.fn>;
|
||||
let insertedSession: Record<string, unknown> | null = null;
|
||||
let mockClientRow: Record<string, unknown> | null = null;
|
||||
let mockStaffRow: Record<string, unknown> | null = null;
|
||||
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
return new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
||||
return () => makeChainable(target);
|
||||
}
|
||||
// @ts-expect-error proxy
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
const impersonationSessions = new Proxy(
|
||||
{ _name: "impersonationSessions" },
|
||||
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
|
||||
);
|
||||
|
||||
const clients = new Proxy(
|
||||
{ _name: "clients" },
|
||||
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
||||
);
|
||||
|
||||
const staff = new Proxy(
|
||||
{ _name: "staff" },
|
||||
{ get: (t, p) => (p === "_name" ? "staff" : { table: "staff", column: p }) }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: { _name: string }) => {
|
||||
if (table._name === "clients") {
|
||||
return makeChainable(mockClientRow ? [mockClientRow] : []);
|
||||
}
|
||||
if (table._name === "staff") {
|
||||
return makeChainable(mockStaffRow ? [mockStaffRow] : []);
|
||||
}
|
||||
return makeChainable([]);
|
||||
},
|
||||
}),
|
||||
insert: (table: { _name: string }) => ({
|
||||
values: (vals: Record<string, unknown>) => ({
|
||||
returning: () => {
|
||||
if (table._name === "impersonationSessions") {
|
||||
insertedSession = { id: "new-session-001", ...vals };
|
||||
return [insertedSession];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
impersonationSessions,
|
||||
clients,
|
||||
staff,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
inArray: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../lib/auth.js", () => ({
|
||||
getAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
const { portalRouter } = await import("../routes/portal.js");
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/portal", portalRouter);
|
||||
|
||||
describe("POST /portal/session-from-auth", () => {
|
||||
beforeEach(() => {
|
||||
insertedSession = null;
|
||||
mockClientRow = null;
|
||||
mockStaffRow = null;
|
||||
mockGetSession = vi.fn();
|
||||
mockGetAuth = vi.fn(() => ({
|
||||
api: {
|
||||
getSession: mockGetSession,
|
||||
},
|
||||
}));
|
||||
vi.mocked(getAuth).mockImplementation(mockGetAuth);
|
||||
});
|
||||
|
||||
it("returns 401 when no Better Auth session", async () => {
|
||||
mockGetSession.mockResolvedValue(null);
|
||||
const res = await app.request("/portal/session-from-auth", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("returns 404 when authenticated user has no client record", async () => {
|
||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
||||
mockClientRow = null;
|
||||
const res = await app.request("/portal/session-from-auth", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("No client record found for this user");
|
||||
});
|
||||
|
||||
it("returns a portal session with sessionId, clientId, clientName when client is found", async () => {
|
||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
||||
mockClientRow = MOCK_CLIENT;
|
||||
mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" };
|
||||
const res = await app.request("/portal/session-from-auth", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty("sessionId");
|
||||
expect(body).toHaveProperty("clientId", CLIENT_ID);
|
||||
expect(body).toHaveProperty("clientName", CLIENT_NAME);
|
||||
});
|
||||
|
||||
it("creates a portal session with reason sso-bridge", async () => {
|
||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
||||
mockClientRow = MOCK_CLIENT;
|
||||
mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" };
|
||||
const res = await app.request("/portal/session-from-auth", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(insertedSession).not.toBeNull();
|
||||
expect((insertedSession as Record<string, unknown>).reason).toBe("sso-bridge");
|
||||
});
|
||||
|
||||
it("returns 201 for uat-customer SSO bridge with correct clientId and clientName", async () => {
|
||||
const uatAuthSession = {
|
||||
user: {
|
||||
id: "auth-user-uat-customer",
|
||||
email: UAT_CUSTOMER_EMAIL,
|
||||
name: UAT_CUSTOMER_NAME,
|
||||
},
|
||||
session: {
|
||||
id: "ba-session-uat-customer",
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
};
|
||||
mockGetSession.mockResolvedValue(uatAuthSession);
|
||||
mockClientRow = { id: UAT_CUSTOMER_ID, email: UAT_CUSTOMER_EMAIL, name: UAT_CUSTOMER_NAME };
|
||||
mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" };
|
||||
const res = await app.request("/portal/session-from-auth", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty("sessionId");
|
||||
expect(body.clientId).toBe(UAT_CUSTOMER_ID);
|
||||
expect(body.clientName).toBe(UAT_CUSTOMER_NAME);
|
||||
expect(insertedSession).not.toBeNull();
|
||||
expect((insertedSession as Record<string, unknown>).reason).toBe("sso-bridge");
|
||||
});
|
||||
|
||||
it("returns 503 when auth is not configured", async () => {
|
||||
mockGetAuth.mockImplementation(() => {
|
||||
throw new Error("Auth not initialized");
|
||||
});
|
||||
const res = await app.request("/portal/session-from-auth", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
+10
-5
@@ -58,8 +58,11 @@ app.use(
|
||||
})
|
||||
);
|
||||
|
||||
// Health check (no auth required)
|
||||
// Health check — no auth required, registered on app at full path before auth middleware
|
||||
// /health: used by Dockerfile HEALTHCHECK and K8s readinessProbe/livenessProbe (port 3000 direct)
|
||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||
// /api/health: used by Gateway HTTPRoute (/api/* → API pod)
|
||||
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
||||
|
||||
// Public booking routes — no auth required, must be registered before auth middleware
|
||||
app.route("/api/book", bookRouter);
|
||||
@@ -282,14 +285,16 @@ startReminderScheduler();
|
||||
|
||||
function shutdown() {
|
||||
console.log("Shutting down gracefully...");
|
||||
// SIGTERM/SIGINT → server.close() → callback → process.exit(0)
|
||||
// If graceful close takes >8s, force-exit to avoid being killed undrained
|
||||
setTimeout(() => {
|
||||
console.error("Graceful close timeout — forcing exit");
|
||||
process.exit(1);
|
||||
}, 8_000);
|
||||
server.close(() => {
|
||||
console.log("HTTP server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => {
|
||||
console.error("Forced shutdown after timeout");
|
||||
process.exit(1);
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
+8
-2
@@ -172,7 +172,7 @@ export async function initAuth(): Promise<void> {
|
||||
clientSecret: oidcClientSecret,
|
||||
issuerUrl: oidcIssuer,
|
||||
internalBaseUrl: process.env.OIDC_INTERNAL_BASE,
|
||||
scopes: "openid profile email",
|
||||
scopes: "openid profile email role",
|
||||
};
|
||||
console.log("[auth] Using env var config (no DB config found)");
|
||||
}
|
||||
@@ -186,7 +186,9 @@ export async function initAuth(): Promise<void> {
|
||||
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
||||
let oidcConfig: Record<string, string> = {};
|
||||
try {
|
||||
const discoveryRes = await fetch(discoveryUrlStr);
|
||||
const discoveryRes = await fetch(discoveryUrlStr, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (discoveryRes.ok) {
|
||||
const discovery = await discoveryRes.json() as {
|
||||
authorization_endpoint?: string;
|
||||
@@ -251,6 +253,10 @@ export async function initAuth(): Promise<void> {
|
||||
},
|
||||
},
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
trustedProviders: ["authentik"],
|
||||
},
|
||||
storeStateStrategy: "cookie" as const,
|
||||
},
|
||||
emailAndPassword: {
|
||||
|
||||
@@ -23,7 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
|
||||
}
|
||||
|
||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
if (c.req.path.startsWith("/api/auth/")) {
|
||||
if (c.req.path.startsWith("/api/auth/") || c.req.path === "/api/health") {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
+49
-4
@@ -1,5 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, sql, staff } from "@groombook/db";
|
||||
import { and, eq, getDb, sql, staff, account } from "@groombook/db";
|
||||
|
||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||
export type StaffRow = typeof staff.$inferSelect;
|
||||
@@ -22,7 +22,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
||||
c,
|
||||
next
|
||||
) => {
|
||||
// Better-Auth's own routes handle their own auth — skip staff resolution
|
||||
// Better-Auth\'s own routes handle their own auth — skip staff resolution
|
||||
// OOBE setup routes also handle their own auth — staff record is created during setup
|
||||
if (c.req.path.startsWith("/api/auth/") || c.req.path.startsWith("/api/setup")) {
|
||||
await next();
|
||||
@@ -110,6 +110,51 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
||||
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.
|
||||
if (jwt.email) {
|
||||
const [oidcAccount] = await db
|
||||
.select({ id: account.id })
|
||||
.from(account)
|
||||
.where(
|
||||
and(
|
||||
eq(account.userId, jwt.sub),
|
||||
sql`${account.providerId} IN (\'authentik\', \'google\', \'github\')`
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (oidcAccount) {
|
||||
// Derive name: prefer jwt.name, fall back to email prefix, then "Unknown"
|
||||
const emailPrefix = jwt.email ? jwt.email.split("@")[0] : "Unknown";
|
||||
const name = jwt.name?.trim() || emailPrefix;
|
||||
|
||||
const [newStaff] = await db
|
||||
.insert(staff)
|
||||
.values({
|
||||
userId: jwt.sub,
|
||||
email: (jwt.email ?? "") as string,
|
||||
name,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
} as Parameters<typeof db.insert>[0] extends { values: infer V } ? V : never)
|
||||
.returning()!;
|
||||
|
||||
if (!newStaff) {
|
||||
return c.json({ error: "Forbidden: auto-provision failed" }, 500);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[rbac] auto-provisioned staff record for OIDC user: ${jwt.sub} -> staff:${newStaff.id} (${name})`
|
||||
);
|
||||
c.set("staff", newStaff);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||
403
|
||||
@@ -135,7 +180,7 @@ export function requireRole(
|
||||
if (!(allowedRoles as string[]).includes(staffRow.role)) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Forbidden: role '${staffRow.role}' is not permitted to access this resource`,
|
||||
error: `Forbidden: role \'${staffRow.role}\' is not permitted to access this resource`,
|
||||
},
|
||||
403
|
||||
);
|
||||
@@ -168,7 +213,7 @@ export function requireRoleOrSuperUser(
|
||||
{
|
||||
error: hasAllowedRole
|
||||
? "Forbidden: super user privileges required"
|
||||
: `Forbidden: role '${staffRow.role}' is not permitted`,
|
||||
: `Forbidden: role \'${staffRow.role}\' is not permitted`,
|
||||
},
|
||||
403
|
||||
);
|
||||
|
||||
@@ -36,6 +36,19 @@ const DEMO_PET = {
|
||||
weightKg: "30.00",
|
||||
};
|
||||
|
||||
const UAT_CLIENT = {
|
||||
name: "UAT Customer",
|
||||
email: "uat-customer@groombook.dev",
|
||||
phone: "555-0100",
|
||||
address: "1 UAT Lane, Test City, CA 90210",
|
||||
status: "active" as const,
|
||||
};
|
||||
|
||||
const UAT_PETS = [
|
||||
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly", weightKg: "20.00" },
|
||||
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "smooth", weightKg: "30.00" },
|
||||
];
|
||||
|
||||
const DEMO_SERVICES = [
|
||||
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
|
||||
@@ -43,7 +56,7 @@ const DEMO_SERVICES = [
|
||||
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
|
||||
];
|
||||
|
||||
adminSeedRouter.post("/seed", async (c) => {
|
||||
adminSeedRouter.post("/", async (c) => {
|
||||
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
return c.json(
|
||||
@@ -128,6 +141,51 @@ adminSeedRouter.post("/seed", async (c) => {
|
||||
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
|
||||
}
|
||||
|
||||
// ── Client: UAT Customer ──────────────────────────────────────────────────
|
||||
const [existingUatClient] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.email, UAT_CLIENT.email));
|
||||
|
||||
let uatClientId: string;
|
||||
if (existingUatClient) {
|
||||
uatClientId = existingUatClient.id;
|
||||
results.push(`Client '${UAT_CLIENT.name}' already exists (id: ${uatClientId})`);
|
||||
} else {
|
||||
const [created] = await db.insert(clients).values(UAT_CLIENT).returning();
|
||||
uatClientId = created!.id;
|
||||
results.push(`Created client '${UAT_CLIENT.name}' (id: ${uatClientId})`);
|
||||
}
|
||||
|
||||
// ── Pets: UAT Customer's Pets ─────────────────────────────────────────────
|
||||
const existingUatPets = await db
|
||||
.select()
|
||||
.from(pets)
|
||||
.where(eq(pets.clientId, uatClientId));
|
||||
|
||||
for (const uatPet of UAT_PETS) {
|
||||
const existing = existingUatPets.find(
|
||||
(p) => p.name === uatPet.name && p.species === uatPet.species
|
||||
);
|
||||
if (existing) {
|
||||
results.push(`Pet '${uatPet.name}' already exists for UAT Customer (id: ${existing.id})`);
|
||||
} else {
|
||||
const [created] = await db
|
||||
.insert(pets)
|
||||
.values({
|
||||
clientId: uatClientId,
|
||||
name: uatPet.name,
|
||||
species: uatPet.species,
|
||||
breed: uatPet.breed,
|
||||
coatType: uatPet.coatType as any,
|
||||
weightKg: uatPet.weightKg,
|
||||
dateOfBirth: new Date("2019-01-01T00:00:00Z"),
|
||||
})
|
||||
.returning();
|
||||
results.push(`Created pet '${uatPet.name}' for UAT Customer (id: ${created!.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Seed complete",
|
||||
details: results,
|
||||
|
||||
+2
-2
@@ -193,8 +193,8 @@ bookRouter.post(
|
||||
name: body.petName,
|
||||
species: body.petSpecies,
|
||||
breed: body.petBreed ?? null,
|
||||
coatType: (body.petCoatType ?? null) as "smooth" | "double" | "wire" | "curly" | "long" | "hairless" | null,
|
||||
petSizeCategory: (body.petSizeCategory ?? null) as "small" | "medium" | "large" | "xlarge" | null,
|
||||
coatType: (body.petCoatType ?? null) as "short" | "medium" | "long" | "double" | "wire" | "silky" | "curly" | "hairless" | null,
|
||||
petSizeCategory: (body.petSizeCategory ?? null) as "small" | "medium" | "large" | "extra_large" | null,
|
||||
})
|
||||
.returning();
|
||||
const pet = petInserted[0];
|
||||
|
||||
@@ -19,8 +19,8 @@ bufferRulesRouter.use("*", requireRole("manager"));
|
||||
|
||||
const createBufferRuleSchema = z.object({
|
||||
serviceId: z.string().uuid(),
|
||||
sizeCategory: z.enum(["small", "medium", "large", "xlarge"]).optional(),
|
||||
coatType: z.enum(["smooth", "double", "wire", "curly", "long", "hairless"]).optional(),
|
||||
sizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
|
||||
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
|
||||
bufferMinutes: z.number().int().positive(),
|
||||
});
|
||||
|
||||
|
||||
+115
-3
@@ -1,7 +1,19 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
exists,
|
||||
getDb,
|
||||
or,
|
||||
pets,
|
||||
appointments,
|
||||
staff,
|
||||
services,
|
||||
sql,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import {
|
||||
getPresignedUploadUrl,
|
||||
@@ -24,8 +36,8 @@ const createPetSchema = z.object({
|
||||
shampooPreference: z.string().max(500).optional(),
|
||||
specialCareNotes: z.string().max(2000).optional(),
|
||||
customFields: z.record(z.string(), z.string()).optional(),
|
||||
sizeCategory: z.enum(["small", "medium", "large", "xlarge"]).optional(),
|
||||
coatType: z.enum(["smooth", "double", "wire", "curly", "long", "hairless"]).optional(),
|
||||
petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
|
||||
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
|
||||
});
|
||||
|
||||
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
||||
@@ -97,6 +109,106 @@ petsRouter.get("/:id", async (c) => {
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
petsRouter.get("/:id/profile-summary", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
// Fetch the pet
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!pet) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
// Groomer RBAC: check appointment linkage to this pet's client
|
||||
if (isGroomer) {
|
||||
const [linkage] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, pet.clientId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!linkage) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
// Recent grooming history — last 10 completed appointments
|
||||
const recentHistory = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
notes: appointments.notes,
|
||||
serviceName: services.name,
|
||||
staffName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")))
|
||||
.orderBy(desc(appointments.startTime))
|
||||
.limit(10);
|
||||
|
||||
// Visit count (completed appointments)
|
||||
const [countRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(appointments)
|
||||
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")));
|
||||
const visitCount = countRow?.count ?? 0;
|
||||
|
||||
// Upcoming appointment (next scheduled or confirmed)
|
||||
const [upcoming] = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
notes: appointments.notes,
|
||||
confirmationStatus: appointments.confirmationStatus,
|
||||
serviceName: services.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, petId),
|
||||
or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed"))
|
||||
)
|
||||
)
|
||||
.orderBy(appointments.startTime)
|
||||
.limit(1);
|
||||
|
||||
return c.json({
|
||||
id: pet.id,
|
||||
name: pet.name,
|
||||
species: pet.species,
|
||||
breed: pet.breed,
|
||||
coatType: pet.coatType,
|
||||
petSizeCategory: pet.petSizeCategory,
|
||||
weightKg: pet.weightKg,
|
||||
dateOfBirth: pet.dateOfBirth,
|
||||
recentGroomingHistory: recentHistory.map((h) => ({
|
||||
id: h.id,
|
||||
startTime: h.startTime,
|
||||
notes: h.notes,
|
||||
serviceName: h.serviceName,
|
||||
staffName: h.staffName,
|
||||
})),
|
||||
visitCount,
|
||||
upcomingAppointment: upcoming
|
||||
? {
|
||||
id: upcoming.id,
|
||||
startTime: upcoming.startTime,
|
||||
notes: upcoming.notes,
|
||||
confirmationStatus: upcoming.confirmationStatus,
|
||||
serviceName: upcoming.serviceName,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
||||
|
||||
+77
-1
@@ -36,7 +36,7 @@ portalRouter.post(
|
||||
return c.json({ error: "Client not found" }, 404);
|
||||
}
|
||||
|
||||
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
||||
const DEMO_STAFF_ID = process.env.DEMO_STAFF_ID ?? "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
let staffId = DEMO_STAFF_ID;
|
||||
const [demoStaff] = await db
|
||||
@@ -71,6 +71,82 @@ portalRouter.post(
|
||||
}
|
||||
);
|
||||
|
||||
// Bridge Better Auth session → portal session for real SSO customers (GRO-1866).
|
||||
// Registered BEFORE the /* middleware so it is NOT subject to validatePortalSession.
|
||||
import { getAuth } from "../lib/auth.js";
|
||||
|
||||
portalRouter.post("/session-from-auth", async (c) => {
|
||||
let auth;
|
||||
try {
|
||||
auth = getAuth();
|
||||
} catch {
|
||||
return c.json({ error: "Authentication not configured" }, 503);
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.email, session.user.email))
|
||||
.limit(1);
|
||||
|
||||
if (!client) {
|
||||
return c.json({ error: "No client record found for this user" }, 404);
|
||||
}
|
||||
|
||||
const DEMO_STAFF_ID = process.env.DEMO_STAFF_ID ?? "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
let staffId = DEMO_STAFF_ID;
|
||||
const [demoStaff] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.id, DEMO_STAFF_ID))
|
||||
.limit(1);
|
||||
|
||||
if (!demoStaff) {
|
||||
const [firstStaff] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.active, true))
|
||||
.limit(1);
|
||||
if (!firstStaff) {
|
||||
return c.json({ error: "No staff records found" }, 500);
|
||||
}
|
||||
staffId = firstStaff.id;
|
||||
}
|
||||
|
||||
const [portalSession] = await db
|
||||
.insert(impersonationSessions)
|
||||
.values({
|
||||
staffId,
|
||||
clientId: client.id,
|
||||
reason: "sso-bridge",
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!portalSession) {
|
||||
return c.json({ error: "Failed to create session" }, 500);
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
sessionId: portalSession.id,
|
||||
clientId: client.id,
|
||||
clientName: client.name,
|
||||
},
|
||||
201
|
||||
);
|
||||
});
|
||||
|
||||
// Apply middleware to all portal routes
|
||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user