Compare commits

..

2 Commits

Author SHA1 Message Date
Chris Farhood 1674a7df4a fix(GRO-1272): update rbac tests and UAT playbook for auto-provision
CI / Lint & Typecheck (pull_request) Failing after 13s
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 user table mock and db.insert returning chain to rbac.test.ts
- Add three new tests: happy-path auto-provision, email-prefix fallback,
  and miss-path (no user → 403)
- Add TC-API-1.4 to UAT_PLAYBOOK.md §4.1 for first-login auto-provision

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 13:03:46 +00:00
Chris Farhood 09187ca277 fix(GRO-1272): auto-provision staff record on first OIDC login
When a user authenticates via OIDC but has no staff record (userId NULL,
oidcSub mismatch, email mismatch), resolveStaffMiddleware now checks for
a Better-Auth user record by jwt.sub and auto-creates a minimal groomer
staff record on first login.

This fixes the UAT regression where all API routes returned 403 for all
authenticated users after GRO-1207, because seedKnownUsers() sets
oidcSub to Authentik integer PKs or emails rather than the actual Authentik
OIDC sub (a UUID). The auto-provision path bridges the gap for all UAT
personas without requiring seed/Terraform changes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 19:03:09 +00:00
146 changed files with 357 additions and 30741 deletions
-1
View File
@@ -1 +0,0 @@
GRO-1757 direct push CI trigger - 2026-05-26T00:15:41Z
-147
View File
@@ -1,147 +0,0 @@
name: CI
on:
push:
branches: [main, dev, uat]
pull_request:
branches: [main, dev, uat]
workflow_dispatch:
inputs:
ref:
description: "Branch or ref to run CI against"
required: false
default: "main"
jobs:
lint-typecheck:
name: Lint & Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: |
pnpm --filter @groombook/api typecheck
pnpm --filter @groombook/db typecheck
- name: Lint
run: pnpm --filter @groombook/api lint
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm --filter @groombook/api test
docker:
name: Build & Push Docker Images
runs-on: ubuntu-latest
needs: [lint-typecheck, test]
steps:
- uses: actions/checkout@v4
- name: Generate image tag
id: version
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
TAG="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::7}"
else
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Image tag: $TAG"
- 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
with:
registry: git.farh.net
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- 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
- 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: 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
+3 -3
View File
@@ -25,7 +25,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
cache: pnpm
- name: Install dependencies
@@ -49,7 +49,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
cache: pnpm
- name: Install dependencies
@@ -71,7 +71,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
cache: pnpm
- name: Install dependencies
+2 -2
View File
@@ -1,10 +1,10 @@
node_modules/
dist/
.DS_Store
*.log
.env
.env.local
*.local
.DS_Store
*.log
.turbo/
coverage/
minimax-output/
+13 -29
View File
@@ -1,54 +1,38 @@
FROM node:22-alpine AS base
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
# Install deps
FROM base AS deps
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY packages/db/package.json packages/db/
COPY packages/types/package.json packages/types/
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/api/package.json apps/api/
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 build
COPY apps/api/ apps/api/
RUN pnpm --filter @groombook/api build
# Runtime
FROM node:22-alpine AS runner
FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
ENV NODE_ENV=production
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY --from=builder /app/package.json ./
COPY --from=builder /app/dist dist/
COPY --from=builder /app/packages/db/package.json packages/db/
COPY --from=builder /app/packages/db/dist packages/db/dist
COPY --from=builder /app/packages/types/package.json packages/types/
COPY --from=builder /app/packages/types/dist packages/types/dist
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
EXPOSE 3000
RUN apk add --no-cache curl
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]
CMD ["node", "apps/api/dist/index.js"]
# Migrate stage — runs drizzle-kit migrate against the database
FROM builder AS migrate
CMD ["pnpm", "--filter", "@groombook/db", "migrate"]
CMD ["pnpm", "--filter", "@groombook/api", "db:migrate"]
# Seed stage — populates the database with test data
FROM builder AS seed
CMD ["pnpm", "--filter", "@groombook/db", "seed"]
CMD ["pnpm", "--filter", "@groombook/api", "db:seed"]
# Reset stage — drops all tables, re-runs migrations, and re-seeds
FROM builder AS reset
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
CMD ["pnpm", "--filter", "@groombook/api", "db:reset"]
+1 -1
View File
@@ -13,7 +13,7 @@ This repository contains the GroomBook API service, including:
## Structure
```
src/ # API service source
apps/api/ # API service source
packages/db/ # Database schema, migrations, and utilities
packages/types/ # Shared TypeScript types
```
+3 -81
View File
@@ -21,52 +21,14 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
## 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 |
|---|----------|-------|----------|
| 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.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 |
| 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 |
| 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 |
| 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 | 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 |
### 4.2 Client Management
@@ -90,30 +52,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-3.5 | Delete pet | DELETE /api/pets/{id} | 200 OK, pet deleted |
| TC-API-3.6 | Upload pet photo | POST /api/pets/{id}/photo/upload-url, then confirm | 200 OK, photo uploaded and key stored |
| TC-API-3.7 | View pet photo | GET /api/pets/{id}/photo | 200 OK, presigned URL returned |
| TC-API-3.8 | Create pet with extended fields | POST /api/pets with coatType, temperamentScore, temperamentFlags, medicalAlerts, preferredCuts | 201 Created, all extended fields stored and returned |
| TC-API-3.9 | Update pet extended fields | PATCH /api/pets/{id} with coatType, temperamentScore, medicalAlerts | 200 OK, extended fields updated |
| TC-API-3.10 | Reject invalid coatType | POST /api/pets with coatType: "smooth" | 400 Bad Request, invalid coatType rejected |
| TC-API-3.11 | Reject out-of-range temperamentScore | POST /api/pets with temperamentScore: 0 or 6 | 400 Bad Request, score out of range rejected |
| TC-API-3.12 | Reject invalid medicalAlert severity | POST /api/pets with medicalAlerts severity: "critical" | 400 Bad Request, invalid severity rejected |
| 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" |
### 4.4 Appointment Scheduling
@@ -175,10 +113,6 @@ 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
@@ -244,18 +178,6 @@ 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 Buffer Rules
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-15.1 | List buffer rules | GET /api/admin/buffer-rules | 200 OK, list of active buffer rules returned |
| TC-API-15.2 | Create buffer rule | POST /api/admin/buffer-rules with service, species, sizeCategory, bufferMinutes | 201 Created, buffer rule created |
| TC-API-15.3 | Update buffer rule | PATCH /api/admin/buffer-rules/{id} with updated bufferMinutes | 200 OK, buffer rule updated |
| TC-API-15.4 | Delete buffer rule | DELETE /api/admin/buffer-rules/{id} | 200 OK, buffer rule removed |
| TC-API-15.5 | Reject invalid bufferMinutes | POST /api/admin/buffer-rules with bufferMinutes: -5 | 400 Bad Request, invalid bufferMinutes rejected |
| TC-API-15.6 | Reject missing required fields | POST /api/admin/buffer-rules with service only | 400 Bad Request, species and sizeCategory required |
| TC-API-15.7 | Booking uses buffer | Book appointment for pet with sizeCategory; verify duration reflects buffer | 201 Created, appointment duration includes buffer time |
## Pass/Fail Criteria
**Pass:**
+1 -1
View File
@@ -1,7 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
schema: "./src/schema.ts",
out: "./migrations",
dialect: "postgresql",
dbCredentials: {
@@ -1,12 +0,0 @@
-- Migration: 0030_extended_pet_profile
-- Adds extended profile fields to the pets table
BEGIN;
ALTER TABLE pets ADD COLUMN coat_type text;
ALTER TABLE pets ADD COLUMN temperament_score integer;
ALTER TABLE pets ADD COLUMN temperament_flags jsonb DEFAULT '[]'::jsonb;
ALTER TABLE pets ADD COLUMN medical_alerts jsonb DEFAULT '[]'::jsonb;
ALTER TABLE pets ADD COLUMN preferred_cuts jsonb DEFAULT '[]'::jsonb;
COMMIT;
@@ -1,48 +0,0 @@
{
"id": "0030_extended_pet_profile",
"prevId": "0028_sms_reminders",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.pets": {
"name": "pets",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"client_id": { "name": "client_id", "type": "uuid", "isNullable": false },
"name": { "name": "name", "type": "text", "isNullable": false },
"species": { "name": "species", "type": "text", "isNullable": false },
"breed": { "name": "breed", "type": "text", "isNullable": true },
"weight_kg": { "name": "weight_kg", "type": "numeric(5, 2)", "isNullable": true },
"date_of_birth": { "name": "date_of_birth", "type": "timestamp", "isNullable": true },
"health_alerts": { "name": "health_alerts", "type": "text", "isNullable": true },
"grooming_notes": { "name": "grooming_notes", "type": "text", "isNullable": true },
"cut_style": { "name": "cut_style", "type": "text", "isNullable": true },
"shampoo_preference": { "name": "shampoo_preference", "type": "text", "isNullable": true },
"special_care_notes": { "name": "special_care_notes", "type": "text", "isNullable": true },
"custom_fields": { "name": "custom_fields", "type": "jsonb", "isNullable": false, "default": "'{}'::jsonb" },
"photo_key": { "name": "photo_key", "type": "text", "isNullable": true },
"photo_uploaded_at": { "name": "photo_uploaded_at", "type": "timestamp", "isNullable": true },
"image": { "name": "image", "type": "text", "isNullable": true },
"coat_type": { "name": "coat_type", "type": "text", "isNullable": true },
"temperament_score": { "name": "temperament_score", "type": "integer", "isNullable": true },
"temperament_flags": { "name": "temperament_flags", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
"medical_alerts": { "name": "medical_alerts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
"preferred_cuts": { "name": "preferred_cuts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
"created_at": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": { "idx_pets_client_id": { "name": "idx_pets_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false } },
"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" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
}
-14
View File
@@ -204,20 +204,6 @@
"when": 1775741667192,
"tag": "0028_sms_reminders",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1775828067192,
"tag": "0029_db_indexes_constraints",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1775914467192,
"tag": "0030_extended_pet_profile",
"breakpoints": true
}
]
}
@@ -1,402 +0,0 @@
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();
});
});
@@ -1,416 +0,0 @@
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 ──────────────────────────────────────────────────────
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(),
};
// ─── Mutable mock state ───────────────────────────────────────────────────────
const CLIENT_ID = "a0000000-0000-4000-8000-000000000001";
const PET_ID = "b0000000-0000-4000-8000-000000000002";
let petRows: Record<string, unknown>[] = [];
let appointmentRows: Record<string, unknown>[] = [];
let insertedValues: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
let deletedId: string | null = null;
function resetMock() {
petRows = [{
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: null,
temperamentScore: null,
temperamentFlags: [],
medicalAlerts: [],
preferredCuts: [],
createdAt: new Date(),
updatedAt: new Date(),
}];
appointmentRows = [];
insertedValues = [];
updatedValues = [];
deletedId = null;
}
function makeSelectChainable(rows: unknown[]): unknown {
const chain = new Proxy([...rows], {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit") {
return () => chain;
}
// @ts-expect-error proxy
return target[prop];
},
});
return chain;
}
function makeInsertChainable(): unknown {
let vals: Record<string, unknown> = {};
const chain = new Proxy({}, {
get(target, prop) {
if (prop === "values") {
return (v: Record<string, unknown>) => { vals = v; return chain; };
}
if (prop === "returning") {
return () => {
insertedValues.push(vals);
return [vals.id ? { ...vals, id: vals.id ?? PET_ID } : { ...vals, id: PET_ID }];
};
}
return chain;
},
});
return chain;
}
function makeUpdateChainable(): unknown {
let vals: Record<string, unknown> = {};
let whereId: string | null = null;
const chain = new Proxy({}, {
get(target, prop) {
if (prop === "set") {
return (v: Record<string, unknown>) => { vals = v; return chain; };
}
if (prop === "where") {
return (cond: unknown) => {
// Extract id from condition if it's an eq call
if (whereId) vals = { ...vals };
return chain;
};
}
if (prop === "returning") {
return () => {
const merged = { ...petRows[0], ...vals };
updatedValues.push(vals);
return [merged];
};
}
return chain;
},
});
return chain;
}
function makeDeleteChainable(): unknown {
let whereId: string | null = null;
const chain = new Proxy({}, {
get(target, prop) {
if (prop === "where") {
return (cond: unknown) => {
whereId = PET_ID;
return chain;
};
}
if (prop === "returning") {
return () => {
const row = petRows[0]!;
deletedId = row.id as string;
return [row];
};
}
return chain;
},
});
return chain;
}
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 {
getDb: () => ({
select: () => ({
from: (table: unknown) => {
const name = (table as { _name?: string })._name;
if (name === "appointments") return makeSelectChainable(appointmentRows);
return makeSelectChainable(petRows);
},
}),
insert: () => makeInsertChainable(),
update: () => makeUpdateChainable(),
delete: () => makeDeleteChainable(),
}),
pets,
appointments,
and: vi.fn(),
eq: vi.fn(),
exists: vi.fn(),
or: vi.fn(),
};
});
// ─── 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);
}
function createApp() {
const app = makeApp(MANAGER);
return app;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("Extended pet profile fields — validation", () => {
beforeEach(resetMock);
it("rejects temperamentScore of 0 (below min)", async () => {
const app = createApp();
const res = await app.request("/pets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 0 }),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.success).toBe(false);
});
it("rejects temperamentScore of 6 (above max)", async () => {
const app = createApp();
const res = await app.request("/pets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 6 }),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.success).toBe(false);
});
it("rejects non-integer temperamentScore", async () => {
const app = createApp();
const res = await app.request("/pets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 3.5 }),
});
expect(res.status).toBe(400);
});
it("rejects invalid medicalAlert severity", async () => {
const app = createApp();
const res = await app.request("/pets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: CLIENT_ID,
name: "Test",
species: "dog",
medicalAlerts: [{ type: "seizure", description: "xyz", severity: "critical" }],
}),
});
expect(res.status).toBe(400);
});
it("accepts valid temperamentScore 15", async () => {
const app = createApp();
for (const score of [1, 2, 3, 4, 5]) {
resetMock();
const res = await app.request("/pets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: score }),
});
expect(res.status).toBe(201);
}
});
it("accepts all valid medicalAlert severity values", async () => {
const app = createApp();
for (const severity of ["low", "medium", "high"] as const) {
resetMock();
const res = await app.request("/pets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: CLIENT_ID,
name: "Test",
species: "dog",
medicalAlerts: [{ type: "allergy", description: "Sensitive to chicken", severity }],
}),
});
expect(res.status).toBe(201);
}
});
});
describe("Extended pet profile fields — create", () => {
beforeEach(resetMock);
it("accepts all extended fields on create", async () => {
const app = createApp();
const res = await app.request("/pets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: CLIENT_ID,
name: "Biscuit",
species: "dog",
breed: "Golden Retriever",
coatType: "double",
temperamentScore: 4,
temperamentFlags: ["anxious_with_dryers", "gentle"],
medicalAlerts: [
{ type: "seizure", description: "Occasional episodes", severity: "medium" },
],
preferredCuts: ["puppy cut", "teddy bear"],
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.coatType).toBe("double");
expect(body.temperamentScore).toBe(4);
expect(body.temperamentFlags).toEqual(["anxious_with_dryers", "gentle"]);
expect(body.medicalAlerts).toEqual([{ type: "seizure", description: "Occasional episodes", severity: "medium" }]);
expect(body.preferredCuts).toEqual(["puppy cut", "teddy bear"]);
});
it("create without extended fields works (all optional)", async () => {
const app = createApp();
const res = await app.request("/pets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId: CLIENT_ID, name: "Basil", species: "cat" }),
});
expect(res.status).toBe(201);
});
});
describe("Extended pet profile fields — update", () => {
beforeEach(resetMock);
it("updates coatType", async () => {
const app = createApp();
const res = await app.request(`/pets/${PET_ID}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ coatType: "double" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.coatType).toBe("double");
});
it("updates temperamentScore", async () => {
const app = createApp();
const res = await app.request(`/pets/${PET_ID}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ temperamentScore: 2 }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.temperamentScore).toBe(2);
});
it("rejects temperamentScore 0 on update", async () => {
const app = createApp();
const res = await app.request(`/pets/${PET_ID}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ temperamentScore: 0 }),
});
expect(res.status).toBe(400);
});
it("rejects invalid severity on update", async () => {
const app = createApp();
const res = await app.request(`/pets/${PET_ID}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
medicalAlerts: [{ type: "x", description: "y", severity: "urgent" }],
}),
});
expect(res.status).toBe(400);
});
it("rejects too many temperamentFlags (>20)", async () => {
const app = createApp();
const flags = Array.from({ length: 21 }, (_, i) => `flag_${i}`);
const res = await app.request("/pets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentFlags: flags }),
});
expect(res.status).toBe(400);
});
it("rejects too many preferredCuts (>20)", async () => {
const app = createApp();
const cuts = Array.from({ length: 21 }, (_, i) => `cut_${i}`);
const res = await app.request("/pets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", preferredCuts: cuts }),
});
expect(res.status).toBe(400);
});
it("rejects too many medicalAlerts (>50)", async () => {
const app = createApp();
const alerts = Array.from({ length: 51 }, (_, i) => ({
type: `type_${i}`,
description: `desc_${i}`,
severity: "low" as const,
}));
const res = await app.request("/pets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", medicalAlerts: alerts }),
});
expect(res.status).toBe(400);
});
it("returns extended fields in GET response", async () => {
petRows = [{ ...petRows[0], coatType: "wire", temperamentScore: 3, temperamentFlags: ["gentle"], medicalAlerts: [], preferredCuts: ["scissor cut"] }];
const app = createApp();
const res = await app.request(`/pets/${PET_ID}`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.coatType).toBe("wire");
expect(body.temperamentScore).toBe(3);
expect(body.temperamentFlags).toEqual(["gentle"]);
expect(body.preferredCuts).toEqual(["scissor cut"]);
});
});
-9
View File
@@ -67,11 +67,6 @@ 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 }) }
@@ -104,12 +99,8 @@ vi.mock("../db", () => {
}),
}),
}),
insert: () => ({
values: () => ({ returning: () => [{}] }),
}),
}),
impersonationSessions,
impersonationAuditLogs,
appointments,
eq: vi.fn(),
and: vi.fn(),
+12 -19
View File
@@ -46,7 +46,7 @@ const GROOMER: StaffRow = {
let staffLookupResult: StaffRow | null = null;
let managerFallbackResult: StaffRow | null = MANAGER;
let userLookupResult: { id: string; name: string | null; email: string | null } | null = null;
let _insertedStaff: StaffRow | null = null;
let insertedStaff: StaffRow | null = null;
vi.mock("../db", () => {
const makeTableProxy = (name: string) =>
@@ -65,17 +65,13 @@ vi.mock("../db", () => {
const user = makeTableProxy("user");
const buildQuery = (result: unknown, fallback: unknown) => ({
[Symbol.iterator]: function* () {
if (result) yield result;
},
limit: (_n: number) => {
const item = result ?? fallback;
return {
[Symbol.iterator]: function* () { if (item) yield item; },
0: item,
length: item ? 1 : 0,
};
},
limit: () => ({
[Symbol.iterator]: function* () {
if (result) yield result;
},
0: result,
length: result ? 1 : 0,
}),
});
return {
@@ -88,7 +84,7 @@ vi.mock("../db", () => {
),
}),
}),
insert: (_table: unknown) => ({
insert: (table: unknown) => ({
values: (vals: Record<string, unknown>) => ({
returning: () => {
const newStaff: StaffRow = {
@@ -104,7 +100,7 @@ vi.mock("../db", () => {
createdAt: new Date(),
updatedAt: new Date(),
};
_insertedStaff = newStaff;
insertedStaff = newStaff;
return [newStaff];
},
}),
@@ -124,7 +120,7 @@ function resetMocks() {
staffLookupResult = null;
managerFallbackResult = MANAGER;
userLookupResult = null;
_insertedStaff = null;
insertedStaff = null;
}
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
@@ -134,10 +130,7 @@ function buildApp(
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", {
sub: userLookupResult?.id ?? staffLookupResult?.userId ?? "unknown-sub",
email: userLookupResult?.email,
});
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
await next();
});
app.use("*", middleware);
@@ -1,431 +0,0 @@
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);
});
});
-5
View File
@@ -103,11 +103,6 @@ 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"),
};
-16
View File
@@ -12,16 +12,6 @@ import {
uuid,
} from "drizzle-orm/pg-core";
// ─── Shared types ───────────────────────────────────────────────────────────────
export type MedicalAlertSeverity = "low" | "medium" | "high";
export interface MedicalAlert {
type: string;
description: string;
severity: MedicalAlertSeverity;
}
// ─── Enums ────────────────────────────────────────────────────────────────────
export const appointmentStatusEnum = pgEnum("appointment_status", [
@@ -156,12 +146,6 @@ export const pets = pgTable(
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
image: text("image"),
// Extended profile fields
coatType: text("coat_type"),
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([]),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
+8 -158
View File
@@ -18,9 +18,8 @@
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { eq, and, sql } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import * as schema from "./schema.js";
import type { MedicalAlert, MedicalAlertSeverity } from "./schema.js";
// ── Seed profile configuration ─────────────────────────────────────────────
@@ -253,38 +252,6 @@ 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",
@@ -544,90 +511,6 @@ 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.
@@ -905,11 +788,6 @@ 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),
});
@@ -945,11 +823,6 @@ 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,
},
@@ -972,18 +845,13 @@ 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", 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"] },
{ 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" },
];
for (const uc of uatClients) {
@@ -991,26 +859,8 @@ 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"),
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),
} });
.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) } });
// Create one completed appointment for this client
const apptId = uuid();
const svcIdx = 0;
-3
View File
@@ -26,7 +26,6 @@ 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";
@@ -212,7 +211,6 @@ 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"));
@@ -270,7 +268,6 @@ 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);
-3
View File
@@ -130,9 +130,6 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
active: true,
})
.returning();
if (!newStaff) {
return c.json({ error: "Internal error: staff record creation failed" }, 500);
}
c.set("staff", newStaff);
await next();
return;
+2 -60
View File
@@ -36,19 +36,6 @@ 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 },
@@ -56,7 +43,7 @@ const DEMO_SERVICES = [
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
];
adminSeedRouter.post("/", async (c) => {
adminSeedRouter.post("/seed", async (c) => {
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
if (process.env.AUTH_DISABLED === "true") {
return c.json(
@@ -141,51 +128,6 @@ adminSeedRouter.post("/", 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,
@@ -194,4 +136,4 @@ adminSeedRouter.post("/", async (c) => {
staffOidcSub: KNOWN_STAFF.oidcSub,
},
});
});
});
-124
View File
@@ -1,124 +0,0 @@
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 });
});
+1 -141
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js";
import { and, eq, exists, getDb, or, pets, appointments } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
import {
getPresignedUploadUrl,
@@ -24,16 +24,6 @@ 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(),
medicalAlerts: z.array(z.object({
type: z.string().max(100),
description: z.string().max(1000),
severity: z.enum(["low", "medium", "high"]),
})).max(50).optional(),
preferredCuts: z.array(z.string().max(200)).max(20).optional(),
});
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
@@ -283,133 +273,3 @@ 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,
});
});
+1 -3
View File
@@ -13,9 +13,7 @@ const createServiceSchema = z.object({
active: z.boolean().default(true),
});
const updateServiceSchema = createServiceSchema.partial().extend({
defaultBufferMinutes: z.number().int().min(0).optional(),
});
const updateServiceSchema = createServiceSchema.partial();
servicesRouter.get("/", async (c) => {
const db = getDb();
-26
View File
@@ -26,19 +26,6 @@ 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;
@@ -55,23 +42,10 @@ export interface Pet {
customFields: Record<string, string>;
photoKey?: string;
photoUploadedAt?: string;
coatType?: string | null;
temperamentScore?: number | null;
temperamentFlags?: string[];
medicalAlerts?: MedicalAlert[];
preferredCuts?: string[];
createdAt: string;
updatedAt: string;
}
export type MedicalAlertSeverity = "low" | "medium" | "high";
export interface MedicalAlert {
type: string;
description: string;
severity: MedicalAlertSeverity;
}
export interface GroomingVisitLog {
id: string;
petId: string;
-11
View File
@@ -1,11 +0,0 @@
import tseslint from "typescript-eslint";
export default tseslint.config(
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
}
);
-37
View File
@@ -3,42 +3,5 @@
"version": "0.0.1",
"private": true,
"type": "module",
"packageManager": "pnpm@9.15.4",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --project .",
"start": "node dist/index.js",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.800.0",
"@aws-sdk/s3-request-presigner": "^3.800.0",
"@groombook/db": "workspace:*",
"@groombook/types": "workspace:*",
"@hono/node-server": "^1.13.7",
"@hono/zod-validator": "^0.7.6",
"better-auth": "^1.5.6",
"drizzle-orm": "^0.38.4",
"hono": "^4.6.17",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.16",
"postgres": "^3.4.5",
"stripe": "^22.0.0",
"telnyx": "^1.23.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.18.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"vitest": "^3.2.4"
},
"license": "AGPL-3.0-only"
}
-10
View File
@@ -1,10 +0,0 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/schema.ts",
out: "./migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
@@ -1,70 +0,0 @@
CREATE TYPE "public"."appointment_status" AS ENUM('scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show');--> statement-breakpoint
CREATE TYPE "public"."staff_role" AS ENUM('groomer', 'receptionist', 'manager');--> statement-breakpoint
CREATE TABLE "appointments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_id" uuid NOT NULL,
"pet_id" uuid NOT NULL,
"service_id" uuid NOT NULL,
"staff_id" uuid,
"status" "appointment_status" DEFAULT 'scheduled' NOT NULL,
"start_time" timestamp NOT NULL,
"end_time" timestamp NOT NULL,
"notes" text,
"price_cents" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "clients" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"email" text,
"phone" text,
"address" text,
"notes" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_id" uuid NOT NULL,
"name" text NOT NULL,
"species" text NOT NULL,
"breed" text,
"weight_kg" numeric(5, 2),
"date_of_birth" timestamp,
"grooming_notes" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "services" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"base_price_cents" integer NOT NULL,
"duration_minutes" integer NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "staff" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"oidc_sub" text,
"role" "staff_role" DEFAULT 'groomer' NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "staff_email_unique" UNIQUE("email"),
CONSTRAINT "staff_oidc_sub_unique" UNIQUE("oidc_sub")
);
--> statement-breakpoint
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_service_id_services_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."services"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pets" ADD CONSTRAINT "pets_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;
@@ -1 +0,0 @@
ALTER TABLE "pets" ADD COLUMN "health_alerts" text;
-31
View File
@@ -1,31 +0,0 @@
CREATE TYPE "public"."invoice_status" AS ENUM('draft', 'pending', 'paid', 'void');--> statement-breakpoint
CREATE TYPE "public"."payment_method" AS ENUM('cash', 'card', 'check', 'other');--> statement-breakpoint
CREATE TABLE "invoices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"appointment_id" uuid,
"client_id" uuid NOT NULL,
"subtotal_cents" integer NOT NULL,
"tax_cents" integer DEFAULT 0 NOT NULL,
"tip_cents" integer DEFAULT 0 NOT NULL,
"total_cents" integer NOT NULL,
"status" "invoice_status" DEFAULT 'draft' NOT NULL,
"payment_method" "payment_method",
"paid_at" timestamp,
"notes" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invoice_line_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invoice_id" uuid NOT NULL,
"description" text NOT NULL,
"quantity" integer DEFAULT 1 NOT NULL,
"unit_price_cents" integer NOT NULL,
"total_cents" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_appointment_id_appointments_id_fk" FOREIGN KEY ("appointment_id") REFERENCES "public"."appointments"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_line_items" ADD CONSTRAINT "invoice_line_items_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;
@@ -1,10 +0,0 @@
-- Add recurring_series table to store recurrence patterns
CREATE TABLE "recurring_series" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"frequency_weeks" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
-- Extend appointments with series tracking
ALTER TABLE "appointments" ADD COLUMN "series_id" uuid REFERENCES "recurring_series"("id") ON DELETE SET NULL;
ALTER TABLE "appointments" ADD COLUMN "series_index" integer;
@@ -1,11 +0,0 @@
-- Add email opt-out flag to clients
ALTER TABLE "clients" ADD COLUMN "email_opt_out" boolean NOT NULL DEFAULT false;
-- Track sent reminders to prevent duplicate sends
CREATE TABLE "reminder_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"appointment_id" uuid NOT NULL REFERENCES "appointments"("id") ON DELETE CASCADE,
"reminder_type" text NOT NULL,
"sent_at" timestamp DEFAULT now() NOT NULL,
UNIQUE ("appointment_id", "reminder_type")
);
@@ -1,12 +0,0 @@
-- Appointment groups: link multiple appointments from the same client visit.
-- Each appointment in a group is for a different pet and may have a different groomer.
CREATE TABLE appointment_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE RESTRICT,
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Link appointments to a group (nullable — non-grouped appointments are unaffected)
ALTER TABLE appointments ADD COLUMN group_id UUID REFERENCES appointment_groups(id) ON DELETE SET NULL;
@@ -1,30 +0,0 @@
-- Extend pet profiles with grooming-specific attributes (closes groombook/groombook#13)
ALTER TABLE "pets"
ADD COLUMN "cut_style" text,
ADD COLUMN "shampoo_preference" text,
ADD COLUMN "special_care_notes" text,
ADD COLUMN "custom_fields" jsonb DEFAULT '{}' NOT NULL;
--> statement-breakpoint
CREATE TABLE "grooming_visit_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pet_id" uuid NOT NULL,
"appointment_id" uuid,
"staff_id" uuid,
"cut_style" text,
"products_used" text,
"notes" text,
"groomed_at" timestamp NOT NULL DEFAULT now(),
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "grooming_visit_logs"
ADD CONSTRAINT "grooming_visit_logs_pet_id_pets_id_fk"
FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "grooming_visit_logs"
ADD CONSTRAINT "grooming_visit_logs_appointment_id_appointments_id_fk"
FOREIGN KEY ("appointment_id") REFERENCES "public"."appointments"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "grooming_visit_logs"
ADD CONSTRAINT "grooming_visit_logs_staff_id_staff_id_fk"
FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;
@@ -1,25 +0,0 @@
-- Add bather/assistant staff tracking to appointments and tip split ledger (closes groombook/groombook#12)
-- Secondary staff member (e.g., bather) who assisted the primary groomer
ALTER TABLE "appointments"
ADD COLUMN "bather_staff_id" uuid REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
-- Stores per-staff tip allocations calculated when an invoice is paid
CREATE TABLE "invoice_tip_splits" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invoice_id" uuid NOT NULL,
"staff_id" uuid,
"staff_name" text NOT NULL,
"share_pct" numeric(5, 2) NOT NULL,
"share_cents" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "invoice_tip_splits"
ADD CONSTRAINT "invoice_tip_splits_invoice_id_invoices_id_fk"
FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "invoice_tip_splits"
ADD CONSTRAINT "invoice_tip_splits_staff_id_staff_id_fk"
FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;
@@ -1,15 +0,0 @@
CREATE TABLE IF NOT EXISTS "business_settings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"business_name" text DEFAULT 'GroomBook' NOT NULL,
"logo_base64" text,
"logo_mime_type" text,
"primary_color" text DEFAULT '#4f8a6f' NOT NULL,
"accent_color" text DEFAULT '#8b7355' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
-- Seed a default row so GET always returns something
INSERT INTO "business_settings" ("business_name", "primary_color", "accent_color")
VALUES ('GroomBook', '#4f8a6f', '#8b7355')
ON CONFLICT DO NOTHING;
@@ -1,6 +0,0 @@
-- Add client status (soft-delete support)
CREATE TYPE "client_status" AS ENUM ('active', 'disabled');
ALTER TABLE "clients"
ADD COLUMN "status" "client_status" NOT NULL DEFAULT 'active',
ADD COLUMN "disabled_at" timestamp;
@@ -1,26 +0,0 @@
-- Create impersonation_session_status enum and tables
CREATE TYPE "impersonation_session_status" AS ENUM ('active', 'ended', 'expired');
CREATE TABLE "impersonation_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"staff_id" uuid NOT NULL,
"client_id" uuid NOT NULL,
"reason" text,
"status" "impersonation_session_status" DEFAULT 'active' NOT NULL,
"started_at" timestamp DEFAULT now() NOT NULL,
"ended_at" timestamp,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "impersonation_sessions_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "staff"("id") ON DELETE restrict,
CONSTRAINT "impersonation_sessions_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE restrict
);
CREATE TABLE "impersonation_audit_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"session_id" uuid NOT NULL,
"action" text NOT NULL,
"page_visited" text,
"metadata" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "impersonation_audit_logs_session_id_impersonation_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "impersonation_sessions"("id") ON DELETE cascade
);
@@ -1,6 +0,0 @@
-- Add indexes on impersonation tables to prevent full table scans
-- Ref: GitHub #95
CREATE INDEX "impersonation_sessions_staff_id_status_idx" ON "impersonation_sessions" USING btree ("staff_id","status");--> statement-breakpoint
CREATE INDEX "impersonation_sessions_client_id_idx" ON "impersonation_sessions" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "impersonation_audit_logs_session_id_idx" ON "impersonation_audit_logs" USING btree ("session_id");
@@ -1,5 +0,0 @@
-- Add photo storage columns to pets table
-- Ref: GitHub #93
ALTER TABLE "pets" ADD COLUMN "photo_key" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "photo_uploaded_at" timestamp;
@@ -1,7 +0,0 @@
ALTER TABLE appointments
ADD COLUMN confirmation_status TEXT NOT NULL DEFAULT 'pending',
ADD COLUMN confirmed_at TIMESTAMPTZ,
ADD COLUMN cancelled_at TIMESTAMPTZ,
ADD COLUMN confirmation_token TEXT UNIQUE;
CREATE INDEX idx_appointments_confirmation_token ON appointments (confirmation_token) WHERE confirmation_token IS NOT NULL;
@@ -1,3 +0,0 @@
ALTER TABLE appointments ADD COLUMN customer_notes TEXT;
CREATE INDEX idx_appointments_customer_notes ON appointments (client_id) WHERE customer_notes IS NOT NULL;
-20
View File
@@ -1,20 +0,0 @@
CREATE TYPE waitlist_status AS ENUM ('active', 'notified', 'expired', 'cancelled');
CREATE TABLE waitlist_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
pet_id UUID NOT NULL REFERENCES pets(id) ON DELETE CASCADE,
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
preferred_date DATE NOT NULL,
preferred_time TIME NOT NULL,
status waitlist_status NOT NULL DEFAULT 'active',
notified_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_waitlist_client_id ON waitlist_entries (client_id);
CREATE INDEX idx_waitlist_preferred_date ON waitlist_entries (preferred_date);
CREATE INDEX idx_waitlist_status ON waitlist_entries (status) WHERE status = 'active';
CREATE UNIQUE INDEX idx_waitlist_active_unique ON waitlist_entries (client_id, pet_id, service_id, preferred_date, preferred_time) WHERE status = 'active';
@@ -1 +0,0 @@
ALTER TABLE staff ADD COLUMN ical_token TEXT UNIQUE;
@@ -1,49 +0,0 @@
-- Better-Auth required tables for session-based authentication
CREATE TABLE "user" (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
email_verified BOOLEAN NOT NULL DEFAULT false,
image TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE "session" (
id TEXT PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
token TEXT NOT NULL UNIQUE,
ip_address TEXT,
user_agent TEXT,
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE "account" (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at TIMESTAMPTZ,
refresh_token_expires_at TIMESTAMPTZ,
scope TEXT,
password TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE "verification" (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Link staff records to auth identity
ALTER TABLE staff ADD COLUMN user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL;
@@ -1,14 +0,0 @@
-- Backfill staff.user_id for staff records created before Better-Auth integration.
-- Staff records that predate this migration have user_id = NULL; the resolveStaffMiddleware
-- now falls back to staff.id (dev mode) and oidcSub (production) so these records still work.
-- This migration populates user_id for the known demo/dev staff seeded by seed.ts.
-- Create demo Better-Auth users for seeded staff (these match the ba-user-* IDs used in tests)
INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
VALUES ('ba-user-manager', 'Demo Manager', 'demo-manager@groombook.dev', true, NOW(), NOW())
ON CONFLICT (id) DO NOTHING;
-- Link the demo manager staff record to the Better-Auth user
UPDATE staff
SET user_id = 'ba-user-manager', updated_at = NOW()
WHERE oidc_sub = 'demo-manager-001' AND user_id IS NULL;
@@ -1 +0,0 @@
ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;
@@ -1,7 +0,0 @@
-- Clean up existing duplicate services before adding unique constraint.
-- Keep the row with the lowest id per name; delete all others.
DELETE FROM services WHERE id NOT IN (
SELECT (MIN(id::text))::uuid FROM services GROUP BY name
);
ALTER TABLE "services" ADD CONSTRAINT "services_name_unique" UNIQUE("name");
@@ -1,2 +0,0 @@
-- Add image field to pets table for demo pet image support
ALTER TABLE "pets" ADD COLUMN "image" text;
-2
View File
@@ -1,2 +0,0 @@
-- Add logo_key column to business_settings for S3-based logo storage
ALTER TABLE "business_settings" ADD COLUMN "logo_key" text;
@@ -1,14 +0,0 @@
CREATE TABLE "auth_provider_config" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"provider_id" text NOT NULL,
"display_name" text NOT NULL,
"issuer_url" text NOT NULL,
"internal_base_url" text,
"client_id" text NOT NULL,
"client_secret" text NOT NULL,
"scopes" text DEFAULT 'openid profile email' NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "auth_provider_config_provider_id_unique" UNIQUE("provider_id")
);
@@ -1,5 +0,0 @@
CREATE INDEX idx_invoices_client_id ON invoices(client_id);
CREATE INDEX idx_invoices_status ON invoices(status);
CREATE INDEX idx_invoices_created_at ON invoices(created_at);
CREATE INDEX idx_invoice_line_items_invoice_id ON invoice_line_items(invoice_id);
CREATE INDEX idx_invoice_tip_splits_invoice_id ON invoice_tip_splits(invoice_id);
@@ -1,6 +0,0 @@
-- Better-Auth rate limiting table (GRO-574)
CREATE TABLE "rate_limit" (
key TEXT NOT NULL PRIMARY KEY,
count INTEGER NOT NULL,
last_request BIGINT NOT NULL
);
@@ -1,6 +0,0 @@
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;
ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
-11
View File
@@ -1,11 +0,0 @@
CREATE TABLE "refunds" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"invoice_id" uuid NOT NULL REFERENCES "invoices"("id") ON DELETE RESTRICT,
"stripe_refund_id" text NOT NULL,
"idempotency_key" text UNIQUE,
"amount_cents" integer,
"created_at" timestamp NOT NULL DEFAULT NOW()
);
CREATE INDEX "idx_refunds_invoice_id" ON "refunds"("invoice_id");
CREATE INDEX "idx_refunds_idempotency_key" ON "refunds"("idempotency_key");
@@ -1,15 +0,0 @@
-- SMS opt-in fields for clients (idempotent)
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_in" boolean NOT NULL DEFAULT false;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_date" timestamp;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_out_date" timestamp;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_text" text;
-- Add channel column to reminder_logs with default 'email' (idempotent)
ALTER TABLE "reminder_logs" ADD COLUMN IF NOT EXISTS "channel" text NOT NULL DEFAULT 'email';
-- Drop old unique constraints if they exist (idempotent)
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_key";
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_unique";
-- Add new unique constraint with channel
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel");
@@ -1,20 +0,0 @@
-- Migration: 0029_db_indexes_constraints.sql
-- Add missing indexes on appointments, pets, clients tables and NOT NULL constraint on clients.email
-- Backfill NULL emails before setting NOT NULL
UPDATE clients SET email = concat('unknown-', id::text, '@placeholder.local') WHERE email IS NULL;
-- Add indexes on appointments table
CREATE INDEX idx_appointments_client_id ON appointments(client_id);
CREATE INDEX idx_appointments_staff_id ON appointments(staff_id);
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
CREATE INDEX idx_appointments_status ON appointments(status);
-- Add index on pets table
CREATE INDEX idx_pets_client_id ON pets(client_id);
-- Add index on clients table
CREATE INDEX idx_clients_email ON clients(email);
-- Set NOT NULL on clients.email (after backfill)
ALTER TABLE clients ALTER COLUMN email SET NOT NULL;
-72
View File
@@ -1,72 +0,0 @@
-- Migration: 0030_messaging.sql
-- Messaging schema: conversations, messages, attachments, consent events + business messaging settings
-- ─── Enums ───────────────────────────────────────────────────────────────────
CREATE TYPE "messaging_channel" AS ENUM ('sms', 'mms');
CREATE TYPE "message_direction" AS ENUM ('inbound', 'outbound');
CREATE TYPE "message_status" AS ENUM ('queued', 'sent', 'delivered', 'failed', 'received');
CREATE TYPE "message_consent_kind" AS ENUM ('opt_in', 'opt_out', 'help');
-- ─── Tables ───────────────────────────────────────────────────────────────────
CREATE TABLE "conversations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"business_id" uuid NOT NULL,
"client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE,
"channel" "messaging_channel" NOT NULL,
"external_number" text NOT NULL,
"business_number" text NOT NULL,
"last_message_at" timestamp,
"status" text NOT NULL DEFAULT 'active',
"created_at" timestamp NOT NULL DEFAULT now(),
"updated_at" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX "idx_conversations_business_id_last_message_at" ON "conversations"("business_id", "last_message_at" DESC);
CREATE UNIQUE INDEX "uq_conversations_business_client_number" ON "conversations"("business_id", "client_id", "business_number");
CREATE TABLE "messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"conversation_id" uuid NOT NULL REFERENCES "conversations"("id") ON DELETE CASCADE,
"direction" "message_direction" NOT NULL,
"body" text,
"status" "message_status" NOT NULL DEFAULT 'queued',
"provider_message_id" text,
"error_code" text,
"error_message" text,
"sent_by_staff_id" uuid REFERENCES "staff"("id") ON DELETE SET NULL,
"created_at" timestamp NOT NULL DEFAULT now(),
"delivered_at" timestamp,
"read_by_client_at" timestamp
);
CREATE INDEX "idx_messages_conversation_id_created_at" ON "messages"("conversation_id", "created_at" DESC);
CREATE UNIQUE INDEX "uq_messages_provider_message_id" ON "messages"("provider_message_id");
CREATE TABLE "message_attachments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE,
"content_type" text NOT NULL,
"url" text NOT NULL,
"size" integer NOT NULL,
"provider_media_id" text
);
CREATE INDEX "idx_message_attachments_message_id" ON "message_attachments"("message_id");
CREATE TABLE "message_consent_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE,
"business_id" uuid NOT NULL,
"kind" "message_consent_kind" NOT NULL,
"source" text,
"created_at" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX "idx_message_consent_events_client_id" ON "message_consent_events"("client_id");
-- ─── Business Settings extensions ────────────────────────────────────────────
ALTER TABLE "business_settings" ADD COLUMN "messaging_phone_number" text;
ALTER TABLE "business_settings" ADD COLUMN "telnyx_messaging_profile_id" text;
@@ -1,33 +0,0 @@
-- Migration: 0031_buffer_rules.sql
-- Buffer rules CRUD: pet size/coat enums, bufferRules table, services.defaultBufferMinutes
-- ─── Enums ───────────────────────────────────────────────────────────────────
CREATE TYPE "pet_size_category" AS ENUM ('small', 'medium', 'large', 'xlarge');
CREATE TYPE "coat_type" AS ENUM ('smooth', 'double', 'wire', 'curly', 'long', 'hairless');
-- ─── 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";
-- ─── Services: add defaultBufferMinutes ───────────────────────────────────────
ALTER TABLE "services" ADD COLUMN "default_buffer_minutes" integer;
-- ─── Buffer Rules table ───────────────────────────────────────────────────────
CREATE TABLE "buffer_rules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"service_id" uuid NOT NULL REFERENCES "services"("id") ON DELETE CASCADE,
"size_category" "pet_size_category",
"coat_type" "coat_type",
"buffer_minutes" integer NOT NULL,
"created_at" timestamp NOT NULL DEFAULT now(),
"updated_at" timestamp NOT NULL DEFAULT now(),
CONSTRAINT "uq_buffer_rules_service_size_coat" UNIQUE ("service_id", "size_category", "coat_type")
);
CREATE INDEX "idx_buffer_rules_service_id" ON "buffer_rules"("service_id");
@@ -1 +0,0 @@
-- no-op: journal entry exists but no schema change was needed
@@ -1,6 +0,0 @@
-- 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;
@@ -1,8 +0,0 @@
-- 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 '[]';
@@ -1,485 +0,0 @@
{
"id": "477cddf9-970f-41c5-9cad-c1ed48c2bedf",
"prevId": "00000000-0000-0000-0000-000000000000",
"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
},
"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
},
"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": {
"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"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.clients": {
"name": "clients",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"notes": {
"name": "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()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"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
},
"grooming_notes": {
"name": "grooming_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()"
}
},
"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
},
"public.services": {
"name": "services",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"base_price_cents": {
"name": "base_price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.staff": {
"name": "staff",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"oidc_sub": {
"name": "oidc_sub",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "staff_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'groomer'"
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"staff_email_unique": {
"name": "staff_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
},
"staff_oidc_sub_unique": {
"name": "staff_oidc_sub_unique",
"nullsNotDistinct": false,
"columns": [
"oidc_sub"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.appointment_status": {
"name": "appointment_status",
"schema": "public",
"values": [
"scheduled",
"confirmed",
"in_progress",
"completed",
"cancelled",
"no_show"
]
},
"public.staff_role": {
"name": "staff_role",
"schema": "public",
"values": [
"groomer",
"receptionist",
"manager"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,504 +0,0 @@
{
"id": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97",
"prevId": "5983a2e9-f185-4f8a-a73f-5a7c0a0eea9c",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"account_id": { "name": "account_id", "type": "text", "primaryKey": false, "notNull": true },
"provider_id": { "name": "provider_id", "type": "text", "primaryKey": false, "notNull": true },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
"access_token": { "name": "access_token", "type": "text", "primaryKey": false, "notNull": false },
"refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, "notNull": false },
"id_token": { "name": "id_token", "type": "text", "primaryKey": false, "notNull": false },
"access_token_expires_at": { "name": "access_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"refresh_token_expires_at": { "name": "refresh_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"scope": { "name": "scope", "type": "text", "primaryKey": false, "notNull": false },
"password": { "name": "password", "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": { "account_user_id_user_id_fk": { "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment_groups": {
"name": "appointment_groups",
"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 },
"notes": { "name": "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()" }
},
"indexes": {},
"foreignKeys": { "appointment_groups_client_id_clients_id_fk": { "name": "appointment_groups_client_id_clients_id_fk", "tableFrom": "appointment_groups", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"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()" }
},
"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.business_settings": {
"name": "business_settings",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"business_name": { "name": "business_name", "type": "text", "primaryKey": false, "notNull": true, "default": "'GroomBook'" },
"logo_base64": { "name": "logo_base64", "type": "text", "primaryKey": false, "notNull": false },
"logo_mime_type": { "name": "logo_mime_type", "type": "text", "primaryKey": false, "notNull": false },
"primary_color": { "name": "primary_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#4f8a6f'" },
"accent_color": { "name": "accent_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#8b7355'" },
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.clients": {
"name": "clients",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": false },
"phone": { "name": "phone", "type": "text", "primaryKey": false, "notNull": false },
"address": { "name": "address", "type": "text", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"email_opt_out": { "name": "email_opt_out", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"status": { "name": "status", "type": "client_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"disabled_at": { "name": "disabled_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()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.grooming_visit_logs": {
"name": "grooming_visit_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false },
"products_used": { "name": "products_used", "type": "text", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"groomed_at": { "name": "groomed_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"grooming_visit_logs_pet_id_pets_id_fk": { "name": "grooming_visit_logs_pet_id_pets_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"grooming_visit_logs_appointment_id_appointments_id_fk": { "name": "grooming_visit_logs_appointment_id_appointments_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"grooming_visit_logs_staff_id_staff_id_fk": { "name": "grooming_visit_logs_staff_id_staff_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.impersonation_audit_logs": {
"name": "impersonation_audit_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"session_id": { "name": "session_id", "type": "uuid", "primaryKey": false, "notNull": true },
"action": { "name": "action", "type": "text", "primaryKey": false, "notNull": true },
"page_visited": { "name": "page_visited", "type": "text", "primaryKey": false, "notNull": false },
"metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": { "impersonation_audit_logs_session_id_idx": { "name": "impersonation_audit_logs_session_id_idx", "columns": [{ "expression": "session_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } },
"foreignKeys": { "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", "tableFrom": "impersonation_audit_logs", "tableTo": "impersonation_sessions", "columnsFrom": ["session_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.impersonation_sessions": {
"name": "impersonation_sessions",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": true },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"reason": { "name": "reason", "type": "text", "primaryKey": false, "notNull": false },
"status": { "name": "status", "type": "impersonation_session_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"started_at": { "name": "started_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"ended_at": { "name": "ended_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {
"impersonation_sessions_staff_id_status_idx": { "name": "impersonation_sessions_staff_id_status_idx", "columns": [{ "expression": "staff_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"impersonation_sessions_client_id_idx": { "name": "impersonation_sessions_client_id_idx", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
},
"foreignKeys": {
"impersonation_sessions_staff_id_staff_id_fk": { "name": "impersonation_sessions_staff_id_staff_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"impersonation_sessions_client_id_clients_id_fk": { "name": "impersonation_sessions_client_id_clients_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoice_line_items": {
"name": "invoice_line_items",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": true },
"quantity": { "name": "quantity", "type": "integer", "primaryKey": false, "notNull": true, "default": 1 },
"unit_price_cents": { "name": "unit_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "invoice_line_items_invoice_id_invoices_id_fk": { "name": "invoice_line_items_invoice_id_invoices_id_fk", "tableFrom": "invoice_line_items", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoice_tip_splits": {
"name": "invoice_tip_splits",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"staff_name": { "name": "staff_name", "type": "text", "primaryKey": false, "notNull": true },
"share_pct": { "name": "share_pct", "type": "numeric(5, 2)", "primaryKey": false, "notNull": true },
"share_cents": { "name": "share_cents", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"invoice_tip_splits_invoice_id_invoices_id_fk": { "name": "invoice_tip_splits_invoice_id_invoices_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"invoice_tip_splits_staff_id_staff_id_fk": { "name": "invoice_tip_splits_staff_id_staff_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoices": {
"name": "invoices",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"subtotal_cents": { "name": "subtotal_cents", "type": "integer", "primaryKey": false, "notNull": true },
"tax_cents": { "name": "tax_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
"tip_cents": { "name": "tip_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
"status": { "name": "status", "type": "invoice_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'draft'" },
"payment_method": { "name": "payment_method", "type": "payment_method", "typeSchema": "public", "primaryKey": false, "notNull": false },
"paid_at": { "name": "paid_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"notes": { "name": "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()" }
},
"indexes": {},
"foreignKeys": {
"invoices_appointment_id_appointments_id_fk": { "name": "invoices_appointment_id_appointments_id_fk", "tableFrom": "invoices", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"invoices_client_id_clients_id_fk": { "name": "invoices_client_id_clients_id_fk", "tableFrom": "invoices", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"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 },
"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
},
"public.recurring_series": {
"name": "recurring_series",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"frequency_weeks": { "name": "frequency_weeks", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reminder_logs": {
"name": "reminder_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": true },
"reminder_type": { "name": "reminder_type", "type": "text", "primaryKey": false, "notNull": true },
"sent_at": { "name": "sent_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "reminder_logs_appointment_id_appointments_id_fk": { "name": "reminder_logs_appointment_id_appointments_id_fk", "tableFrom": "reminder_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "reminder_logs_appointment_id_reminder_type_unique": { "name": "reminder_logs_appointment_id_reminder_type_unique", "nullsNotDistinct": false, "columns": ["appointment_id", "reminder_type"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.services": {
"name": "services",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": false },
"base_price_cents": { "name": "base_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
"duration_minutes": { "name": "duration_minutes", "type": "integer", "primaryKey": false, "notNull": true },
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "services_name_unique": { "name": "services_name_unique", "nullsNotDistinct": false, "columns": ["name"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"token": { "name": "token", "type": "text", "primaryKey": false, "notNull": true },
"ip_address": { "name": "ip_address", "type": "text", "primaryKey": false, "notNull": false },
"user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
"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": { "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, "columns": ["token"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.staff": {
"name": "staff",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
"oidc_sub": { "name": "oidc_sub", "type": "text", "primaryKey": false, "notNull": false },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": false },
"role": { "name": "role", "type": "staff_role", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'groomer'" },
"is_super_user": { "name": "is_super_user", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
"ical_token": { "name": "ical_token", "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": { "staff_user_id_user_id_fk": { "name": "staff_user_id_user_id_fk", "tableFrom": "staff", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {
"staff_email_unique": { "name": "staff_email_unique", "nullsNotDistinct": false, "columns": ["email"] },
"staff_oidc_sub_unique": { "name": "staff_oidc_sub_unique", "nullsNotDistinct": false, "columns": ["oidc_sub"] },
"staff_ical_token_unique": { "name": "staff_ical_token_unique", "nullsNotDistinct": false, "columns": ["ical_token"] }
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
"email_verified": { "name": "email_verified", "type": "boolean", "primaryKey": false, "notNull": true, "default": 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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, "columns": ["email"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"identifier": { "name": "identifier", "type": "text", "primaryKey": false, "notNull": true },
"value": { "name": "value", "type": "text", "primaryKey": false, "notNull": true },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.waitlist_entries": {
"name": "waitlist_entries",
"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 },
"preferred_date": { "name": "preferred_date", "type": "text", "primaryKey": false, "notNull": true },
"preferred_time": { "name": "preferred_time", "type": "text", "primaryKey": false, "notNull": true },
"status": { "name": "status", "type": "waitlist_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"notified_at": { "name": "notified_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"expires_at": { "name": "expires_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()" }
},
"indexes": {
"idx_waitlist_client_id": { "name": "idx_waitlist_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"idx_waitlist_preferred_date": { "name": "idx_waitlist_preferred_date", "columns": [{ "expression": "preferred_date", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"idx_waitlist_status": { "name": "idx_waitlist_status", "columns": [{ "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
},
"foreignKeys": {
"waitlist_entries_client_id_clients_id_fk": { "name": "waitlist_entries_client_id_clients_id_fk", "tableFrom": "waitlist_entries", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"waitlist_entries_pet_id_pets_id_fk": { "name": "waitlist_entries_pet_id_pets_id_fk", "tableFrom": "waitlist_entries", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"waitlist_entries_service_id_services_id_fk": { "name": "waitlist_entries_service_id_services_id_fk", "tableFrom": "waitlist_entries", "tableTo": "services", "columnsFrom": ["service_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": {} }
}
@@ -1,505 +0,0 @@
{
"id": "9e8d3f2a-1c7b-4a6d-8f0e-5c2b9a3d7e1f",
"prevId": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"account_id": { "name": "account_id", "type": "text", "primaryKey": false, "notNull": true },
"provider_id": { "name": "provider_id", "type": "text", "primaryKey": false, "notNull": true },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
"access_token": { "name": "access_token", "type": "text", "primaryKey": false, "notNull": false },
"refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, "notNull": false },
"id_token": { "name": "id_token", "type": "text", "primaryKey": false, "notNull": false },
"access_token_expires_at": { "name": "access_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"refresh_token_expires_at": { "name": "refresh_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"scope": { "name": "scope", "type": "text", "primaryKey": false, "notNull": false },
"password": { "name": "password", "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": { "account_user_id_user_id_fk": { "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment_groups": {
"name": "appointment_groups",
"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 },
"notes": { "name": "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()" }
},
"indexes": {},
"foreignKeys": { "appointment_groups_client_id_clients_id_fk": { "name": "appointment_groups_client_id_clients_id_fk", "tableFrom": "appointment_groups", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"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()" }
},
"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.business_settings": {
"name": "business_settings",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"business_name": { "name": "business_name", "type": "text", "primaryKey": false, "notNull": true, "default": "'GroomBook'" },
"logo_base64": { "name": "logo_base64", "type": "text", "primaryKey": false, "notNull": false },
"logo_mime_type": { "name": "logo_mime_type", "type": "text", "primaryKey": false, "notNull": false },
"logo_key": { "name": "logo_key", "type": "text", "primaryKey": false, "notNull": false },
"primary_color": { "name": "primary_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#4f8a6f'" },
"accent_color": { "name": "accent_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#8b7355'" },
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.clients": {
"name": "clients",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": false },
"phone": { "name": "phone", "type": "text", "primaryKey": false, "notNull": false },
"address": { "name": "address", "type": "text", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"email_opt_out": { "name": "email_opt_out", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"status": { "name": "status", "type": "client_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"disabled_at": { "name": "disabled_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()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.grooming_visit_logs": {
"name": "grooming_visit_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false },
"products_used": { "name": "products_used", "type": "text", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"groomed_at": { "name": "groomed_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"grooming_visit_logs_pet_id_pets_id_fk": { "name": "grooming_visit_logs_pet_id_pets_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"grooming_visit_logs_appointment_id_appointments_id_fk": { "name": "grooming_visit_logs_appointment_id_appointments_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"grooming_visit_logs_staff_id_staff_id_fk": { "name": "grooming_visit_logs_staff_id_staff_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.impersonation_audit_logs": {
"name": "impersonation_audit_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"session_id": { "name": "session_id", "type": "uuid", "primaryKey": false, "notNull": true },
"action": { "name": "action", "type": "text", "primaryKey": false, "notNull": true },
"page_visited": { "name": "page_visited", "type": "text", "primaryKey": false, "notNull": false },
"metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": { "impersonation_audit_logs_session_id_idx": { "name": "impersonation_audit_logs_session_id_idx", "columns": [{ "expression": "session_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } },
"foreignKeys": { "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", "tableFrom": "impersonation_audit_logs", "tableTo": "impersonation_sessions", "columnsFrom": ["session_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.impersonation_sessions": {
"name": "impersonation_sessions",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": true },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"reason": { "name": "reason", "type": "text", "primaryKey": false, "notNull": false },
"status": { "name": "status", "type": "impersonation_session_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"started_at": { "name": "started_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"ended_at": { "name": "ended_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {
"impersonation_sessions_staff_id_status_idx": { "name": "impersonation_sessions_staff_id_status_idx", "columns": [{ "expression": "staff_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"impersonation_sessions_client_id_idx": { "name": "impersonation_sessions_client_id_idx", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
},
"foreignKeys": {
"impersonation_sessions_staff_id_staff_id_fk": { "name": "impersonation_sessions_staff_id_staff_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"impersonation_sessions_client_id_clients_id_fk": { "name": "impersonation_sessions_client_id_clients_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoice_line_items": {
"name": "invoice_line_items",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": true },
"quantity": { "name": "quantity", "type": "integer", "primaryKey": false, "notNull": true, "default": 1 },
"unit_price_cents": { "name": "unit_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "invoice_line_items_invoice_id_invoices_id_fk": { "name": "invoice_line_items_invoice_id_invoices_id_fk", "tableFrom": "invoice_line_items", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoice_tip_splits": {
"name": "invoice_tip_splits",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"staff_name": { "name": "staff_name", "type": "text", "primaryKey": false, "notNull": true },
"share_pct": { "name": "share_pct", "type": "numeric(5, 2)", "primaryKey": false, "notNull": true },
"share_cents": { "name": "share_cents", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"invoice_tip_splits_invoice_id_invoices_id_fk": { "name": "invoice_tip_splits_invoice_id_invoices_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"invoice_tip_splits_staff_id_staff_id_fk": { "name": "invoice_tip_splits_staff_id_staff_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoices": {
"name": "invoices",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"subtotal_cents": { "name": "subtotal_cents", "type": "integer", "primaryKey": false, "notNull": true },
"tax_cents": { "name": "tax_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
"tip_cents": { "name": "tip_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
"status": { "name": "status", "type": "invoice_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'draft'" },
"payment_method": { "name": "payment_method", "type": "payment_method", "typeSchema": "public", "primaryKey": false, "notNull": false },
"paid_at": { "name": "paid_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"notes": { "name": "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()" }
},
"indexes": {},
"foreignKeys": {
"invoices_appointment_id_appointments_id_fk": { "name": "invoices_appointment_id_appointments_id_fk", "tableFrom": "invoices", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"invoices_client_id_clients_id_fk": { "name": "invoices_client_id_clients_id_fk", "tableFrom": "invoices", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"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 },
"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
},
"public.recurring_series": {
"name": "recurring_series",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"frequency_weeks": { "name": "frequency_weeks", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reminder_logs": {
"name": "reminder_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": true },
"reminder_type": { "name": "reminder_type", "type": "text", "primaryKey": false, "notNull": true },
"sent_at": { "name": "sent_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "reminder_logs_appointment_id_appointments_id_fk": { "name": "reminder_logs_appointment_id_appointments_id_fk", "tableFrom": "reminder_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "reminder_logs_appointment_id_reminder_type_unique": { "name": "reminder_logs_appointment_id_reminder_type_unique", "nullsNotDistinct": false, "columns": ["appointment_id", "reminder_type"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.services": {
"name": "services",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": false },
"base_price_cents": { "name": "base_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
"duration_minutes": { "name": "duration_minutes", "type": "integer", "primaryKey": false, "notNull": true },
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "services_name_unique": { "name": "services_name_unique", "nullsNotDistinct": false, "columns": ["name"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"token": { "name": "token", "type": "text", "primaryKey": false, "notNull": true },
"ip_address": { "name": "ip_address", "type": "text", "primaryKey": false, "notNull": false },
"user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
"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": { "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, "columns": ["token"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.staff": {
"name": "staff",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
"oidc_sub": { "name": "oidc_sub", "type": "text", "primaryKey": false, "notNull": false },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": false },
"role": { "name": "role", "type": "staff_role", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'groomer'" },
"is_super_user": { "name": "is_super_user", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
"ical_token": { "name": "ical_token", "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": { "staff_user_id_user_id_fk": { "name": "staff_user_id_user_id_fk", "tableFrom": "staff", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {
"staff_email_unique": { "name": "staff_email_unique", "nullsNotDistinct": false, "columns": ["email"] },
"staff_oidc_sub_unique": { "name": "staff_oidc_sub_unique", "nullsNotDistinct": false, "columns": ["oidc_sub"] },
"staff_ical_token_unique": { "name": "staff_ical_token_unique", "nullsNotDistinct": false, "columns": ["ical_token"] }
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
"email_verified": { "name": "email_verified", "type": "boolean", "primaryKey": false, "notNull": true, "default": 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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, "columns": ["email"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"identifier": { "name": "identifier", "type": "text", "primaryKey": false, "notNull": true },
"value": { "name": "value", "type": "text", "primaryKey": false, "notNull": true },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.waitlist_entries": {
"name": "waitlist_entries",
"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 },
"preferred_date": { "name": "preferred_date", "type": "text", "primaryKey": false, "notNull": true },
"preferred_time": { "name": "preferred_time", "type": "text", "primaryKey": false, "notNull": true },
"status": { "name": "status", "type": "waitlist_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"notified_at": { "name": "notified_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"expires_at": { "name": "expires_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()" }
},
"indexes": {
"idx_waitlist_client_id": { "name": "idx_waitlist_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"idx_waitlist_preferred_date": { "name": "idx_waitlist_preferred_date", "columns": [{ "expression": "preferred_date", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"idx_waitlist_status": { "name": "idx_waitlist_status", "columns": [{ "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
},
"foreignKeys": {
"waitlist_entries_client_id_clients_id_fk": { "name": "waitlist_entries_client_id_clients_id_fk", "tableFrom": "waitlist_entries", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"waitlist_entries_pet_id_pets_id_fk": { "name": "waitlist_entries_pet_id_pets_id_fk", "tableFrom": "waitlist_entries", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"waitlist_entries_service_id_services_id_fk": { "name": "waitlist_entries_service_id_services_id_fk", "tableFrom": "waitlist_entries", "tableTo": "services", "columnsFrom": ["service_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": {} }
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,103 +0,0 @@
{
"id": "0026_stripe_payment",
"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": {}
}
@@ -1,103 +0,0 @@
{
"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": {}
}
@@ -1,210 +0,0 @@
{
"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": {}
}
-251
View File
@@ -1,251 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1773771452946,
"tag": "0000_colossal_colossus",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1742241600000,
"tag": "0001_pet_health_alerts",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1773777600000,
"tag": "0002_invoices",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1742169600000,
"tag": "0003_recurring_series",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1773779939000,
"tag": "0004_reminder_logs",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1773783000000,
"tag": "0005_appointment_groups",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1773783600000,
"tag": "0006_pet_profile_attributes",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1773820800000,
"tag": "0007_tip_splitting",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1773907200000,
"tag": "0008_business_settings",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1773993600000,
"tag": "0009_client_soft_delete",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1742500800000,
"tag": "0010_impersonation_sessions",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1742587200000,
"tag": "0011_impersonation_indexes",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1774080000000,
"tag": "0012_pet_photo",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1774166400000,
"tag": "0013_appointment_confirmation",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1774252800000,
"tag": "0014_customer_notes",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1774339200000,
"tag": "0015_waitlist",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1774425600000,
"tag": "0016_ical_token",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1774512000000,
"tag": "0017_better_auth_tables",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1774598400000,
"tag": "0018_backfill_staff_user_id",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1774729055924,
"tag": "0019_concerned_sunfire",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1775050467192,
"tag": "0020_typical_daimon_hellstrom",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1775136867192,
"tag": "0021_pet_image",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1775223267192,
"tag": "0022_logo_key",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1775309667192,
"tag": "0023_auth_provider_config",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1775396067192,
"tag": "0024_invoice_indexes",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1775482467192,
"tag": "0025_rate_limit",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1775568867192,
"tag": "0026_stripe_payment",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1775655267192,
"tag": "0027_refunds",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1775741667192,
"tag": "0028_sms_reminders",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1775784467192,
"tag": "0029_db_indexes_constraints",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"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
}
]
}
-39
View File
@@ -1,39 +0,0 @@
{
"name": "@groombook/db",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./src/index.ts",
"exports": {
".": {
"default": "./dist/index.js",
"types": "./src/index.ts"
},
"./factories": {
"default": "./src/factories.ts",
"types": "./src/factories.ts"
}
},
"scripts": {
"build": "tsc --project .",
"generate": "drizzle-kit generate",
"migrate": "drizzle-kit migrate",
"seed": "tsx src/seed.ts",
"reset": "tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts",
"studio": "drizzle-kit studio",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"better-auth": "^1.5.6",
"drizzle-orm": "^0.38.4",
"postgres": "^3.4.5"
},
"devDependencies": {
"@types/node": "^22.10.7",
"drizzle-kit": "^0.30.4",
"tsx": "^4.19.0",
"typescript": "^5.7.3"
},
"license": "AGPL-3.0-only"
}
-94
View File
@@ -1,94 +0,0 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12; // 96-bit IV for GCM
const AUTH_TAG_LENGTH = 16; // 128-bit auth tag
const SALT_LENGTH = 16;
/**
* Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt.
* A unique random salt is generated per encryptSecret() call and prepended to the output.
*/
function deriveKey(secret: string, salt: Buffer): Buffer {
return scryptSync(secret, salt, 32);
}
/**
* Encrypts a plaintext string using AES-256-GCM.
* Returns a base64-encoded string in the format: salt:iv:ciphertext:authTag
*/
export function encryptSecret(plaintext: string): string {
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error("BETTER_AUTH_SECRET environment variable is required");
}
const salt = randomBytes(SALT_LENGTH);
const key = deriveKey(secret, salt);
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_LENGTH,
});
let ciphertext = cipher.update(plaintext, "utf8");
ciphertext = Buffer.concat([ciphertext, cipher.final()]);
const authTag = cipher.getAuthTag();
// Format: base64(salt):base64(iv):base64(ciphertext):base64(authTag)
return [
salt.toString("base64"),
iv.toString("base64"),
ciphertext.toString("base64"),
authTag.toString("base64"),
].join(":");
}
/**
* Decrypts a ciphertext string produced by encryptSecret.
* Supports both new format (salt:iv:ciphertext:authTag) and legacy format (iv:ciphertext:authTag).
*/
export function decryptSecret(encrypted: string): string {
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error("BETTER_AUTH_SECRET environment variable is required");
}
const parts = encrypted.split(":");
let salt: Buffer;
let iv: Buffer;
let ciphertext: Buffer;
let authTag: Buffer;
if (parts.length === 4) {
// New format: salt:iv:ciphertext:authTag
salt = Buffer.from(parts[0]!, "base64");
iv = Buffer.from(parts[1]!, "base64");
ciphertext = Buffer.from(parts[2]!, "base64");
authTag = Buffer.from(parts[3]!, "base64");
} else if (parts.length === 3) {
// Legacy format: iv:ciphertext:authTag — use fixed package salt
salt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH);
iv = Buffer.from(parts[0]!, "base64");
ciphertext = Buffer.from(parts[1]!, "base64");
authTag = Buffer.from(parts[2]!, "base64");
} else {
throw new Error(
"Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"
);
}
const key = deriveKey(secret, salt);
const decipher = createDecipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_LENGTH,
});
decipher.setAuthTag(authTag);
let plaintext = decipher.update(ciphertext);
plaintext = Buffer.concat([plaintext, decipher.final()]);
return plaintext.toString("utf8");
}
-164
View File
@@ -1,164 +0,0 @@
/**
* Test factories — build typed in-memory entities for unit tests.
*
* Each factory returns a fully-populated object with valid defaults.
* Pass an overrides object to customise specific fields.
*
* IDs are generated with a deterministic counter so tests produce stable,
* readable values (e.g. "staff-1", "client-2") without needing crypto.
*
* Usage:
* import { buildStaff, buildClient, buildPet } from "@groombook/db/factories";
*
* const manager = buildStaff({ role: "manager" });
* const client = buildClient({ name: "Alice Smith" });
* const pet = buildPet({ clientId: client.id });
*/
import type { staff, clients, pets, services, appointments } from "./schema.js";
// ── Counter-based ID factory ─────────────────────────────────────────────────
const counters: Record<string, number> = {};
function nextId(prefix: string): string {
counters[prefix] = (counters[prefix] ?? 0) + 1;
return `${prefix}-${counters[prefix]}`;
}
/** Reset all counters. Call in beforeEach() to keep tests independent. */
export function resetFactoryCounters(): void {
for (const key of Object.keys(counters)) {
delete counters[key];
}
}
// ── Type aliases ─────────────────────────────────────────────────────────────
export type StaffRow = typeof staff.$inferSelect;
export type ClientRow = typeof clients.$inferSelect;
export type PetRow = typeof pets.$inferSelect;
export type ServiceRow = typeof services.$inferSelect;
export type AppointmentRow = typeof appointments.$inferSelect;
// ── Factories ────────────────────────────────────────────────────────────────
export function buildStaff(overrides: Partial<StaffRow> = {}): StaffRow {
const id = nextId("staff");
return {
id,
name: `Staff Member ${id}`,
email: `${id}@groombook.test`,
oidcSub: `oidc-${id}`,
userId: null,
role: "groomer",
isSuperUser: false,
active: true,
icalToken: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
...overrides,
};
}
export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
const id = nextId("client");
return {
id,
name: `Client ${id}`,
email: `${id}@example.com`,
phone: "555-0100",
address: "1 Main St, Springfield, CA 90000",
notes: null,
emailOptOut: false,
smsOptIn: false,
smsConsentDate: null,
smsOptOutDate: null,
smsConsentText: null,
stripeCustomerId: null,
status: "active",
disabledAt: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
...overrides,
};
}
export function buildPet(overrides: Partial<PetRow> & { clientId: string }): PetRow {
const id = nextId("pet");
const defaults: PetRow = {
id,
clientId: overrides.clientId,
name: `Pet ${id}`,
species: "Dog",
breed: "Mixed Breed",
weightKg: "15.00",
dateOfBirth: new Date("2020-06-15T00:00:00Z"),
healthAlerts: null,
groomingNotes: null,
cutStyle: null,
shampooPreference: null,
specialCareNotes: null,
coatType: null,
petSizeCategory: null,
customFields: {},
photoKey: null,
photoUploadedAt: null,
image: null,
temperamentScore: null,
temperamentFlags: [],
medicalAlerts: [],
preferredCuts: [],
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
};
return { ...defaults, ...overrides };
}
export function buildService(overrides: Partial<ServiceRow> = {}): ServiceRow {
const id = nextId("service");
return {
id,
name: `Service ${id}`,
description: "A grooming service",
basePriceCents: 6500,
durationMinutes: 60,
defaultBufferMinutes: 0,
active: true,
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
...overrides,
};
}
export function buildAppointment(
overrides: Partial<AppointmentRow> & { clientId: string; petId: string; serviceId: string; staffId: string }
): AppointmentRow {
const id = nextId("appointment");
const startTime = new Date("2025-06-01T10:00:00Z");
const endTime = new Date("2025-06-01T11:00:00Z");
const defaults: AppointmentRow = {
id,
clientId: overrides.clientId,
petId: overrides.petId,
serviceId: overrides.serviceId,
staffId: overrides.staffId,
batherStaffId: null,
seriesId: null,
seriesIndex: null,
groupId: null,
status: "scheduled",
startTime,
endTime,
notes: null,
priceCents: null,
confirmationStatus: "pending",
confirmedAt: null,
cancelledAt: null,
confirmationToken: null,
customerNotes: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
};
return { ...defaults, ...overrides };
}
-20
View File
@@ -1,20 +0,0 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema.js";
export * from "./schema.js";
export { encryptSecret, decryptSecret } from "./crypto.js";
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
let _db: ReturnType<typeof drizzle> | null = null;
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, connect_timeout: 5 });
_db = drizzle(client, { schema });
return _db;
}
export type Db = ReturnType<typeof getDb>;
-70
View File
@@ -1,70 +0,0 @@
/**
* reset.ts — Drop all application tables and re-run migrations + seed.
*
* Intended for local development only. Never run against production.
*
* Usage:
* DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts
*/
import postgres from "postgres";
async function reset() {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL is not set");
process.exit(1);
}
if (process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true") {
console.error("[FATAL] db:reset must not be run in production without ALLOW_RESET=true.");
process.exit(1);
}
const client = postgres(url, { max: 1 });
console.log("Dropping all application tables...\n");
// Drop in dependency order (children before parents)
await client`
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
) LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
`;
// Drop custom enums
await client`
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (
SELECT typname FROM pg_type
WHERE typtype = 'e' AND typnamespace = (
SELECT oid FROM pg_namespace WHERE nspname = 'public'
)
) LOOP
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
END LOOP;
END $$;
`;
// Drop the drizzle migrations tracking table
await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`;
await client`DROP SCHEMA IF EXISTS drizzle CASCADE`;
console.log("✓ All tables and enums dropped\n");
await client.end();
}
reset().catch((err) => {
console.error("Reset failed:", err);
process.exit(1);
});
-660
View File
@@ -1,660 +0,0 @@
import {
boolean,
index,
integer,
jsonb,
numeric,
pgEnum,
pgTable,
text,
timestamp,
unique,
uuid,
} from "drizzle-orm/pg-core";
import type { MedicalAlert } from "@groombook/types";
// ─── Enums ────────────────────────────────────────────────────────────────────
export const appointmentStatusEnum = pgEnum("appointment_status", [
"scheduled",
"confirmed",
"in_progress",
"completed",
"cancelled",
"no_show",
]);
export const staffRoleEnum = pgEnum("staff_role", [
"groomer",
"receptionist",
"manager",
]);
export const invoiceStatusEnum = pgEnum("invoice_status", [
"draft",
"pending",
"paid",
"void",
]);
export const paymentMethodEnum = pgEnum("payment_method", [
"cash",
"card",
"check",
"other",
]);
export const clientStatusEnum = pgEnum("client_status", [
"active",
"disabled",
]);
// ─── Better-Auth Tables ──────────────────────────────────────────────────────
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull().default(false),
image: text("image"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
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(
"clients",
{
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
email: text("email").notNull(),
phone: text("phone"),
address: text("address"),
notes: text("notes"),
emailOptOut: boolean("email_opt_out").notNull().default(false),
smsOptIn: boolean("sms_opt_in").notNull().default(false),
smsConsentDate: timestamp("sms_consent_date"),
smsOptOutDate: timestamp("sms_opt_out_date"),
smsConsentText: text("sms_consent_text"),
stripeCustomerId: text("stripe_customer_id"),
status: clientStatusEnum("status").notNull().default("active"),
disabledAt: timestamp("disabled_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [index("idx_clients_email").on(t.email)]
);
export const pets = pgTable(
"pets",
{
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
name: text("name").notNull(),
species: text("species").notNull(),
breed: text("breed"),
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
dateOfBirth: timestamp("date_of_birth"),
healthAlerts: text("health_alerts"),
groomingNotes: text("grooming_notes"),
cutStyle: text("cut_style"),
shampooPreference: text("shampoo_preference"),
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"),
image: text("image"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [index("idx_pets_client_id").on(t.clientId)]
);
export const services = pgTable("services", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull().unique(),
description: text("description"),
basePriceCents: integer("base_price_cents").notNull(),
durationMinutes: integer("duration_minutes").notNull(),
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 staff = pgTable("staff", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
// oidcSub links to the Authentik OIDC subject claim
oidcSub: text("oidc_sub").unique(),
// Better-Auth user ID — links staff business record to auth identity
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
role: staffRoleEnum("role").notNull().default("groomer"),
// Super users bypass appointment-booking restrictions and access admin panels
isSuperUser: boolean("is_super_user").notNull().default(false),
active: boolean("active").notNull().default(true),
// Token for iCal calendar feed subscription (no auth required)
icalToken: text("ical_token").unique(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const recurringSeries = pgTable("recurring_series", {
id: uuid("id").primaryKey().defaultRandom(),
// How many weeks between each appointment in the series
frequencyWeeks: integer("frequency_weeks").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
// appointmentGroups links multiple appointments from the same client visit.
// Each pet in the group gets its own appointment row with its own groomer.
export const appointmentGroups = pgTable("appointment_groups", {
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "restrict" }),
notes: text("notes"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const appointments = pgTable(
"appointments",
{
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "restrict" }),
petId: uuid("pet_id")
.notNull()
.references(() => pets.id, { onDelete: "restrict" }),
serviceId: uuid("service_id")
.notNull()
.references(() => services.id, { onDelete: "restrict" }),
staffId: uuid("staff_id").references(() => staff.id, {
onDelete: "set null",
}),
// Optional secondary staff (bather/assistant) for tip-split tracking
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
onDelete: "set null",
}),
status: appointmentStatusEnum("status").notNull().default("scheduled"),
startTime: timestamp("start_time").notNull(),
endTime: timestamp("end_time").notNull(),
notes: text("notes"),
// Override price at time of booking (null = use service base price)
priceCents: integer("price_cents"),
// Recurring series support
seriesId: uuid("series_id").references(() => recurringSeries.id, {
onDelete: "set null",
}),
seriesIndex: integer("series_index"),
// Multi-pet group booking: links this appointment to others in the same visit
groupId: uuid("group_id").references(() => appointmentGroups.id, {
onDelete: "set null",
}),
// Customer confirmation/cancellation tracking
// Values: "pending" | "confirmed" | "cancelled"
confirmationStatus: text("confirmation_status").notNull().default("pending"),
confirmedAt: timestamp("confirmed_at"),
cancelledAt: timestamp("cancelled_at"),
// Token for tokenized email confirm/cancel links (no auth required)
confirmationToken: text("confirmation_token").unique(),
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
customerNotes: text("customer_notes"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
index("idx_appointments_client_id").on(t.clientId),
index("idx_appointments_staff_id").on(t.staffId),
index("idx_appointments_start_time").on(t.startTime),
index("idx_appointments_status").on(t.status),
]
);
export const invoices = pgTable(
"invoices",
{
id: uuid("id").primaryKey().defaultRandom(),
appointmentId: uuid("appointment_id").references(() => appointments.id, {
onDelete: "restrict",
}),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "restrict" }),
subtotalCents: integer("subtotal_cents").notNull(),
taxCents: integer("tax_cents").notNull().default(0),
tipCents: integer("tip_cents").notNull().default(0),
totalCents: integer("total_cents").notNull(),
status: invoiceStatusEnum("status").notNull().default("draft"),
paymentMethod: paymentMethodEnum("payment_method"),
paidAt: timestamp("paid_at"),
stripePaymentIntentId: text("stripe_payment_intent_id"),
stripeRefundId: text("stripe_refund_id"),
paymentFailureReason: text("payment_failure_reason"),
notes: text("notes"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
index("idx_invoices_client_id").on(t.clientId),
index("idx_invoices_status").on(t.status),
index("idx_invoices_created_at").on(t.createdAt),
index("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
]
);
export const invoiceLineItems = pgTable(
"invoice_line_items",
{
id: uuid("id").primaryKey().defaultRandom(),
invoiceId: uuid("invoice_id")
.notNull()
.references(() => invoices.id, { onDelete: "cascade" }),
description: text("description").notNull(),
quantity: integer("quantity").notNull().default(1),
unitPriceCents: integer("unit_price_cents").notNull(),
totalCents: integer("total_cents").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [index("idx_invoice_line_items_invoice_id").on(t.invoiceId)]
);
// Per-staff tip allocation calculated when an invoice is paid.
// staff_name is snapshotted at calculation time so reports remain accurate if staff is deleted.
export const invoiceTipSplits = pgTable(
"invoice_tip_splits",
{
id: uuid("id").primaryKey().defaultRandom(),
invoiceId: uuid("invoice_id")
.notNull()
.references(() => invoices.id, { onDelete: "cascade" }),
staffId: uuid("staff_id").references(() => staff.id, { onDelete: "set null" }),
staffName: text("staff_name").notNull(),
sharePct: numeric("share_pct", { precision: 5, scale: 2 }).notNull(),
shareCents: integer("share_cents").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)]
);
// Refund records with idempotency key support
export const refunds = pgTable(
"refunds",
{
id: uuid("id").primaryKey().defaultRandom(),
invoiceId: uuid("invoice_id")
.notNull()
.references(() => invoices.id, { onDelete: "restrict" }),
stripeRefundId: text("stripe_refund_id").notNull(),
idempotencyKey: text("idempotency_key").unique(),
amountCents: integer("amount_cents"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [
index("idx_refunds_invoice_id").on(t.invoiceId),
index("idx_refunds_idempotency_key").on(t.idempotencyKey),
]
);
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
// reminder_type values: "confirmation", "24h", "2h"
// channel values: "email", "sms"
export const reminderLogs = pgTable(
"reminder_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
appointmentId: uuid("appointment_id")
.notNull()
.references(() => appointments.id, { onDelete: "cascade" }),
// "confirmation" | "24h" | "2h"
reminderType: text("reminder_type").notNull(),
// "email" | "sms"
channel: text("channel").notNull().default("email"),
sentAt: timestamp("sent_at").notNull().defaultNow(),
},
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)]
);
// ─── Impersonation ──────────────────────────────────────────────────────────
export const impersonationSessionStatusEnum = pgEnum(
"impersonation_session_status",
["active", "ended", "expired"]
);
export const impersonationSessions = pgTable(
"impersonation_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
staffId: uuid("staff_id")
.notNull()
.references(() => staff.id, { onDelete: "restrict" }),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "restrict" }),
reason: text("reason"),
status: impersonationSessionStatusEnum("status")
.notNull()
.default("active"),
startedAt: timestamp("started_at").notNull().defaultNow(),
endedAt: timestamp("ended_at"),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [
index("impersonation_sessions_staff_id_status_idx").on(t.staffId, t.status),
index("impersonation_sessions_client_id_idx").on(t.clientId),
]
);
export const impersonationAuditLogs = pgTable(
"impersonation_audit_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
sessionId: uuid("session_id")
.notNull()
.references(() => impersonationSessions.id, { onDelete: "cascade" }),
action: text("action").notNull(),
pageVisited: text("page_visited"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [index("impersonation_audit_logs_session_id_idx").on(t.sessionId)]
);
// ─── Messaging ───────────────────────────────────────────────────────────────
export const messagingChannelEnum = pgEnum("messaging_channel", ["sms", "mms"]);
export const messageDirectionEnum = pgEnum("message_direction", [
"inbound",
"outbound",
]);
export const messageStatusEnum = pgEnum("message_status", [
"queued",
"sent",
"delivered",
"failed",
"received",
]);
export const messageConsentKindEnum = pgEnum("message_consent_kind", [
"opt_in",
"opt_out",
"help",
]);
export const conversations = pgTable(
"conversations",
{
id: uuid("id").primaryKey().defaultRandom(),
businessId: uuid("business_id").notNull(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
channel: messagingChannelEnum("channel").notNull(),
externalNumber: text("external_number").notNull(),
businessNumber: text("business_number").notNull(),
lastMessageAt: timestamp("last_message_at"),
status: text("status").notNull().default("active"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
index("idx_conversations_business_id_last_message_at").on(
t.businessId,
t.lastMessageAt.desc()
),
unique("uq_conversations_business_client_number").on(
t.businessId,
t.clientId,
t.businessNumber
),
]
);
export const messages = pgTable(
"messages",
{
id: uuid("id").primaryKey().defaultRandom(),
conversationId: uuid("conversation_id")
.notNull()
.references(() => conversations.id, { onDelete: "cascade" }),
direction: messageDirectionEnum("direction").notNull(),
body: text("body"),
status: messageStatusEnum("status").notNull().default("queued"),
providerMessageId: text("provider_message_id"),
errorCode: text("error_code"),
errorMessage: text("error_message"),
sentByStaffId: uuid("sent_by_staff_id").references(() => staff.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").notNull().defaultNow(),
deliveredAt: timestamp("delivered_at"),
readByClientAt: timestamp("read_by_client_at"),
},
(t) => [
index("idx_messages_conversation_id_created_at").on(
t.conversationId,
t.createdAt.desc()
),
unique("uq_messages_provider_message_id").on(t.providerMessageId),
]
);
export const messageAttachments = pgTable(
"message_attachments",
{
id: uuid("id").primaryKey().defaultRandom(),
messageId: uuid("message_id")
.notNull()
.references(() => messages.id, { onDelete: "cascade" }),
contentType: text("content_type").notNull(),
url: text("url").notNull(),
size: integer("size").notNull(),
providerMediaId: text("provider_media_id"),
},
(t) => [index("idx_message_attachments_message_id").on(t.messageId)]
);
export const messageConsentEvents = pgTable(
"message_consent_events",
{
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
businessId: uuid("business_id").notNull(),
kind: messageConsentKindEnum("kind").notNull(),
source: text("source"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [index("idx_message_consent_events_client_id").on(t.clientId)]
);
export const businessSettings = pgTable("business_settings", {
id: uuid("id").primaryKey().defaultRandom(),
businessName: text("business_name").notNull().default("GroomBook"),
logoBase64: text("logo_base64"),
logoMimeType: text("logo_mime_type"),
logoKey: text("logo_key"),
primaryColor: text("primary_color").notNull().default("#4f8a6f"),
accentColor: text("accent_color").notNull().default("#8b7355"),
messagingPhoneNumber: text("messaging_phone_number"),
telnyxMessagingProfileId: text("telnyx_messaging_profile_id"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const groomingVisitLogs = pgTable("grooming_visit_logs", {
id: uuid("id").primaryKey().defaultRandom(),
petId: uuid("pet_id")
.notNull()
.references(() => pets.id, { onDelete: "cascade" }),
appointmentId: uuid("appointment_id").references(() => appointments.id, {
onDelete: "set null",
}),
staffId: uuid("staff_id").references(() => staff.id, {
onDelete: "set null",
}),
cutStyle: text("cut_style"),
productsUsed: text("products_used"),
notes: text("notes"),
groomedAt: timestamp("groomed_at").notNull().defaultNow(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const waitlistStatusEnum = pgEnum("waitlist_status", [
"active",
"notified",
"expired",
"cancelled",
]);
export const waitlistEntries = pgTable(
"waitlist_entries",
{
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
petId: uuid("pet_id")
.notNull()
.references(() => pets.id, { onDelete: "cascade" }),
serviceId: uuid("service_id")
.notNull()
.references(() => services.id, { onDelete: "cascade" }),
preferredDate: text("preferred_date").notNull(),
preferredTime: text("preferred_time").notNull(),
status: waitlistStatusEnum("status").notNull().default("active"),
notifiedAt: timestamp("notified_at"),
expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
index("idx_waitlist_client_id").on(t.clientId),
index("idx_waitlist_preferred_date").on(t.preferredDate),
index("idx_waitlist_status").on(t.status),
]
);
// ─── Auth Provider Config ──────────────────────────────────────────────────
export const authProviderConfig = pgTable("auth_provider_config", {
id: uuid("id").primaryKey().defaultRandom(),
providerId: text("provider_id").notNull().unique(), // e.g. "authentik", "okta", "entra-id"
displayName: text("display_name").notNull(), // shown on login button
issuerUrl: text("issuer_url").notNull(), // OIDC issuer/discovery URL
internalBaseUrl: text("internal_base_url"), // for hairpin NAT / K8s internal routing
clientId: text("client_id").notNull(),
clientSecret: text("client_secret").notNull(), // AES-256-GCM encrypted using BETTER_AUTH_SECRET
scopes: text("scopes").notNull().default("openid profile email"),
enabled: boolean("enabled").notNull().default(true),
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),
]
);
File diff suppressed because it is too large Load Diff
-13
View File
@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
-22
View File
@@ -1,22 +0,0 @@
{
"name": "@groombook/types",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./src/index.ts",
"exports": {
".": {
"default": "./dist/index.js",
"types": "./src/index.ts"
}
},
"scripts": {
"build": "tsc --project .",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.7.3"
},
"license": "AGPL-3.0-only"
}
-258
View File
@@ -1,258 +0,0 @@
// Shared domain types for Groom Book
export type AppointmentStatus =
| "scheduled"
| "confirmed"
| "in_progress"
| "completed"
| "cancelled"
| "no_show";
export type ConfirmationStatus = "pending" | "confirmed" | "cancelled";
export type ClientStatus = "active" | "disabled";
export interface Client {
id: string;
name: string;
email: string | null;
phone: string | null;
address: string | null;
notes: string | null;
emailOptOut: boolean;
status: ClientStatus;
disabledAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface Pet {
id: string;
clientId: string;
name: string;
species: string;
breed: string | null;
weightKg: number | null;
dateOfBirth: string | null;
healthAlerts: string | null;
groomingNotes: string | null;
cutStyle: string | null;
shampooPreference: string | null;
specialCareNotes: string | null;
coatType: string | null;
petSizeCategory: string | null;
preferredCuts: string[];
medicalAlerts: MedicalAlert[];
temperamentScore?: number;
temperamentFlags?: string[];
customFields: Record<string, string>;
photoKey?: string;
photoUploadedAt?: string;
createdAt: string;
updatedAt: string;
}
export interface GroomingVisitLog {
id: string;
petId: string;
appointmentId: string | null;
staffId: string | null;
cutStyle: string | null;
productsUsed: string | null;
notes: string | null;
groomedAt: string;
createdAt: string;
}
export interface Service {
id: string;
name: string;
description: string | null;
basePriceCents: number;
durationMinutes: number;
active: boolean;
createdAt: string;
updatedAt: string;
}
export interface Staff {
id: string;
name: string;
email: string;
role: "groomer" | "receptionist" | "manager";
isSuperUser: boolean;
active: boolean;
createdAt: string;
updatedAt: string;
}
export interface RecurringSeries {
id: string;
frequencyWeeks: number;
createdAt: string;
}
export interface AppointmentGroup {
id: string;
clientId: string;
notes: string | null;
createdAt: string;
updatedAt: string;
}
export interface Appointment {
id: string;
clientId: string;
petId: string;
serviceId: string;
staffId: string | null;
batherStaffId: string | null;
status: AppointmentStatus;
startTime: string;
endTime: string;
notes: string | null;
priceCents: number | null;
seriesId: string | null;
seriesIndex: number | null;
groupId: string | null;
confirmationStatus: ConfirmationStatus;
confirmedAt: string | null;
cancelledAt: string | null;
confirmationToken: string | null;
customerNotes: string | null;
createdAt: string;
updatedAt: string;
}
export interface InvoiceTipSplit {
id: string;
invoiceId: string;
staffId: string | null;
staffName: string;
sharePct: string;
shareCents: number;
createdAt: string;
}
export type InvoiceStatus = "draft" | "pending" | "paid" | "void";
export type PaymentMethod = "cash" | "card" | "check" | "other";
export interface InvoiceLineItem {
id: string;
invoiceId: string;
description: string;
quantity: number;
unitPriceCents: number;
totalCents: number;
createdAt: string;
}
export interface Invoice {
id: string;
appointmentId: string | null;
clientId: string;
subtotalCents: number;
taxCents: number;
tipCents: number;
totalCents: number;
status: InvoiceStatus;
paymentMethod: PaymentMethod | null;
paidAt: string | null;
stripePaymentIntentId: string | null;
stripeRefundId: string | null;
paymentFailureReason: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
lineItems?: InvoiceLineItem[];
// Transient fields populated from Stripe API (not stored in DB)
cardLast4?: string | null;
paymentStatus?: string | null;
tipSplits?: InvoiceTipSplit[];
}
// ─── Impersonation ──────────────────────────────────────────────────────────
export type ImpersonationSessionStatus = "active" | "ended" | "expired";
export interface ImpersonationSession {
id: string;
staffId: string;
clientId: string;
reason: string | null;
status: ImpersonationSessionStatus;
startedAt: string;
endedAt: string | null;
expiresAt: string;
createdAt: string;
}
export interface ImpersonationAuditLog {
id: string;
sessionId: string;
action: string;
pageVisited: string | null;
metadata: Record<string, unknown> | null;
createdAt: string;
}
export interface BusinessSettings {
id: string;
businessName: string;
logoBase64: string | null;
logoMimeType: string | null;
primaryColor: string;
accentColor: string;
createdAt: string;
updatedAt: string;
}
// Paginated list response
export interface PaginatedList<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export type AlertSeverity = "low" | "medium" | "high";
export interface MedicalAlert {
id: string;
type: string;
description: string;
severity: AlertSeverity;
}
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;
}
-13
View File
@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
+309 -353
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,2 +1,2 @@
packages:
- "packages/*"
- "apps/*"
-10
View File
@@ -1,10 +0,0 @@
{
"$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"}
]
}
-152
View File
@@ -1,152 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mutable state to control mock behavior per test
let dbSelectResult: unknown[] = [];
const mockEq = vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val }));
const mockDecryptSecret = vi.fn((s: string) => `decrypted:${s}`);
vi.mock("@groombook/db", () => {
const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" },
{
get(target, prop) {
if (prop === "_name") return "auth_provider_config";
if (prop === "$inferSelect") return {};
return { table: "auth_provider_config", column: prop };
},
}
);
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => dbSelectResult,
[Symbol.iterator]: function* () {
for (const item of dbSelectResult) yield item;
},
0: dbSelectResult[0],
length: dbSelectResult.length,
}),
}),
}),
}),
authProviderConfig,
eq: mockEq,
decryptSecret: mockDecryptSecret,
};
});
async function reimportAuth() {
vi.resetModules();
vi.doMock("@groombook/db", () => ({
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => dbSelectResult,
[Symbol.iterator]: function* () {
for (const item of dbSelectResult) yield item;
},
0: dbSelectResult[0],
length: dbSelectResult.length,
}),
}),
}),
}),
authProviderConfig: {},
eq: mockEq,
decryptSecret: mockDecryptSecret,
}));
const mod = await import("../lib/auth.js");
return mod;
}
describe("auth init", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
dbSelectResult = [];
vi.clearAllMocks();
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("falls back to env vars when DB returns empty", async () => {
process.env = {
...originalEnv,
OIDC_ISSUER: "https://issuer.example.com",
OIDC_CLIENT_ID: "test-client-id",
OIDC_CLIENT_SECRET: "test-client-secret",
BETTER_AUTH_SECRET: "test-secret",
BETTER_AUTH_URL: "http://localhost:3000",
NODE_ENV: "test",
};
const { initAuth, getAuth } = await reimportAuth();
await initAuth();
expect(getAuth()).toBeDefined();
});
it("uses DB config and decrypts clientSecret when DB has enabled provider", async () => {
const dbConfig = {
id: "config-id",
providerId: "okta",
displayName: "Okta",
issuerUrl: "https://okta.example.com",
internalBaseUrl: null,
clientId: "okta-client-id",
clientSecret: "encrypted:okta-secret",
scopes: "openid profile email",
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
dbSelectResult = [dbConfig];
process.env = {
...originalEnv,
BETTER_AUTH_SECRET: "test-secret",
BETTER_AUTH_URL: "http://localhost:3000",
NODE_ENV: "test",
};
const { initAuth, getAuth } = await reimportAuth();
await initAuth();
expect(getAuth()).toBeDefined();
expect(mockDecryptSecret).toHaveBeenCalledWith("encrypted:okta-secret");
});
it("throws when BETTER_AUTH_SECRET is missing and AUTH_DISABLED is not set", async () => {
process.env = {
...originalEnv,
OIDC_ISSUER: "",
OIDC_CLIENT_ID: "",
OIDC_CLIENT_SECRET: "",
NODE_ENV: "test",
};
delete process.env.BETTER_AUTH_SECRET;
delete process.env.AUTH_DISABLED;
const { initAuth } = await reimportAuth();
await expect(initAuth()).rejects.toThrow(
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
);
});
it("builds placeholder auth when AUTH_DISABLED=true without throwing", async () => {
process.env = {
...originalEnv,
AUTH_DISABLED: "true",
NODE_ENV: "test",
};
delete process.env.BETTER_AUTH_SECRET;
const { initAuth, getAuth } = await reimportAuth();
await expect(initAuth()).resolves.toBeUndefined();
expect(getAuth()).toBeDefined();
});
});
-273
View File
@@ -1,273 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import { authProviderRouter } from "../routes/authProvider.js";
// ─── Mock auth module ─────────────────────────────────────────────────────────
vi.mock("../lib/auth.js", () => ({
reinitAuth: vi.fn().mockResolvedValue(undefined),
}));
// ─── Types ────────────────────────────────────────────────────────────────────
interface MockStaff {
id: string;
role: string;
isSuperUser: boolean;
}
// ─── Mock DB state ────────────────────────────────────────────────────────────
let dbRows: Record<string, unknown>[] = [];
let deletedRows: string[] = [];
let insertedRows: Record<string, unknown>[] = [];
let encryptCalls: string[] = [];
function resetMock() {
dbRows = [];
deletedRows = [];
insertedRows = [];
encryptCalls = [];
}
// ─── Mock staff context ───────────────────────────────────────────────────────
const mockSuperUser: MockStaff = { id: "staff-1", role: "manager", isSuperUser: true };
const mockManager: MockStaff = { id: "staff-2", role: "manager", isSuperUser: false };
const mockGroomer: MockStaff = { id: "staff-3", role: "groomer", isSuperUser: false };
// ─── Mock db module ───────────────────────────────────────────────────────────
vi.mock("@groombook/db", () => {
const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" },
{
get(_target, prop) {
if (prop === "_name") return "auth_provider_config";
if (prop === "$inferSelect") return {};
return { table: "auth_provider_config", column: prop };
},
}
);
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => [...dbRows],
[Symbol.iterator]: function* () {
for (const item of dbRows) yield item;
},
0: dbRows[0],
length: dbRows.length,
}),
}),
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
insertedRows.push(vals);
return {
returning: () => [{ ...vals, id: "new-id-1", createdAt: new Date(), updatedAt: new Date() }],
};
},
}),
delete: () => {
// Execute immediately - route doesn't chain .returning()
deletedRows.push("all");
return Promise.resolve([]);
},
transaction: <T>(fn: (tx: {
delete: () => Promise<unknown>;
insert: () => { values: (v: Record<string, unknown>) => { returning: () => T[] } };
}) => Promise<T>) => {
const tx = {
delete: () => { deletedRows.push("all"); return Promise.resolve([]); },
insert: () => ({
values: (vals: Record<string, unknown>) => ({
returning: () => [{ ...vals, id: "new-id-1", createdAt: new Date(), updatedAt: new Date() }] as T[],
}),
}),
};
return fn(tx);
},
}),
authProviderConfig,
eq: (_col: unknown, _val: unknown) => ({ col: _col, val: _val }),
encryptSecret: (val: string) => {
encryptCalls.push(val);
return `encrypted:${val}`;
},
};
});
// ─── Build test app ───────────────────────────────────────────────────────────
function makeApp(staff: MockStaff | null) {
const app = new Hono();
// Inject staff context + super user guard per route
// Must match both exact path and wildcard subpaths
app.use(
"/admin/auth-provider/*",
async (c, next) => {
if (!staff) {
return c.json({ error: "Forbidden: no staff record resolved" }, 403);
}
if (!staff.isSuperUser) {
return c.json({ error: "Forbidden: super user privileges required" }, 403);
}
(c as any).set("staff", staff);
await next();
}
);
app.route("/admin/auth-provider", authProviderRouter as unknown as Hono);
return app;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function get<T extends Hono = Hono>(app: T, path: string, staff: MockStaff | null) {
const res = await app.request(path, { method: "GET" }, { allCtx: { staff } as { staff: MockStaff } });
return { status: res.status, body: await res.json() };
}
async function put<T extends Hono = Hono>(app: T, path: string, body: unknown, staff: MockStaff | null) {
const res = await app.request(path, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}, { allCtx: { staff } as { staff: MockStaff } });
return { status: res.status, body: await res.json() };
}
async function post<T extends Hono = Hono>(app: T, path: string, body: unknown, staff: MockStaff | null) {
const res = await app.request(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}, { allCtx: { staff } as { staff: MockStaff } });
return { status: res.status, body: await res.json() };
}
async function del<T extends Hono = Hono>(app: T, path: string, staff: MockStaff | null) {
const res = await app.request(path, { method: "DELETE" }, { allCtx: { staff } as { staff: MockStaff } });
return { status: res.status, body: await res.json() };
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("GET /admin/auth-provider", () => {
beforeEach(resetMock);
it("returns 404 when no provider configured", async () => {
dbRows = [];
const app = makeApp(mockSuperUser);
const { status, body } = await get(app, "/admin/auth-provider", mockSuperUser);
expect(status).toBe(404);
expect(body.error).toBe("No auth provider configured");
});
it("returns config with secret redacted", async () => {
dbRows = [{
id: "prov-1",
providerId: "authentik",
displayName: "Authentik",
issuerUrl: "https://auth.example.com",
internalBaseUrl: null,
clientId: "client-123",
clientSecret: "encrypted:secret",
scopes: "openid profile email",
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
}];
const app = makeApp(mockSuperUser);
const { status, body } = await get(app, "/admin/auth-provider", mockSuperUser);
expect(status).toBe(200);
expect(body.clientSecret).toBe("••••••••");
expect(body.providerId).toBe("authentik");
});
it("returns 403 when not super user", async () => {
dbRows = [];
const app = makeApp(mockManager);
const { status } = await get(app, "/admin/auth-provider", mockManager);
expect(status).toBe(403);
});
});
describe("PUT /admin/auth-provider", () => {
beforeEach(resetMock);
it("stores encrypted secret", async () => {
const app = makeApp(mockSuperUser);
const { status, body } = await put(app, "/admin/auth-provider", {
providerId: "authentik",
displayName: "Authentik SSO",
issuerUrl: "https://auth.example.com",
clientId: "my-client",
clientSecret: "my-secret",
scopes: "openid profile email",
}, mockSuperUser);
expect(status).toBe(200);
expect(encryptCalls).toContain("my-secret");
expect(body.clientSecret).toBe("••••••••");
expect(body.providerId).toBe("authentik");
});
it("returns 400 for invalid schema", async () => {
const app = makeApp(mockSuperUser);
const { status } = await put(app, "/admin/auth-provider", {
providerId: "",
issuerUrl: "not-a-url",
}, mockSuperUser);
expect(status).toBe(400);
});
});
describe("POST /admin/auth-provider/test", () => {
beforeEach(resetMock);
it("returns ok=false for unreachable issuer", async () => {
const app = makeApp(mockSuperUser);
const { status, body } = await post(app, "/admin/auth-provider/test", {
providerId: "authentik",
displayName: "Authentik",
issuerUrl: "https://192.0.2.1/", // TEST-NET, never reachable
clientId: "client",
scopes: "openid profile email",
}, mockSuperUser);
expect(status).toBe(200);
expect(body.ok).toBe(false);
expect(body.error).toBeTruthy();
}, 15000); // timeout must exceed the 10s fetch timeout in the route handler
it("returns 400 for missing clientSecret (not required for test)", async () => {
const app = makeApp(mockSuperUser);
const { status } = await post(app, "/admin/auth-provider/test", {
providerId: "authentik",
displayName: "Authentik",
issuerUrl: "https://auth.example.com",
clientId: "client",
}, mockSuperUser);
expect(status).toBe(200); // clientSecret omitted intentionally for test
});
});
describe("DELETE /admin/auth-provider", () => {
beforeEach(resetMock);
it("deletes all config rows", async () => {
const app = makeApp(mockSuperUser);
const { status, body } = await del(app, "/admin/auth-provider", mockSuperUser);
expect(status).toBe(200);
expect(body.ok).toBe(true);
expect(deletedRows).toContain("all");
});
it("returns 403 when not super user", async () => {
const app = makeApp(mockGroomer);
const { status } = await del(app, "/admin/auth-provider", mockGroomer);
expect(status).toBe(403);
});
});
-16
View File
@@ -1,16 +0,0 @@
import { describe, it, expect } from "vitest";
import { generateIcalToken } from "../routes/calendar.js";
describe("generateIcalToken", () => {
it("generates a 64-character hex token", () => {
const token = generateIcalToken();
expect(token).toHaveLength(64);
expect(token).toMatch(/^[a-f0-9]+$/);
});
it("generates unique tokens", () => {
const token1 = generateIcalToken();
const token2 = generateIcalToken();
expect(token1).not.toBe(token2);
});
});
-294
View File
@@ -1,294 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mock data ────────────────────────────────────────────────────────────────
const ACTIVE_CLIENT = {
id: "client-uuid-1",
name: "Alice",
email: "alice@example.com",
phone: "555-1234",
address: "1 Main St",
notes: null,
status: "active",
disabledAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const DISABLED_CLIENT = {
...ACTIVE_CLIENT,
id: "client-uuid-2",
name: "Bob",
status: "disabled",
disabledAt: new Date(),
};
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
let selectRows: Record<string, unknown>[] = [];
let appointmentRows: Record<string, unknown>[] = [];
let insertedValues: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
let deletedId: string | null = null;
function resetMock() {
selectRows = [];
appointmentRows = [];
insertedValues = [];
updatedValues = [];
deletedId = null;
}
vi.mock("@groombook/db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit") {
return () => chain;
}
// @ts-expect-error proxy
return target[prop];
},
});
return chain;
}
const clients = new Proxy(
{ _name: "clients" },
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
);
const appointments = new Proxy(
{ _name: "appointments" },
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: (table: unknown) => {
const tableName = (table as { _name?: string })._name;
const rows = tableName === "appointments" ? appointmentRows : selectRows;
return makeChainable(rows);
},
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
insertedValues.push(vals);
return {
returning: () => [{ ...ACTIVE_CLIENT, ...vals, id: "client-uuid-new" }],
};
},
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => {
updatedValues.push(vals);
return {
returning: () =>
selectRows.length > 0
? [{ ...selectRows[0], ...vals }]
: [],
};
},
}),
}),
delete: () => ({
where: () => {
deletedId = "client-uuid-1";
return {
returning: () =>
selectRows.length > 0 ? [selectRows[0]] : [],
};
},
}),
}),
clients,
appointments,
eq: vi.fn(),
and: vi.fn(),
or: vi.fn(),
};
});
// ─── App setup ────────────────────────────────────────────────────────────────
const { clientsRouter } = await import("../routes/clients.js");
const app = new Hono();
app.route("/clients", clientsRouter);
function jsonRequest(method: string, path: string, body?: unknown) {
return app.request(path, {
method,
headers: { "Content-Type": "application/json" },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
beforeEach(() => resetMock());
// ─── GET / ────────────────────────────────────────────────────────────────────
describe("GET /clients", () => {
it("returns active clients", async () => {
selectRows = [ACTIVE_CLIENT];
const res = await app.request("/clients");
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(1);
});
it("returns all clients when includeDisabled=true", async () => {
selectRows = [ACTIVE_CLIENT, DISABLED_CLIENT];
const res = await app.request("/clients?includeDisabled=true");
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveLength(2);
});
it("returns empty array when no clients exist", async () => {
selectRows = [];
const res = await app.request("/clients");
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
// ─── GET /:id ─────────────────────────────────────────────────────────────────
describe("GET /clients/:id", () => {
it("returns a single client", async () => {
selectRows = [ACTIVE_CLIENT];
const res = await app.request("/clients/client-uuid-1");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe("client-uuid-1");
expect(body.name).toBe("Alice");
});
it("returns 404 for a nonexistent client", async () => {
selectRows = [];
const res = await app.request("/clients/nonexistent");
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toMatch(/not found/i);
});
});
// ─── POST / ───────────────────────────────────────────────────────────────────
describe("POST /clients", () => {
it("creates a client with valid data", async () => {
const res = await jsonRequest("POST", "/clients", {
name: "Charlie",
email: "charlie@example.com",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.name).toBe("Charlie");
expect(insertedValues).toHaveLength(1);
expect(insertedValues[0]!.name).toBe("Charlie");
});
it("creates a client with name and email", async () => {
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
expect(res.status).toBe(201);
expect(insertedValues[0]!.name).toBe("Dana");
expect(insertedValues[0]!.email).toBe("dana@example.com");
});
it("rejects empty name", async () => {
const res = await jsonRequest("POST", "/clients", { name: "" });
expect(res.status).toBe(400);
});
it("rejects invalid email format", async () => {
const res = await jsonRequest("POST", "/clients", {
name: "Eve",
email: "not-an-email",
});
expect(res.status).toBe(400);
});
it("rejects missing body", async () => {
const res = await app.request("/clients", { method: "POST" });
expect(res.status).toBe(400);
});
});
// ─── PATCH /:id ───────────────────────────────────────────────────────────────
describe("PATCH /clients/:id", () => {
it("updates client fields", async () => {
selectRows = [ACTIVE_CLIENT];
const res = await jsonRequest("PATCH", "/clients/client-uuid-1", {
name: "Alice Updated",
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.name).toBe("Alice Updated");
expect(updatedValues[0]!.name).toBe("Alice Updated");
});
it("sets disabledAt when status is set to disabled", async () => {
selectRows = [ACTIVE_CLIENT];
await jsonRequest("PATCH", "/clients/client-uuid-1", {
status: "disabled",
});
expect(updatedValues[0]!.status).toBe("disabled");
expect(updatedValues[0]!.disabledAt).toBeDefined();
});
it("clears disabledAt when re-enabling", async () => {
selectRows = [DISABLED_CLIENT];
await jsonRequest("PATCH", "/clients/client-uuid-2", {
status: "active",
});
expect(updatedValues[0]!.disabledAt).toBeNull();
});
it("returns 404 when client not found", async () => {
selectRows = [];
const res = await jsonRequest("PATCH", "/clients/nonexistent", {
name: "Ghost",
});
expect(res.status).toBe(404);
});
});
// ─── DELETE /:id ──────────────────────────────────────────────────────────────
describe("DELETE /clients/:id", () => {
it("requires ?confirm=true", async () => {
const res = await app.request("/clients/client-uuid-1", {
method: "DELETE",
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/confirm/i);
});
it("deletes a client with ?confirm=true", async () => {
selectRows = [ACTIVE_CLIENT];
const res = await app.request("/clients/client-uuid-1?confirm=true", {
method: "DELETE",
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
expect(deletedId).toBe("client-uuid-1");
});
it("returns 404 when client not found", async () => {
selectRows = [];
const res = await app.request("/clients/nonexistent?confirm=true", {
method: "DELETE",
});
expect(res.status).toBe(404);
});
});
-340
View File
@@ -1,340 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mock appointment data ────────────────────────────────────────────────────
const FUTURE_TIME = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 1 week from now
const PAST_TIME = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
const BASE_APPT = {
id: "appt-uuid-1",
clientId: "client-uuid-1",
petId: "pet-uuid-1",
serviceId: "service-uuid-1",
staffId: "staff-uuid-1",
batherStaffId: null,
status: "scheduled" as const,
startTime: FUTURE_TIME,
endTime: new Date(FUTURE_TIME.getTime() + 3600_000),
notes: null,
priceCents: null,
seriesId: null,
seriesIndex: null,
groupId: null,
confirmationStatus: "pending",
confirmedAt: null,
cancelledAt: null,
confirmationToken: "valid-token-abc123",
createdAt: new Date(),
updatedAt: new Date(),
};
// ─── Shared mock DB state ─────────────────────────────────────────────────────
let mockAppt: typeof BASE_APPT | null = BASE_APPT;
let lastUpdate: Record<string, unknown> = {};
function resetMock() {
mockAppt = { ...BASE_APPT };
lastUpdate = {};
}
vi.mock("@groombook/db", () => {
const appointments = new Proxy(
{ _name: "appointments" },
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => (mockAppt ? [mockAppt] : []),
}),
}),
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => {
lastUpdate = { ...vals };
if (mockAppt) {
mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT;
}
return { returning: () => (mockAppt ? [mockAppt] : []) };
},
}),
}),
}),
appointments,
eq: () => ({}),
and: (..._clauses: unknown[]) => ({}),
};
});
// ─── Book router (tokenized endpoints) ───────────────────────────────────────
async function makeBookApp() {
const { bookRouter } = await import("../routes/book.js");
const app = new Hono();
app.route("/api/book", bookRouter);
return app;
}
// ─── Appointments router (portal endpoints) ────────────────────────────────
async function makeAppointmentsApp() {
const { appointmentsRouter } = await import("../routes/appointments.js");
const app = new Hono();
app.route("/api/appointments", appointmentsRouter);
return app;
}
// ─── Tests: tokenized confirm endpoint ────────────────────────────────────────
describe("GET /api/book/confirm/:token", () => {
let app: Hono;
beforeEach(async () => {
vi.resetModules();
resetMock();
app = await makeBookApp();
});
it("redirects to /booking/confirmed on valid token and future appointment", async () => {
const res = await app.request("/api/book/confirm/valid-token-abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/confirmed");
});
it("sets confirmationStatus to confirmed", async () => {
await app.request("/api/book/confirm/valid-token-abc123");
expect(lastUpdate.confirmationStatus).toBe("confirmed");
expect(lastUpdate.confirmedAt).toBeInstanceOf(Date);
});
it("redirects to /booking/error when token not found", async () => {
mockAppt = null;
const res = await app.request("/api/book/confirm/bad-token");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/error");
});
it("redirects to /booking/error when appointment is in the past", async () => {
mockAppt = { ...BASE_APPT, startTime: PAST_TIME };
const res = await app.request("/api/book/confirm/valid-token-abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/error");
});
it("redirects to /booking/confirmed idempotently when already confirmed", async () => {
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
const res = await app.request("/api/book/confirm/valid-token-abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/confirmed");
});
it("redirects to /booking/error when appointment is already customer-cancelled", async () => {
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
const res = await app.request("/api/book/confirm/valid-token-abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/error");
});
});
// ─── Tests: tokenized cancel endpoint ────────────────────────────────────────
describe("GET /api/book/cancel/:token", () => {
let app: Hono;
beforeEach(async () => {
vi.resetModules();
resetMock();
app = await makeBookApp();
});
it("redirects to /booking/cancelled on valid token and future appointment", async () => {
const res = await app.request("/api/book/cancel/valid-token-abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/cancelled");
});
it("sets confirmationStatus to cancelled and nullifies token (single-use)", async () => {
await app.request("/api/book/cancel/valid-token-abc123");
expect(lastUpdate.confirmationStatus).toBe("cancelled");
expect(lastUpdate.cancelledAt).toBeInstanceOf(Date);
expect(lastUpdate.confirmationToken).toBeNull();
});
it("redirects to /booking/error when token not found", async () => {
mockAppt = null;
const res = await app.request("/api/book/cancel/bad-token");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/error");
});
it("redirects to /booking/error when appointment is in the past", async () => {
mockAppt = { ...BASE_APPT, startTime: PAST_TIME };
const res = await app.request("/api/book/cancel/valid-token-abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/error");
});
it("redirects to /booking/error when already customer-cancelled", async () => {
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
const res = await app.request("/api/book/cancel/valid-token-abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/booking/error");
});
});
// ─── Tests: portal confirm endpoint ──────────────────────────────────────────
describe("POST /api/appointments/:id/confirm", () => {
let app: Hono;
beforeEach(async () => {
vi.resetModules();
resetMock();
app = await makeAppointmentsApp();
});
it("confirms a pending appointment", async () => {
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
method: "POST",
});
expect(res.status).toBe(200);
expect(lastUpdate.confirmationStatus).toBe("confirmed");
expect(lastUpdate.confirmedAt).toBeInstanceOf(Date);
});
it("returns 404 when appointment not found", async () => {
mockAppt = null;
const res = await app.request("/api/appointments/nonexistent/confirm", {
method: "POST",
});
expect(res.status).toBe(404);
});
it("returns 409 when appointment is already customer-cancelled", async () => {
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
method: "POST",
});
expect(res.status).toBe(409);
});
it("returns 200 idempotently when appointment is already confirmed", async () => {
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
method: "POST",
});
expect(res.status).toBe(200);
});
});
// ─── Tests: portal cancel endpoint ───────────────────────────────────────────
describe("POST /api/appointments/:id/cancel", () => {
let app: Hono;
beforeEach(async () => {
vi.resetModules();
resetMock();
app = await makeAppointmentsApp();
});
it("cancels a pending appointment and nullifies the token", async () => {
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
method: "POST",
});
expect(res.status).toBe(200);
expect(lastUpdate.confirmationStatus).toBe("cancelled");
expect(lastUpdate.cancelledAt).toBeInstanceOf(Date);
expect(lastUpdate.confirmationToken).toBeNull();
});
it("returns 404 when appointment not found", async () => {
mockAppt = null;
const res = await app.request("/api/appointments/nonexistent/cancel", {
method: "POST",
});
expect(res.status).toBe(404);
});
it("returns 409 when appointment is already customer-cancelled", async () => {
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
method: "POST",
});
expect(res.status).toBe(409);
});
it("can cancel a confirmed appointment", async () => {
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
method: "POST",
});
expect(res.status).toBe(200);
expect(lastUpdate.confirmationStatus).toBe("cancelled");
});
});
// ─── Tests: token generation helper ──────────────────────────────────────────
describe("generateConfirmationToken", () => {
it("generates a 64-character hex string", async () => {
const { generateConfirmationToken } = await import("../routes/appointments.js");
const token = generateConfirmationToken();
expect(token).toMatch(/^[0-9a-f]{64}$/);
});
it("generates unique tokens on each call", async () => {
const { generateConfirmationToken } = await import("../routes/appointments.js");
const t1 = generateConfirmationToken();
const t2 = generateConfirmationToken();
expect(t1).not.toBe(t2);
});
});
// ─── Tests: reminder email with action links ──────────────────────────────────
describe("buildReminderEmail with confirmation token", () => {
it("includes confirm and cancel links when token is provided", async () => {
const { buildReminderEmail } = await import("../services/email.js");
const mail = buildReminderEmail(
"client@example.com",
{
clientName: "Jane",
petName: "Biscuit",
serviceName: "Full Groom",
groomerName: null,
startTime: new Date(),
},
24,
"abc123token"
);
expect(mail.text).toContain("abc123token");
expect(mail.html as string).toContain("abc123token");
expect(mail.html as string).toContain("Confirm Appointment");
expect(mail.html as string).toContain("Cancel Appointment");
});
it("omits action links when no token is provided", async () => {
const { buildReminderEmail } = await import("../services/email.js");
const mail = buildReminderEmail(
"client@example.com",
{
clientName: "Jane",
petName: "Biscuit",
serviceName: "Full Groom",
groomerName: null,
startTime: new Date(),
},
24,
null
);
expect(mail.html as string).not.toContain("Confirm Appointment");
expect(mail.html as string).not.toContain("Cancel Appointment");
});
});
-97
View File
@@ -1,97 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { encryptSecret, decryptSecret } from "@groombook/db";
describe("encryptSecret / decryptSecret", () => {
const originalEnv = process.env.BETTER_AUTH_SECRET;
beforeEach(() => {
process.env.BETTER_AUTH_SECRET = "test-secret-key-for-unit-tests-32bytes!";
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env.BETTER_AUTH_SECRET = originalEnv;
} else {
delete process.env.BETTER_AUTH_SECRET;
}
});
it("encrypts and decrypts a simple secret", () => {
const plaintext = "my-client-secret-123";
const encrypted = encryptSecret(plaintext);
const decrypted = decryptSecret(encrypted);
expect(decrypted).toBe(plaintext);
});
it("produces output in salt:iv:ciphertext:authTag format", () => {
const encrypted = encryptSecret("test");
const parts = encrypted.split(":");
expect(parts).toHaveLength(4);
// Each part should be valid base64
parts.forEach((part) => {
expect(() => Buffer.from(part, "base64")).not.toThrow();
});
});
it("different plaintexts produce different ciphertexts", () => {
const encrypted1 = encryptSecret("secret1");
const encrypted2 = encryptSecret("secret2");
expect(encrypted1).not.toBe(encrypted2);
});
it("same plaintext produces different ciphertexts (due to random IV)", () => {
const encrypted1 = encryptSecret("same-secret");
const encrypted2 = encryptSecret("same-secret");
expect(encrypted1).not.toBe(encrypted2);
// But both should decrypt to the same value
expect(decryptSecret(encrypted1)).toBe("same-secret");
expect(decryptSecret(encrypted2)).toBe("same-secret");
});
it("throws if BETTER_AUTH_SECRET is not set", () => {
delete process.env.BETTER_AUTH_SECRET;
expect(() => encryptSecret("test")).toThrow(
"BETTER_AUTH_SECRET environment variable is required"
);
});
it("throws when decrypting invalid format (wrong number of parts)", () => {
const encrypted = encryptSecret("test");
// Replace the last two parts with a single part to create a 2-part string
// This can't be parsed as either legacy (3 parts) or new (4 parts) format
const invalid = encrypted.replace(/:[^:]+$/, "").replace(/:[^:]+$/, "");
expect(() => decryptSecret(invalid)).toThrow(
"Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"
);
});
it("handles empty string secret", () => {
const plaintext = "";
const encrypted = encryptSecret(plaintext);
const decrypted = decryptSecret(encrypted);
expect(decrypted).toBe(plaintext);
});
it("handles unicode secret", () => {
const plaintext = "密码🔐中文";
const encrypted = encryptSecret(plaintext);
const decrypted = decryptSecret(encrypted);
expect(decrypted).toBe(plaintext);
});
it("handles long secret", () => {
const plaintext = "a".repeat(10000);
const encrypted = encryptSecret(plaintext);
const decrypted = decryptSecret(encrypted);
expect(decrypted).toBe(plaintext);
});
});
-106
View File
@@ -1,106 +0,0 @@
import { describe, it, expect } from "vitest";
import {
buildConfirmationEmail,
buildReminderEmail,
} from "../services/email.js";
const START = new Date("2026-03-25T15:00:00Z");
const BASE = {
clientName: "Jane Doe",
petName: "Biscuit",
serviceName: "Full Groom",
groomerName: "Alex",
startTime: START,
};
describe("buildConfirmationEmail", () => {
it("addresses the correct recipient", () => {
const mail = buildConfirmationEmail("jane@example.com", BASE);
expect(mail.to).toBe("jane@example.com");
});
it("includes the pet name in the subject", () => {
const mail = buildConfirmationEmail("jane@example.com", BASE);
expect(mail.subject).toContain("Biscuit");
});
it("includes confirmation wording in subject", () => {
const mail = buildConfirmationEmail("jane@example.com", BASE);
expect(mail.subject).toMatch(/confirmed/i);
});
it("includes client name in the plain text body", () => {
const mail = buildConfirmationEmail("jane@example.com", BASE);
expect(mail.text).toContain("Jane Doe");
});
it("includes service name in plain text body", () => {
const mail = buildConfirmationEmail("jane@example.com", BASE);
expect(mail.text).toContain("Full Groom");
});
it("includes groomer name when provided", () => {
const mail = buildConfirmationEmail("jane@example.com", BASE);
expect(mail.text).toContain("Alex");
});
it("omits groomer when groomerName is null", () => {
const mail = buildConfirmationEmail("jane@example.com", {
...BASE,
groomerName: null,
});
expect(mail.text).not.toContain("with ");
});
it("includes HTML body", () => {
const mail = buildConfirmationEmail("jane@example.com", BASE);
expect(mail.html).toBeTruthy();
expect(mail.html).toContain("Biscuit");
});
});
describe("buildReminderEmail", () => {
it("addresses the correct recipient", () => {
const mail = buildReminderEmail("jane@example.com", BASE, 24);
expect(mail.to).toBe("jane@example.com");
});
it("says 'tomorrow' for 24-hour reminder", () => {
const mail = buildReminderEmail("jane@example.com", BASE, 24);
expect(mail.subject).toContain("tomorrow");
expect(mail.text).toContain("tomorrow");
});
it("says 'in X hours' for sub-24-hour reminders", () => {
const mail = buildReminderEmail("jane@example.com", BASE, 2);
expect(mail.subject).toContain("in 2 hours");
expect(mail.text).toContain("in 2 hours");
});
it("includes pet name in subject", () => {
const mail = buildReminderEmail("jane@example.com", BASE, 24);
expect(mail.subject).toContain("Biscuit");
});
it("includes service name in plain text body", () => {
const mail = buildReminderEmail("jane@example.com", BASE, 24);
expect(mail.text).toContain("Full Groom");
});
it("includes groomer name when provided", () => {
const mail = buildReminderEmail("jane@example.com", BASE, 24);
expect(mail.text).toContain("Alex");
});
it("omits groomer when groomerName is null", () => {
const mail = buildReminderEmail("jane@example.com", { ...BASE, groomerName: null }, 24);
expect(mail.text).not.toContain("with ");
});
it("includes HTML body", () => {
const mail = buildReminderEmail("jane@example.com", BASE, 24);
expect(mail.html).toBeTruthy();
expect(mail.html).toContain("Biscuit");
});
});
-216
View File
@@ -1,216 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
resetFactoryCounters,
buildStaff,
buildClient,
buildPet,
buildService,
buildAppointment,
} from "@groombook/db/factories";
describe("resetFactoryCounters", () => {
it("resets all counters so IDs restart from 1", () => {
buildStaff();
buildStaff();
buildClient();
resetFactoryCounters();
const staff = buildStaff();
const client = buildClient();
expect(staff.id).toBe("staff-1");
expect(client.id).toBe("client-1");
});
it("resets counters for every entity type", () => {
const client = buildClient();
const pet = buildPet({ clientId: client.id });
const service = buildService();
buildAppointment({
clientId: client.id,
petId: pet.id,
serviceId: service.id,
staffId: "staff-1",
});
resetFactoryCounters();
expect(buildStaff().id).toBe("staff-1");
expect(buildClient().id).toBe("client-1");
expect(buildService().id).toBe("service-1");
const c = buildClient();
expect(buildPet({ clientId: c.id }).id).toBe("pet-1");
const s = buildService();
const p = buildPet({ clientId: c.id });
expect(
buildAppointment({ clientId: c.id, petId: p.id, serviceId: s.id, staffId: "s-1" }).id
).toBe("appointment-1");
});
});
describe("counter determinism", () => {
beforeEach(() => {
resetFactoryCounters();
});
it("increments staff IDs sequentially", () => {
expect(buildStaff().id).toBe("staff-1");
expect(buildStaff().id).toBe("staff-2");
expect(buildStaff().id).toBe("staff-3");
});
it("increments client IDs sequentially", () => {
expect(buildClient().id).toBe("client-1");
expect(buildClient().id).toBe("client-2");
});
it("increments pet IDs sequentially", () => {
const client = buildClient();
expect(buildPet({ clientId: client.id }).id).toBe("pet-1");
expect(buildPet({ clientId: client.id }).id).toBe("pet-2");
});
it("increments service IDs sequentially", () => {
expect(buildService().id).toBe("service-1");
expect(buildService().id).toBe("service-2");
});
it("increments appointment IDs sequentially", () => {
const client = buildClient();
const pet = buildPet({ clientId: client.id });
const service = buildService();
const required = { clientId: client.id, petId: pet.id, serviceId: service.id, staffId: "staff-1" };
expect(buildAppointment(required).id).toBe("appointment-1");
expect(buildAppointment(required).id).toBe("appointment-2");
});
it("each entity type maintains its own independent counter", () => {
buildStaff();
buildStaff();
buildClient();
// staff counter is at 2; client counter is at 1
expect(buildStaff().id).toBe("staff-3");
expect(buildClient().id).toBe("client-2");
});
});
describe("override merging", () => {
beforeEach(() => {
resetFactoryCounters();
});
it("buildStaff applies overrides over defaults", () => {
const staff = buildStaff({ role: "manager", name: "Boss" });
expect(staff.role).toBe("manager");
expect(staff.name).toBe("Boss");
expect(staff.id).toBe("staff-1");
expect(staff.active).toBe(true); // default preserved
});
it("buildStaff id override is respected without disrupting the counter", () => {
const staff = buildStaff({ id: "custom-id" });
expect(staff.id).toBe("custom-id");
// counter still ticked — next call gets staff-2
expect(buildStaff().id).toBe("staff-2");
});
it("buildClient applies overrides over defaults", () => {
const client = buildClient({ name: "Alice Smith", emailOptOut: true });
expect(client.name).toBe("Alice Smith");
expect(client.emailOptOut).toBe(true);
expect(client.status).toBe("active"); // default preserved
});
it("buildPet merges overrides and sets clientId from required arg", () => {
const pet = buildPet({ clientId: "client-99", name: "Fluffy", breed: "Poodle" });
expect(pet.clientId).toBe("client-99");
expect(pet.name).toBe("Fluffy");
expect(pet.breed).toBe("Poodle");
expect(pet.species).toBe("Dog"); // default preserved
});
it("buildService applies overrides over defaults", () => {
const service = buildService({ basePriceCents: 9900, active: false });
expect(service.basePriceCents).toBe(9900);
expect(service.active).toBe(false);
expect(service.durationMinutes).toBe(60); // default preserved
});
it("buildAppointment applies overrides over defaults", () => {
const client = buildClient();
const pet = buildPet({ clientId: client.id });
const service = buildService();
const appt = buildAppointment({
clientId: client.id,
petId: pet.id,
serviceId: service.id,
staffId: "staff-1",
status: "confirmed",
notes: "allergic to lavender",
});
expect(appt.status).toBe("confirmed");
expect(appt.notes).toBe("allergic to lavender");
expect(appt.clientId).toBe(client.id);
expect(appt.petId).toBe(pet.id);
// defaults preserved
expect(appt.batherStaffId).toBeNull();
expect(appt.priceCents).toBeNull();
});
});
describe("buildAppointment required fields", () => {
beforeEach(() => {
resetFactoryCounters();
});
it("produces a fully-populated AppointmentRow", () => {
const client = buildClient();
const pet = buildPet({ clientId: client.id });
const service = buildService();
const appt = buildAppointment({
clientId: client.id,
petId: pet.id,
serviceId: service.id,
staffId: "staff-1",
});
expect(appt.id).toBeDefined();
expect(appt.clientId).toBe(client.id);
expect(appt.petId).toBe(pet.id);
expect(appt.serviceId).toBe(service.id);
expect(appt.staffId).toBe("staff-1");
expect(appt.startTime).toBeInstanceOf(Date);
expect(appt.endTime).toBeInstanceOf(Date);
expect(appt.status).toBe("scheduled");
expect(appt.batherStaffId).toBeNull();
expect(appt.seriesId).toBeNull();
expect(appt.seriesIndex).toBeNull();
expect(appt.groupId).toBeNull();
expect(appt.notes).toBeNull();
expect(appt.priceCents).toBeNull();
expect(appt.createdAt).toBeInstanceOf(Date);
expect(appt.updatedAt).toBeInstanceOf(Date);
});
// TypeScript compile-time enforcement: omitting any required field produces a type error.
// The overrides parameter type is `Partial<AppointmentRow> & { clientId: string; petId: string; serviceId: string; staffId: string }`.
// The test below verifies the type signature is correct by using @ts-expect-error.
it("type error when required fields are missing — compile-time enforcement", () => {
// @ts-expect-error clientId is required
buildAppointment({ petId: "p", serviceId: "s", staffId: "st" });
// @ts-expect-error petId is required
buildAppointment({ clientId: "c", serviceId: "s", staffId: "st" });
// @ts-expect-error serviceId is required
buildAppointment({ clientId: "c", petId: "p", staffId: "st" });
// @ts-expect-error staffId is required
buildAppointment({ clientId: "c", petId: "p", serviceId: "s" });
});
});
-106
View File
@@ -1,106 +0,0 @@
/**
* Groomer Isolation Tests
*
* Validates row-level data scoping for the groomer role.
*
* The role guard tests verify the core groomer identification logic.
* Integration tests with the real database validate the full filter behavior.
*/
import { describe, it, expect } from "vitest";
import type { StaffRow } from "../middleware/rbac.js";
// ─── Mock staff ───────────────────────────────────────────────────────────────
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
userId: null,
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const GROOMER: StaffRow = {
...MANAGER,
id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub",
role: "groomer",
name: "Groomer Gary",
email: "groomer@example.com",
};
const RECEPTIONIST: StaffRow = {
...MANAGER,
id: "staff-receptionist-id",
oidcSub: "oidc-receptionist-sub",
role: "receptionist",
name: "Receptionist Rita",
email: "receptionist@example.com",
};
// ─── Role guard ──────────────────────────────────────────────────────────────
/**
* The isGroomer guard (staffRow?.role === "groomer") is the foundation of
* all row-level filtering in appointments.ts, clients.ts, and pets.ts.
* These tests verify it handles all roles correctly.
*/
describe("Groomer role guard", () => {
const isGroomer = (s: StaffRow | undefined) => s?.role === "groomer";
it("manager is not groomer", () => expect(isGroomer(MANAGER)).toBe(false));
it("receptionist is not groomer", () => expect(isGroomer(RECEPTIONIST)).toBe(false));
it("groomer is groomer", () => expect(isGroomer(GROOMER)).toBe(true));
/** Safe fallback when staff context is not set (e.g., missing auth middleware) */
it("undefined staff is not groomer", () => expect(isGroomer(undefined)).toBe(false));
});
// ─── Groomer filter data shapes ───────────────────────────────────────────────
/**
* These constants match the shape used in route handlers to validate
* the groomer filter conditions:
* or(eq(appointments.staffId, staffRow.id), eq(appointments.batherStaffId, staffRow.id))
* This verifies the groomer can see appointments they own OR bathe.
*/
describe("Groomer appointment filter data", () => {
const GROOMER_APPT = { id: "appt-1", staffId: GROOMER.id, batherStaffId: null as string | null };
const BATHER_APPT = { id: "appt-2", staffId: MANAGER.id, batherStaffId: GROOMER.id };
const OTHER_APPT = { id: "appt-3", staffId: MANAGER.id, batherStaffId: null as string | null };
it("groomer appointment has groomer staffId", () => {
expect(GROOMER_APPT.staffId).toBe(GROOMER.id);
expect(GROOMER_APPT.batherStaffId).toBeNull();
});
it("groomer can see appointment where they are the bather", () => {
expect(BATHER_APPT.batherStaffId).toBe(GROOMER.id);
expect(BATHER_APPT.staffId).toBe(MANAGER.id);
});
it("other appointment is not assigned to groomer", () => {
expect(OTHER_APPT.staffId).toBe(MANAGER.id);
expect(OTHER_APPT.batherStaffId).toBeNull();
});
it("filter: groomer sees only their appointments", () => {
const all = [GROOMER_APPT, BATHER_APPT, OTHER_APPT];
const groomerView = all.filter(
(a) => a.staffId === GROOMER.id || a.batherStaffId === GROOMER.id
);
expect(groomerView).toHaveLength(2);
expect(groomerView.map((a) => a.id)).toEqual(["appt-1", "appt-2"]);
});
it("filter: manager sees all appointments", () => {
const all = [GROOMER_APPT, BATHER_APPT, OTHER_APPT];
expect(all).toHaveLength(3);
});
});
-560
View File
@@ -1,560 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
import { buildStaff } from "@groombook/db/factories";
// ─── Mock data (built with factories for schema-safe defaults) ────────────────
const MANAGER_STAFF = buildStaff({ id: "staff-manager-id", oidcSub: "oidc-manager-sub", role: "manager", name: "Manager" });
const GROOMER_STAFF = buildStaff({ id: "staff-groomer-id", oidcSub: "oidc-groomer-sub", role: "groomer", name: "Groomer" });
const CLIENT = { id: "aabbccdd-1111-2222-3333-444444444444", name: "Fido Owner" };
const futureDate = () => new Date(Date.now() + 30 * 60_000);
const pastDate = () => new Date(Date.now() - 5 * 60_000);
function makeSession(overrides: Record<string, unknown> = {}) {
return {
id: "session-uuid-1",
staffId: MANAGER_STAFF.id,
clientId: CLIENT.id,
reason: "Testing portal",
status: "active" as string,
startedAt: new Date(),
endedAt: null as Date | null,
expiresAt: futureDate(),
createdAt: new Date(),
...overrides,
};
}
function makeAuditLog(overrides: Record<string, unknown> = {}) {
return {
id: "audit-uuid-1",
sessionId: "session-uuid-1",
action: "session_started",
pageVisited: null,
metadata: null,
createdAt: new Date(),
...overrides,
};
}
// ─── Queue-based mock DB ─────────────────────────────────────────────────────
let selectQueue: unknown[][] = [];
let insertedValues: Array<{ table: string; vals: unknown }> = [];
let updatedValues: Array<{ table: string; set: Record<string, unknown> }> = [];
function resetMock() {
selectQueue = [];
insertedValues = [];
updatedValues = [];
}
/**
* Returns a chainable object that acts like a drizzle query result.
* Any method call (.where, .orderBy, .limit) returns the same chainable,
* but the FIRST terminal call (.where or .orderBy when no further chain)
* resolves the result from the queue.
*
* To handle `.where().orderBy()` chaining, we make the result of shifting
* also have .orderBy/.limit methods, and we wrap the shifted array in a proxy.
*/
function makeChainableResult(data: unknown[]): unknown {
// Make data act both as array and as chainable
const arr = [...data];
return new Proxy(arr, {
get(target, prop) {
if (prop === "orderBy" || prop === "limit") {
// Further chaining just returns the same data
return () => makeChainableResult(data);
}
// @ts-expect-error proxy access
return target[prop];
},
});
}
vi.mock("@groombook/db", () => {
function makeTable(name: string) {
return new Proxy(
{ _name: name },
{
get(target, prop) {
if (prop === "_name") return name;
if (prop === "$inferSelect") return {};
return { table: name, column: prop };
},
}
);
}
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => {
const data = selectQueue.shift() ?? [];
return makeChainableResult(data);
},
orderBy: () => {
const data = selectQueue.shift() ?? [];
return makeChainableResult(data);
},
limit: () => {
const data = selectQueue.shift() ?? [];
return makeChainableResult(data);
},
}),
}),
insert: (table: { _name: string }) => ({
values: (vals: unknown) => {
const tableName = table?._name ?? "unknown";
insertedValues.push({ table: tableName, vals });
return {
returning: () => {
if (tableName === "sessions") {
return [makeSession(vals as Record<string, unknown>)];
}
return [makeAuditLog(vals as Record<string, unknown>)];
},
};
},
}),
update: (table: { _name: string }) => ({
set: (data: Record<string, unknown>) => ({
where: () => {
const tableName = table?._name ?? "unknown";
updatedValues.push({ table: tableName, set: data });
return {
returning: () => {
const base = makeSession();
return [{ ...base, ...data }];
},
};
},
}),
}),
}),
staff: makeTable("staff"),
clients: makeTable("clients"),
impersonationSessions: makeTable("sessions"),
impersonationAuditLogs: makeTable("auditLogs"),
eq: vi.fn(),
and: vi.fn(),
desc: vi.fn(),
};
});
// ─── App setup ───────────────────────────────────────────────────────────────
const { impersonationRouter } = await import("../routes/impersonation.js");
const { requireRole } = await import("../middleware/rbac.js");
/**
* Build a test app. If staffRow is null the middleware simulates
* resolveStaffMiddleware returning 403 (staff not found). An optional
* roleGuard applies requireRole(...roles) before the router.
*/
function createApp(
staffRow: (typeof MANAGER_STAFF) | null,
roleGuard?: string[]
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
if (!staffRow) {
return c.json({ error: "Forbidden: no staff record found for authenticated user" }, 403);
}
c.set("jwtPayload", { sub: staffRow.oidcSub } as { sub: string; email?: string; name?: string });
c.set("staff", staffRow as unknown as StaffRow);
await next();
});
if (roleGuard && roleGuard.length > 0) {
app.use("*", requireRole(...(roleGuard as Parameters<typeof requireRole>)) as never);
}
app.route("/impersonation", impersonationRouter);
return app;
}
function jsonPost(path: string, body: unknown) {
return {
method: "POST" as const,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
};
}
// ─── Tests ───────────────────────────────────────────────────────────────────
beforeEach(() => resetMock());
// ─── POST /sessions — Create session ─────────────────────────────────────────
describe("POST /impersonation/sessions", () => {
it("creates a session for a manager", async () => {
const app = createApp(MANAGER_STAFF, ["manager"]);
selectQueue.push(
[CLIENT], // client lookup
[], // expireTimedOutSessions active query
[] // existing active check
);
const res = await app.request(
"/impersonation/sessions",
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
);
expect(res.status).toBe(201);
expect(insertedValues.some((v) => v.table === "sessions")).toBe(true);
expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true);
});
it("rejects non-managers via requireRole guard", async () => {
const app = createApp(GROOMER_STAFF, ["manager"]);
const res = await app.request(
"/impersonation/sessions",
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/forbidden/i);
});
it("returns 403 when staff record not found", async () => {
const app = createApp(null);
const res = await app.request(
"/impersonation/sessions",
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
);
expect(res.status).toBe(403);
});
it("returns 404 when client not found", async () => {
const app = createApp(MANAGER_STAFF, ["manager"]);
selectQueue.push(
[] // client not found
);
const res = await app.request(
"/impersonation/sessions",
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
);
expect(res.status).toBe(404);
});
it("returns 409 when active session already exists", async () => {
const app = createApp(MANAGER_STAFF, ["manager"]);
const existing = makeSession();
selectQueue.push(
[CLIENT], // client lookup
[], // expireTimedOutSessions
[existing] // existing active session
);
const res = await app.request(
"/impersonation/sessions",
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
);
expect(res.status).toBe(409);
const body = await res.json();
expect(body.error).toMatch(/already have an active/i);
});
});
// ─── GET /sessions/:id — Authorization ───────────────────────────────────────
describe("GET /impersonation/sessions/:id", () => {
it("returns session for the owning staff member", async () => {
const app = createApp(MANAGER_STAFF);
const session = makeSession();
selectQueue.push(
[session] // session lookup
);
const res = await app.request("/impersonation/sessions/session-uuid-1");
expect(res.status).toBe(200);
});
it("returns 403 for a different staff member", async () => {
const app = createApp(GROOMER_STAFF);
const session = makeSession(); // owned by manager
selectQueue.push(
[session] // session lookup
);
const res = await app.request("/impersonation/sessions/session-uuid-1");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/not your session/i);
});
it("returns 404 for nonexistent session", async () => {
const app = createApp(MANAGER_STAFF);
selectQueue.push(
[] // no session
);
const res = await app.request("/impersonation/sessions/nonexistent");
expect(res.status).toBe(404);
});
it("auto-expires a timed-out session", async () => {
const app = createApp(MANAGER_STAFF);
const session = makeSession({ expiresAt: pastDate() });
selectQueue.push(
[session] // session lookup
);
const res = await app.request("/impersonation/sessions/session-uuid-1");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("expired");
// Should have called update to mark expired
expect(updatedValues).toHaveLength(1);
expect(updatedValues[0]!.set.status).toBe("expired");
});
});
// ─── POST /sessions/:id/extend ───────────────────────────────────────────────
describe("POST /impersonation/sessions/:id/extend", () => {
it("extends an active non-expired session", async () => {
const app = createApp(MANAGER_STAFF);
const session = makeSession();
selectQueue.push(
[session] // session lookup
);
const res = await app.request(
"/impersonation/sessions/session-uuid-1/extend",
{ method: "POST" }
);
expect(res.status).toBe(200);
// Should have extended (updated expiresAt) and logged
expect(updatedValues).toHaveLength(1);
expect(insertedValues.some((v) => {
const vals = v.vals as Record<string, unknown>;
return vals.action === "session_extended";
})).toBe(true);
});
it("returns 400 when extending a time-expired session", async () => {
const app = createApp(MANAGER_STAFF);
const session = makeSession({ expiresAt: pastDate() });
selectQueue.push(
[session] // session lookup
);
const res = await app.request(
"/impersonation/sessions/session-uuid-1/extend",
{ method: "POST" }
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/expired/i);
});
it("returns 403 for non-owner", async () => {
const app = createApp(GROOMER_STAFF);
const session = makeSession();
selectQueue.push(
[session] // owned by manager
);
const res = await app.request(
"/impersonation/sessions/session-uuid-1/extend",
{ method: "POST" }
);
expect(res.status).toBe(403);
});
it("returns 400 for an ended session", async () => {
const app = createApp(MANAGER_STAFF);
const session = makeSession({ status: "ended" });
selectQueue.push(
[session]
);
const res = await app.request(
"/impersonation/sessions/session-uuid-1/extend",
{ method: "POST" }
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/not active/i);
});
});
// ─── POST /sessions/:id/end ──────────────────────────────────────────────────
describe("POST /impersonation/sessions/:id/end", () => {
it("ends an active non-expired session", async () => {
const app = createApp(MANAGER_STAFF);
const session = makeSession();
selectQueue.push(
[session]
);
const res = await app.request(
"/impersonation/sessions/session-uuid-1/end",
{ method: "POST" }
);
expect(res.status).toBe(200);
expect(updatedValues).toHaveLength(1);
expect(updatedValues[0]!.set.status).toBe("ended");
});
it("returns 400 when ending a time-expired session", async () => {
const app = createApp(MANAGER_STAFF);
const session = makeSession({ expiresAt: pastDate() });
selectQueue.push(
[session]
);
const res = await app.request(
"/impersonation/sessions/session-uuid-1/end",
{ method: "POST" }
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/expired/i);
});
it("returns 403 for non-owner", async () => {
const app = createApp(GROOMER_STAFF);
const session = makeSession();
selectQueue.push(
[session]
);
const res = await app.request(
"/impersonation/sessions/session-uuid-1/end",
{ method: "POST" }
);
expect(res.status).toBe(403);
});
});
// ─── POST /sessions/:id/log — Authorization + expiry ─────────────────────────
describe("POST /impersonation/sessions/:id/log", () => {
const logBody = { action: "page_visit", pageVisited: "/dashboard" };
it("logs an audit entry for the session owner", async () => {
const app = createApp(MANAGER_STAFF);
const session = makeSession();
selectQueue.push(
[session]
);
const res = await app.request(
"/impersonation/sessions/session-uuid-1/log",
jsonPost("/", logBody)
);
expect(res.status).toBe(201);
expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true);
});
it("returns 403 for non-owner", async () => {
const app = createApp(GROOMER_STAFF);
const session = makeSession();
selectQueue.push(
[session]
);
const res = await app.request(
"/impersonation/sessions/session-uuid-1/log",
jsonPost("/", logBody)
);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/not your session/i);
});
it("returns 400 when session has expired by time", async () => {
const app = createApp(MANAGER_STAFF);
const session = makeSession({ expiresAt: pastDate() });
selectQueue.push(
[session]
);
const res = await app.request(
"/impersonation/sessions/session-uuid-1/log",
jsonPost("/", logBody)
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/expired/i);
});
it("returns 400 for an ended session", async () => {
const app = createApp(MANAGER_STAFF);
const session = makeSession({ status: "ended" });
selectQueue.push(
[session]
);
const res = await app.request(
"/impersonation/sessions/session-uuid-1/log",
jsonPost("/", logBody)
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/not active/i);
});
});
// ─── GET /sessions/:id/audit-log — Authorization ────────────────────────────
describe("GET /impersonation/sessions/:id/audit-log", () => {
it("returns audit logs for the session owner", async () => {
const app = createApp(MANAGER_STAFF);
const session = makeSession();
const logs = [makeAuditLog(), makeAuditLog({ id: "audit-uuid-2", action: "page_visit" })];
selectQueue.push(
[session], // session lookup
logs // audit logs query (where + orderBy chain)
);
const res = await app.request(
"/impersonation/sessions/session-uuid-1/audit-log"
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveLength(2);
});
it("returns 403 for non-owner", async () => {
const app = createApp(GROOMER_STAFF);
const session = makeSession();
selectQueue.push(
[session]
);
const res = await app.request(
"/impersonation/sessions/session-uuid-1/audit-log"
);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/not your session/i);
});
it("returns 404 for nonexistent session", async () => {
const app = createApp(MANAGER_STAFF);
selectQueue.push(
[]
);
const res = await app.request(
"/impersonation/sessions/nonexistent/audit-log"
);
expect(res.status).toBe(404);
});
});

Some files were not shown because too many files have changed in this diff Show More