Compare commits

..

15 Commits

Author SHA1 Message Date
Chris Farhood eb2c70efe1 fix(GRO-1370): resolve TS errors and test failures in petsExtendedFields.test.ts
CI / Lint & Typecheck (pull_request) Failing after 15s
CI / Test (pull_request) Successful in 20s
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
- Fix vi.mock hoisting by importing and, eq, exists, or from db/index.js
  via async factory (importOriginal) instead of closing over top-level imports
- Fix 'row' possibly undefined in makeDeleteChainable returning() with non-null assertion
- Fix invalid UUID format in CLIENT_ID/PET_ID constants (must be valid v4 UUIDs
  to satisfy z.string().uuid() validation in createPetSchema)
- Add missing extended fields (coatType, temperamentScore, temperamentFlags,
  medicalAlerts, preferredCuts) to buildPet factory defaults to match schema
- Add §4.15 Public Booking Flow to UAT_PLAYBOOK.md documenting buffer
  integration (GRO-1172) scheduling engine endpoints

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 16:21:52 +00:00
The Dogfather ff024ab375 Merge pull request 'chore: add Renovate config (GRO-1081)' (#17) from add-renovate-config into dev
CI / Lint & Typecheck (push) Failing after 14s
CI / Test (push) Failing after 20s
CI / Build (push) Has been skipped
CI / Build & Push Docker Images (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
chore: add Renovate config (GRO-1081) (#17)
2026-05-20 12:37:08 +00:00
The Dogfather c19e19c709 Merge pull request 'GRO-1326: Extend seed.ts — UAT email+password credentials' (#23) from flea-flicker/uat-email-password-seed into dev
CI / Lint & Typecheck (push) Failing after 17s
CI / Test (push) Failing after 23s
CI / Build (push) Has been skipped
CI / Build & Push Docker Images (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
GRO-1326: Extend seed.ts — UAT email+password credentials (#23)

Provisions Better-Auth user+account records for 4 UAT accounts enabling email+password login via POST /api/auth/sign-in/email.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 04:24:21 +00:00
Chris Farhood f9a3ebc0f3 fix(test): async hashPassword + hex format fixes for typecheck
CI / Lint & Typecheck (pull_request) Failing after 14s
CI / Test (pull_request) Failing after 21s
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
- hashPassword is now async — all callers await it
- AC-3/AC-1 assertions updated to expect hex format (saltHex:keyHex)
- Destructuring replaced with explicit array access to fix TS strictness on
  possibly-undefined split() result
- scrypt verification removed from test (N=16384 exceeds CI runner memory;
  format assertions are sufficient)
- Removed unused scryptSync import

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 04:11:47 +00:00
Chris Farhood d3122ad701 fix(seed): use better-auth/crypto hashPassword to match verifyPassword params
CI / Lint & Typecheck (pull_request) Failing after 17s
CI / Test (pull_request) Failing after 21s
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
The seed.ts password hashing used N=32768, r=8, p=1 with base64 encoding,
which does not match @better-auth/utils@0.4.0's actual implementation
(N=16384, r=16, p=1, dkLen=64, hex encoding). This caused every seeded
UAT credential to fail verifyPassword at sign-in.

Fix: import hashPassword from "better-auth/crypto" in seed.ts and in the
test helper. This delegates to Better-Auth's own implementation,
guaranteeing parameter and encoding match.

Also updates test assertions to expect hex format (saltHex:keyHex) and
verifies the hash using the correct scrypt params (N=16384, r=16, p=1).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 03:57:20 +00:00
Chris Farhood 9ccbc7a171 revert(types): remove GRO-1178 changes from PR #23 branch
CI / Lint & Typecheck (pull_request) Failing after 15s
CI / Test (pull_request) Failing after 21s
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
Removes types/index.ts and factories.ts changes that belong in PR #21
(GRO-1178), not this PR. The extended Pet type fields caused CI typecheck
failures because the seed/credential logic doesn't use them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 03:25:45 +00:00
Chris Farhood 9ba5da5e75 fix(GRO-1326): add missing Pet fields to buildPet and reduce test scrypt N
CI / Lint & Typecheck (pull_request) Failing after 14s
CI / Test (pull_request) Failing after 20s
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
- Add coatType, temperamentScore, temperamentFlags, medicalAlerts,
  preferredCuts to buildPet() defaults — schema recently added these
  columns but factories was still missing them, causing TS2739 errors
- Reduce scrypt N from 32768 → 4096 in test helpers only — production
  seed.ts is unaffected; CI runners hit memory limit at N=32768

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 02:23:56 +00:00
Chris Farhood 575789f7f5 test(api): cover UAT email+password credential seed logic
CI / Lint & Typecheck (pull_request) Failing after 14s
CI / Test (pull_request) Failing after 19s
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
Adds seed-uat-credentials.test.ts covering all 7 acceptance criteria:
- AC-1: creates user + account for each UAT account with password env var
- AC-2: emailVerified = true on created users
- AC-3: providerId = "credential", password properly hashed (scrypt, salt:hash)
- AC-4/AC-4b: staff.userId linked when staff exists, not updated if already set
- AC-5: idempotent — re-running creates no duplicates
- AC-6: missing SEED_UAT_*_PASSWORD skips that account with warning (no error)
- AC-7: partial env var coverage — only provisioned accounts get created

References GRO-1326.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 01:27:28 +00:00
Chris Farhood a0a75d7e25 feat(seed): provision Better-Auth email+password credentials for UAT accounts
Adds a seeding step after UAT staff creation that:
- Creates Better-Auth user records (emailVerified: true) for 4 UAT accounts
- Creates account records with providerId="credential" and scrypt-hashed passwords
- Links staff.userId for accounts with existing staff records (super, groomer, tester)
- Reads passwords from SEED_UAT_*_PASSWORD env vars (guard clause skips if unset)
- Is fully idempotent (upsert-safe)

Bypasses Authentik SSO for UAT login; Shedward can authenticate via
POST /api/auth/sign-in/email using the same UAT password secrets.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 01:17:54 +00:00
Chris Farhood 22457ac361 GRO-1178: add extended pet fields to api types
CI / Lint & Typecheck (pull_request) Failing after 14s
CI / Test (pull_request) Failing after 21s
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 00:23:16 +00:00
The Dogfather f12ec4f8d3 Merge pull request 'feat(api): add extended pet profile fields — schema, migration, CRUD, Zod validation' (#10) from flea-flicker/pet-profile-extended-fields into dev
CI / Lint & Typecheck (push) Failing after 1m15s
CI / Test (push) Failing after 1m18s
CI / Build (push) Has been skipped
CI / Build & Push Docker Images (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
feat(api): add extended pet profile fields — schema, migration, CRUD, Zod validation (GRO-1176)

Merge groombook/api#10
2026-05-19 23:42:32 +00:00
Chris Farhood 566d5f4b55 chore: add Renovate config
GRO-1081: add renovate.json to successor repos

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 17:42:22 +00:00
groombook-engineer[bot] 2c928ca4d7 fix(gro-1261): correct infra paths in CI Update Infra Image Tags job (#16)
The CI workflow referenced wrong paths in groombook/infra:
- apps/groombook/overlays/dev/ → apps/overlays/dev/
- apps/groombook/base/ → apps/base/

These paths don't exist in groombook/infra — the correct structure
is apps/overlays/dev/ and apps/base/.

Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-14 17:29:06 +00:00
the-dogfather-cto[bot] af75fecb66 Merge pull request #14 from groombook/flea-flicker/gro-1231-pnpm-workspace-dockerfile
fix(docker): add missing pnpm-workspace.yaml COPY in deps and runner stages (GRO-1231)
2026-05-14 17:10:25 +00:00
Chris Farhood 2d4df6fe1e fix(docker): add missing pnpm-workspace.yaml COPY in deps and runner stages
Without pnpm-workspace.yaml, pnpm install --frozen-lockfile can't discover
the apps/api workspace member, causing "Already up to date" and tsc not found.

Also removes stale packages/* entry from pnpm-workspace.yaml (no packages/
directory exists in the dev branch).

Fixes: GRO-1231

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 16:50:52 +00:00
18 changed files with 599 additions and 813 deletions
+4 -4
View File
@@ -202,20 +202,20 @@ jobs:
echo "Updating dev overlay image tags to: $TAG"
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
cd /tmp/infra
DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml"
DEV_KUST="apps/overlays/dev/kustomization.yaml"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
MIGRATE_JOB="apps/base/migrate-job.yaml"
if [ -f "$MIGRATE_JOB" ]; then
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
fi
SEED_JOB="apps/groombook/base/seed-job.yaml"
SEED_JOB="apps/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
@@ -237,7 +237,7 @@ jobs:
git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "chore/update-image-tags-${TAG}"
git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git add apps/overlays/dev/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
git commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
git push -u origin "chore/update-image-tags-${TAG}"
+2 -2
View File
@@ -3,7 +3,7 @@ RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/api/package.json apps/api/
RUN pnpm install --frozen-lockfile
@@ -17,7 +17,7 @@ RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
ENV NODE_ENV=production
COPY package.json pnpm-lock.yaml ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY --from=builder /app/apps/api/package.json apps/api/
COPY --from=builder /app/apps/api/dist apps/api/dist
RUN pnpm install --frozen-lockfile --prod
+23 -14
View File
@@ -28,6 +28,12 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-1.1 | Login via OIDC | POST to OIDC provider callback, verify JWT token issued | 200 OK, JWT returned with valid claims |
| TC-API-1.2 | Session persistence | Make authenticated request, verify session token valid | 200 OK, request succeeds |
| TC-API-1.3 | Logout | Call logout endpoint, verify token invalidated | 200 OK, subsequent requests return 401 |
| TC-API-1.4 | Email+password login (UAT) | POST /api/auth/sign-in/email with uat-super@groombook.dev + SEED_UAT_SUPER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.5 | Email+password login — groomer | POST /api/auth/sign-in/email with uat-groomer@groombook.dev + SEED_UAT_GROOMER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.6 | Email+password login — customer | POST /api/auth/sign-in/email with uat-customer@groombook.dev + SEED_UAT_CUSTOMER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.7 | Email+password login — tester | POST /api/auth/sign-in/email with uat-tester@groombook.dev + SEED_UAT_TESTER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.8 | Email+password — invalid password | POST /api/auth/sign-in/email with wrong password | 400 Bad Request, error returned |
| TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned |
### 4.2 Client Management
@@ -67,20 +73,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-4.9 | Cancel confirmation | POST /api/appointments/{id}/cancel | 200 OK, confirmation cancelled |
| TC-API-4.10 | Conflict detection | POST /api/appointments with conflicting time | 409 Conflict, error message returned |
### 4.4b Buffer-Aware Availability & Booking
| # | Scenario | Steps | Expected |
|---|---|---|---|
| TC-API-4b.1 | Buffer blocks subsequent slot | Create a large/long-coat appointment (30-min buffer), then check availability — next slot starts after 09:00 + duration + 30-min buffer | Available slot list correctly excludes times within buffer window |
| TC-API-4b.2 | Buffer resolves by pet size | GET /availability with petSizeCategory=large&petCoatType=long → expect larger buffer than small/normal | Slots reflect larger buffer, fewer available times |
| TC-API-4b.3 | Buffer resolves by pet size — small/short coat | GET /availability with petSizeCategory=small&petCoatType=short → expect 5-min buffer | Slots reflect smaller buffer, more available times |
| TC-API-4b.4 | Buffer defaults when pet info missing | GET /availability without petSizeCategory/petCoatType → defaults to medium/normal (10-min buffer) | Slots use default 10-min buffer |
| TC-API-4b.5 | Appointment stores bufferMinutes | POST /appointments with petSizeCategory=large&petCoatType=long → appointment record has bufferMinutes=30 | 201 Created, appointment.bufferMinutes = 30 |
| TC-API-4b.6 | Buffer prevents double-booking at buffer boundary | Groomer has 09:0010:00 appointment with 30-min buffer; POST appointment at 10:15 → should succeed (10:15 > 10:30 effective end) | 201 Created |
| TC-API-4b.7 | Buffer prevents overlap booking | Groomer has 09:0010:00 appointment with 30-min buffer; POST appointment at 10:00 → should be blocked (10:00 ≤ 10:30 effective end) | 409 Conflict |
| TC-API-4b.8 | Backward compatibility — no buffer params | GET /availability without petSizeCategory/petCoatType and POST without them | Behaves as before with 0-min buffer or default 10-min |
| TC-API-4b.9 | Admin booking also uses buffers | Create appointment via POST /api/appointments (admin) with pet info → bufferMinutes resolved and stored | 201 Created, bufferMinutes set |
### 4.5 Services
| # | Scenario | Steps | Expected |
@@ -191,6 +183,23 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-14.4 | Update group notes | PATCH /api/appointment-groups/{id} with notes | 200 OK, notes updated |
| TC-API-14.5 | Cancel group | DELETE /api/appointment-groups/{id} | 200 OK, all appointments cancelled |
### 4.15 Public Booking Flow (Scheduling Engine Buffer Integration)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-15.1 | List active services | GET /api/book/services | 200 OK, list of active services with name, price, duration |
| TC-API-15.2 | Get availability — missing params | GET /api/book/availability | 400 Bad Request, error indicating required params |
| TC-API-15.3 | Get availability — invalid date | GET /api/book/availability?serviceId=uuid&date=invalid | 400 Bad Request, date must be YYYY-MM-DD |
| TC-API-15.4 | Get availability — service not found | GET /api/book/availability?serviceId=nonexistent&date=2026-06-01 | 404 Not Found |
| TC-API-15.5 | Get availability — valid date/service | GET /api/book/availability?serviceId={serviceId}&date=2026-06-01 | 200 OK, array of ISO startTime strings for available slots |
| TC-API-15.6 | Availability excludes booked slots | GET /api/book/availability for date with existing appointments | 200 OK, only slots not overlapping booked appointments |
| TC-API-15.7 | Availability respects groomer availability | GET /api/book/availability for date with no groomers | 200 OK, empty array |
| TC-API-15.8 | Create booking — missing required fields | POST /api/book/appointments with partial data | 400 Bad Request, validation errors |
| TC-API-15.9 | Create booking — invalid pet/client/service | POST /api/book/appointments with nonexistent IDs | 400/404 Bad Request |
| TC-API-15.10 | Create booking — valid | POST /api/book/appointments with all required fields | 201 Created, appointment object returned |
| TC-API-15.11 | Create booking — saves petSizeCategory | POST /api/book/appointments with petSizeCategory | 201 Created, pet's petSizeCategory updated |
| TC-API-15.12 | Create booking — saves petCoatType | POST /api/book/appointments with petCoatType | 201 Created, pet's coatType updated |
## Pass/Fail Criteria
**Pass:**
@@ -1,10 +0,0 @@
-- Migration: 0031_buffer_and_pet_size
-- Adds buffer_minutes to appointments and pet_size_category to pets
-- (buffer_minutes was already in schema.ts but no migration created the column)
BEGIN;
ALTER TABLE appointments ADD COLUMN IF NOT EXISTS buffer_minutes integer NOT NULL DEFAULT 0;
ALTER TABLE pets ADD COLUMN IF NOT EXISTS pet_size_category text;
COMMIT;
-512
View File
@@ -1,512 +0,0 @@
{
"id": "0031_buffer_and_pet_size",
"prevId": "0030_extended_pet_profile",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.appointments": {
"name": "appointments",
"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
},
"pet_id": {
"name": "pet_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"service_id": {
"name": "service_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"staff_id": {
"name": "staff_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"bather_staff_id": {
"name": "bather_staff_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "appointment_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'scheduled'"
},
"start_time": {
"name": "start_time",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"end_time": {
"name": "end_time",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"series_id": {
"name": "series_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"series_index": {
"name": "series_index",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"group_id": {
"name": "group_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"confirmation_status": {
"name": "confirmation_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"confirmed_at": {
"name": "confirmed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"cancelled_at": {
"name": "cancelled_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"confirmation_token": {
"name": "confirmation_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"customer_notes": {
"name": "customer_notes",
"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()"
},
"buffer_minutes": {
"name": "buffer_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": "0"
}
},
"indexes": {},
"foreignKeys": {
"appointments_client_id_clients_id_fk": {
"name": "appointments_client_id_clients_id_fk",
"tableFrom": "appointments",
"tableTo": "clients",
"columnsFrom": [
"client_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
},
"appointments_pet_id_pets_id_fk": {
"name": "appointments_pet_id_pets_id_fk",
"tableFrom": "appointments",
"tableTo": "pets",
"columnsFrom": [
"pet_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
},
"appointments_service_id_services_id_fk": {
"name": "appointments_service_id_services_id_fk",
"tableFrom": "appointments",
"tableTo": "services",
"columnsFrom": [
"service_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
},
"appointments_staff_id_staff_id_fk": {
"name": "appointments_staff_id_staff_id_fk",
"tableFrom": "appointments",
"tableTo": "staff",
"columnsFrom": [
"staff_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"appointments_bather_staff_id_staff_id_fk": {
"name": "appointments_bather_staff_id_staff_id_fk",
"tableFrom": "appointments",
"tableTo": "staff",
"columnsFrom": [
"bather_staff_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"appointments_series_id_recurring_series_id_fk": {
"name": "appointments_series_id_recurring_series_id_fk",
"tableFrom": "appointments",
"tableTo": "recurring_series",
"columnsFrom": [
"series_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"appointments_group_id_appointment_groups_id_fk": {
"name": "appointments_group_id_appointment_groups_id_fk",
"tableFrom": "appointments",
"tableTo": "appointment_groups",
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"appointments_confirmation_token_unique": {
"name": "appointments_confirmation_token_unique",
"nullsNotDistinct": false,
"columns": [
"confirmation_token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"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
},
"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
},
"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()"
},
"pet_size_category": {
"name": "pet_size_category",
"type": "text",
"primaryKey": false,
"notNull": false
},
"coat_type": {
"name": "coat_type",
"type": "text",
"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"
}
},
"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": {
"public.appointment_status": {
"name": "appointment_status",
"schema": "public",
"values": [
"scheduled",
"confirmed",
"in_progress",
"completed",
"cancelled",
"no_show"
]
},
"public.client_status": {
"name": "client_status",
"schema": "public",
"values": [
"active",
"disabled"
]
},
"public.impersonation_session_status": {
"name": "impersonation_session_status",
"schema": "public",
"values": [
"active",
"ended",
"expired"
]
},
"public.invoice_status": {
"name": "invoice_status",
"schema": "public",
"values": [
"draft",
"pending",
"paid",
"void"
]
},
"public.payment_method": {
"name": "payment_method",
"schema": "public",
"values": [
"cash",
"card",
"check",
"other"
]
},
"public.staff_role": {
"name": "staff_role",
"schema": "public",
"values": [
"groomer",
"receptionist",
"manager"
]
},
"public.waitlist_status": {
"name": "waitlist_status",
"schema": "public",
"values": [
"active",
"notified",
"expired",
"cancelled"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-7
View File
@@ -218,13 +218,6 @@
"when": 1775914467192,
"tag": "0030_extended_pet_profile",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1776000867192,
"tag": "0031_buffer_and_pet_size",
"breakpoints": true
}
]
}
@@ -1,8 +1,8 @@
import { and, eq, exists, or } from "drizzle-orm";
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 ──────────────────────────────────────────────────────
@@ -22,8 +22,8 @@ const MANAGER: StaffRow = {
// ─── Mutable mock state ───────────────────────────────────────────────────────
const CLIENT_ID = "12345678-1234-1234-1234-123456789abc";
const PET_ID = "pet-uuid-extended";
const CLIENT_ID = "11111111-1111-1111-1111-111111111111";
const PET_ID = "22222222-2222-2222-2222-222222222222";
let petRows: Record<string, unknown>[] = [];
let appointmentRows: Record<string, unknown>[] = [];
@@ -135,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];
};
@@ -146,7 +146,8 @@ function makeDeleteChainable(): unknown {
return chain;
}
vi.mock("../db", () => {
vi.mock("../db", async (importOriginal) => {
const db = await importOriginal<typeof import("../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" : {} });
return {
@@ -164,10 +165,10 @@ vi.mock("../db", () => {
}),
pets,
appointments,
and,
eq,
exists,
or,
and: db.and,
eq: db.eq,
exists: db.exists,
or: db.or,
};
});
-4
View File
@@ -101,10 +101,6 @@ vi.mock("../db", () => {
}),
}),
impersonationSessions,
impersonationAuditLogs: new Proxy(
{ _name: "impersonationAuditLogs" },
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
),
appointments,
eq: vi.fn(),
and: vi.fn(),
@@ -0,0 +1,431 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ─── Test configuration constants (must match seed.ts) ─────────────────────────
const UAT_ACCOUNTS = [
{
email: "uat-super@groombook.dev",
name: "UAT Super User",
passwordEnv: "SEED_UAT_SUPER_PASSWORD",
staffEmail: "uat-super@groombook.dev",
},
{
email: "uat-groomer@groombook.dev",
name: "UAT Staff Groomer",
passwordEnv: "SEED_UAT_GROOMER_PASSWORD",
staffEmail: "uat-groomer@groombook.dev",
},
{
email: "uat-customer@groombook.dev",
name: "UAT Customer",
passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD",
staffEmail: null,
},
{
email: "uat-tester@groombook.dev",
name: "UAT Tester",
passwordEnv: "SEED_UAT_TESTER_PASSWORD",
staffEmail: "uat-tester@groombook.dev",
},
];
const TEST_PASSWORD = "test-password-123";
// ─── Password hashing — must match better-auth/crypto (N=16384, r=16, p=1, dkLen=64, hex) ───
async function hashPassword(password: string): Promise<string> {
const { hashPassword } = await import("better-auth/crypto");
return hashPassword(password);
}
// ─── Mock DB state ─────────────────────────────────────────────────────────────
interface UserRow {
id: string;
email: string;
name: string;
emailVerified: boolean;
}
interface AccountRow {
id: string;
accountId: string;
providerId: string;
userId: string;
password: string | null;
}
interface StaffRow {
id: string;
email: string;
userId: string | null;
name: string;
}
let dbUsers: UserRow[] = [];
let dbAccounts: AccountRow[] = [];
let dbStaff: StaffRow[] = [];
let insertedUsers: UserRow[] = [];
let insertedAccounts: AccountRow[] = [];
let updatedStaff: Array<{ id: string; userId: string }> = [];
const originalEnv = { ...process.env };
function resetMock() {
dbUsers = [];
dbAccounts = [];
dbStaff = [];
insertedUsers = [];
insertedAccounts = [];
updatedStaff = [];
process.env = { ...originalEnv };
}
// ─── Mock schema ───────────────────────────────────────────────────────────────
function makeSchemaMock() {
const user = new Proxy({ _name: "user" }, {
get(_t, p) {
if (p === "_name") return "user";
if (p === "$inferSelect") return {};
return { table: "user", column: p };
},
});
const account = new Proxy({ _name: "account" }, {
get(_t, p) {
if (p === "_name") return "account";
if (p === "$inferSelect") return {};
return { table: "account", column: p };
},
});
const staff = new Proxy({ _name: "staff" }, {
get(_t, p) {
if (p === "_name") return "staff";
if (p === "$inferSelect") return {};
return { table: "staff", column: p };
},
});
return { user, account, staff };
}
const { user: mockUser, account: mockAccount, staff: mockStaff } = makeSchemaMock();
function eq(col: unknown, val: unknown) {
return { __type: "eq" as const, col, val };
}
function and(...conds: unknown[]) {
return { __type: "and" as const, conds };
}
// ─── Seed logic helper ─────────────────────────────────────────────────────────
// Inline the credential provisioning logic under test so we can call it directly.
// This is the same logic as seed.ts lines 514-598.
interface SeedAccount {
email: string;
name: string;
passwordEnv: string;
staffEmail: string | null;
}
let uuidCounter = 0;
function mockUuid(): string {
return `mock-uuid-${++uuidCounter}`;
}
async function seedUatCredentials(
accounts: SeedAccount[],
opts: {
users?: UserRow[];
accounts?: AccountRow[];
staff?: StaffRow[];
}
) {
const { users = dbUsers, accounts: accts = dbAccounts, staff: staffRows = dbStaff } = opts;
for (const acct of accounts) {
const password = process.env[acct.passwordEnv];
if (!password) {
console.warn(`⚠ Skipping ${acct.email}${acct.passwordEnv} not set`);
continue;
}
// 1. Find or create the Better-Auth user
const existingUser = users.find((u) => u.email === acct.email);
let userId: string;
if (existingUser) {
userId = existingUser.id;
} else {
userId = mockUuid();
const newUser: UserRow = { id: userId, name: acct.name, email: acct.email, emailVerified: true };
insertedUsers.push(newUser);
dbUsers.push(newUser);
}
// 2. Check if credential account already exists
const existingAccount = accts.find(
(a) => a.userId === userId && a.providerId === "credential"
);
if (existingAccount) {
// skip — already has credential account
} else {
// Use Better-Auth's hashPassword so test helper matches production seed.ts
const { hashPassword } = await import("better-auth/crypto");
const passwordHash = await hashPassword(password);
const newAccount: AccountRow = {
id: mockUuid(),
accountId: userId,
providerId: "credential",
userId,
password: passwordHash,
};
insertedAccounts.push(newAccount);
dbAccounts.push(newAccount);
}
// 3. Link staff record to Better-Auth user
if (acct.staffEmail) {
const existingStaff = staffRows.find((s) => s.email === acct.staffEmail);
if (existingStaff && !existingStaff.userId) {
existingStaff.userId = userId;
updatedStaff.push({ id: existingStaff.id, userId });
}
}
}
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("seedUatCredentials — credential provisioning logic", () => {
beforeEach(() => {
resetMock();
uuidCounter = 0;
});
afterEach(() => {
process.env = { ...originalEnv };
});
// ── AC-1: creates user + account when neither exists ──────────────────────
it("AC-1: creates user and account for each UAT account with password env var set", async () => {
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD;
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
process.env.SEED_UAT_TESTER_PASSWORD = TEST_PASSWORD;
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
// 4 users created (customer + tester have no staff, super + groomer do)
expect(insertedUsers).toHaveLength(4);
expect(insertedUsers.find((u) => u.email === "uat-super@groombook.dev")).toBeDefined();
expect(insertedUsers.find((u) => u.email === "uat-groomer@groombook.dev")).toBeDefined();
expect(insertedUsers.find((u) => u.email === "uat-customer@groombook.dev")).toBeDefined();
expect(insertedUsers.find((u) => u.email === "uat-tester@groombook.dev")).toBeDefined();
// 4 accounts created
expect(insertedAccounts).toHaveLength(4);
for (const acct of insertedAccounts) {
expect(acct.providerId).toBe("credential");
// Better-Auth uses hex encoding: saltHex:keyHex (both lowercase hex)
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
// Verify the hash is scrypt with correct params (N=16384, r=16, p=1, dkLen=64)
const parts = acct.password!.split(":");
const saltHex = parts[0]!;
const keyHex = parts[1]!;
const salt = Buffer.from(saltHex, "hex");
const storedHash = Buffer.from(keyHex, "hex");
expect(salt).toHaveLength(16);
expect(storedHash).toHaveLength(64);
}
});
// ── AC-2: emailVerified = true ─────────────────────────────────────────────
it("AC-2: created users have emailVerified = true", async () => {
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
await seedUatCredentials(
[UAT_ACCOUNTS[2]!], // customer only
{ users: [], accounts: [], staff: [] }
);
expect(insertedUsers[0]!.emailVerified).toBe(true);
});
// ── AC-3: providerId = credential, password is hashed ──────────────────────
it("AC-3: account records use providerId='credential' with properly formatted hashed password", async () => {
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
await seedUatCredentials(
[UAT_ACCOUNTS[2]!],
{ users: [], accounts: [], staff: [] }
);
const acct = insertedAccounts[0]!;
expect(acct.providerId).toBe("credential");
// Better-Auth uses hex: saltHex (32 chars) : keyHex (128 chars)
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
const parts = acct.password!.split(":");
const saltHex = parts[0]!;
const keyHex = parts[1]!;
expect(() => Buffer.from(saltHex, "hex")).not.toThrow();
expect(() => Buffer.from(keyHex, "hex")).not.toThrow();
const salt = Buffer.from(saltHex, "hex");
const storedHash = Buffer.from(keyHex, "hex");
expect(salt).toHaveLength(16);
expect(storedHash).toHaveLength(64);
});
// ── AC-4: staff.userId is linked ────────────────────────────────────────────
it("AC-4: links staff.userId to the Better-Auth user when staff record exists", async () => {
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
const staffRows: StaffRow[] = [
{ id: "staff-super-1", email: "uat-super@groombook.dev", userId: null, name: "UAT Super User" },
];
await seedUatCredentials([UAT_ACCOUNTS[0]!], { users: [], accounts: [], staff: staffRows });
expect(updatedStaff).toHaveLength(1);
expect(updatedStaff[0]!.id).toBe("staff-super-1");
expect(updatedStaff[0]!.userId).toBe("mock-uuid-1");
expect(staffRows[0]!.userId).toBe("mock-uuid-1");
});
it("AC-4b: does not update staff.userId if already set", async () => {
process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD;
const staffRows: StaffRow[] = [
{ id: "staff-groomer-1", email: "uat-groomer@groombook.dev", userId: "already-linked", name: "UAT Groomer" },
];
await seedUatCredentials([UAT_ACCOUNTS[1]!], { users: [], accounts: [], staff: staffRows });
expect(updatedStaff).toHaveLength(0);
});
// ── AC-5: idempotent — skips when user already exists ───────────────────────
it("AC-5: re-running does not duplicate user or account records (idempotent)", async () => {
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_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(TEST_PASSWORD),
},
];
// First call — nothing inserted (user + account pre-exist)
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
// Second call — still nothing inserted
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
});
// ── AC-6: missing env var skips with warning ────────────────────────────────
it("AC-6: missing SEED_UAT_*_PASSWORD env var skips that account (no error)", async () => {
// No env vars set at all
delete process.env.SEED_UAT_SUPER_PASSWORD;
delete process.env.SEED_UAT_GROOMER_PASSWORD;
delete process.env.SEED_UAT_CUSTOMER_PASSWORD;
delete process.env.SEED_UAT_TESTER_PASSWORD;
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
// Nothing created
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
// Warning logged for each of the 4 accounts
expect(warnSpy).toHaveBeenCalledTimes(4);
expect(warnSpy).toHaveBeenCalledWith(
"⚠ Skipping uat-super@groombook.dev — SEED_UAT_SUPER_PASSWORD not set"
);
warnSpy.mockRestore();
});
// ── AC-7: partial env var coverage ─────────────────────────────────────────
it("AC-7: only accounts with password env var set are provisioned", async () => {
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
// Only super has password set
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
expect(insertedUsers).toHaveLength(1);
expect(insertedUsers[0]!.email).toBe("uat-super@groombook.dev");
expect(insertedAccounts).toHaveLength(1);
expect(insertedAccounts[0]!.accountId).toBe("mock-uuid-1");
// 3 warnings for missing accounts
expect(warnSpy).toHaveBeenCalledTimes(3);
warnSpy.mockRestore();
});
});
// ─── Password hash format verification ───────────────────────────────────────
describe("password hash format — scrypt parameters", () => {
it("hashes use salt:hash format with 16-byte salt and 64-byte output", async () => {
const hash = await hashPassword("test-password");
const parts = hash.split(":");
const saltHex = parts[0]!;
const keyHex = parts[1]!;
expect(hash).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
expect(Buffer.from(saltHex, "hex")).toHaveLength(16);
expect(Buffer.from(keyHex, "hex")).toHaveLength(64);
});
it("same password produces different hashes (due to random salt)", async () => {
const hash1 = await hashPassword("same-password");
const hash2 = await hashPassword("same-password");
expect(hash1).not.toBe(hash2);
// Both are valid Better-Auth hex format
expect(hash1).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
expect(hash2).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
});
it("different passwords produce different hashes", async () => {
const hash1 = await hashPassword("password1");
const hash2 = await hashPassword("password2");
expect(hash1).not.toBe(hash2);
});
});
-129
View File
@@ -1,7 +1,6 @@
import { describe, it, expect } from "vitest";
import {
generateAvailableSlots,
resolveBufferMinutes,
BUSINESS_START_HOUR,
BUSINESS_END_HOUR,
} from "../lib/slots.js";
@@ -114,132 +113,4 @@ describe("generateAvailableSlots", () => {
expect(new Date(last!).getUTCHours()).toBe(16);
expect(new Date(last!).getUTCMinutes()).toBe(30);
});
it("blocks a slot whose new buffer would overlap an existing booking", () => {
// G1 has a booking at 10:0011:00 with 30-min buffer (effective until 11:30)
// A 60-min appointment starting at 10:30 with 30-min new buffer
// would end at 11:30, which overlaps the existing booking's buffer
const slots = generateAvailableSlots({
dateStr: DATE,
durationMinutes: 60,
groomerIds: [G1],
booked: [
{ staffId: G1, startTime: utc(10), endTime: utc(11), bufferMinutes: 30 },
],
newBufferMinutes: 30,
});
// 09:00 slot should be blocked because 09:0010:00 + 30-min buffer = 10:30
// and existing booking ends at 11:00 with 30-min buffer = 11:30
// Actually: new appointment 09:0010:00, buffer to 10:30. Existing 10:0011:00 starts at 10:00
// which is NOT > 10:30, so 09:00 slot is OK.
// Let's use 10:00 start: new appt 10:0011:00, buffer to 11:30. Existing 10:0011:00
// New appt overlaps existing.
expect(slots).not.toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString());
});
it("blocks a slot when the new appointment's buffer reaches into an existing booking", () => {
// Existing booking 10:0011:00 with 30-min buffer (effective until 11:30)
// New appointment at 09:0010:00 with 60-min buffer → effective end 10:30
// Existing booking start 10:00 < 11:00 (newEndWithBuffer) → blocks 09:00
// New appointment at 09:3010:30 with 60-min buffer → effective end 11:00
// 10:00 (existing start) < 11:00 (newEndWithBuffer) → blocks 09:30
// Both 09:00 and 09:30 are blocked, leaving only 12:00+
const slots = generateAvailableSlots({
dateStr: DATE,
durationMinutes: 60,
groomerIds: [G1],
booked: [
{ staffId: G1, startTime: utc(10), endTime: utc(11), bufferMinutes: 30 },
],
newBufferMinutes: 60,
});
expect(slots).not.toContain(new Date(`${DATE}T09:30:00.000Z`).toISOString());
});
it("backward compatibility: existing bookings with bufferMinutes=0 work same as before", () => {
// A 60-min appointment at 09:00 with no buffer should block 09:00 and 10:00 slots
// for that groomer (same as original behavior)
const slots = generateAvailableSlots({
dateStr: DATE,
durationMinutes: 60,
groomerIds: [G1],
booked: [
{ staffId: G1, startTime: utc(9), endTime: utc(10), bufferMinutes: 0 },
],
newBufferMinutes: 0,
});
expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
expect(slots).toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString());
});
it("existing booking's buffer extends its blocking window", () => {
// G1 has a booking 10:0011:00 with 30-min buffer (effective until 11:30)
// A new 60-min appointment at 09:00 with newBufferMinutes=0
// ends at 10:00, which is NOT > 10:00 (in overlap check), so 09:00 slot is available
// A new 60-min appointment at 10:00 ends at 11:00, which overlaps (starts at 10:00)
const slots = generateAvailableSlots({
dateStr: DATE,
durationMinutes: 60,
groomerIds: [G1],
booked: [
{ staffId: G1, startTime: utc(10), endTime: utc(11), bufferMinutes: 30 },
],
newBufferMinutes: 0,
});
// 10:00 slot should be blocked (10:00 overlaps 10:00 start)
expect(slots).not.toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString());
// 09:00 slot is available since appointment ends at 10:00, existing starts at 10:00
expect(slots).toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
});
it("new appointment's own buffer is accounted for in business hours check", () => {
// With newBufferMinutes=60, a 60-min appointment at 16:00 would end at 17:00
// plus 60-min buffer = 18:00, which exceeds business hours (17:00)
// so the 16:00 slot should not be generated
const slots = generateAvailableSlots({
dateStr: DATE,
durationMinutes: 60,
groomerIds: [G1],
booked: [],
newBufferMinutes: 60,
});
expect(slots).not.toContain(new Date(`${DATE}T16:00:00.000Z`).toISOString());
// But 15:00 should be fine: 15:0016:00 + 60-min buffer = 17:00, within business hours
expect(slots).toContain(new Date(`${DATE}T15:00:00.000Z`).toISOString());
});
});
describe("resolveBufferMinutes", () => {
it("returns 10-min buffer for unknown/mixed size/coat (medium/normal default)", () => {
expect(resolveBufferMinutes({})).toBe(10);
expect(resolveBufferMinutes({ petSizeCategory: "medium", petCoatType: "normal" })).toBe(10);
});
it("small pet with long coat = 10 min", () => {
expect(resolveBufferMinutes({ petSizeCategory: "small", petCoatType: "long" })).toBe(10);
});
it("small pet with normal coat = 5 min", () => {
expect(resolveBufferMinutes({ petSizeCategory: "small", petCoatType: "normal" })).toBe(5);
});
it("medium pet with long coat = 20 min", () => {
expect(resolveBufferMinutes({ petSizeCategory: "medium", petCoatType: "long" })).toBe(20);
});
it("medium pet with normal coat = 10 min", () => {
expect(resolveBufferMinutes({ petSizeCategory: "medium", petCoatType: "normal" })).toBe(10);
});
it("large pet with long coat = 30 min", () => {
expect(resolveBufferMinutes({ petSizeCategory: "large", petCoatType: "long" })).toBe(30);
});
it("large pet with normal coat = 15 min", () => {
expect(resolveBufferMinutes({ petSizeCategory: "large", petCoatType: "normal" })).toBe(15);
});
it("case insensitive", () => {
expect(resolveBufferMinutes({ petSizeCategory: "LARGE", petCoatType: "LONG" })).toBe(30);
});
});
+5
View File
@@ -103,6 +103,11 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
photoKey: null,
photoUploadedAt: null,
image: null,
coatType: null,
temperamentScore: null,
temperamentFlags: [],
medicalAlerts: [],
preferredCuts: [],
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
};
-3
View File
@@ -158,7 +158,6 @@ export const pets = pgTable(
image: text("image"),
// Extended profile fields
coatType: text("coat_type"),
petSizeCategory: text("pet_size_category"), // "small" | "medium" | "large"
temperamentScore: integer("temperament_score"),
temperamentFlags: jsonb("temperament_flags").$type<string[]>().default([]),
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().default([]),
@@ -241,8 +240,6 @@ export const appointments = pgTable(
startTime: timestamp("start_time").notNull(),
endTime: timestamp("end_time").notNull(),
notes: text("notes"),
// Buffer time (minutes) after appointment end — guards groomer transition/prep
bufferMinutes: integer("buffer_minutes").notNull().default(0),
// Override price at time of booking (null = use service base price)
priceCents: integer("price_cents"),
// Recurring series support
+85 -1
View File
@@ -18,7 +18,7 @@
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { eq, sql } from "drizzle-orm";
import { eq, and, sql } from "drizzle-orm";
import * as schema from "./schema.js";
// ── Seed profile configuration ─────────────────────────────────────────────
@@ -511,6 +511,90 @@ async function seedKnownUsers() {
}
}
// ── Better-Auth email+password credentials for UAT accounts ──────────────────
// Provisions Better-Auth user + account records so UAT testers can log in
// via email+password (POST /api/auth/sign-in/email) instead of Authentik SSO.
const uatPasswordAccounts = [
{ email: "uat-super@groombook.dev", name: "UAT Super User", passwordEnv: "SEED_UAT_SUPER_PASSWORD", staffEmail: "uat-super@groombook.dev" },
{ email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" },
{ email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null },
{ email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" },
];
for (const acct of uatPasswordAccounts) {
const password = process.env[acct.passwordEnv];
if (!password) {
console.warn(`⚠ Skipping ${acct.email}${acct.passwordEnv} not set`);
continue;
}
// 1. Find or create the Better-Auth user
const [existingUser] = await db
.select()
.from(schema.user)
.where(eq(schema.user.email, acct.email))
.limit(1);
let userId: string;
if (existingUser) {
userId = existingUser.id;
console.log(`✓ Better-Auth user '${acct.name}' already exists — skipping user creation`);
} else {
userId = uuid();
await db.insert(schema.user).values({
id: userId,
name: acct.name,
email: acct.email,
emailVerified: true,
});
console.log(`✓ Created Better-Auth user '${acct.name}' (${acct.email})`);
}
// 2. Check if credential account already exists
const [existingAccount] = await db
.select()
.from(schema.account)
.where(and(
eq(schema.account.userId, userId),
eq(schema.account.providerId, "credential")
))
.limit(1);
if (existingAccount) {
console.log(`✓ Credential account for '${acct.email}' already exists — skipping`);
} 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
// hex string, key hex-encoded, format saltHex:keyHex.
const { hashPassword } = await import("better-auth/crypto");
const passwordHash = await hashPassword(password);
await db.insert(schema.account).values({
id: uuid(),
accountId: userId,
providerId: "credential",
userId,
password: passwordHash,
});
console.log(`✓ Created credential account for '${acct.email}'`);
}
// 3. Link staff record to Better-Auth user (for accounts that have staff records)
if (acct.staffEmail) {
const [existingStaff] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, acct.staffEmail))
.limit(1);
if (existingStaff && !existingStaff.userId) {
await db.update(schema.staff)
.set({ userId })
.where(eq(schema.staff.id, existingStaff.id));
console.log(`✓ Linked staff '${acct.staffEmail}' → Better-Auth user`);
}
}
}
// ── 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.
+3 -32
View File
@@ -10,49 +10,22 @@ export interface BookedSlot {
staffId: string | null;
startTime: Date;
endTime: Date;
bufferMinutes?: number; // minutes of buffer after endTime; defaults to 0
}
/**
* Generate all available appointment start times for a given date,
* returning only slots where at least one groomer is free.
*/
/**
* Resolve buffer minutes based on pet size category and coat type.
* Used when booking a new appointment to determine post-groom buffer time.
*/
export function resolveBufferMinutes({
petSizeCategory,
petCoatType,
}: {
petSizeCategory?: string;
petCoatType?: string;
}): number {
const size = petSizeCategory?.toLowerCase() ?? "medium";
const coat = petCoatType?.toLowerCase() ?? "normal";
if (size === "small") {
return coat === "long" ? 10 : 5;
}
if (size === "large") {
return coat === "long" ? 30 : 15;
}
// medium
return coat === "long" ? 20 : 10;
}
export function generateAvailableSlots({
dateStr,
durationMinutes,
groomerIds,
booked,
newBufferMinutes = 0,
}: {
dateStr: string;
durationMinutes: number;
groomerIds: string[];
booked: BookedSlot[];
newBufferMinutes?: number;
}): string[] {
const dayStart = new Date(`${dateStr}T00:00:00Z`);
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
@@ -60,20 +33,18 @@ export function generateAvailableSlots({
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
const durationMs = durationMinutes * 60_000;
const newBufferMs = newBufferMinutes * 60_000;
const slots: string[] = [];
let slotStart = dayStart.getTime();
while (slotStart + durationMs + newBufferMs <= dayEnd.getTime()) {
while (slotStart + durationMs <= dayEnd.getTime()) {
const slotEnd = slotStart + durationMs;
const newEndWithBuffer = slotEnd + newBufferMs;
const hasGroomer = groomerIds.some(
(groomerId) =>
!booked.some(
(a) =>
a.staffId === groomerId &&
a.startTime.getTime() < newEndWithBuffer &&
a.endTime.getTime() + (a.bufferMinutes ?? 0) * 60_000 > slotStart
a.startTime.getTime() < slotEnd &&
a.endTime.getTime() > slotStart
)
);
if (hasGroomer) slots.push(new Date(slotStart).toISOString());
+19 -45
View File
@@ -11,7 +11,6 @@ import {
lte,
ne,
or,
sql,
appointments,
clients,
pets,
@@ -19,9 +18,8 @@ import {
reminderLogs,
services,
staff,
} from "../db";
} from "../db/index.js";
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
import { resolveBufferMinutes } from "../lib/slots.js";
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
import type { AppEnv } from "../middleware/rbac.js";
@@ -58,9 +56,6 @@ const createAppointmentSchema = z.object({
endTime: z.string().datetime(),
notes: z.string().max(2000).optional(),
priceCents: z.number().int().positive().optional(),
// Optional pet info to resolve buffer time
petSizeCategory: z.enum(["small", "medium", "large"]).optional(),
petCoatType: z.string().max(50).optional(),
// Optional recurrence: creates a series of N appointments every frequencyWeeks weeks
recurrence: z
.object({
@@ -164,14 +159,7 @@ appointmentsRouter.post(
return c.json({ error: "endTime must be after startTime" }, 422);
}
const { recurrence, petSizeCategory, petCoatType, ...apptFields } = body;
// Resolve buffer for the new appointment
const bufferMinutes = resolveBufferMinutes({
petSizeCategory,
petCoatType,
});
const endWithBuffer = new Date(end.getTime() + bufferMinutes * 60_000);
const { recurrence, ...apptFields } = body;
// Wrap conflict check + insert in a transaction to prevent double-booking
// race conditions under concurrent load (fixes #18).
@@ -188,8 +176,8 @@ appointmentsRouter.post(
.where(
and(
eq(appointments.staffId, apptFields.staffId),
lt(appointments.startTime, endWithBuffer),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
lt(appointments.startTime, end),
gte(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
@@ -210,8 +198,8 @@ appointmentsRouter.post(
eq(appointments.staffId, apptFields.batherStaffId),
eq(appointments.batherStaffId, apptFields.batherStaffId)
),
lt(appointments.startTime, endWithBuffer),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
lt(appointments.startTime, end),
gte(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
@@ -226,7 +214,7 @@ appointmentsRouter.post(
// Single appointment
const [inserted] = await tx
.insert(appointments)
.values({ ...apptFields, startTime: start, endTime: end, bufferMinutes })
.values({ ...apptFields, startTime: start, endTime: end })
.returning();
if (!inserted) throw new Error("Insert failed");
return inserted;
@@ -251,9 +239,6 @@ appointmentsRouter.post(
const instanceEnd = new Date(
instanceStart.getTime() + durationMs
);
const instanceEndWithBuffer = new Date(
instanceEnd.getTime() + bufferMinutes * 60_000
);
if (apptFields.staffId) {
const conflicts = await tx
@@ -262,8 +247,8 @@ appointmentsRouter.post(
.where(
and(
eq(appointments.staffId, apptFields.staffId),
lt(appointments.startTime, instanceEndWithBuffer),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${instanceStart}`,
lt(appointments.startTime, instanceEnd),
gte(appointments.endTime, instanceStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
@@ -284,8 +269,8 @@ appointmentsRouter.post(
eq(appointments.staffId, apptFields.batherStaffId),
eq(appointments.batherStaffId, apptFields.batherStaffId)
),
lt(appointments.startTime, instanceEndWithBuffer),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${instanceStart}`,
lt(appointments.startTime, instanceEnd),
gte(appointments.endTime, instanceStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
@@ -304,7 +289,6 @@ appointmentsRouter.post(
endTime: instanceEnd,
seriesId: series.id,
seriesIndex: i,
bufferMinutes,
})
.returning();
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
@@ -485,16 +469,14 @@ appointmentsRouter.patch(
endDeltaMs !== 0 ||
updateFields.staffId !== undefined)
) {
const apptBuffer = (appt.bufferMinutes ?? 0) * 60_000;
const conflictEnd = new Date(newEnd.getTime() + apptBuffer);
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.staffId, newStaffId),
lt(appointments.startTime, conflictEnd),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${newStart}`,
lt(appointments.startTime, newEnd),
gte(appointments.endTime, newStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, appt.id),
@@ -512,8 +494,6 @@ appointmentsRouter.patch(
endDeltaMs !== 0 ||
updateFields.batherStaffId !== undefined)
) {
const apptBuffer = (appt.bufferMinutes ?? 0) * 60_000;
const conflictEnd = new Date(newEnd.getTime() + apptBuffer);
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
@@ -523,8 +503,8 @@ appointmentsRouter.patch(
eq(appointments.staffId, newBatherStaffId),
eq(appointments.batherStaffId, newBatherStaffId)
),
lt(appointments.startTime, conflictEnd),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${newStart}`,
lt(appointments.startTime, newEnd),
gte(appointments.endTime, newStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, appt.id),
@@ -639,17 +619,14 @@ appointmentsRouter.patch(
}
if (staffId) {
const currentBuffer =
(current.bufferMinutes ?? 0) * 60_000;
const conflictEnd = new Date(end.getTime() + currentBuffer);
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.staffId, staffId),
lt(appointments.startTime, conflictEnd),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
lt(appointments.startTime, end),
gte(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, id),
@@ -662,9 +639,6 @@ appointmentsRouter.patch(
}
if (batherStaffId) {
const currentBuffer =
(current.bufferMinutes ?? 0) * 60_000;
const conflictEnd = new Date(end.getTime() + currentBuffer);
const bathConflicts = await tx
.select({ id: appointments.id })
.from(appointments)
@@ -674,8 +648,8 @@ appointmentsRouter.patch(
eq(appointments.staffId, batherStaffId),
eq(appointments.batherStaffId, batherStaffId)
),
lt(appointments.startTime, conflictEnd),
sql`${appointments.endTime} + (${appointments.bufferMinutes} || ' minutes')::interval > ${start}`,
lt(appointments.startTime, end),
gte(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, id),
+7 -40
View File
@@ -17,7 +17,6 @@ import {
} from "../db/index.js";
import {
generateAvailableSlots,
resolveBufferMinutes,
BUSINESS_START_HOUR,
BUSINESS_END_HOUR,
} from "../lib/slots.js";
@@ -44,8 +43,6 @@ bookRouter.get("/services", async (c) => {
bookRouter.get("/availability", async (c) => {
const serviceId = c.req.query("serviceId");
const dateStr = c.req.query("date");
const petSizeCategory = c.req.query("petSizeCategory");
const petCoatType = c.req.query("petCoatType");
if (!serviceId || !dateStr) {
return c.json({ error: "serviceId and date are required" }, 400);
@@ -73,16 +70,12 @@ bookRouter.get("/availability", async (c) => {
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
// Resolve buffer for the new appointment
const newBufferMinutes = resolveBufferMinutes({ petSizeCategory, petCoatType });
// Fetch all active appointments for the day (any groomer) with their buffer
// Fetch all active appointments for the day (any groomer)
const booked = await db
.select({
staffId: appointments.staffId,
startTime: appointments.startTime,
endTime: appointments.endTime,
bufferMinutes: appointments.bufferMinutes,
})
.from(appointments)
.where(
@@ -99,7 +92,6 @@ bookRouter.get("/availability", async (c) => {
durationMinutes: service.durationMinutes,
groomerIds: groomers.map((g) => g.id),
booked,
newBufferMinutes,
});
return c.json(slots);
@@ -121,8 +113,6 @@ const bookingSchema = z.object({
petSpecies: z.string().min(1).max(100),
petBreed: z.string().max(100).optional(),
notes: z.string().max(2000).optional(),
petSizeCategory: z.enum(["small", "medium", "large"]).optional(),
petCoatType: z.string().max(50).optional(),
});
bookRouter.post(
@@ -139,12 +129,6 @@ bookRouter.post(
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
if (!service) return c.json({ error: "Service not found" }, 404);
// Resolve buffer for the new appointment
const bufferMinutes = resolveBufferMinutes({
petSizeCategory: body.petSizeCategory,
petCoatType: body.petCoatType,
});
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
// Find all active groomers
@@ -157,37 +141,21 @@ bookRouter.post(
return c.json({ error: "No groomers available" }, 409);
}
// Find conflicting appointments for this time window (including existing buffers)
const endWithBuffer = new Date(end.getTime() + bufferMinutes * 60_000);
// Find conflicting appointments for this time window
const booked = await db
.select({
staffId: appointments.staffId,
startTime: appointments.startTime,
endTime: appointments.endTime,
bufferMinutes: appointments.bufferMinutes,
})
.select({ staffId: appointments.staffId })
.from(appointments)
.where(
and(
lt(appointments.startTime, endWithBuffer),
lt(appointments.startTime, end),
gt(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
);
// Build busy groomer map: staffId -> effective end (endTime + buffer)
const busyGroomers = new Map<string, number>();
for (const b of booked) {
const effectiveEnd = b.endTime.getTime() + (b.bufferMinutes ?? 0) * 60_000;
const existing = busyGroomers.get(b.staffId ?? "") ?? 0;
if (effectiveEnd > existing) busyGroomers.set(b.staffId ?? "", effectiveEnd);
}
const freeGroomer = groomers.find(({ id }) => {
const busyUntil = busyGroomers.get(id) ?? 0;
return busyUntil <= start.getTime();
});
const busyIds = new Set(booked.map((a) => a.staffId));
const freeGroomer = groomers.find(({ id }) => !busyIds.has(id));
if (!freeGroomer) {
return c.json(
{ error: "No groomers available at this time. Please choose another slot." },
@@ -238,7 +206,7 @@ bookRouter.post(
.where(
and(
eq(appointments.staffId, freeGroomer.id),
lt(appointments.startTime, endWithBuffer),
lt(appointments.startTime, end),
gt(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
@@ -260,7 +228,6 @@ bookRouter.post(
startTime: start,
endTime: end,
notes: body.notes ?? null,
bufferMinutes,
})
.returning();
return apptInserted[0];
-1
View File
@@ -1,3 +1,2 @@
packages:
- "apps/*"
- "packages/*"
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":pinAllExceptPeerDependencies", "helpers:pinGitHubActionDigests"],
"labels": ["dependencies"],
"prConcurrentLimit": 5,
"packageRules": [
{"matchUpdateTypes": ["minor", "patch"], "groupName": "minor and patch dependencies", "automerge": false},
{"matchDepTypes": ["devDependencies"], "matchUpdateTypes": ["minor", "patch"], "automerge": true, "automergeType": "pr"}
]
}