Compare commits
190 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf064b3ada | |||
| 4df7d96020 | |||
| aee82efbac | |||
| 4cc0676d52 | |||
| dff0e17a63 | |||
| 612c0467a1 | |||
| 543d9560ec | |||
| 17b44e3b00 | |||
| 2e0d63f7f6 | |||
| 7bdb92999a | |||
| b96b6c06fc | |||
| fa67b75b76 | |||
| 7e329ff72f | |||
| b050fb9a5f | |||
| 63ed91e5f3 | |||
| 45b3d4343d | |||
| 32156e9a45 | |||
| ed3d7df1c9 | |||
| 9622b109d0 | |||
| a25b2fe281 | |||
| de33edd7c6 | |||
| 3b9e82adff | |||
| 385ed10211 | |||
| 8e8a87767c | |||
| b796d36aed | |||
| d9ba6045ad | |||
| 2f17b1ab85 | |||
| b83a793de4 | |||
| a610ef9d39 | |||
| 2a0b3cf3d3 | |||
| cf3d30f19e | |||
| 0625961adf | |||
| 78762b5278 | |||
| b61d899f81 | |||
| 38047d5ea3 | |||
| fbcaedf155 | |||
| 7cfb24d542 | |||
| b0d9e5816f | |||
| 7a0662541d | |||
| 5e78df85f1 | |||
| aa9670d4dc | |||
| 0a2259b67f | |||
| cc09a8e1e8 | |||
| 74da042d13 | |||
| ad1b210de1 | |||
| e5f16a5fe5 | |||
| a03771f7e7 | |||
| baeff6c4f5 | |||
| 040ff4a253 | |||
| a1466b44c9 | |||
| 8d9a9d8dba | |||
| b486c44a82 | |||
| 8c62ce2368 | |||
| b5a08a2c7e | |||
| 06d72b5baf | |||
| 2380698128 | |||
| 33aa63b10f | |||
| e26d960046 | |||
| 4e8c66f3ca | |||
| 00c6a36021 | |||
| ea28095434 | |||
| 3b9c72c2c4 | |||
| 49f70eb74b | |||
| f4561b539f | |||
| 62dfc7776b | |||
| d847343090 | |||
| 68df697cf3 | |||
| 174d1c667b | |||
| 190c39f905 | |||
| 9fe6e15012 | |||
| 002e6575ba | |||
| 122d32d635 | |||
| f9c679b392 | |||
| ce0739b3ba | |||
| 3609087980 | |||
| 7b2b533c16 | |||
| 55894c6ff2 | |||
| d458f93600 | |||
| f55c74983f | |||
| 8bdab69288 | |||
| 634e9d03e1 | |||
| 8f7104c3a0 | |||
| dab9bfab71 | |||
| 974dade8f7 | |||
| 70bc946a0d | |||
| 3eaefb4911 | |||
| 40422a14f0 | |||
| 9462915a66 | |||
| 46f134a294 | |||
| 4086b6f5c0 | |||
| d7d98791c7 | |||
| 3bec5d095a | |||
| 3fddf80fac | |||
| 1ea319e122 | |||
| da913d600f | |||
| ce9fcfb362 | |||
| 59893908e2 | |||
| 2b78fcf731 | |||
| c9e176f08c | |||
| 0cab1522cf | |||
| ff6f8471d5 | |||
| 2a27e8bee2 | |||
| d6f7ade7bd | |||
| 00dadac0a1 | |||
| 9692476202 | |||
| 44da26820b | |||
| 21981fbdc4 | |||
| 85fc803548 | |||
| 6045024150 | |||
| 6a3c1aa65e | |||
| df5e413930 | |||
| 490ab06e8c | |||
| 609f86b927 | |||
| a74423c8b4 | |||
| 05cb91a13e | |||
| 1b264d715d | |||
| 7cb5fda3e3 | |||
| 73461f2200 | |||
| 9b24e299db | |||
| 9f2809e89b | |||
| 76540cea0d | |||
| e6803c7061 | |||
| 24c1a603ec | |||
| 07eb611549 | |||
| 1345db3620 | |||
| b067ba8b85 | |||
| 3d41820f02 | |||
| 73f39951b3 | |||
| d83210e7e2 | |||
| fdd9d62ee9 | |||
| c1d28635ba | |||
| 5c9cac7a28 | |||
| 51b45b529d | |||
| 4204bea2b3 | |||
| ea825dfdda | |||
| f9b68eb932 | |||
| 4a80440513 | |||
| ce83b1847d | |||
| f36a3626a8 | |||
| 90b3811577 | |||
| 467b85abc7 | |||
| e417d8f6a7 | |||
| fc82e24ead | |||
| c3c99ad6c4 | |||
| fad99dc032 | |||
| a205fe1138 | |||
| ff024ab375 | |||
| 01069f8c6c | |||
| 43f17dc612 | |||
| d9bfed4424 | |||
| 1403517067 | |||
| 9c5e470737 | |||
| f1258023ac | |||
| faf7def77d | |||
| 247570abc8 | |||
| c19e19c709 | |||
| f9a3ebc0f3 | |||
| d3122ad701 | |||
| 539ef21d89 | |||
| 9ccbc7a171 | |||
| 9ba5da5e75 | |||
| 575789f7f5 | |||
| 4f981bbebd | |||
| d8f2135506 | |||
| a0a75d7e25 | |||
| 22457ac361 | |||
| f12ec4f8d3 | |||
| 566d5f4b55 | |||
| 2c928ca4d7 | |||
| 4f5ec60961 | |||
| af75fecb66 | |||
| 2d4df6fe1e | |||
| 39ffdccac7 | |||
| db10320c8f | |||
| 40a4023c65 | |||
| 1ff0d4230c | |||
| d598511b75 | |||
| 434c7b94e2 | |||
| 70af9da338 | |||
| e714200b71 | |||
| 1e70e01046 | |||
| be5e9d8fc7 | |||
| 83d7fecdd3 | |||
| 2448887924 | |||
| f4995d987d | |||
| c9b699527c | |||
| d9ee14b17e | |||
| 9ed28f8bab | |||
| abac9dfe6c | |||
| 4d7baec939 |
@@ -0,0 +1 @@
|
||||
GRO-1757 direct push CI trigger - 2026-05-26T00:15:41Z
|
||||
@@ -0,0 +1,147 @@
|
||||
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
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -202,20 +202,20 @@ jobs:
|
||||
echo "Updating dev overlay image tags to: $TAG"
|
||||
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
|
||||
cd /tmp/infra
|
||||
DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml"
|
||||
DEV_KUST="apps/overlays/dev/kustomization.yaml"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
|
||||
|
||||
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||
MIGRATE_JOB="apps/base/migrate-job.yaml"
|
||||
if [ -f "$MIGRATE_JOB" ]; then
|
||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
|
||||
fi
|
||||
|
||||
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||
SEED_JOB="apps/base/seed-job.yaml"
|
||||
if [ -f "$SEED_JOB" ]; then
|
||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
||||
@@ -237,7 +237,7 @@ jobs:
|
||||
git config user.name "groombook-engineer[bot]"
|
||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||
git checkout -b "chore/update-image-tags-${TAG}"
|
||||
git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
||||
git add apps/overlays/dev/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
|
||||
git commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
|
||||
|
||||
git push -u origin "chore/update-image-tags-${TAG}"
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
.DS_Store
|
||||
*.log
|
||||
.turbo/
|
||||
coverage/
|
||||
minimax-output/
|
||||
|
||||
+29
-13
@@ -1,38 +1,54 @@
|
||||
FROM node:20-alpine AS base
|
||||
FROM node:22-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-lock.yaml ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/types/package.json packages/types/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build
|
||||
FROM deps AS builder
|
||||
RUN mkdir -p /home/node/.cache/node/corepack
|
||||
COPY apps/api/ apps/api/
|
||||
RUN pnpm --filter @groombook/api build
|
||||
COPY packages/ packages/
|
||||
COPY src/ src/
|
||||
COPY tsconfig.json ./
|
||||
RUN pnpm --filter @groombook/types build && \
|
||||
pnpm --filter @groombook/db build && \
|
||||
pnpm build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
# Runtime
|
||||
FROM node:22-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY --from=builder /app/apps/api/package.json apps/api/
|
||||
COPY --from=builder /app/apps/api/dist apps/api/dist
|
||||
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
|
||||
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", "apps/api/dist/index.js"]
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
# Migrate stage — runs drizzle-kit migrate against the database
|
||||
FROM builder AS migrate
|
||||
CMD ["pnpm", "--filter", "@groombook/api", "db:migrate"]
|
||||
CMD ["pnpm", "--filter", "@groombook/db", "migrate"]
|
||||
|
||||
# Seed stage — populates the database with test data
|
||||
FROM builder AS seed
|
||||
CMD ["pnpm", "--filter", "@groombook/api", "db:seed"]
|
||||
CMD ["pnpm", "--filter", "@groombook/db", "seed"]
|
||||
|
||||
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
||||
FROM builder AS reset
|
||||
CMD ["pnpm", "--filter", "@groombook/api", "db:reset"]
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
|
||||
|
||||
@@ -13,7 +13,7 @@ This repository contains the GroomBook API service, including:
|
||||
## Structure
|
||||
|
||||
```
|
||||
apps/api/ # API service source
|
||||
src/ # API service source
|
||||
packages/db/ # Database schema, migrations, and utilities
|
||||
packages/types/ # Shared TypeScript types
|
||||
```
|
||||
|
||||
+81
-2
@@ -21,13 +21,52 @@ 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.2 | Session persistence | Make authenticated request, verify session token valid | 200 OK, request succeeds |
|
||||
| TC-API-1.3 | Logout | Call logout endpoint, verify token invalidated | 200 OK, subsequent requests return 401 |
|
||||
| TC-API-1.4 | Email+password login (UAT) | POST /api/auth/sign-in/email with uat-super@groombook.dev + SEED_UAT_SUPER_PASSWORD | 200 OK, session cookie returned |
|
||||
| TC-API-1.5 | Email+password login — groomer | POST /api/auth/sign-in/email with uat-groomer@groombook.dev + SEED_UAT_GROOMER_PASSWORD | 200 OK, session cookie returned |
|
||||
| TC-API-1.6 | Email+password login — customer | POST /api/auth/sign-in/email with uat-customer@groombook.dev + SEED_UAT_CUSTOMER_PASSWORD | 200 OK, session cookie returned |
|
||||
| TC-API-1.7 | Email+password login — tester | POST /api/auth/sign-in/email with uat-tester@groombook.dev + SEED_UAT_TESTER_PASSWORD | 200 OK, session cookie returned |
|
||||
| TC-API-1.8 | Email+password — invalid password | POST /api/auth/sign-in/email with wrong password | 400 Bad Request, error returned |
|
||||
| TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned |
|
||||
| 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 |
|
||||
|
||||
### 4.2 Client Management
|
||||
|
||||
@@ -51,6 +90,30 @@ 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
|
||||
|
||||
@@ -112,6 +175,10 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
||||
| TC-API-8.5 | Add waitlist entry | POST /api/portal/waitlist with pet and service | 201 Created, waitlist entry created |
|
||||
| TC-API-8.6 | View portal invoices | GET /api/portal/invoices | 200 OK, list of client's invoices returned |
|
||||
| TC-API-8.7 | Pay multiple invoices | POST /api/portal/invoices/pay-multiple with invoice IDs | 200 OK, payment intent created |
|
||||
| TC-API-8.8 | SSO bridge — valid Better Auth session | POST /api/portal/session-from-auth with valid Better Auth session cookie (authenticated SSO user with matching client email) | 201 Created, `{sessionId, clientId, clientName}` returned |
|
||||
| TC-API-8.9 | SSO bridge — no Better Auth session | POST /api/portal/session-from-auth without Better Auth session cookie | 401 Unauthorized |
|
||||
| TC-API-8.10 | SSO bridge — no matching client | POST /api/portal/session-from-auth with valid Better Auth session for a user with no client record | 404 Not Found, error "No client record found for this user" |
|
||||
| TC-API-8.11 | SSO bridge — returned session works on portal routes | After TC-API-8.8, use returned sessionId as `X-Impersonation-Session-Id` header on GET /api/portal/me | 200 OK, client profile returned |
|
||||
|
||||
### 4.9 Waitlist
|
||||
|
||||
@@ -177,6 +244,18 @@ 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,7 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/schema.ts",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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": {} }
|
||||
}
|
||||
@@ -204,6 +204,20 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ 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("./db", () => {
|
||||
vi.mock("../db", () => {
|
||||
const authProviderConfig = new Proxy(
|
||||
{ _name: "auth_provider_config" },
|
||||
{
|
||||
|
||||
@@ -38,7 +38,7 @@ const mockGroomer: MockStaff = { id: "staff-3", role: "groomer", isSuperUser: fa
|
||||
|
||||
// ─── Mock db module ───────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("./db", () => {
|
||||
vi.mock("../db", () => {
|
||||
const authProviderConfig = new Proxy(
|
||||
{ _name: "auth_provider_config" },
|
||||
{
|
||||
|
||||
@@ -40,7 +40,7 @@ function resetMock() {
|
||||
deletedId = null;
|
||||
}
|
||||
|
||||
vi.mock("./db", () => {
|
||||
vi.mock("../db", () => {
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
|
||||
@@ -39,7 +39,7 @@ function resetMock() {
|
||||
lastUpdate = {};
|
||||
}
|
||||
|
||||
vi.mock("./db", () => {
|
||||
vi.mock("../db", () => {
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
|
||||
@@ -76,7 +76,7 @@ function makeChainableResult(data: unknown[]): unknown {
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("./db", () => {
|
||||
vi.mock("../db", () => {
|
||||
function makeTable(name: string) {
|
||||
return new Proxy(
|
||||
{ _name: name },
|
||||
|
||||
@@ -40,7 +40,7 @@ function resetDb() {
|
||||
|
||||
// ─── Module mocks ─────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("./db", () => {
|
||||
vi.mock("../db", () => {
|
||||
const pets = new Proxy(
|
||||
{ _name: "pets" },
|
||||
{ get(t, p) { return p === "_name" ? "pets" : {}; } }
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
import { petsRouter } from "../routes/pets.js";
|
||||
|
||||
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
||||
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: null,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const GROOMER: StaffRow = {
|
||||
id: "staff-groomer-id",
|
||||
oidcSub: "oidc-groomer-sub",
|
||||
userId: null,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
name: "Groomer McGroome",
|
||||
email: "groomer@example.com",
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// ─── Mutable mock state ───────────────────────────────────────────────────────
|
||||
|
||||
const CLIENT_ID = "client-uuid-summary";
|
||||
const PET_ID = "pet-uuid-summary";
|
||||
|
||||
interface MockState {
|
||||
pets: Record<string, unknown>[];
|
||||
appointments: Record<string, unknown>[];
|
||||
groomingLogs: Record<string, unknown>[];
|
||||
staffMembers: Record<string, unknown>[];
|
||||
services: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
let mock: MockState;
|
||||
|
||||
function resetMock() {
|
||||
mock = {
|
||||
pets: [{
|
||||
id: PET_ID,
|
||||
clientId: CLIENT_ID,
|
||||
name: "Biscuit",
|
||||
species: "dog",
|
||||
breed: "Golden Retriever",
|
||||
weightKg: "30.00",
|
||||
dateOfBirth: null,
|
||||
healthAlerts: null,
|
||||
groomingNotes: null,
|
||||
cutStyle: null,
|
||||
shampooPreference: null,
|
||||
specialCareNotes: null,
|
||||
customFields: {},
|
||||
photoKey: null,
|
||||
photoUploadedAt: null,
|
||||
image: null,
|
||||
coatType: "double",
|
||||
temperamentScore: 3,
|
||||
temperamentFlags: ["gentle"],
|
||||
medicalAlerts: [],
|
||||
preferredCuts: ["puppy cut"],
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
}],
|
||||
appointments: [
|
||||
{
|
||||
id: "appt-completed-1",
|
||||
clientId: CLIENT_ID,
|
||||
petId: PET_ID,
|
||||
serviceId: "service-1",
|
||||
staffId: "staff-groomer-id",
|
||||
batherStaffId: null,
|
||||
status: "completed",
|
||||
startTime: new Date("2024-06-01T09:00:00Z"),
|
||||
endTime: new Date("2024-06-01T11:00:00Z"),
|
||||
notes: null,
|
||||
priceCents: 6000,
|
||||
seriesId: null,
|
||||
seriesIndex: null,
|
||||
groupId: null,
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: null,
|
||||
cancelledAt: null,
|
||||
confirmationToken: null,
|
||||
customerNotes: null,
|
||||
createdAt: new Date("2024-05-15"),
|
||||
updatedAt: new Date("2024-05-15"),
|
||||
},
|
||||
{
|
||||
id: "appt-upcoming-1",
|
||||
clientId: CLIENT_ID,
|
||||
petId: PET_ID,
|
||||
serviceId: "service-2",
|
||||
staffId: "staff-groomer-id",
|
||||
batherStaffId: null,
|
||||
status: "confirmed",
|
||||
startTime: new Date("2024-12-01T09:00:00Z"),
|
||||
endTime: new Date("2024-12-01T11:00:00Z"),
|
||||
notes: null,
|
||||
priceCents: 6500,
|
||||
seriesId: null,
|
||||
seriesIndex: null,
|
||||
groupId: null,
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: null,
|
||||
cancelledAt: null,
|
||||
confirmationToken: null,
|
||||
customerNotes: null,
|
||||
createdAt: new Date("2024-11-01"),
|
||||
updatedAt: new Date("2024-11-01"),
|
||||
},
|
||||
],
|
||||
groomingLogs: [
|
||||
{
|
||||
id: "log-1",
|
||||
petId: PET_ID,
|
||||
appointmentId: "appt-completed-1",
|
||||
staffId: "staff-groomer-id",
|
||||
cutStyle: "puppy cut",
|
||||
productsUsed: "oatmeal shampoo",
|
||||
notes: "Trimmed nails",
|
||||
groomedAt: new Date("2024-06-01T10:00:00Z"),
|
||||
createdAt: new Date("2024-06-01T10:00:00Z"),
|
||||
},
|
||||
],
|
||||
staffMembers: [
|
||||
{
|
||||
id: "staff-groomer-id",
|
||||
name: "Groomer McGroome",
|
||||
email: "groomer@example.com",
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
oidcSub: "oidc-groomer-sub",
|
||||
userId: null,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "staff-manager-id",
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: null,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
services: [
|
||||
{ id: "service-1", name: "Full Groom", description: null, basePriceCents: 6000, durationMinutes: 120, active: true, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: "service-2", name: "Bath & Brush", description: null, basePriceCents: 4000, durationMinutes: 60, active: true, createdAt: new Date(), updatedAt: new Date() },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../db/index.js", () => {
|
||||
const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} });
|
||||
const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} });
|
||||
const groomingVisitLogs = new Proxy({ _name: "groomingVisitLogs" }, { get: (t, p) => p === "_name" ? "groomingVisitLogs" : {} });
|
||||
const staff = new Proxy({ _name: "staff" }, { get: (t, p) => p === "_name" ? "staff" : {} });
|
||||
const services = new Proxy({ _name: "services" }, { get: (t, p) => p === "_name" ? "services" : {} });
|
||||
|
||||
// Tracks { [tableName]: { [alias]: SQLExpression } } for the current select() call
|
||||
let selectedColumns: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
function makeChainable(rows: unknown[]) {
|
||||
const arr = rows as unknown[];
|
||||
return new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "leftJoin" || prop === "from") {
|
||||
return () => makeChainable(target);
|
||||
}
|
||||
if (prop === Symbol.iterator) {
|
||||
return function* () { for (const v of target) yield v; };
|
||||
}
|
||||
if (prop === Symbol.asyncIterator) {
|
||||
return async function* () { for (const v of target) yield v; };
|
||||
}
|
||||
// @ts-expect-error proxy
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// sql mock: returns an object with .as() so drizzle's select() can alias it
|
||||
function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) {
|
||||
const queryString = _strings[0];
|
||||
const asFn = (alias: string) => ({
|
||||
sql: { queryChunks: [_strings[0]] },
|
||||
fieldAlias: alias,
|
||||
getSQL() { return this.sql; },
|
||||
});
|
||||
return { queryChunks: [queryString], as: asFn };
|
||||
}
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: (cols?: Record<string, unknown>) => {
|
||||
selectedColumns = {};
|
||||
if (cols) {
|
||||
// Inspect cols to find sql-aliased expressions and their aliases
|
||||
for (const [alias, expr] of Object.entries(cols)) {
|
||||
if (expr && typeof expr === "object" && "as" in expr && typeof (expr as Record<string, unknown>).as === "function") {
|
||||
const aliased = (expr as { as: (a: string) => { fieldAlias: string; sql: unknown } }).as(alias);
|
||||
// Detect count(*) queries
|
||||
if (typeof aliased.sql === "object" && aliased.sql !== null && "queryChunks" in (aliased.sql as Record<string, unknown>) && String((aliased.sql as { queryChunks?: unknown[] }).queryChunks).includes("count")) {
|
||||
// Store count query intent — we'll resolve it in from()
|
||||
if (!selectedColumns["appointments"]) selectedColumns["appointments"] = {};
|
||||
selectedColumns["appointments"][alias] = { _isCountQuery: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
from: (table: unknown) => {
|
||||
const name = (table as { _name?: string })._name;
|
||||
const tableCols = selectedColumns[name] || {};
|
||||
// If this table has a count query, return computed count result
|
||||
const countQueryEntry = Object.entries(tableCols).find(([, v]) =>
|
||||
typeof v === "object" && v !== null && "_isCountQuery" in v
|
||||
);
|
||||
if (countQueryEntry) {
|
||||
const [countAlias] = countQueryEntry;
|
||||
const count = (name === "appointments" ? mock.appointments : [])
|
||||
.filter((row: Record<string, unknown>) => row.status === "completed").length;
|
||||
return makeChainable([{ [countAlias]: count }]);
|
||||
}
|
||||
if (name === "pets") return makeChainable(mock.pets);
|
||||
if (name === "appointments") return makeChainable(mock.appointments);
|
||||
if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs);
|
||||
if (name === "staff") return makeChainable(mock.staffMembers);
|
||||
if (name === "services") return makeChainable(mock.services);
|
||||
return makeChainable([]);
|
||||
},
|
||||
};
|
||||
},
|
||||
insert: () => ({ values: () => ({ returning: () => [{}] }) }),
|
||||
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
|
||||
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
|
||||
}),
|
||||
pets,
|
||||
appointments,
|
||||
groomingVisitLogs,
|
||||
staff,
|
||||
services,
|
||||
and: vi.fn((a: unknown, b: unknown) => [a, b]),
|
||||
desc: vi.fn((c: unknown) => c),
|
||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||
exists: vi.fn(() => true),
|
||||
gte: vi.fn((a: unknown, b: unknown) => ({ col: a, val: b })),
|
||||
or: vi.fn((a: unknown, b: unknown) => [a, b]),
|
||||
sql: sqlMock,
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeApp(staff: StaffRow = MANAGER) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("staff", staff);
|
||||
await next();
|
||||
});
|
||||
return app.route("/pets", petsRouter);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /:id/profile-summary", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("returns 404 for non-existent pet", async () => {
|
||||
const app = makeApp();
|
||||
mock.pets = [];
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 403 for groomer with no pet linkage", async () => {
|
||||
const app = makeApp(GROOMER);
|
||||
// Groomer has no linkage to this pet's client — clear appointments
|
||||
mock.appointments = [];
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns complete aggregated profile for manager", async () => {
|
||||
const app = makeApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.id).toBe(PET_ID);
|
||||
expect(body.name).toBe("Biscuit");
|
||||
expect(body.species).toBe("dog");
|
||||
expect(body.recentGroomingHistory).toBeInstanceOf(Array);
|
||||
expect(body.lastVisitDate).toBeTruthy();
|
||||
expect(body.visitCount).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("groomer with pet linkage returns 200", async () => {
|
||||
const app = makeApp(GROOMER);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("recentGroomingHistory is limited to 10 entries", async () => {
|
||||
const app = makeApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.recentGroomingHistory.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it("returns null upcomingAppointment when none scheduled", async () => {
|
||||
const app = makeApp(MANAGER);
|
||||
mock.appointments = [];
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.upcomingAppointment).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /:id/profile-summary — visitCount", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("returns visitCount >= 2 when pet has 2+ completed appointments", async () => {
|
||||
const app = makeApp(MANAGER);
|
||||
// Add a second completed appointment
|
||||
mock.appointments = [
|
||||
...mock.appointments,
|
||||
{
|
||||
id: "appt-completed-2",
|
||||
clientId: CLIENT_ID,
|
||||
petId: PET_ID,
|
||||
serviceId: "service-1",
|
||||
staffId: "staff-groomer-id",
|
||||
batherStaffId: null,
|
||||
status: "completed",
|
||||
startTime: new Date("2024-07-01T09:00:00Z"),
|
||||
endTime: new Date("2024-07-01T11:00:00Z"),
|
||||
notes: null,
|
||||
priceCents: 6000,
|
||||
seriesId: null,
|
||||
seriesIndex: null,
|
||||
groupId: null,
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: null,
|
||||
cancelledAt: null,
|
||||
confirmationToken: null,
|
||||
customerNotes: null,
|
||||
createdAt: new Date("2024-06-15"),
|
||||
updatedAt: new Date("2024-06-15"),
|
||||
},
|
||||
];
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.visitCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("returns visitCount = 0 when no completed appointments", async () => {
|
||||
const app = makeApp(MANAGER);
|
||||
mock.appointments = mock.appointments.map((a) => ({ ...a, status: "cancelled" }));
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.visitCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /:id/profile-summary — empty history", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("returns empty history array when no grooming logs", async () => {
|
||||
const app = makeApp(MANAGER);
|
||||
mock.groomingLogs = [];
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.recentGroomingHistory).toEqual([]);
|
||||
expect(body.lastVisitDate).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
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 1–5", 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"]);
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ function resetMock() {
|
||||
updatedValues = [];
|
||||
}
|
||||
|
||||
vi.mock("./db", () => {
|
||||
vi.mock("../db", () => {
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
@@ -67,6 +67,11 @@ vi.mock("./db", () => {
|
||||
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
|
||||
);
|
||||
|
||||
const impersonationAuditLogs = new Proxy(
|
||||
{ _name: "impersonationAuditLogs" },
|
||||
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
|
||||
);
|
||||
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
@@ -99,8 +104,12 @@ vi.mock("./db", () => {
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: () => ({ returning: () => [{}] }),
|
||||
}),
|
||||
}),
|
||||
impersonationSessions,
|
||||
impersonationAuditLogs,
|
||||
appointments,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
|
||||
@@ -45,40 +45,76 @@ 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;
|
||||
|
||||
vi.mock("./db", () => {
|
||||
const staff = new Proxy(
|
||||
{ _name: "staff" },
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop === "_name") return "staff";
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: "staff", column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
vi.mock("../db", () => {
|
||||
const makeTableProxy = (name: string) =>
|
||||
new Proxy(
|
||||
{ _name: name },
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop === "_name") return name;
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: name, column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const staff = makeTableProxy("staff");
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => {
|
||||
// dev mode fallback to first manager
|
||||
return managerFallbackResult ? [managerFallbackResult] : [];
|
||||
},
|
||||
[Symbol.iterator]: function* () {
|
||||
if (staffLookupResult) yield staffLookupResult;
|
||||
},
|
||||
0: staffLookupResult,
|
||||
length: staffLookupResult ? 1 : 0,
|
||||
}),
|
||||
from: (table: unknown) => ({
|
||||
where: () => buildQuery(
|
||||
table === staff ? staffLookupResult : userLookupResult,
|
||||
table === staff ? managerFallbackResult : null
|
||||
),
|
||||
}),
|
||||
}),
|
||||
insert: (_table: unknown) => ({
|
||||
values: (vals: Record<string, unknown>) => ({
|
||||
returning: () => {
|
||||
const newStaff: StaffRow = {
|
||||
id: "new-staff-id",
|
||||
oidcSub: null,
|
||||
userId: vals.userId as string,
|
||||
role: vals.role as StaffRow["role"],
|
||||
isSuperUser: false,
|
||||
name: vals.name as string,
|
||||
email: vals.email as string,
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
_insertedStaff = newStaff;
|
||||
return [newStaff];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
staff,
|
||||
user,
|
||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||
and: vi.fn((..._clauses: unknown[]) => ({})),
|
||||
sql: vi.fn((..._args: unknown[]) => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -87,6 +123,8 @@ vi.mock("./db", () => {
|
||||
function resetMocks() {
|
||||
staffLookupResult = null;
|
||||
managerFallbackResult = MANAGER;
|
||||
userLookupResult = null;
|
||||
_insertedStaff = null;
|
||||
}
|
||||
|
||||
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
|
||||
@@ -96,7 +134,10 @@ function buildApp(
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
|
||||
c.set("jwtPayload", {
|
||||
sub: userLookupResult?.id ?? staffLookupResult?.userId ?? "unknown-sub",
|
||||
email: userLookupResult?.email,
|
||||
});
|
||||
await next();
|
||||
});
|
||||
app.use("*", middleware);
|
||||
@@ -202,6 +243,50 @@ describe("resolveStaffMiddleware", () => {
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/no staff records found/i);
|
||||
});
|
||||
|
||||
it("auto-provision: creates groomer staff record on first login when Better-Auth user exists", async () => {
|
||||
staffLookupResult = null;
|
||||
userLookupResult = { id: "ba-user-new", name: "New User", email: "newuser@example.com" };
|
||||
let capturedStaff: StaffRow | null = null;
|
||||
const app = buildApp(resolveStaffMiddleware, (c) => {
|
||||
capturedStaff = c.get("staff");
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedStaff).not.toBeNull();
|
||||
expect(capturedStaff!.role).toBe("groomer");
|
||||
expect(capturedStaff!.userId).toBe("ba-user-new");
|
||||
expect(capturedStaff!.name).toBe("New User");
|
||||
expect(capturedStaff!.email).toBe("newuser@example.com");
|
||||
expect(capturedStaff!.isSuperUser).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-provision: falls back to email prefix when user has no name", async () => {
|
||||
staffLookupResult = null;
|
||||
userLookupResult = { id: "ba-user-noname", name: null, email: "firstlogin@example.com" };
|
||||
let capturedStaff: StaffRow | null = null;
|
||||
const app = buildApp(resolveStaffMiddleware, (c) => {
|
||||
capturedStaff = c.get("staff");
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedStaff!.name).toBe("firstlogin");
|
||||
});
|
||||
|
||||
it("auto-provision: returns 403 when no staff record and no Better-Auth user exists", async () => {
|
||||
staffLookupResult = null;
|
||||
userLookupResult = null;
|
||||
const app = buildApp(resolveStaffMiddleware);
|
||||
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/no staff record found for authenticated user/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── requireRole tests ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,7 +23,7 @@ const PET_ROW = {
|
||||
let clientResults: typeof ACTIVE_CLIENT[] = [];
|
||||
let petResults: typeof PET_ROW[] = [];
|
||||
|
||||
vi.mock("./db", () => {
|
||||
vi.mock("../db", () => {
|
||||
// Proxy objects for table/column references — values don't matter for tests
|
||||
const tableProxy = (name: string) =>
|
||||
new Proxy(
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// ─── Test configuration constants (must match seed.ts) ─────────────────────────
|
||||
|
||||
const UAT_ACCOUNTS = [
|
||||
{
|
||||
email: "uat-super@groombook.dev",
|
||||
name: "UAT Super User",
|
||||
passwordEnv: "SEED_UAT_SUPER_PASSWORD",
|
||||
staffEmail: "uat-super@groombook.dev",
|
||||
},
|
||||
{
|
||||
email: "uat-groomer@groombook.dev",
|
||||
name: "UAT Staff Groomer",
|
||||
passwordEnv: "SEED_UAT_GROOMER_PASSWORD",
|
||||
staffEmail: "uat-groomer@groombook.dev",
|
||||
},
|
||||
{
|
||||
email: "uat-customer@groombook.dev",
|
||||
name: "UAT Customer",
|
||||
passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD",
|
||||
staffEmail: null,
|
||||
},
|
||||
{
|
||||
email: "uat-tester@groombook.dev",
|
||||
name: "UAT Tester",
|
||||
passwordEnv: "SEED_UAT_TESTER_PASSWORD",
|
||||
staffEmail: "uat-tester@groombook.dev",
|
||||
},
|
||||
];
|
||||
|
||||
const TEST_PASSWORD = "test-password-123";
|
||||
|
||||
// ─── Password hashing — must match better-auth/crypto (N=16384, r=16, p=1, dkLen=64, hex) ───
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
const { hashPassword } = await import("better-auth/crypto");
|
||||
return hashPassword(password);
|
||||
}
|
||||
|
||||
// ─── Mock DB state ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
emailVerified: boolean;
|
||||
}
|
||||
|
||||
interface AccountRow {
|
||||
id: string;
|
||||
accountId: string;
|
||||
providerId: string;
|
||||
userId: string;
|
||||
password: string | null;
|
||||
}
|
||||
|
||||
interface StaffRow {
|
||||
id: string;
|
||||
email: string;
|
||||
userId: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
let dbUsers: UserRow[] = [];
|
||||
let dbAccounts: AccountRow[] = [];
|
||||
let dbStaff: StaffRow[] = [];
|
||||
let insertedUsers: UserRow[] = [];
|
||||
let insertedAccounts: AccountRow[] = [];
|
||||
let updatedStaff: Array<{ id: string; userId: string }> = [];
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
function resetMock() {
|
||||
dbUsers = [];
|
||||
dbAccounts = [];
|
||||
dbStaff = [];
|
||||
insertedUsers = [];
|
||||
insertedAccounts = [];
|
||||
updatedStaff = [];
|
||||
process.env = { ...originalEnv };
|
||||
}
|
||||
|
||||
// ─── Mock schema ───────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSchemaMock() {
|
||||
const user = new Proxy({ _name: "user" }, {
|
||||
get(_t, p) {
|
||||
if (p === "_name") return "user";
|
||||
if (p === "$inferSelect") return {};
|
||||
return { table: "user", column: p };
|
||||
},
|
||||
});
|
||||
|
||||
const account = new Proxy({ _name: "account" }, {
|
||||
get(_t, p) {
|
||||
if (p === "_name") return "account";
|
||||
if (p === "$inferSelect") return {};
|
||||
return { table: "account", column: p };
|
||||
},
|
||||
});
|
||||
|
||||
const staff = new Proxy({ _name: "staff" }, {
|
||||
get(_t, p) {
|
||||
if (p === "_name") return "staff";
|
||||
if (p === "$inferSelect") return {};
|
||||
return { table: "staff", column: p };
|
||||
},
|
||||
});
|
||||
|
||||
return { user, account, staff };
|
||||
}
|
||||
|
||||
const { user: mockUser, account: mockAccount, staff: mockStaff } = makeSchemaMock();
|
||||
|
||||
function eq(col: unknown, val: unknown) {
|
||||
return { __type: "eq" as const, col, val };
|
||||
}
|
||||
|
||||
function and(...conds: unknown[]) {
|
||||
return { __type: "and" as const, conds };
|
||||
}
|
||||
|
||||
// ─── Seed logic helper ─────────────────────────────────────────────────────────
|
||||
// Inline the credential provisioning logic under test so we can call it directly.
|
||||
// This is the same logic as seed.ts lines 514-598.
|
||||
|
||||
interface SeedAccount {
|
||||
email: string;
|
||||
name: string;
|
||||
passwordEnv: string;
|
||||
staffEmail: string | null;
|
||||
}
|
||||
|
||||
let uuidCounter = 0;
|
||||
function mockUuid(): string {
|
||||
return `mock-uuid-${++uuidCounter}`;
|
||||
}
|
||||
|
||||
async function seedUatCredentials(
|
||||
accounts: SeedAccount[],
|
||||
opts: {
|
||||
users?: UserRow[];
|
||||
accounts?: AccountRow[];
|
||||
staff?: StaffRow[];
|
||||
}
|
||||
) {
|
||||
const { users = dbUsers, accounts: accts = dbAccounts, staff: staffRows = dbStaff } = opts;
|
||||
|
||||
for (const acct of accounts) {
|
||||
const password = process.env[acct.passwordEnv];
|
||||
if (!password) {
|
||||
console.warn(`⚠ Skipping ${acct.email} — ${acct.passwordEnv} not set`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Find or create the Better-Auth user
|
||||
const existingUser = users.find((u) => u.email === acct.email);
|
||||
|
||||
let userId: string;
|
||||
if (existingUser) {
|
||||
userId = existingUser.id;
|
||||
} else {
|
||||
userId = mockUuid();
|
||||
const newUser: UserRow = { id: userId, name: acct.name, email: acct.email, emailVerified: true };
|
||||
insertedUsers.push(newUser);
|
||||
dbUsers.push(newUser);
|
||||
}
|
||||
|
||||
// 2. Check if credential account already exists
|
||||
const existingAccount = accts.find(
|
||||
(a) => a.userId === userId && a.providerId === "credential"
|
||||
);
|
||||
|
||||
if (existingAccount) {
|
||||
// skip — already has credential account
|
||||
} else {
|
||||
// Use Better-Auth's hashPassword so test helper matches production seed.ts
|
||||
const { hashPassword } = await import("better-auth/crypto");
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
const newAccount: AccountRow = {
|
||||
id: mockUuid(),
|
||||
accountId: userId,
|
||||
providerId: "credential",
|
||||
userId,
|
||||
password: passwordHash,
|
||||
};
|
||||
insertedAccounts.push(newAccount);
|
||||
dbAccounts.push(newAccount);
|
||||
}
|
||||
|
||||
// 3. Link staff record to Better-Auth user
|
||||
if (acct.staffEmail) {
|
||||
const existingStaff = staffRows.find((s) => s.email === acct.staffEmail);
|
||||
if (existingStaff && !existingStaff.userId) {
|
||||
existingStaff.userId = userId;
|
||||
updatedStaff.push({ id: existingStaff.id, userId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("seedUatCredentials — credential provisioning logic", () => {
|
||||
beforeEach(() => {
|
||||
resetMock();
|
||||
uuidCounter = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
// ── AC-1: creates user + account when neither exists ──────────────────────
|
||||
|
||||
it("AC-1: creates user and account for each UAT account with password env var set", async () => {
|
||||
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
|
||||
process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD;
|
||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
||||
process.env.SEED_UAT_TESTER_PASSWORD = TEST_PASSWORD;
|
||||
|
||||
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
|
||||
|
||||
// 4 users created (customer + tester have no staff, super + groomer do)
|
||||
expect(insertedUsers).toHaveLength(4);
|
||||
expect(insertedUsers.find((u) => u.email === "uat-super@groombook.dev")).toBeDefined();
|
||||
expect(insertedUsers.find((u) => u.email === "uat-groomer@groombook.dev")).toBeDefined();
|
||||
expect(insertedUsers.find((u) => u.email === "uat-customer@groombook.dev")).toBeDefined();
|
||||
expect(insertedUsers.find((u) => u.email === "uat-tester@groombook.dev")).toBeDefined();
|
||||
|
||||
// 4 accounts created
|
||||
expect(insertedAccounts).toHaveLength(4);
|
||||
for (const acct of insertedAccounts) {
|
||||
expect(acct.providerId).toBe("credential");
|
||||
// Better-Auth uses hex encoding: saltHex:keyHex (both lowercase hex)
|
||||
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
||||
// Verify the hash is scrypt with correct params (N=16384, r=16, p=1, dkLen=64)
|
||||
const parts = acct.password!.split(":");
|
||||
const saltHex = parts[0]!;
|
||||
const keyHex = parts[1]!;
|
||||
const salt = Buffer.from(saltHex, "hex");
|
||||
const storedHash = Buffer.from(keyHex, "hex");
|
||||
expect(salt).toHaveLength(16);
|
||||
expect(storedHash).toHaveLength(64);
|
||||
}
|
||||
});
|
||||
|
||||
// ── AC-2: emailVerified = true ─────────────────────────────────────────────
|
||||
|
||||
it("AC-2: created users have emailVerified = true", async () => {
|
||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
||||
|
||||
await seedUatCredentials(
|
||||
[UAT_ACCOUNTS[2]!], // customer only
|
||||
{ users: [], accounts: [], staff: [] }
|
||||
);
|
||||
|
||||
expect(insertedUsers[0]!.emailVerified).toBe(true);
|
||||
});
|
||||
|
||||
// ── AC-3: providerId = credential, password is hashed ──────────────────────
|
||||
|
||||
it("AC-3: account records use providerId='credential' with properly formatted hashed password", async () => {
|
||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
||||
|
||||
await seedUatCredentials(
|
||||
[UAT_ACCOUNTS[2]!],
|
||||
{ users: [], accounts: [], staff: [] }
|
||||
);
|
||||
|
||||
const acct = insertedAccounts[0]!;
|
||||
expect(acct.providerId).toBe("credential");
|
||||
// Better-Auth uses hex: saltHex (32 chars) : keyHex (128 chars)
|
||||
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
||||
const parts = acct.password!.split(":");
|
||||
const saltHex = parts[0]!;
|
||||
const keyHex = parts[1]!;
|
||||
expect(() => Buffer.from(saltHex, "hex")).not.toThrow();
|
||||
expect(() => Buffer.from(keyHex, "hex")).not.toThrow();
|
||||
const salt = Buffer.from(saltHex, "hex");
|
||||
const storedHash = Buffer.from(keyHex, "hex");
|
||||
expect(salt).toHaveLength(16);
|
||||
expect(storedHash).toHaveLength(64);
|
||||
});
|
||||
|
||||
// ── AC-4: staff.userId is linked ────────────────────────────────────────────
|
||||
|
||||
it("AC-4: links staff.userId to the Better-Auth user when staff record exists", async () => {
|
||||
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
|
||||
const staffRows: StaffRow[] = [
|
||||
{ id: "staff-super-1", email: "uat-super@groombook.dev", userId: null, name: "UAT Super User" },
|
||||
];
|
||||
|
||||
await seedUatCredentials([UAT_ACCOUNTS[0]!], { users: [], accounts: [], staff: staffRows });
|
||||
|
||||
expect(updatedStaff).toHaveLength(1);
|
||||
expect(updatedStaff[0]!.id).toBe("staff-super-1");
|
||||
expect(updatedStaff[0]!.userId).toBe("mock-uuid-1");
|
||||
expect(staffRows[0]!.userId).toBe("mock-uuid-1");
|
||||
});
|
||||
|
||||
it("AC-4b: does not update staff.userId if already set", async () => {
|
||||
process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD;
|
||||
const staffRows: StaffRow[] = [
|
||||
{ id: "staff-groomer-1", email: "uat-groomer@groombook.dev", userId: "already-linked", name: "UAT Groomer" },
|
||||
];
|
||||
|
||||
await seedUatCredentials([UAT_ACCOUNTS[1]!], { users: [], accounts: [], staff: staffRows });
|
||||
|
||||
expect(updatedStaff).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── AC-5: idempotent — skips when user already exists ───────────────────────
|
||||
|
||||
it("AC-5: re-running does not duplicate user or account records (idempotent)", async () => {
|
||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
||||
|
||||
const preExistingUsers: UserRow[] = [
|
||||
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
|
||||
];
|
||||
const preExistingAccounts: AccountRow[] = [
|
||||
{
|
||||
id: "pre-existing-acct",
|
||||
accountId: "pre-existing-user",
|
||||
providerId: "credential",
|
||||
userId: "pre-existing-user",
|
||||
password: await hashPassword(TEST_PASSWORD),
|
||||
},
|
||||
];
|
||||
|
||||
// First call — nothing inserted (user + account pre-exist)
|
||||
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
|
||||
users: preExistingUsers,
|
||||
accounts: preExistingAccounts,
|
||||
staff: [],
|
||||
});
|
||||
|
||||
expect(insertedUsers).toHaveLength(0);
|
||||
expect(insertedAccounts).toHaveLength(0);
|
||||
|
||||
// Second call — still nothing inserted
|
||||
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
|
||||
users: preExistingUsers,
|
||||
accounts: preExistingAccounts,
|
||||
staff: [],
|
||||
});
|
||||
|
||||
expect(insertedUsers).toHaveLength(0);
|
||||
expect(insertedAccounts).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── AC-6: missing env var skips with warning ────────────────────────────────
|
||||
|
||||
it("AC-6: missing SEED_UAT_*_PASSWORD env var skips that account (no error)", async () => {
|
||||
// No env vars set at all
|
||||
delete process.env.SEED_UAT_SUPER_PASSWORD;
|
||||
delete process.env.SEED_UAT_GROOMER_PASSWORD;
|
||||
delete process.env.SEED_UAT_CUSTOMER_PASSWORD;
|
||||
delete process.env.SEED_UAT_TESTER_PASSWORD;
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
|
||||
|
||||
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
|
||||
|
||||
// Nothing created
|
||||
expect(insertedUsers).toHaveLength(0);
|
||||
expect(insertedAccounts).toHaveLength(0);
|
||||
// Warning logged for each of the 4 accounts
|
||||
expect(warnSpy).toHaveBeenCalledTimes(4);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"⚠ Skipping uat-super@groombook.dev — SEED_UAT_SUPER_PASSWORD not set"
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
// ── AC-7: partial env var coverage ─────────────────────────────────────────
|
||||
|
||||
it("AC-7: only accounts with password env var set are provisioned", async () => {
|
||||
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
|
||||
// Only super has password set
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
|
||||
|
||||
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
|
||||
|
||||
expect(insertedUsers).toHaveLength(1);
|
||||
expect(insertedUsers[0]!.email).toBe("uat-super@groombook.dev");
|
||||
expect(insertedAccounts).toHaveLength(1);
|
||||
expect(insertedAccounts[0]!.accountId).toBe("mock-uuid-1");
|
||||
|
||||
// 3 warnings for missing accounts
|
||||
expect(warnSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Password hash format verification ───────────────────────────────────────
|
||||
|
||||
describe("password hash format — scrypt parameters", () => {
|
||||
it("hashes use salt:hash format with 16-byte salt and 64-byte output", async () => {
|
||||
const hash = await hashPassword("test-password");
|
||||
const parts = hash.split(":");
|
||||
const saltHex = parts[0]!;
|
||||
const keyHex = parts[1]!;
|
||||
|
||||
expect(hash).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
||||
expect(Buffer.from(saltHex, "hex")).toHaveLength(16);
|
||||
expect(Buffer.from(keyHex, "hex")).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("same password produces different hashes (due to random salt)", async () => {
|
||||
const hash1 = await hashPassword("same-password");
|
||||
const hash2 = await hashPassword("same-password");
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
// Both are valid Better-Auth hex format
|
||||
expect(hash1).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
||||
expect(hash2).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
||||
});
|
||||
|
||||
it("different passwords produce different hashes", async () => {
|
||||
const hash1 = await hashPassword("password1");
|
||||
const hash2 = await hashPassword("password2");
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ function clearAuthEnv() {
|
||||
|
||||
// ─── Mock db module ───────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("./db", () => {
|
||||
vi.mock("../db", () => {
|
||||
const authProviderConfig = new Proxy(
|
||||
{ _name: "auth_provider_config" },
|
||||
{
|
||||
|
||||
@@ -49,7 +49,7 @@ function resetMock() {
|
||||
updatedValues = [];
|
||||
}
|
||||
|
||||
vi.mock("./db", () => {
|
||||
vi.mock("../db", () => {
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
|
||||
@@ -103,6 +103,11 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
|
||||
photoKey: null,
|
||||
photoUploadedAt: null,
|
||||
image: null,
|
||||
coatType: null,
|
||||
temperamentScore: null,
|
||||
temperamentFlags: [],
|
||||
medicalAlerts: [],
|
||||
preferredCuts: [],
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
@@ -12,6 +12,16 @@ 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", [
|
||||
@@ -146,6 +156,12 @@ 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(),
|
||||
},
|
||||
|
||||
+185
-14
@@ -18,8 +18,9 @@
|
||||
|
||||
import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import * as schema from "./schema.js";
|
||||
import type { MedicalAlert, MedicalAlertSeverity } from "./schema.js";
|
||||
|
||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||
|
||||
@@ -94,11 +95,6 @@ function pick<T>(arr: T[]): T {
|
||||
return arr[Math.floor(rand() * arr.length)]!;
|
||||
}
|
||||
|
||||
/** Return n distinct random elements from an array. */
|
||||
function pickN<T>(arr: T[], n: number): T[] {
|
||||
const shuffled = [...arr].sort(() => rand() - 0.5);
|
||||
return shuffled.slice(0, n);
|
||||
}
|
||||
|
||||
function randInt(min: number, max: number): number {
|
||||
return Math.floor(rand() * (max - min + 1)) + min;
|
||||
@@ -257,6 +253,38 @@ const appointmentNotes = [
|
||||
"Client running late, pushed start by 15min",
|
||||
];
|
||||
|
||||
const temperamentScores = [3, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9];
|
||||
|
||||
const temperamentFlags = [
|
||||
[], ["anxious"], ["friendly"], ["nippy"], ["anxious", "sensitive"],
|
||||
["friendly", "calm"], ["nippy", "territorial"], ["calm"], ["sensitive"],
|
||||
["friendly", "nippy"], ["anxious", "territorial"],
|
||||
];
|
||||
|
||||
const medicalAlertsList = [
|
||||
[] as MedicalAlert[],
|
||||
[] as MedicalAlert[],
|
||||
[{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }],
|
||||
[{ type: "ear", description: "Ear infection prone — dry ears thoroughly", severity: "medium" as MedicalAlertSeverity }],
|
||||
[{ type: "mobility", description: "Hip dysplasia — handle with care", severity: "high" as MedicalAlertSeverity }],
|
||||
[{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }],
|
||||
[{ type: "medical", description: "Seizure history — avoid stress triggers", severity: "high" as MedicalAlertSeverity }],
|
||||
[{ type: "skin", description: "Skin allergies — use hypoallergenic products only", severity: "medium" as MedicalAlertSeverity }],
|
||||
[{ type: "behavioral", description: "Aggressive when nails trimmed — muzzle required", severity: "high" as MedicalAlertSeverity }],
|
||||
[{ type: "cardiac", description: "Heart murmur — monitor during grooming", severity: "high" as MedicalAlertSeverity }],
|
||||
[{ type: "dietary", description: "Diabetic — owner brings treats", severity: "medium" as MedicalAlertSeverity }],
|
||||
];
|
||||
|
||||
const preferredCutsList = [
|
||||
[], ["Puppy Cut"], ["Teddy Bear Cut"], ["Breed Standard"],
|
||||
["Puppy Cut", "Sanitary Trim"], ["Full Groom"], ["Lion Cut"],
|
||||
["Kennel Cut", "Face & Feet Trim"], ["Teddy Bear Cut", "Sanitary Trim"],
|
||||
["Breed Standard", "Sanitary Trim"], ["Summer Shave"],
|
||||
["Puppy Cut", "Face & Feet Trim", "Sanitary Trim"],
|
||||
];
|
||||
|
||||
const coatTypes: string[] = ["short", "medium", "long", "curly", "wire", "double", "silky"];
|
||||
|
||||
const visitLogNotes = [
|
||||
null, null,
|
||||
"Coat in great condition",
|
||||
@@ -459,6 +487,32 @@ async function seedKnownUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff: UAT Tester (oidcSub from SEED_UAT_TESTER_OIDC_SUB env var) ──
|
||||
const uatTesterOidcSub = process.env.SEED_UAT_TESTER_OIDC_SUB;
|
||||
if (uatTesterOidcSub) {
|
||||
const UAT_TESTER_STAFF_ID = "00000000-0000-0000-0000-000000000007";
|
||||
const [existingUatTester] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, "uat-tester@groombook.dev"))
|
||||
.limit(1);
|
||||
|
||||
if (existingUatTester) {
|
||||
console.log(`✓ Staff 'UAT Tester' already exists — skipping`);
|
||||
} else {
|
||||
await db.insert(schema.staff).values({
|
||||
id: UAT_TESTER_STAFF_ID,
|
||||
name: "UAT Tester",
|
||||
email: "uat-tester@groombook.dev",
|
||||
oidcSub: uatTesterOidcSub,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
});
|
||||
console.log(`✓ Created staff 'UAT Tester' (oidcSub: ${uatTesterOidcSub})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
||||
@@ -490,6 +544,90 @@ async function seedKnownUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Better-Auth email+password credentials for UAT accounts ──────────────────
|
||||
// Provisions Better-Auth user + account records so UAT testers can log in
|
||||
// via email+password (POST /api/auth/sign-in/email) instead of Authentik SSO.
|
||||
const uatPasswordAccounts = [
|
||||
{ email: "uat-super@groombook.dev", name: "UAT Super User", passwordEnv: "SEED_UAT_SUPER_PASSWORD", staffEmail: "uat-super@groombook.dev" },
|
||||
{ email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" },
|
||||
{ email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null },
|
||||
{ email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" },
|
||||
];
|
||||
|
||||
for (const acct of uatPasswordAccounts) {
|
||||
const password = process.env[acct.passwordEnv];
|
||||
if (!password) {
|
||||
console.warn(`⚠ Skipping ${acct.email} — ${acct.passwordEnv} not set`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Find or create the Better-Auth user
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.email, acct.email))
|
||||
.limit(1);
|
||||
|
||||
let userId: string;
|
||||
if (existingUser) {
|
||||
userId = existingUser.id;
|
||||
console.log(`✓ Better-Auth user '${acct.name}' already exists — skipping user creation`);
|
||||
} else {
|
||||
userId = uuid();
|
||||
await db.insert(schema.user).values({
|
||||
id: userId,
|
||||
name: acct.name,
|
||||
email: acct.email,
|
||||
emailVerified: true,
|
||||
});
|
||||
console.log(`✓ Created Better-Auth user '${acct.name}' (${acct.email})`);
|
||||
}
|
||||
|
||||
// 2. Check if credential account already exists
|
||||
const [existingAccount] = await db
|
||||
.select()
|
||||
.from(schema.account)
|
||||
.where(and(
|
||||
eq(schema.account.userId, userId),
|
||||
eq(schema.account.providerId, "credential")
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existingAccount) {
|
||||
console.log(`✓ Credential account for '${acct.email}' already exists — skipping`);
|
||||
} else {
|
||||
// Use Better-Auth's own hashPassword to guarantee parameter/encoding match.
|
||||
// better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64, salt as 16-byte random
|
||||
// hex string, key hex-encoded, format saltHex:keyHex.
|
||||
const { hashPassword } = await import("better-auth/crypto");
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
await db.insert(schema.account).values({
|
||||
id: uuid(),
|
||||
accountId: userId,
|
||||
providerId: "credential",
|
||||
userId,
|
||||
password: passwordHash,
|
||||
});
|
||||
console.log(`✓ Created credential account for '${acct.email}'`);
|
||||
}
|
||||
|
||||
// 3. Link staff record to Better-Auth user (for accounts that have staff records)
|
||||
if (acct.staffEmail) {
|
||||
const [existingStaff] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, acct.staffEmail))
|
||||
.limit(1);
|
||||
if (existingStaff && !existingStaff.userId) {
|
||||
await db.update(schema.staff)
|
||||
.set({ userId })
|
||||
.where(eq(schema.staff.id, existingStaff.id));
|
||||
console.log(`✓ Linked staff '${acct.staffEmail}' → Better-Auth user`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||
@@ -767,6 +905,11 @@ async function seed() {
|
||||
cutStyle: pick(cutStyles),
|
||||
shampooPreference: pick(shampoos),
|
||||
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
|
||||
coatType: pick(coatTypes),
|
||||
temperamentScore: pick(temperamentScores),
|
||||
temperamentFlags: pick(temperamentFlags),
|
||||
medicalAlerts: pick(medicalAlertsList),
|
||||
preferredCuts: pick(preferredCutsList),
|
||||
customFields: {},
|
||||
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
|
||||
});
|
||||
@@ -802,6 +945,11 @@ async function seed() {
|
||||
cutStyle: pet.cutStyle,
|
||||
shampooPreference: pet.shampooPreference,
|
||||
specialCareNotes: pet.specialCareNotes,
|
||||
coatType: pet.coatType,
|
||||
temperamentScore: pet.temperamentScore,
|
||||
temperamentFlags: pet.temperamentFlags,
|
||||
medicalAlerts: pet.medicalAlerts,
|
||||
preferredCuts: pet.preferredCuts,
|
||||
customFields: pet.customFields,
|
||||
image: pet.image,
|
||||
},
|
||||
@@ -824,13 +972,18 @@ async function seed() {
|
||||
petId: string;
|
||||
petName: string;
|
||||
petBreed: string;
|
||||
petCoatType: string;
|
||||
petTemperamentScore: number;
|
||||
petTemperamentFlags: string[];
|
||||
petMedicalAlerts: MedicalAlert[];
|
||||
petPreferredCuts: string[];
|
||||
}
|
||||
const uatClients: UatClient[] = [
|
||||
{ id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever" },
|
||||
{ id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever" },
|
||||
{ id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle" },
|
||||
{ id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog" },
|
||||
{ id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle" },
|
||||
{ id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever", petCoatType: "double", petTemperamentScore: 7, petTemperamentFlags: ["calm", "friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Breed Standard"] },
|
||||
{ id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever", petCoatType: "short", petTemperamentScore: 8, petTemperamentFlags: ["friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Bath & Brush", "Sanitary Trim"] },
|
||||
{ id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle", petCoatType: "curly", petTemperamentScore: 9, petTemperamentFlags: ["calm"], petMedicalAlerts: [{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }], petPreferredCuts: ["Teddy Bear Cut"] },
|
||||
{ id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog", petCoatType: "short", petTemperamentScore: 6, petTemperamentFlags: ["nippy"], petMedicalAlerts: [{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }], petPreferredCuts: ["Puppy Cut"] },
|
||||
{ id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle", petCoatType: "short", petTemperamentScore: 7, petTemperamentFlags: ["friendly", "energetic"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Full Groom", "Nail Trim"] },
|
||||
];
|
||||
|
||||
for (const uc of uatClients) {
|
||||
@@ -838,8 +991,26 @@ async function seed() {
|
||||
.values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address })
|
||||
.onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
|
||||
await db.insert(schema.pets)
|
||||
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) })
|
||||
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } });
|
||||
.values({
|
||||
id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed,
|
||||
weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"),
|
||||
coatType: uc.petCoatType,
|
||||
temperamentScore: uc.petTemperamentScore,
|
||||
temperamentFlags: uc.petTemperamentFlags,
|
||||
medicalAlerts: uc.petMedicalAlerts,
|
||||
preferredCuts: uc.petPreferredCuts,
|
||||
image: pick(demoPetImages),
|
||||
})
|
||||
.onConflictDoUpdate({ target: schema.pets.id, set: {
|
||||
clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed,
|
||||
weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"),
|
||||
coatType: uc.petCoatType,
|
||||
temperamentScore: uc.petTemperamentScore,
|
||||
temperamentFlags: uc.petTemperamentFlags,
|
||||
medicalAlerts: uc.petMedicalAlerts,
|
||||
preferredCuts: uc.petPreferredCuts,
|
||||
image: pick(demoPetImages),
|
||||
} });
|
||||
// Create one completed appointment for this client
|
||||
const apptId = uuid();
|
||||
const svcIdx = 0;
|
||||
@@ -1079,7 +1250,7 @@ async function seed() {
|
||||
const groomer = pick(groomers);
|
||||
const bather = bathers.length > 0 && rand() < 0.6 ? pick(bathers) : null;
|
||||
|
||||
let startTime = randDate(appointmentsBackDate, now);
|
||||
const startTime = randDate(appointmentsBackDate, now);
|
||||
startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
|
||||
const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000);
|
||||
const effectivePrice = svc.price;
|
||||
|
||||
@@ -22,10 +22,11 @@ import { searchRouter } from "./routes/search.js";
|
||||
import { getObject } from "./lib/s3.js";
|
||||
import { calendarRouter } from "./routes/calendar.js";
|
||||
import { setupRouter } from "./routes/setup.js";
|
||||
import { getDb, businessSettings, eq, staff } from "./db";
|
||||
import { getDb, businessSettings, eq, staff } from "./db/index.js";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
|
||||
import { devRouter } from "./routes/dev.js";
|
||||
import { bufferRulesRouter } from "./routes/buffer-rules.js";
|
||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||
import { startReminderScheduler } from "./services/reminders.js";
|
||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||
@@ -211,6 +212,7 @@ api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
|
||||
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
|
||||
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
||||
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
||||
api.use("/buffer-rules/*", requireRole("manager"));
|
||||
api.use("/admin/settings/*", requireSuperUser());
|
||||
api.use("/reports/*", requireRole("manager"));
|
||||
api.use("/invoices/*", requireRole("manager", "groomer"));
|
||||
@@ -268,6 +270,7 @@ api.route("/impersonation", impersonationRouter);
|
||||
api.route("/admin/settings", settingsRouter);
|
||||
api.route("/admin/auth-provider", authProviderRouter);
|
||||
api.route("/admin/seed", adminSeedRouter);
|
||||
api.route("/buffer-rules", bufferRulesRouter);
|
||||
api.route("/search", searchRouter);
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { getDb, authProviderConfig, eq } from "./db";
|
||||
import { decryptSecret } from "./db";
|
||||
import { getDb, authProviderConfig, eq } from "../db/index.js";
|
||||
import { decryptSecret } from "../db/index.js";
|
||||
import { sendEmail } from "../services/email.js";
|
||||
|
||||
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
||||
@@ -97,6 +97,9 @@ export async function initAuth(): Promise<void> {
|
||||
window: 10,
|
||||
storage: "memory",
|
||||
customRules: {
|
||||
"/sign-in/social": { max: 10, window: 60 },
|
||||
"/sign-in/email": { max: 10, window: 60 },
|
||||
"/sign-up/email": { max: 5, window: 60 },
|
||||
"/get-session": false,
|
||||
},
|
||||
},
|
||||
@@ -247,6 +250,9 @@ export async function initAuth(): Promise<void> {
|
||||
window: 10,
|
||||
storage: "memory",
|
||||
customRules: {
|
||||
"/sign-in/social": { max: 10, window: 60 },
|
||||
"/sign-in/email": { max: 10, window: 60 },
|
||||
"/sign-up/email": { max: 5, window: 60 },
|
||||
"/get-session": false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { getDb, impersonationAuditLogs } from "../db";
|
||||
import { getDb, impersonationAuditLogs } from "../db/index.js";
|
||||
import type { PortalEnv } from "./portalSession.js";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, impersonationSessions } from "../db";
|
||||
import { and, eq, getDb, impersonationSessions } from "../db/index.js";
|
||||
|
||||
export interface PortalEnv {
|
||||
Variables: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, sql, staff } from "../db";
|
||||
import { and, eq, getDb, sql, staff, user } from "../db/index.js";
|
||||
|
||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||
export type StaffRow = typeof staff.$inferSelect;
|
||||
@@ -110,6 +110,33 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Auto-provision: no staff record exists for this user at all, but a valid
|
||||
// Better-Auth user session exists (jwt.sub = user.id from user table).
|
||||
// Create a minimal groomer staff record on first login.
|
||||
const [userRow] = await db
|
||||
.select({ id: user.id, name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, jwt.sub))
|
||||
.limit(1);
|
||||
if (userRow) {
|
||||
const [newStaff] = await db
|
||||
.insert(staff)
|
||||
.values({
|
||||
name: userRow.name ?? jwt.email?.split("@")[0] ?? "Unknown",
|
||||
email: userRow.email ?? jwt.email ?? "",
|
||||
userId: jwt.sub,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
})
|
||||
.returning();
|
||||
if (!newStaff) {
|
||||
return c.json({ error: "Internal error: staff record creation failed" }, 500);
|
||||
}
|
||||
c.set("staff", newStaff);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||
403
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { eq, getDb, staff, clients, pets, services } from "./db";
|
||||
import { eq, getDb, staff, clients, pets, services } from "../../db/index.js";
|
||||
|
||||
export const adminSeedRouter = new Hono();
|
||||
|
||||
@@ -36,6 +36,19 @@ const DEMO_PET = {
|
||||
weightKg: "30.00",
|
||||
};
|
||||
|
||||
const UAT_CLIENT = {
|
||||
name: "UAT Customer",
|
||||
email: "uat-customer@groombook.dev",
|
||||
phone: "555-0100",
|
||||
address: "1 UAT Lane, Test City, CA 90210",
|
||||
status: "active" as const,
|
||||
};
|
||||
|
||||
const UAT_PETS = [
|
||||
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly" as const, weightKg: "20.00" },
|
||||
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "smooth" as const, weightKg: "30.00" },
|
||||
];
|
||||
|
||||
const DEMO_SERVICES = [
|
||||
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
|
||||
@@ -43,7 +56,7 @@ const DEMO_SERVICES = [
|
||||
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
|
||||
];
|
||||
|
||||
adminSeedRouter.post("/seed", async (c) => {
|
||||
adminSeedRouter.post("/", async (c) => {
|
||||
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
return c.json(
|
||||
@@ -128,6 +141,51 @@ adminSeedRouter.post("/seed", async (c) => {
|
||||
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
|
||||
}
|
||||
|
||||
// ── Client: UAT Customer ──────────────────────────────────────────────────
|
||||
const [existingUatClient] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.email, UAT_CLIENT.email));
|
||||
|
||||
let uatClientId: string;
|
||||
if (existingUatClient) {
|
||||
uatClientId = existingUatClient.id;
|
||||
results.push(`Client '${UAT_CLIENT.name}' already exists (id: ${uatClientId})`);
|
||||
} else {
|
||||
const [created] = await db.insert(clients).values(UAT_CLIENT).returning();
|
||||
uatClientId = created!.id;
|
||||
results.push(`Created client '${UAT_CLIENT.name}' (id: ${uatClientId})`);
|
||||
}
|
||||
|
||||
// ── Pets: UAT Customer's Pets ─────────────────────────────────────────────
|
||||
const existingUatPets = await db
|
||||
.select()
|
||||
.from(pets)
|
||||
.where(eq(pets.clientId, uatClientId));
|
||||
|
||||
for (const uatPet of UAT_PETS) {
|
||||
const existingPet = existingUatPets.find(
|
||||
(p) => p.name === uatPet.name && p.species === uatPet.species
|
||||
);
|
||||
if (existingPet) {
|
||||
results.push(`Pet '${uatPet.name}' already exists for UAT Customer (id: ${existingPet.id})`);
|
||||
} else {
|
||||
const [created] = await db
|
||||
.insert(pets)
|
||||
.values({
|
||||
clientId: uatClientId,
|
||||
name: uatPet.name,
|
||||
species: uatPet.species,
|
||||
breed: uatPet.breed,
|
||||
coatType: uatPet.coatType,
|
||||
weightKg: uatPet.weightKg,
|
||||
dateOfBirth: new Date("2019-01-01T00:00:00Z"),
|
||||
})
|
||||
.returning();
|
||||
results.push(`Created pet '${uatPet.name}' for UAT Customer (id: ${created!.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Seed complete",
|
||||
details: results,
|
||||
@@ -136,4 +194,4 @@ adminSeedRouter.post("/seed", async (c) => {
|
||||
staffOidcSub: KNOWN_STAFF.oidcSub,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
pets,
|
||||
services,
|
||||
staff,
|
||||
} from "../db";
|
||||
} from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const appointmentGroupsRouter = new Hono<AppEnv>();
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
reminderLogs,
|
||||
services,
|
||||
staff,
|
||||
} from "../db";
|
||||
} from "../db/index.js";
|
||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, authProviderConfig, encryptSecret } from "../db";
|
||||
import { eq, getDb, authProviderConfig, encryptSecret } from "../db/index.js";
|
||||
import { requireSuperUser } from "../middleware/rbac.js";
|
||||
import { reinitAuth } from "../lib/auth.js";
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
} from "../db";
|
||||
} from "../db/index.js";
|
||||
import {
|
||||
generateAvailableSlots,
|
||||
BUSINESS_START_HOUR,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, getDb, isNull } from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import { bufferRules, services } from "../db/index.js";
|
||||
|
||||
export const bufferRulesRouter = new Hono<AppEnv>();
|
||||
|
||||
const createBufferRuleSchema = z.object({
|
||||
serviceId: z.string().uuid(),
|
||||
sizeCategory: z
|
||||
.enum(["small", "medium", "large", "extra_large"])
|
||||
.optional(),
|
||||
coatType: z
|
||||
.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"])
|
||||
.optional(),
|
||||
bufferMinutes: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const updateBufferRuleSchema = z.object({
|
||||
bufferMinutes: z.number().int().positive(),
|
||||
});
|
||||
|
||||
// GET / — list all buffer rules, optionally filtered by serviceId
|
||||
bufferRulesRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const serviceId = c.req.query("serviceId");
|
||||
|
||||
const conditions = [];
|
||||
if (serviceId) conditions.push(eq(bufferRules.serviceId, serviceId));
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: bufferRules.id,
|
||||
serviceId: bufferRules.serviceId,
|
||||
sizeCategory: bufferRules.sizeCategory,
|
||||
coatType: bufferRules.coatType,
|
||||
bufferMinutes: bufferRules.bufferMinutes,
|
||||
createdAt: bufferRules.createdAt,
|
||||
updatedAt: bufferRules.updatedAt,
|
||||
serviceName: services.name,
|
||||
})
|
||||
.from(bufferRules)
|
||||
.innerJoin(services, eq(bufferRules.serviceId, services.id))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(bufferRules.createdAt);
|
||||
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
// POST / — create a buffer rule
|
||||
bufferRulesRouter.post(
|
||||
"/",
|
||||
zValidator("json", createBufferRuleSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// Validate serviceId exists
|
||||
const [svc] = await db
|
||||
.select({ id: services.id })
|
||||
.from(services)
|
||||
.where(eq(services.id, body.serviceId));
|
||||
if (!svc) return c.json({ error: "Service not found" }, 404);
|
||||
|
||||
// Check for duplicate (service + size + coat)
|
||||
const [existing] = await db
|
||||
.select({ id: bufferRules.id })
|
||||
.from(bufferRules)
|
||||
.where(
|
||||
and(
|
||||
eq(bufferRules.serviceId, body.serviceId),
|
||||
body.sizeCategory !== undefined
|
||||
? eq(bufferRules.sizeCategory, body.sizeCategory)
|
||||
: isNull(bufferRules.sizeCategory),
|
||||
body.coatType !== undefined
|
||||
? eq(bufferRules.coatType, body.coatType)
|
||||
: isNull(bufferRules.coatType)
|
||||
)
|
||||
);
|
||||
if (existing) return c.json({ error: "Duplicate rule for this service+size+coat combination" }, 409);
|
||||
|
||||
const [row] = await db
|
||||
.insert(bufferRules)
|
||||
.values({
|
||||
serviceId: body.serviceId,
|
||||
sizeCategory: body.sizeCategory ?? null,
|
||||
coatType: body.coatType ?? null,
|
||||
bufferMinutes: body.bufferMinutes,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(row, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /:id — update bufferMinutes only
|
||||
bufferRulesRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateBufferRuleSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.update(bufferRules)
|
||||
.set({ bufferMinutes: body.bufferMinutes, updatedAt: new Date() })
|
||||
.where(eq(bufferRules.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /:id — delete a buffer rule
|
||||
bufferRulesRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.delete(bufferRules)
|
||||
.where(eq(bufferRules.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
pets,
|
||||
services,
|
||||
staff,
|
||||
} from "../db";
|
||||
} from "../db/index.js";
|
||||
|
||||
export const calendarRouter = new Hono();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, clients, appointments } from "../db";
|
||||
import { and, eq, exists, getDb, or, clients, appointments } from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const clientsRouter = new Hono<AppEnv>();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { getDb, staff, clients, eq, sql } from "../db";
|
||||
import { getDb, staff, clients, eq, sql } from "../db/index.js";
|
||||
|
||||
const devRouter = new Hono();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "../db";
|
||||
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const groomingLogsRouter = new Hono<AppEnv>();
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
impersonationAuditLogs,
|
||||
clients,
|
||||
desc,
|
||||
} from "../db";
|
||||
} from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const impersonationRouter = new Hono<AppEnv>();
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
services,
|
||||
clients,
|
||||
sql,
|
||||
} from "../db";
|
||||
} from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const invoicesRouter = new Hono<AppEnv>();
|
||||
|
||||
+141
-1
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, pets, appointments } from "../db";
|
||||
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import {
|
||||
getPresignedUploadUrl,
|
||||
@@ -24,6 +24,16 @@ 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 });
|
||||
@@ -273,3 +283,133 @@ petsRouter.get("/:petId/photo", async (c) => {
|
||||
const url = await getPresignedGetUrl(pet.photoKey);
|
||||
return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt });
|
||||
});
|
||||
|
||||
// ─── Profile Summary ───────────────────────────────────────────────────────────
|
||||
|
||||
async function groomerLinkageCheck(
|
||||
db: ReturnType<typeof getDb>,
|
||||
clientId: string,
|
||||
staffRow: NonNullable<AppEnv["Variables"]["staff"]>
|
||||
): Promise<boolean> {
|
||||
const [linkage] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, clientId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return !!linkage;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /:id/profile-summary
|
||||
* Returns aggregated profile: basic pet fields + grooming history + visit stats + upcoming appointment.
|
||||
* Groomer RBAC: same visibility rules as GET /:id.
|
||||
*/
|
||||
petsRouter.get("/:id/profile-summary", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [row] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const hasLinkage = await groomerLinkageCheck(db, row.clientId, staffRow);
|
||||
if (!hasLinkage) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
// Recent grooming history: last 10, with staff name join
|
||||
const historyRows = await db
|
||||
.select({
|
||||
id: groomingVisitLogs.id,
|
||||
petId: groomingVisitLogs.petId,
|
||||
appointmentId: groomingVisitLogs.appointmentId,
|
||||
staffId: groomingVisitLogs.staffId,
|
||||
staffName: staff.name,
|
||||
cutStyle: groomingVisitLogs.cutStyle,
|
||||
productsUsed: groomingVisitLogs.productsUsed,
|
||||
notes: groomingVisitLogs.notes,
|
||||
groomedAt: groomingVisitLogs.groomedAt,
|
||||
createdAt: groomingVisitLogs.createdAt,
|
||||
})
|
||||
.from(groomingVisitLogs)
|
||||
.leftJoin(staff, eq(staff.id, groomingVisitLogs.staffId))
|
||||
.where(eq(groomingVisitLogs.petId, petId))
|
||||
.orderBy(desc(groomingVisitLogs.groomedAt))
|
||||
.limit(10);
|
||||
|
||||
const recentGroomingHistory = historyRows.map((r) => ({
|
||||
id: r.id,
|
||||
petId: r.petId,
|
||||
appointmentId: r.appointmentId,
|
||||
staffId: r.staffId,
|
||||
staffName: r.staffName,
|
||||
cutStyle: r.cutStyle,
|
||||
productsUsed: r.productsUsed,
|
||||
notes: r.notes,
|
||||
groomedAt: r.groomedAt?.toISOString() ?? null,
|
||||
createdAt: r.createdAt?.toISOString() ?? null,
|
||||
}));
|
||||
|
||||
const lastVisitDate = historyRows[0]?.groomedAt?.toISOString() ?? null;
|
||||
|
||||
// Completed appointment count for this pet
|
||||
const [{ count: visitCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(appointments)
|
||||
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")));
|
||||
|
||||
// Upcoming appointment: next scheduled or confirmed
|
||||
const [nextAppt] = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
serviceId: appointments.serviceId,
|
||||
staffId: appointments.staffId,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
status: appointments.status,
|
||||
serviceName: services.name,
|
||||
staffName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.leftJoin(services, eq(services.id, appointments.serviceId))
|
||||
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, petId),
|
||||
or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed")),
|
||||
gte(appointments.startTime, new Date())
|
||||
)
|
||||
)
|
||||
.orderBy(appointments.startTime)
|
||||
.limit(1);
|
||||
|
||||
const upcomingAppointment = nextAppt
|
||||
? {
|
||||
id: nextAppt.id,
|
||||
serviceId: nextAppt.serviceId,
|
||||
serviceName: nextAppt.serviceName,
|
||||
staffId: nextAppt.staffId,
|
||||
staffName: nextAppt.staffName,
|
||||
startTime: nextAppt.startTime?.toISOString() ?? null,
|
||||
endTime: nextAppt.endTime?.toISOString() ?? null,
|
||||
status: nextAppt.status,
|
||||
}
|
||||
: null;
|
||||
|
||||
return c.json({
|
||||
...row,
|
||||
recentGroomingHistory,
|
||||
lastVisitDate,
|
||||
visitCount,
|
||||
upcomingAppointment,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, inArray } from "../db";
|
||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "../db";
|
||||
import { eq, inArray } from "../db/index.js";
|
||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "../db/index.js";
|
||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
||||
import { portalAudit } from "../middleware/portalAudit.js";
|
||||
import type { PortalEnv } from "../middleware/portalSession.js";
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
invoiceTipSplits,
|
||||
services,
|
||||
staff,
|
||||
} from "../db";
|
||||
} from "../db/index.js";
|
||||
|
||||
export const reportsRouter = new Hono();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { and, eq, getDb, clients, ilike, or, pets } from "../db";
|
||||
import { and, eq, getDb, clients, ilike, or, pets } from "../db/index.js";
|
||||
|
||||
export const searchRouter = new Hono();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, services } from "../db";
|
||||
import { eq, getDb, services } from "../db/index.js";
|
||||
|
||||
export const servicesRouter = new Hono();
|
||||
|
||||
@@ -13,7 +13,9 @@ const createServiceSchema = z.object({
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const updateServiceSchema = createServiceSchema.partial();
|
||||
const updateServiceSchema = createServiceSchema.partial().extend({
|
||||
defaultBufferMinutes: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
servicesRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, businessSettings } from "../db";
|
||||
import { eq, getDb, businessSettings } from "../db/index.js";
|
||||
import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js";
|
||||
import { requireSuperUser } from "../middleware/rbac.js";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "../db";
|
||||
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq, getDb, ne, staff, appointments } from "../db";
|
||||
import { and, eq, getDb, ne, staff, appointments } from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const staffRouter = new Hono<AppEnv>();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import Stripe from "stripe";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, invoices } from "../db";
|
||||
import { eq, getDb, invoices } from "../db/index.js";
|
||||
import { getStripeClient } from "../services/payment.js";
|
||||
|
||||
export const webhooksRouter = new Hono();
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
} from "../db";
|
||||
} from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const waitlistRouter = new Hono<AppEnv>();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Stripe from "stripe";
|
||||
import { getDb, clients, eq, inArray, invoices } from "../db";
|
||||
import { getDb, clients, eq, inArray, invoices } from "../db/index.js";
|
||||
|
||||
let _stripe: Stripe | null | undefined;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
staff,
|
||||
reminderLogs,
|
||||
session,
|
||||
} from "../db";
|
||||
} from "../db/index.js";
|
||||
import {
|
||||
buildReminderEmail,
|
||||
sendEmail,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, getDb, waitlistEntries, clients, pets, services } from "../db";
|
||||
import { and, eq, getDb, waitlistEntries, clients, pets, services } from "../db/index.js";
|
||||
import { buildWaitlistNotificationEmail, sendEmail } from "./email.js";
|
||||
|
||||
export async function notifyWaitlistForAppointment(
|
||||
|
||||
@@ -26,6 +26,19 @@ export interface Client {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ─── Medical Alerts ────────────────────────────────────────────────────────────
|
||||
|
||||
export type AlertSeverity = "low" | "medium" | "high";
|
||||
|
||||
export interface MedicalAlert {
|
||||
type: string;
|
||||
description: string;
|
||||
severity: AlertSeverity;
|
||||
}
|
||||
|
||||
// ─── Pet Profile Summary ────────────────────────────────────────────────────
|
||||
|
||||
export type CoatType = "short" | "medium" | "long" | "double" | "wire" | "silky" | "curly" | "hairless";
|
||||
export interface Pet {
|
||||
id: string;
|
||||
clientId: string;
|
||||
@@ -42,10 +55,23 @@ 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;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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: "^_" }],
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -3,5 +3,42 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/schema.ts",
|
||||
out: "./migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "pets" ADD COLUMN "health_alerts" text;
|
||||
@@ -0,0 +1,31 @@
|
||||
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;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 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")
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,15 @@
|
||||
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;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 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
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 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");
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,7 @@
|
||||
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;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE appointments ADD COLUMN customer_notes TEXT;
|
||||
|
||||
CREATE INDEX idx_appointments_customer_notes ON appointments (client_id) WHERE customer_notes IS NOT NULL;
|
||||
@@ -0,0 +1,20 @@
|
||||
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';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE staff ADD COLUMN ical_token TEXT UNIQUE;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 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");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add image field to pets table for demo pet image support
|
||||
ALTER TABLE "pets" ADD COLUMN "image" text;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add logo_key column to business_settings for S3-based logo storage
|
||||
ALTER TABLE "business_settings" ADD COLUMN "logo_key" text;
|
||||
@@ -0,0 +1,14 @@
|
||||
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")
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
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);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 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
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
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");
|
||||
@@ -0,0 +1,11 @@
|
||||
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");
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 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");
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,72 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- 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");
|
||||
@@ -0,0 +1 @@
|
||||
-- no-op: journal entry exists but no schema change was needed
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Migration: 0033_add_services_default_buffer_minutes.sql
|
||||
-- Adds missing default_buffer_minutes column to services table.
|
||||
-- 0031_buffer_rules was applied to the DB but its journal entry was missing,
|
||||
-- so this ensures idempotent column addition for fresh DB restores.
|
||||
|
||||
ALTER TABLE "services" ADD COLUMN IF NOT EXISTS "default_buffer_minutes" integer DEFAULT 0 NOT NULL;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Migration: 0034_extend_pet_profile_columns.sql
|
||||
-- GRO-1850: Adds temperament_score, temperament_flags, medical_alerts,
|
||||
-- and preferred_cuts columns to the pets table.
|
||||
|
||||
ALTER TABLE "pets" ADD COLUMN "temperament_score" integer;
|
||||
ALTER TABLE "pets" ADD COLUMN "temperament_flags" jsonb DEFAULT '[]';
|
||||
ALTER TABLE "pets" ADD COLUMN "medical_alerts" jsonb DEFAULT '[]';
|
||||
ALTER TABLE "pets" ADD COLUMN "preferred_cuts" jsonb DEFAULT '[]';
|
||||
@@ -0,0 +1,485 @@
|
||||
{
|
||||
"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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user