Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91af671c4e |
@@ -1,11 +0,0 @@
|
||||
node_modules
|
||||
.git
|
||||
*.md
|
||||
.github
|
||||
apps/e2e
|
||||
apps/web/dist
|
||||
apps/api/dist
|
||||
packages/db/dist
|
||||
packages/types/dist
|
||||
.turbo
|
||||
screenshots/
|
||||
@@ -1,12 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -1,36 +0,0 @@
|
||||
# Groom Book — Environment Variables
|
||||
# Copy this file to .env and adjust values for your deployment.
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
DATABASE_URL=postgres://groombook:groombook@postgres:5432/groombook
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────────────────
|
||||
# Set AUTH_DISABLED=true to skip OIDC validation (useful for local dev/Docker).
|
||||
# In production, configure an Authentik instance and set these values.
|
||||
AUTH_DISABLED=false
|
||||
OIDC_ISSUER=https://authentik.example.com
|
||||
OIDC_AUDIENCE=groombook
|
||||
|
||||
# ── Setup Wizard ─────────────────────────────────────────────────────────────
|
||||
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
|
||||
# super user exists in the database. Useful in dev/test environments where the
|
||||
# database has data but the setup wizard would otherwise block access.
|
||||
SKIP_OOBE=false
|
||||
|
||||
# ── API ───────────────────────────────────────────────────────────────────────
|
||||
PORT=3000
|
||||
CORS_ORIGIN=http://localhost:8080
|
||||
|
||||
# ── Email Reminders (optional) ────────────────────────────────────────────────
|
||||
# Leave SMTP_HOST unset to disable email notifications entirely.
|
||||
# When configured, appointment confirmation and reminder emails are sent via SMTP.
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=user@example.com
|
||||
SMTP_PASS=password
|
||||
SMTP_FROM="Groom Book <noreply@example.com>"
|
||||
|
||||
# Hours before appointment to send reminder emails (defaults: 24 and 2)
|
||||
REMINDER_HOURS_EARLY=24
|
||||
REMINDER_HOURS_LATE=2
|
||||
@@ -1,99 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
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: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Lint
|
||||
run: pnpm 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: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Image
|
||||
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
|
||||
|
||||
- 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:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
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
|
||||
+6
-160
@@ -32,10 +32,10 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm --filter @groombook/api typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Lint
|
||||
run: pnpm --filter @groombook/api lint
|
||||
run: pnpm lint
|
||||
|
||||
test:
|
||||
name: Test
|
||||
@@ -56,39 +56,16 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm --filter @groombook/api test
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-typecheck, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm --filter @groombook/api build
|
||||
run: pnpm test
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Images
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
needs: [lint-typecheck, test]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -118,140 +95,9 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: runner
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/groombook/api:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/api:latest' || '' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push Migrate image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: migrate
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/groombook/migrate:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/migrate:latest' || '' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push Seed image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: seed
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/groombook/seed:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/seed:latest' || '' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push Reset image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: reset
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/groombook/reset:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/reset:latest' || '' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
cd:
|
||||
name: Update Infra Image Tags
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Generate infra repo token
|
||||
id: infra-token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ vars.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clone groombook/infra
|
||||
run: |
|
||||
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
||||
|
||||
- name: Install yq
|
||||
run: |
|
||||
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
|
||||
sudo chmod +x /usr/local/bin/yq
|
||||
|
||||
- name: Update dev overlay image tags
|
||||
env:
|
||||
TAG: ${{ needs.docker.outputs.tag }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="$(date -u +%Y.%m.%d)-${SHA::7}"
|
||||
fi
|
||||
export SHORT_SHA="${SHA::7}"
|
||||
echo "Updating dev overlay image tags to: $TAG"
|
||||
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
|
||||
cd /tmp/infra
|
||||
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/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/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"
|
||||
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
|
||||
fi
|
||||
|
||||
git -C /tmp/infra diff --stat
|
||||
|
||||
- name: Create PR on groombook/infra
|
||||
env:
|
||||
TAG: ${{ needs.docker.outputs.tag }}
|
||||
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
||||
run: |
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||
fi
|
||||
|
||||
cd /tmp/infra
|
||||
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/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}"
|
||||
|
||||
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
|
||||
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
|
||||
else
|
||||
PR_URL=$(gh pr create \
|
||||
--repo groombook/infra \
|
||||
--base main \
|
||||
--head "chore/update-image-tags-${TAG}" \
|
||||
--title "chore: deploy ${TAG} to dev" \
|
||||
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
||||
gh pr merge "$PR_URL" --merge
|
||||
fi
|
||||
|
||||
-17
@@ -4,20 +4,3 @@ dist/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
.turbo/
|
||||
coverage/
|
||||
minimax-output/
|
||||
|
||||
# Agent runtime artifacts — never commit
|
||||
.gh-token
|
||||
*.gh-token
|
||||
.config/gh/
|
||||
**/.config/gh/
|
||||
infra-repo
|
||||
infra-repo/
|
||||
**/instructions/.gh-token
|
||||
**/AGENT_HOME/**
|
||||
$AGENT_HOME/**
|
||||
.claude/
|
||||
.codex/
|
||||
|
||||
+2
-3
@@ -14,10 +14,9 @@ FROM deps AS builder
|
||||
RUN mkdir -p /home/node/.cache/node/corepack
|
||||
COPY packages/ packages/
|
||||
COPY src/ src/
|
||||
COPY tsconfig.json ./
|
||||
RUN pnpm --filter @groombook/types build && \
|
||||
pnpm --filter @groombook/db build && \
|
||||
pnpm --filter @groombook/api build
|
||||
pnpm build
|
||||
|
||||
# Runtime
|
||||
FROM node:20-alpine AS runner
|
||||
@@ -50,4 +49,4 @@ CMD ["pnpm", "db:seed"]
|
||||
|
||||
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
||||
FROM builder AS reset
|
||||
CMD ["pnpm", "db:reset"]
|
||||
CMD ["pnpm", "db:reset"]
|
||||
@@ -1,38 +1,2 @@
|
||||
# GroomBook API
|
||||
|
||||
GroomBook API service — extracted from the [groombook/app](https://github.com/groombook/app) monorepo.
|
||||
|
||||
## Overview
|
||||
|
||||
This repository contains the GroomBook API service, including:
|
||||
- REST API endpoints
|
||||
- Database schema and migrations (via Drizzle ORM)
|
||||
- Authentication (via Better Auth)
|
||||
- Background job handlers
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/ # API service source
|
||||
packages/db/ # Database schema, migrations, and utilities
|
||||
packages/types/ # Shared TypeScript types
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env # Fill in required environment variables
|
||||
pnpm --filter @groombook/api dev
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t ghcr.io/groombook/api:latest .
|
||||
docker run -p 3000:3000 ghcr.io/groombook/api:latest
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only
|
||||
# api
|
||||
GroomBook API service (extracted from groombook/app monorepo)
|
||||
|
||||
-218
@@ -1,218 +0,0 @@
|
||||
# UAT Playbook — GroomBook API
|
||||
|
||||
## Overview
|
||||
|
||||
GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet grooming management platform. Handles authentication, client/pet management, appointment scheduling, invoicing, payments, staff management, and the customer portal.
|
||||
|
||||
## Environments
|
||||
|
||||
| Environment | URL |
|
||||
|------------|-----|
|
||||
| Dev | `dev.groombook.dev` |
|
||||
| UAT | `uat.groombook.dev` |
|
||||
| Prod | `demo.groombook.app` |
|
||||
|
||||
## Pre-conditions
|
||||
|
||||
- UAT environment accessible and healthy
|
||||
- Test accounts seeded (manager, staff, client personas)
|
||||
- OIDC authentication provider configured
|
||||
- Seed data present (clients, pets, services, staff)
|
||||
|
||||
## Test Cases
|
||||
|
||||
### 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 |
|
||||
|
||||
### 4.2 Client Management
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-2.1 | List clients | GET /api/clients | 200 OK, list of active clients returned |
|
||||
| TC-API-2.2 | Get client details | GET /api/clients/{id} | 200 OK, client details returned |
|
||||
| TC-API-2.3 | Create client | POST /api/clients with valid data | 201 Created, client record created |
|
||||
| TC-API-2.4 | Update client | PATCH /api/clients/{id} with updated fields | 200 OK, client updated |
|
||||
| TC-API-2.5 | Disable client | PATCH /api/clients/{id} with status: "disabled" | 200 OK, client marked as disabled |
|
||||
| TC-API-2.6 | Delete client | DELETE /api/clients/{id}?confirm=true | 200 OK, client deleted (if no appointments) |
|
||||
|
||||
### 4.3 Pet Management
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-3.1 | List pets | GET /api/pets | 200 OK, list of pets returned |
|
||||
| TC-API-3.2 | Get pet details | GET /api/pets/{id} | 200 OK, pet details including history returned |
|
||||
| TC-API-3.3 | Add pet | POST /api/pets with valid pet data | 201 Created, pet record created |
|
||||
| TC-API-3.4 | Update pet | PATCH /api/pets/{id} with updated fields | 200 OK, pet updated |
|
||||
| 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 |
|
||||
|
||||
### 4.4 Appointment Scheduling
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-4.1 | List appointments | GET /api/appointments | 200 OK, list of appointments returned |
|
||||
| TC-API-4.2 | Get appointment details | GET /api/appointments/{id} | 200 OK, appointment details returned |
|
||||
| TC-API-4.3 | Create single appointment | POST /api/appointments with valid data | 201 Created, appointment created |
|
||||
| TC-API-4.4 | Create recurring appointment | POST /api/appointments with recurrence object | 201 Created, series of appointments created |
|
||||
| TC-API-4.5 | Update appointment | PATCH /api/appointments/{id} with updated fields | 200 OK, appointment updated |
|
||||
| TC-API-4.6 | Reschedule with cascade | PATCH /api/appointments/{id} with cascadeMode: "this_and_future" | 200 OK, future appointments updated |
|
||||
| TC-API-4.7 | Cancel appointment | DELETE /api/appointments/{id} | 200 OK, appointment marked as cancelled |
|
||||
| TC-API-4.8 | Confirm appointment | POST /api/appointments/{id}/confirm | 200 OK, confirmation status set to confirmed |
|
||||
| TC-API-4.9 | Cancel confirmation | POST /api/appointments/{id}/cancel | 200 OK, confirmation cancelled |
|
||||
| TC-API-4.10 | Conflict detection | POST /api/appointments with conflicting time | 409 Conflict, error message returned |
|
||||
|
||||
### 4.5 Services
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-5.1 | List services | GET /api/services | 200 OK, list of active services returned |
|
||||
| TC-API-5.2 | Get service details | GET /api/services/{id} | 200 OK, service details returned |
|
||||
| TC-API-5.3 | Create service | POST /api/services with valid data | 201 Created, service created |
|
||||
| TC-API-5.4 | Update service | PATCH /api/services/{id} with updated fields | 200 OK, service updated |
|
||||
| TC-API-5.5 | Delete service | DELETE /api/services/{id} | 200 OK, service deleted |
|
||||
|
||||
### 4.6 Staff Management
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-6.1 | List staff | GET /api/staff | 200 OK, list of active staff returned |
|
||||
| TC-API-6.2 | Get staff details | GET /api/staff/{id} | 200 OK, staff details returned |
|
||||
| TC-API-6.3 | Create staff | POST /api/staff with valid data | 201 Created, staff created |
|
||||
| TC-API-6.4 | Update staff | PATCH /api/staff/{id} with updated fields | 200 OK, staff updated |
|
||||
| TC-API-6.5 | Delete staff | DELETE /api/staff/{id} | 200 OK, staff deleted (if no appointments) |
|
||||
| TC-API-6.6 | RBAC check | Access manager-only endpoint as groomer | 403 Forbidden, error message returned |
|
||||
|
||||
### 4.7 Invoicing & Payments
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-7.1 | List invoices | GET /api/invoices | 200 OK, list of invoices returned |
|
||||
| TC-API-7.2 | Get invoice details | GET /api/invoices/{id} | 200 OK, invoice with line items returned |
|
||||
| TC-API-7.3 | Create invoice | POST /api/invoices with line items | 201 Created, invoice created |
|
||||
| TC-API-7.4 | Create from appointment | POST /api/invoices/from-appointment/{appointmentId} | 201 Created, invoice created from appointment |
|
||||
| TC-API-7.5 | Update invoice | PATCH /api/invoices/{id} with status and payment method | 200 OK, invoice updated |
|
||||
| TC-API-7.6 | Process payment via Stripe | POST /api/invoices/{id}/pay with Stripe data | 200 OK, payment intent created |
|
||||
| TC-API-7.7 | Save tip splits | POST /api/invoices/{id}/tip-splits with splits array | 201 Created, tip splits saved |
|
||||
| TC-API-7.8 | Process refund | POST /api/invoices/{id}/refund with amount | 200 OK, refund processed |
|
||||
|
||||
### 4.8 Customer Portal
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-8.1 | Access portal | GET /api/portal/me with valid session token | 200 OK, client profile returned |
|
||||
| TC-API-8.2 | View portal appointments | GET /api/portal/appointments | 200 OK, list of client's appointments returned |
|
||||
| TC-API-8.3 | Confirm appointment via portal | POST /api/portal/appointments/{id}/confirm | 200 OK, appointment confirmed |
|
||||
| TC-API-8.4 | Cancel appointment via portal | POST /api/portal/appointments/{id}/cancel | 200 OK, appointment cancelled |
|
||||
| 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 |
|
||||
|
||||
### 4.9 Waitlist
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-9.1 | List waitlist | GET /api/waitlist | 200 OK, list of waitlist entries returned |
|
||||
| TC-API-9.2 | Add to waitlist | POST /api/waitlist with client, pet, service | 201 Created, entry added |
|
||||
| TC-API-9.3 | Promote from waitlist | Create appointment from waitlist entry | 201 Created, appointment created, waitlist updated |
|
||||
|
||||
### 4.10 Search
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-10.1 | Global search clients | GET /api/search?q={client_name} | 200 OK, matching clients returned |
|
||||
| TC-API-10.2 | Global search pets | GET /api/search?q={pet_name} | 200 OK, matching pets with owners returned |
|
||||
| TC-API-10.3 | Search by email | GET /api/search?q={email} | 200 OK, matching client returned |
|
||||
| TC-API-10.4 | Search by phone | GET /api/search?q={phone} | 200 OK, matching client returned |
|
||||
|
||||
### 4.11 Reports
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-11.1 | Revenue summary | GET /api/reports/summary?from={date}&to={date} | 200 OK, revenue KPIs returned |
|
||||
| TC-API-11.2 | Revenue by period | GET /api/reports/revenue?groupBy=day | 200 OK, daily revenue breakdown returned |
|
||||
| TC-API-11.3 | Appointment analytics | GET /api/reports/appointments | 200 OK, appointment stats returned |
|
||||
| TC-API-11.4 | Service popularity | GET /api/reports/services | 200 OK, service usage stats returned |
|
||||
| TC-API-11.5 | Client retention | GET /api/reports/clients | 200 OK, new/returning/churn client data returned |
|
||||
| TC-API-11.6 | Tip splits report | GET /api/reports/tip-splits | 200 OK, tip earnings per staff returned |
|
||||
| TC-API-11.7 | Export revenue CSV | GET /api/reports/export.csv?type=revenue | 200 OK, CSV file downloaded |
|
||||
|
||||
### 4.12 Impersonation
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-12.1 | Start impersonation session | POST /api/impersonation/sessions with clientId | 201 Created, session token returned |
|
||||
| TC-API-12.2 | Get session details | GET /api/impersonation/sessions/{id} | 200 OK, session details returned |
|
||||
| TC-API-12.3 | Extend session | POST /api/impersonation/sessions/{id}/extend | 200 OK, session expiry extended |
|
||||
| TC-API-12.4 | End session | POST /api/impersonation/sessions/{id}/end | 200 OK, session marked as ended |
|
||||
| TC-API-12.5 | Log audit entry | POST /api/impersonation/sessions/{id}/log | 201 Created, audit log entry created |
|
||||
| TC-API-12.6 | View audit log | GET /api/impersonation/sessions/{id}/audit-log | 200 OK, audit trail returned |
|
||||
|
||||
### 4.13 Settings & Setup
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned |
|
||||
| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated |
|
||||
| TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored |
|
||||
| TC-API-13.4 | View logo | GET /api/admin/settings/logo | 200 OK, logo image returned |
|
||||
| TC-API-13.5 | Delete logo | DELETE /api/admin/settings/logo | 200 OK, logo removed |
|
||||
| TC-API-13.6 | Check setup status | GET /api/setup/status | 200 OK, setup needs returned |
|
||||
| TC-API-13.7 | Complete setup | POST /api/setup with business name | 201 Created, super user created |
|
||||
| TC-API-13.8 | Configure auth provider | POST /api/setup/auth-provider with OIDC config | 201 Created, auth provider configured |
|
||||
| TC-API-13.9 | Test auth provider | POST /api/setup/auth-provider/test with issuer URL | 200 OK, OIDC discovery successful |
|
||||
|
||||
### 4.14 Appointment Groups
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-14.1 | List appointment groups | GET /api/appointment-groups | 200 OK, list of groups returned |
|
||||
| TC-API-14.2 | Get group details | GET /api/appointment-groups/{id} | 200 OK, group with appointments returned |
|
||||
| TC-API-14.3 | Create group booking | POST /api/appointment-groups with client and pets | 201 Created, group and appointments created |
|
||||
| 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:**
|
||||
- All test cases execute without errors
|
||||
- Expected results match actual results
|
||||
- No regressions in previously working features
|
||||
- API responses have correct status codes and data structures
|
||||
- Authentication and authorization enforced correctly
|
||||
- Business rules (conflicts, validations) work as expected
|
||||
|
||||
**Fail:**
|
||||
- Any unexpected result or error
|
||||
- API returns incorrect status codes
|
||||
- Data integrity issues
|
||||
- Authentication/authorization bypass
|
||||
- Business rules not enforced
|
||||
- Severity documented with steps to reproduce and screenshot
|
||||
|
||||
## Update Policy
|
||||
|
||||
Any PR that changes user-facing behaviour MUST update this file. Test cases must be added, modified, or removed to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.4 — new appointment rescheduling flow").
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1,70 +0,0 @@
|
||||
CREATE TYPE "public"."appointment_status" AS ENUM('scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show');--> statement-breakpoint
|
||||
CREATE TYPE "public"."staff_role" AS ENUM('groomer', 'receptionist', 'manager');--> statement-breakpoint
|
||||
CREATE TABLE "appointments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"client_id" uuid NOT NULL,
|
||||
"pet_id" uuid NOT NULL,
|
||||
"service_id" uuid NOT NULL,
|
||||
"staff_id" uuid,
|
||||
"status" "appointment_status" DEFAULT 'scheduled' NOT NULL,
|
||||
"start_time" timestamp NOT NULL,
|
||||
"end_time" timestamp NOT NULL,
|
||||
"notes" text,
|
||||
"price_cents" integer,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "clients" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"email" text,
|
||||
"phone" text,
|
||||
"address" text,
|
||||
"notes" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pets" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"client_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"species" text NOT NULL,
|
||||
"breed" text,
|
||||
"weight_kg" numeric(5, 2),
|
||||
"date_of_birth" timestamp,
|
||||
"grooming_notes" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "services" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"base_price_cents" integer NOT NULL,
|
||||
"duration_minutes" integer NOT NULL,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "staff" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"oidc_sub" text,
|
||||
"role" "staff_role" DEFAULT 'groomer' NOT NULL,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "staff_email_unique" UNIQUE("email"),
|
||||
CONSTRAINT "staff_oidc_sub_unique" UNIQUE("oidc_sub")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_service_id_services_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."services"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pets" ADD CONSTRAINT "pets_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "pets" ADD COLUMN "health_alerts" text;
|
||||
@@ -1,31 +0,0 @@
|
||||
CREATE TYPE "public"."invoice_status" AS ENUM('draft', 'pending', 'paid', 'void');--> statement-breakpoint
|
||||
CREATE TYPE "public"."payment_method" AS ENUM('cash', 'card', 'check', 'other');--> statement-breakpoint
|
||||
CREATE TABLE "invoices" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"appointment_id" uuid,
|
||||
"client_id" uuid NOT NULL,
|
||||
"subtotal_cents" integer NOT NULL,
|
||||
"tax_cents" integer DEFAULT 0 NOT NULL,
|
||||
"tip_cents" integer DEFAULT 0 NOT NULL,
|
||||
"total_cents" integer NOT NULL,
|
||||
"status" "invoice_status" DEFAULT 'draft' NOT NULL,
|
||||
"payment_method" "payment_method",
|
||||
"paid_at" timestamp,
|
||||
"notes" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "invoice_line_items" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"invoice_id" uuid NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"quantity" integer DEFAULT 1 NOT NULL,
|
||||
"unit_price_cents" integer NOT NULL,
|
||||
"total_cents" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_appointment_id_appointments_id_fk" FOREIGN KEY ("appointment_id") REFERENCES "public"."appointments"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invoice_line_items" ADD CONSTRAINT "invoice_line_items_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Add recurring_series table to store recurrence patterns
|
||||
CREATE TABLE "recurring_series" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"frequency_weeks" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Extend appointments with series tracking
|
||||
ALTER TABLE "appointments" ADD COLUMN "series_id" uuid REFERENCES "recurring_series"("id") ON DELETE SET NULL;
|
||||
ALTER TABLE "appointments" ADD COLUMN "series_index" integer;
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Add email opt-out flag to clients
|
||||
ALTER TABLE "clients" ADD COLUMN "email_opt_out" boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Track sent reminders to prevent duplicate sends
|
||||
CREATE TABLE "reminder_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"appointment_id" uuid NOT NULL REFERENCES "appointments"("id") ON DELETE CASCADE,
|
||||
"reminder_type" text NOT NULL,
|
||||
"sent_at" timestamp DEFAULT now() NOT NULL,
|
||||
UNIQUE ("appointment_id", "reminder_type")
|
||||
);
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Appointment groups: link multiple appointments from the same client visit.
|
||||
-- Each appointment in a group is for a different pet and may have a different groomer.
|
||||
CREATE TABLE appointment_groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE RESTRICT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Link appointments to a group (nullable — non-grouped appointments are unaffected)
|
||||
ALTER TABLE appointments ADD COLUMN group_id UUID REFERENCES appointment_groups(id) ON DELETE SET NULL;
|
||||
@@ -1,30 +0,0 @@
|
||||
-- Extend pet profiles with grooming-specific attributes (closes groombook/groombook#13)
|
||||
ALTER TABLE "pets"
|
||||
ADD COLUMN "cut_style" text,
|
||||
ADD COLUMN "shampoo_preference" text,
|
||||
ADD COLUMN "special_care_notes" text,
|
||||
ADD COLUMN "custom_fields" jsonb DEFAULT '{}' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "grooming_visit_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"pet_id" uuid NOT NULL,
|
||||
"appointment_id" uuid,
|
||||
"staff_id" uuid,
|
||||
"cut_style" text,
|
||||
"products_used" text,
|
||||
"notes" text,
|
||||
"groomed_at" timestamp NOT NULL DEFAULT now(),
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "grooming_visit_logs"
|
||||
ADD CONSTRAINT "grooming_visit_logs_pet_id_pets_id_fk"
|
||||
FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "grooming_visit_logs"
|
||||
ADD CONSTRAINT "grooming_visit_logs_appointment_id_appointments_id_fk"
|
||||
FOREIGN KEY ("appointment_id") REFERENCES "public"."appointments"("id") ON DELETE set null ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "grooming_visit_logs"
|
||||
ADD CONSTRAINT "grooming_visit_logs_staff_id_staff_id_fk"
|
||||
FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;
|
||||
@@ -1,25 +0,0 @@
|
||||
-- Add bather/assistant staff tracking to appointments and tip split ledger (closes groombook/groombook#12)
|
||||
|
||||
-- Secondary staff member (e.g., bather) who assisted the primary groomer
|
||||
ALTER TABLE "appointments"
|
||||
ADD COLUMN "bather_staff_id" uuid REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Stores per-staff tip allocations calculated when an invoice is paid
|
||||
CREATE TABLE "invoice_tip_splits" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"invoice_id" uuid NOT NULL,
|
||||
"staff_id" uuid,
|
||||
"staff_name" text NOT NULL,
|
||||
"share_pct" numeric(5, 2) NOT NULL,
|
||||
"share_cents" integer NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "invoice_tip_splits"
|
||||
ADD CONSTRAINT "invoice_tip_splits_invoice_id_invoices_id_fk"
|
||||
FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "invoice_tip_splits"
|
||||
ADD CONSTRAINT "invoice_tip_splits_staff_id_staff_id_fk"
|
||||
FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;
|
||||
@@ -1,15 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS "business_settings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"business_name" text DEFAULT 'GroomBook' NOT NULL,
|
||||
"logo_base64" text,
|
||||
"logo_mime_type" text,
|
||||
"primary_color" text DEFAULT '#4f8a6f' NOT NULL,
|
||||
"accent_color" text DEFAULT '#8b7355' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Seed a default row so GET always returns something
|
||||
INSERT INTO "business_settings" ("business_name", "primary_color", "accent_color")
|
||||
VALUES ('GroomBook', '#4f8a6f', '#8b7355')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Add client status (soft-delete support)
|
||||
CREATE TYPE "client_status" AS ENUM ('active', 'disabled');
|
||||
|
||||
ALTER TABLE "clients"
|
||||
ADD COLUMN "status" "client_status" NOT NULL DEFAULT 'active',
|
||||
ADD COLUMN "disabled_at" timestamp;
|
||||
@@ -1,26 +0,0 @@
|
||||
-- Create impersonation_session_status enum and tables
|
||||
CREATE TYPE "impersonation_session_status" AS ENUM ('active', 'ended', 'expired');
|
||||
|
||||
CREATE TABLE "impersonation_sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"staff_id" uuid NOT NULL,
|
||||
"client_id" uuid NOT NULL,
|
||||
"reason" text,
|
||||
"status" "impersonation_session_status" DEFAULT 'active' NOT NULL,
|
||||
"started_at" timestamp DEFAULT now() NOT NULL,
|
||||
"ended_at" timestamp,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "impersonation_sessions_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "staff"("id") ON DELETE restrict,
|
||||
CONSTRAINT "impersonation_sessions_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE restrict
|
||||
);
|
||||
|
||||
CREATE TABLE "impersonation_audit_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"session_id" uuid NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"page_visited" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "impersonation_audit_logs_session_id_impersonation_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "impersonation_sessions"("id") ON DELETE cascade
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Add indexes on impersonation tables to prevent full table scans
|
||||
-- Ref: GitHub #95
|
||||
|
||||
CREATE INDEX "impersonation_sessions_staff_id_status_idx" ON "impersonation_sessions" USING btree ("staff_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "impersonation_sessions_client_id_idx" ON "impersonation_sessions" USING btree ("client_id");--> statement-breakpoint
|
||||
CREATE INDEX "impersonation_audit_logs_session_id_idx" ON "impersonation_audit_logs" USING btree ("session_id");
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Add photo storage columns to pets table
|
||||
-- Ref: GitHub #93
|
||||
|
||||
ALTER TABLE "pets" ADD COLUMN "photo_key" text;--> statement-breakpoint
|
||||
ALTER TABLE "pets" ADD COLUMN "photo_uploaded_at" timestamp;
|
||||
@@ -1,7 +0,0 @@
|
||||
ALTER TABLE appointments
|
||||
ADD COLUMN confirmation_status TEXT NOT NULL DEFAULT 'pending',
|
||||
ADD COLUMN confirmed_at TIMESTAMPTZ,
|
||||
ADD COLUMN cancelled_at TIMESTAMPTZ,
|
||||
ADD COLUMN confirmation_token TEXT UNIQUE;
|
||||
|
||||
CREATE INDEX idx_appointments_confirmation_token ON appointments (confirmation_token) WHERE confirmation_token IS NOT NULL;
|
||||
@@ -1,3 +0,0 @@
|
||||
ALTER TABLE appointments ADD COLUMN customer_notes TEXT;
|
||||
|
||||
CREATE INDEX idx_appointments_customer_notes ON appointments (client_id) WHERE customer_notes IS NOT NULL;
|
||||
@@ -1,20 +0,0 @@
|
||||
CREATE TYPE waitlist_status AS ENUM ('active', 'notified', 'expired', 'cancelled');
|
||||
|
||||
CREATE TABLE waitlist_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
pet_id UUID NOT NULL REFERENCES pets(id) ON DELETE CASCADE,
|
||||
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||
preferred_date DATE NOT NULL,
|
||||
preferred_time TIME NOT NULL,
|
||||
status waitlist_status NOT NULL DEFAULT 'active',
|
||||
notified_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_waitlist_client_id ON waitlist_entries (client_id);
|
||||
CREATE INDEX idx_waitlist_preferred_date ON waitlist_entries (preferred_date);
|
||||
CREATE INDEX idx_waitlist_status ON waitlist_entries (status) WHERE status = 'active';
|
||||
CREATE UNIQUE INDEX idx_waitlist_active_unique ON waitlist_entries (client_id, pet_id, service_id, preferred_date, preferred_time) WHERE status = 'active';
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE staff ADD COLUMN ical_token TEXT UNIQUE;
|
||||
@@ -1,49 +0,0 @@
|
||||
-- Better-Auth required tables for session-based authentication
|
||||
CREATE TABLE "user" (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
image TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE "session" (
|
||||
id TEXT PRIMARY KEY,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE "account" (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
id_token TEXT,
|
||||
access_token_expires_at TIMESTAMPTZ,
|
||||
refresh_token_expires_at TIMESTAMPTZ,
|
||||
scope TEXT,
|
||||
password TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE "verification" (
|
||||
id TEXT PRIMARY KEY,
|
||||
identifier TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Link staff records to auth identity
|
||||
ALTER TABLE staff ADD COLUMN user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL;
|
||||
@@ -1,14 +0,0 @@
|
||||
-- Backfill staff.user_id for staff records created before Better-Auth integration.
|
||||
-- Staff records that predate this migration have user_id = NULL; the resolveStaffMiddleware
|
||||
-- now falls back to staff.id (dev mode) and oidcSub (production) so these records still work.
|
||||
-- This migration populates user_id for the known demo/dev staff seeded by seed.ts.
|
||||
|
||||
-- Create demo Better-Auth users for seeded staff (these match the ba-user-* IDs used in tests)
|
||||
INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
|
||||
VALUES ('ba-user-manager', 'Demo Manager', 'demo-manager@groombook.dev', true, NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Link the demo manager staff record to the Better-Auth user
|
||||
UPDATE staff
|
||||
SET user_id = 'ba-user-manager', updated_at = NOW()
|
||||
WHERE oidc_sub = 'demo-manager-001' AND user_id IS NULL;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Clean up existing duplicate services before adding unique constraint.
|
||||
-- Keep the row with the lowest id per name; delete all others.
|
||||
DELETE FROM services WHERE id NOT IN (
|
||||
SELECT (MIN(id::text))::uuid FROM services GROUP BY name
|
||||
);
|
||||
|
||||
ALTER TABLE "services" ADD CONSTRAINT "services_name_unique" UNIQUE("name");
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Add image field to pets table for demo pet image support
|
||||
ALTER TABLE "pets" ADD COLUMN "image" text;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Add logo_key column to business_settings for S3-based logo storage
|
||||
ALTER TABLE "business_settings" ADD COLUMN "logo_key" text;
|
||||
@@ -1,14 +0,0 @@
|
||||
CREATE TABLE "auth_provider_config" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"display_name" text NOT NULL,
|
||||
"issuer_url" text NOT NULL,
|
||||
"internal_base_url" text,
|
||||
"client_id" text NOT NULL,
|
||||
"client_secret" text NOT NULL,
|
||||
"scopes" text DEFAULT 'openid profile email' NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "auth_provider_config_provider_id_unique" UNIQUE("provider_id")
|
||||
);
|
||||
@@ -1,5 +0,0 @@
|
||||
CREATE INDEX idx_invoices_client_id ON invoices(client_id);
|
||||
CREATE INDEX idx_invoices_status ON invoices(status);
|
||||
CREATE INDEX idx_invoices_created_at ON invoices(created_at);
|
||||
CREATE INDEX idx_invoice_line_items_invoice_id ON invoice_line_items(invoice_id);
|
||||
CREATE INDEX idx_invoice_tip_splits_invoice_id ON invoice_tip_splits(invoice_id);
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Better-Auth rate limiting table (GRO-574)
|
||||
CREATE TABLE "rate_limit" (
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
count INTEGER NOT NULL,
|
||||
last_request BIGINT NOT NULL
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;
|
||||
ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");
|
||||
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;
|
||||
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;
|
||||
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;
|
||||
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
|
||||
@@ -1,11 +0,0 @@
|
||||
CREATE TABLE "refunds" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"invoice_id" uuid NOT NULL REFERENCES "invoices"("id") ON DELETE RESTRICT,
|
||||
"stripe_refund_id" text NOT NULL,
|
||||
"idempotency_key" text UNIQUE,
|
||||
"amount_cents" integer,
|
||||
"created_at" timestamp NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX "idx_refunds_invoice_id" ON "refunds"("invoice_id");
|
||||
CREATE INDEX "idx_refunds_idempotency_key" ON "refunds"("idempotency_key");
|
||||
@@ -1,15 +0,0 @@
|
||||
-- SMS opt-in fields for clients (idempotent)
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_in" boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_date" timestamp;
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_out_date" timestamp;
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_text" text;
|
||||
|
||||
-- Add channel column to reminder_logs with default 'email' (idempotent)
|
||||
ALTER TABLE "reminder_logs" ADD COLUMN IF NOT EXISTS "channel" text NOT NULL DEFAULT 'email';
|
||||
|
||||
-- Drop old unique constraints if they exist (idempotent)
|
||||
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_key";
|
||||
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_unique";
|
||||
|
||||
-- Add new unique constraint with channel
|
||||
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel");
|
||||
@@ -1,20 +0,0 @@
|
||||
-- Migration: 0029_db_indexes_constraints.sql
|
||||
-- Add missing indexes on appointments, pets, clients tables and NOT NULL constraint on clients.email
|
||||
|
||||
-- Backfill NULL emails before setting NOT NULL
|
||||
UPDATE clients SET email = concat('unknown-', id::text, '@placeholder.local') WHERE email IS NULL;
|
||||
|
||||
-- Add indexes on appointments table
|
||||
CREATE INDEX idx_appointments_client_id ON appointments(client_id);
|
||||
CREATE INDEX idx_appointments_staff_id ON appointments(staff_id);
|
||||
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
|
||||
CREATE INDEX idx_appointments_status ON appointments(status);
|
||||
|
||||
-- Add index on pets table
|
||||
CREATE INDEX idx_pets_client_id ON pets(client_id);
|
||||
|
||||
-- Add index on clients table
|
||||
CREATE INDEX idx_clients_email ON clients(email);
|
||||
|
||||
-- Set NOT NULL on clients.email (after backfill)
|
||||
ALTER TABLE clients ALTER COLUMN email SET NOT NULL;
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Migration: 0030_extended_pet_profile
|
||||
-- Adds extended profile fields to the pets table
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE pets ADD COLUMN coat_type text;
|
||||
ALTER TABLE pets ADD COLUMN temperament_score integer;
|
||||
ALTER TABLE pets ADD COLUMN temperament_flags jsonb DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE pets ADD COLUMN medical_alerts jsonb DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE pets ADD COLUMN preferred_cuts jsonb DEFAULT '[]'::jsonb;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,485 +0,0 @@
|
||||
{
|
||||
"id": "477cddf9-970f-41c5-9cad-c1ed48c2bedf",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.appointments": {
|
||||
"name": "appointments",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pet_id": {
|
||||
"name": "pet_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"service_id": {
|
||||
"name": "service_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"staff_id": {
|
||||
"name": "staff_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "appointment_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'scheduled'"
|
||||
},
|
||||
"start_time": {
|
||||
"name": "start_time",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"end_time": {
|
||||
"name": "end_time",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"price_cents": {
|
||||
"name": "price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"appointments_client_id_clients_id_fk": {
|
||||
"name": "appointments_client_id_clients_id_fk",
|
||||
"tableFrom": "appointments",
|
||||
"tableTo": "clients",
|
||||
"columnsFrom": [
|
||||
"client_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "restrict",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"appointments_pet_id_pets_id_fk": {
|
||||
"name": "appointments_pet_id_pets_id_fk",
|
||||
"tableFrom": "appointments",
|
||||
"tableTo": "pets",
|
||||
"columnsFrom": [
|
||||
"pet_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "restrict",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"appointments_service_id_services_id_fk": {
|
||||
"name": "appointments_service_id_services_id_fk",
|
||||
"tableFrom": "appointments",
|
||||
"tableTo": "services",
|
||||
"columnsFrom": [
|
||||
"service_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "restrict",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"appointments_staff_id_staff_id_fk": {
|
||||
"name": "appointments_staff_id_staff_id_fk",
|
||||
"tableFrom": "appointments",
|
||||
"tableTo": "staff",
|
||||
"columnsFrom": [
|
||||
"staff_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.clients": {
|
||||
"name": "clients",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"address": {
|
||||
"name": "address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pets": {
|
||||
"name": "pets",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"species": {
|
||||
"name": "species",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"breed": {
|
||||
"name": "breed",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"weight_kg": {
|
||||
"name": "weight_kg",
|
||||
"type": "numeric(5, 2)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"date_of_birth": {
|
||||
"name": "date_of_birth",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"grooming_notes": {
|
||||
"name": "grooming_notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"pets_client_id_clients_id_fk": {
|
||||
"name": "pets_client_id_clients_id_fk",
|
||||
"tableFrom": "pets",
|
||||
"tableTo": "clients",
|
||||
"columnsFrom": [
|
||||
"client_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.services": {
|
||||
"name": "services",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"base_price_cents": {
|
||||
"name": "base_price_cents",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"duration_minutes": {
|
||||
"name": "duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"active": {
|
||||
"name": "active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.staff": {
|
||||
"name": "staff",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"oidc_sub": {
|
||||
"name": "oidc_sub",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "staff_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'groomer'"
|
||||
},
|
||||
"active": {
|
||||
"name": "active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"staff_email_unique": {
|
||||
"name": "staff_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"staff_oidc_sub_unique": {
|
||||
"name": "staff_oidc_sub_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"oidc_sub"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.appointment_status": {
|
||||
"name": "appointment_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"scheduled",
|
||||
"confirmed",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"no_show"
|
||||
]
|
||||
},
|
||||
"public.staff_role": {
|
||||
"name": "staff_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"groomer",
|
||||
"receptionist",
|
||||
"manager"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,504 +0,0 @@
|
||||
{
|
||||
"id": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97",
|
||||
"prevId": "5983a2e9-f185-4f8a-a73f-5a7c0a0eea9c",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
|
||||
"account_id": { "name": "account_id", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"provider_id": { "name": "provider_id", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"access_token": { "name": "access_token", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"id_token": { "name": "id_token", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"access_token_expires_at": { "name": "access_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"refresh_token_expires_at": { "name": "refresh_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"scope": { "name": "scope", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"password": { "name": "password", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": { "account_user_id_user_id_fk": { "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.appointment_groups": {
|
||||
"name": "appointment_groups",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": { "appointment_groups_client_id_clients_id_fk": { "name": "appointment_groups_client_id_clients_id_fk", "tableFrom": "appointment_groups", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.appointments": {
|
||||
"name": "appointments",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"bather_staff_id": { "name": "bather_staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"status": { "name": "status", "type": "appointment_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'scheduled'" },
|
||||
"start_time": { "name": "start_time", "type": "timestamp", "primaryKey": false, "notNull": true },
|
||||
"end_time": { "name": "end_time", "type": "timestamp", "primaryKey": false, "notNull": true },
|
||||
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"price_cents": { "name": "price_cents", "type": "integer", "primaryKey": false, "notNull": false },
|
||||
"series_id": { "name": "series_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"series_index": { "name": "series_index", "type": "integer", "primaryKey": false, "notNull": false },
|
||||
"group_id": { "name": "group_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"confirmation_status": { "name": "confirmation_status", "type": "text", "primaryKey": false, "notNull": true, "default": "'pending'" },
|
||||
"confirmed_at": { "name": "confirmed_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"cancelled_at": { "name": "cancelled_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"confirmation_token": { "name": "confirmation_token", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"customer_notes": { "name": "customer_notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"appointments_client_id_clients_id_fk": { "name": "appointments_client_id_clients_id_fk", "tableFrom": "appointments", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
|
||||
"appointments_pet_id_pets_id_fk": { "name": "appointments_pet_id_pets_id_fk", "tableFrom": "appointments", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
|
||||
"appointments_service_id_services_id_fk": { "name": "appointments_service_id_services_id_fk", "tableFrom": "appointments", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
|
||||
"appointments_staff_id_staff_id_fk": { "name": "appointments_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
|
||||
"appointments_bather_staff_id_staff_id_fk": { "name": "appointments_bather_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["bather_staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
|
||||
"appointments_series_id_recurring_series_id_fk": { "name": "appointments_series_id_recurring_series_id_fk", "tableFrom": "appointments", "tableTo": "recurring_series", "columnsFrom": ["series_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
|
||||
"appointments_group_id_appointment_groups_id_fk": { "name": "appointments_group_id_appointment_groups_id_fk", "tableFrom": "appointments", "tableTo": "appointment_groups", "columnsFrom": ["group_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "appointments_confirmation_token_unique": { "name": "appointments_confirmation_token_unique", "nullsNotDistinct": false, "columns": ["confirmation_token"] } },
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.business_settings": {
|
||||
"name": "business_settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"business_name": { "name": "business_name", "type": "text", "primaryKey": false, "notNull": true, "default": "'GroomBook'" },
|
||||
"logo_base64": { "name": "logo_base64", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"logo_mime_type": { "name": "logo_mime_type", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"primary_color": { "name": "primary_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#4f8a6f'" },
|
||||
"accent_color": { "name": "accent_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#8b7355'" },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.clients": {
|
||||
"name": "clients",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"phone": { "name": "phone", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"address": { "name": "address", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"email_opt_out": { "name": "email_opt_out", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
|
||||
"status": { "name": "status", "type": "client_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
|
||||
"disabled_at": { "name": "disabled_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.grooming_visit_logs": {
|
||||
"name": "grooming_visit_logs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"products_used": { "name": "products_used", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"groomed_at": { "name": "groomed_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"grooming_visit_logs_pet_id_pets_id_fk": { "name": "grooming_visit_logs_pet_id_pets_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
|
||||
"grooming_visit_logs_appointment_id_appointments_id_fk": { "name": "grooming_visit_logs_appointment_id_appointments_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
|
||||
"grooming_visit_logs_staff_id_staff_id_fk": { "name": "grooming_visit_logs_staff_id_staff_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.impersonation_audit_logs": {
|
||||
"name": "impersonation_audit_logs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"session_id": { "name": "session_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"action": { "name": "action", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"page_visited": { "name": "page_visited", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": { "impersonation_audit_logs_session_id_idx": { "name": "impersonation_audit_logs_session_id_idx", "columns": [{ "expression": "session_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } },
|
||||
"foreignKeys": { "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", "tableFrom": "impersonation_audit_logs", "tableTo": "impersonation_sessions", "columnsFrom": ["session_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.impersonation_sessions": {
|
||||
"name": "impersonation_sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"reason": { "name": "reason", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"status": { "name": "status", "type": "impersonation_session_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
|
||||
"started_at": { "name": "started_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"ended_at": { "name": "ended_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {
|
||||
"impersonation_sessions_staff_id_status_idx": { "name": "impersonation_sessions_staff_id_status_idx", "columns": [{ "expression": "staff_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
|
||||
"impersonation_sessions_client_id_idx": { "name": "impersonation_sessions_client_id_idx", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
|
||||
},
|
||||
"foreignKeys": {
|
||||
"impersonation_sessions_staff_id_staff_id_fk": { "name": "impersonation_sessions_staff_id_staff_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
|
||||
"impersonation_sessions_client_id_clients_id_fk": { "name": "impersonation_sessions_client_id_clients_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.invoice_line_items": {
|
||||
"name": "invoice_line_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"quantity": { "name": "quantity", "type": "integer", "primaryKey": false, "notNull": true, "default": 1 },
|
||||
"unit_price_cents": { "name": "unit_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": { "invoice_line_items_invoice_id_invoices_id_fk": { "name": "invoice_line_items_invoice_id_invoices_id_fk", "tableFrom": "invoice_line_items", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.invoice_tip_splits": {
|
||||
"name": "invoice_tip_splits",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"staff_name": { "name": "staff_name", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"share_pct": { "name": "share_pct", "type": "numeric(5, 2)", "primaryKey": false, "notNull": true },
|
||||
"share_cents": { "name": "share_cents", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"invoice_tip_splits_invoice_id_invoices_id_fk": { "name": "invoice_tip_splits_invoice_id_invoices_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
|
||||
"invoice_tip_splits_staff_id_staff_id_fk": { "name": "invoice_tip_splits_staff_id_staff_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.invoices": {
|
||||
"name": "invoices",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"subtotal_cents": { "name": "subtotal_cents", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"tax_cents": { "name": "tax_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
|
||||
"tip_cents": { "name": "tip_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
|
||||
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"status": { "name": "status", "type": "invoice_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'draft'" },
|
||||
"payment_method": { "name": "payment_method", "type": "payment_method", "typeSchema": "public", "primaryKey": false, "notNull": false },
|
||||
"paid_at": { "name": "paid_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"invoices_appointment_id_appointments_id_fk": { "name": "invoices_appointment_id_appointments_id_fk", "tableFrom": "invoices", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
|
||||
"invoices_client_id_clients_id_fk": { "name": "invoices_client_id_clients_id_fk", "tableFrom": "invoices", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pets": {
|
||||
"name": "pets",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"species": { "name": "species", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"breed": { "name": "breed", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"weight_kg": { "name": "weight_kg", "type": "numeric(5, 2)", "primaryKey": false, "notNull": false },
|
||||
"date_of_birth": { "name": "date_of_birth", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"health_alerts": { "name": "health_alerts", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"grooming_notes": { "name": "grooming_notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"shampoo_preference": { "name": "shampoo_preference", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"special_care_notes": { "name": "special_care_notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"custom_fields": { "name": "custom_fields", "type": "jsonb", "primaryKey": false, "notNull": true, "default": "'{}'::jsonb" },
|
||||
"photo_key": { "name": "photo_key", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"photo_uploaded_at": { "name": "photo_uploaded_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"image": { "name": "image", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": { "pets_client_id_clients_id_fk": { "name": "pets_client_id_clients_id_fk", "tableFrom": "pets", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.recurring_series": {
|
||||
"name": "recurring_series",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"frequency_weeks": { "name": "frequency_weeks", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.reminder_logs": {
|
||||
"name": "reminder_logs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"reminder_type": { "name": "reminder_type", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"sent_at": { "name": "sent_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": { "reminder_logs_appointment_id_appointments_id_fk": { "name": "reminder_logs_appointment_id_appointments_id_fk", "tableFrom": "reminder_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "reminder_logs_appointment_id_reminder_type_unique": { "name": "reminder_logs_appointment_id_reminder_type_unique", "nullsNotDistinct": false, "columns": ["appointment_id", "reminder_type"] } },
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.services": {
|
||||
"name": "services",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"base_price_cents": { "name": "base_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"duration_minutes": { "name": "duration_minutes", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "services_name_unique": { "name": "services_name_unique", "nullsNotDistinct": false, "columns": ["name"] } },
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
|
||||
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
|
||||
"token": { "name": "token", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"ip_address": { "name": "ip_address", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": { "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, "columns": ["token"] } },
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.staff": {
|
||||
"name": "staff",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"oidc_sub": { "name": "oidc_sub", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"role": { "name": "role", "type": "staff_role", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'groomer'" },
|
||||
"is_super_user": { "name": "is_super_user", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
|
||||
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
|
||||
"ical_token": { "name": "ical_token", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": { "staff_user_id_user_id_fk": { "name": "staff_user_id_user_id_fk", "tableFrom": "staff", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"staff_email_unique": { "name": "staff_email_unique", "nullsNotDistinct": false, "columns": ["email"] },
|
||||
"staff_oidc_sub_unique": { "name": "staff_oidc_sub_unique", "nullsNotDistinct": false, "columns": ["oidc_sub"] },
|
||||
"staff_ical_token_unique": { "name": "staff_ical_token_unique", "nullsNotDistinct": false, "columns": ["ical_token"] }
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
|
||||
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"email_verified": { "name": "email_verified", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
|
||||
"image": { "name": "image", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, "columns": ["email"] } },
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
|
||||
"identifier": { "name": "identifier", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"value": { "name": "value", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.waitlist_entries": {
|
||||
"name": "waitlist_entries",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"preferred_date": { "name": "preferred_date", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"preferred_time": { "name": "preferred_time", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"status": { "name": "status", "type": "waitlist_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
|
||||
"notified_at": { "name": "notified_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {
|
||||
"idx_waitlist_client_id": { "name": "idx_waitlist_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
|
||||
"idx_waitlist_preferred_date": { "name": "idx_waitlist_preferred_date", "columns": [{ "expression": "preferred_date", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
|
||||
"idx_waitlist_status": { "name": "idx_waitlist_status", "columns": [{ "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
|
||||
},
|
||||
"foreignKeys": {
|
||||
"waitlist_entries_client_id_clients_id_fk": { "name": "waitlist_entries_client_id_clients_id_fk", "tableFrom": "waitlist_entries", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
|
||||
"waitlist_entries_pet_id_pets_id_fk": { "name": "waitlist_entries_pet_id_pets_id_fk", "tableFrom": "waitlist_entries", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
|
||||
"waitlist_entries_service_id_services_id_fk": { "name": "waitlist_entries_service_id_services_id_fk", "tableFrom": "waitlist_entries", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.appointment_status": { "name": "appointment_status", "schema": "public", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
|
||||
"public.client_status": { "name": "client_status", "schema": "public", "values": ["active", "disabled"] },
|
||||
"public.impersonation_session_status": { "name": "impersonation_session_status", "schema": "public", "values": ["active", "ended", "expired"] },
|
||||
"public.invoice_status": { "name": "invoice_status", "schema": "public", "values": ["draft", "pending", "paid", "void"] },
|
||||
"public.payment_method": { "name": "payment_method", "schema": "public", "values": ["cash", "card", "check", "other"] },
|
||||
"public.staff_role": { "name": "staff_role", "schema": "public", "values": ["groomer", "receptionist", "manager"] },
|
||||
"public.waitlist_status": { "name": "waitlist_status", "schema": "public", "values": ["active", "notified", "expired", "cancelled"] }
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
|
||||
}
|
||||
@@ -1,505 +0,0 @@
|
||||
{
|
||||
"id": "9e8d3f2a-1c7b-4a6d-8f0e-5c2b9a3d7e1f",
|
||||
"prevId": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
|
||||
"account_id": { "name": "account_id", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"provider_id": { "name": "provider_id", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"access_token": { "name": "access_token", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"id_token": { "name": "id_token", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"access_token_expires_at": { "name": "access_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"refresh_token_expires_at": { "name": "refresh_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"scope": { "name": "scope", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"password": { "name": "password", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": { "account_user_id_user_id_fk": { "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.appointment_groups": {
|
||||
"name": "appointment_groups",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": { "appointment_groups_client_id_clients_id_fk": { "name": "appointment_groups_client_id_clients_id_fk", "tableFrom": "appointment_groups", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.appointments": {
|
||||
"name": "appointments",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"bather_staff_id": { "name": "bather_staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"status": { "name": "status", "type": "appointment_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'scheduled'" },
|
||||
"start_time": { "name": "start_time", "type": "timestamp", "primaryKey": false, "notNull": true },
|
||||
"end_time": { "name": "end_time", "type": "timestamp", "primaryKey": false, "notNull": true },
|
||||
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"price_cents": { "name": "price_cents", "type": "integer", "primaryKey": false, "notNull": false },
|
||||
"series_id": { "name": "series_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"series_index": { "name": "series_index", "type": "integer", "primaryKey": false, "notNull": false },
|
||||
"group_id": { "name": "group_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"confirmation_status": { "name": "confirmation_status", "type": "text", "primaryKey": false, "notNull": true, "default": "'pending'" },
|
||||
"confirmed_at": { "name": "confirmed_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"cancelled_at": { "name": "cancelled_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"confirmation_token": { "name": "confirmation_token", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"customer_notes": { "name": "customer_notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"appointments_client_id_clients_id_fk": { "name": "appointments_client_id_clients_id_fk", "tableFrom": "appointments", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
|
||||
"appointments_pet_id_pets_id_fk": { "name": "appointments_pet_id_pets_id_fk", "tableFrom": "appointments", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
|
||||
"appointments_service_id_services_id_fk": { "name": "appointments_service_id_services_id_fk", "tableFrom": "appointments", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
|
||||
"appointments_staff_id_staff_id_fk": { "name": "appointments_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
|
||||
"appointments_bather_staff_id_staff_id_fk": { "name": "appointments_bather_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["bather_staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
|
||||
"appointments_series_id_recurring_series_id_fk": { "name": "appointments_series_id_recurring_series_id_fk", "tableFrom": "appointments", "tableTo": "recurring_series", "columnsFrom": ["series_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
|
||||
"appointments_group_id_appointment_groups_id_fk": { "name": "appointments_group_id_appointment_groups_id_fk", "tableFrom": "appointments", "tableTo": "appointment_groups", "columnsFrom": ["group_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "appointments_confirmation_token_unique": { "name": "appointments_confirmation_token_unique", "nullsNotDistinct": false, "columns": ["confirmation_token"] } },
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.business_settings": {
|
||||
"name": "business_settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"business_name": { "name": "business_name", "type": "text", "primaryKey": false, "notNull": true, "default": "'GroomBook'" },
|
||||
"logo_base64": { "name": "logo_base64", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"logo_mime_type": { "name": "logo_mime_type", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"logo_key": { "name": "logo_key", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"primary_color": { "name": "primary_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#4f8a6f'" },
|
||||
"accent_color": { "name": "accent_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#8b7355'" },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.clients": {
|
||||
"name": "clients",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"phone": { "name": "phone", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"address": { "name": "address", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"email_opt_out": { "name": "email_opt_out", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
|
||||
"status": { "name": "status", "type": "client_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
|
||||
"disabled_at": { "name": "disabled_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.grooming_visit_logs": {
|
||||
"name": "grooming_visit_logs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"products_used": { "name": "products_used", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"groomed_at": { "name": "groomed_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"grooming_visit_logs_pet_id_pets_id_fk": { "name": "grooming_visit_logs_pet_id_pets_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
|
||||
"grooming_visit_logs_appointment_id_appointments_id_fk": { "name": "grooming_visit_logs_appointment_id_appointments_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
|
||||
"grooming_visit_logs_staff_id_staff_id_fk": { "name": "grooming_visit_logs_staff_id_staff_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.impersonation_audit_logs": {
|
||||
"name": "impersonation_audit_logs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"session_id": { "name": "session_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"action": { "name": "action", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"page_visited": { "name": "page_visited", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": { "impersonation_audit_logs_session_id_idx": { "name": "impersonation_audit_logs_session_id_idx", "columns": [{ "expression": "session_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } },
|
||||
"foreignKeys": { "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", "tableFrom": "impersonation_audit_logs", "tableTo": "impersonation_sessions", "columnsFrom": ["session_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.impersonation_sessions": {
|
||||
"name": "impersonation_sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"reason": { "name": "reason", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"status": { "name": "status", "type": "impersonation_session_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
|
||||
"started_at": { "name": "started_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"ended_at": { "name": "ended_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {
|
||||
"impersonation_sessions_staff_id_status_idx": { "name": "impersonation_sessions_staff_id_status_idx", "columns": [{ "expression": "staff_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
|
||||
"impersonation_sessions_client_id_idx": { "name": "impersonation_sessions_client_id_idx", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
|
||||
},
|
||||
"foreignKeys": {
|
||||
"impersonation_sessions_staff_id_staff_id_fk": { "name": "impersonation_sessions_staff_id_staff_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
|
||||
"impersonation_sessions_client_id_clients_id_fk": { "name": "impersonation_sessions_client_id_clients_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.invoice_line_items": {
|
||||
"name": "invoice_line_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"quantity": { "name": "quantity", "type": "integer", "primaryKey": false, "notNull": true, "default": 1 },
|
||||
"unit_price_cents": { "name": "unit_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": { "invoice_line_items_invoice_id_invoices_id_fk": { "name": "invoice_line_items_invoice_id_invoices_id_fk", "tableFrom": "invoice_line_items", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.invoice_tip_splits": {
|
||||
"name": "invoice_tip_splits",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"staff_name": { "name": "staff_name", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"share_pct": { "name": "share_pct", "type": "numeric(5, 2)", "primaryKey": false, "notNull": true },
|
||||
"share_cents": { "name": "share_cents", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"invoice_tip_splits_invoice_id_invoices_id_fk": { "name": "invoice_tip_splits_invoice_id_invoices_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
|
||||
"invoice_tip_splits_staff_id_staff_id_fk": { "name": "invoice_tip_splits_staff_id_staff_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.invoices": {
|
||||
"name": "invoices",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"subtotal_cents": { "name": "subtotal_cents", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"tax_cents": { "name": "tax_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
|
||||
"tip_cents": { "name": "tip_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
|
||||
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"status": { "name": "status", "type": "invoice_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'draft'" },
|
||||
"payment_method": { "name": "payment_method", "type": "payment_method", "typeSchema": "public", "primaryKey": false, "notNull": false },
|
||||
"paid_at": { "name": "paid_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"invoices_appointment_id_appointments_id_fk": { "name": "invoices_appointment_id_appointments_id_fk", "tableFrom": "invoices", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
|
||||
"invoices_client_id_clients_id_fk": { "name": "invoices_client_id_clients_id_fk", "tableFrom": "invoices", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pets": {
|
||||
"name": "pets",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"species": { "name": "species", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"breed": { "name": "breed", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"weight_kg": { "name": "weight_kg", "type": "numeric(5, 2)", "primaryKey": false, "notNull": false },
|
||||
"date_of_birth": { "name": "date_of_birth", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"health_alerts": { "name": "health_alerts", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"grooming_notes": { "name": "grooming_notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"shampoo_preference": { "name": "shampoo_preference", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"special_care_notes": { "name": "special_care_notes", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"custom_fields": { "name": "custom_fields", "type": "jsonb", "primaryKey": false, "notNull": true, "default": "'{}'::jsonb" },
|
||||
"photo_key": { "name": "photo_key", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"photo_uploaded_at": { "name": "photo_uploaded_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"image": { "name": "image", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": { "pets_client_id_clients_id_fk": { "name": "pets_client_id_clients_id_fk", "tableFrom": "pets", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.recurring_series": {
|
||||
"name": "recurring_series",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"frequency_weeks": { "name": "frequency_weeks", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.reminder_logs": {
|
||||
"name": "reminder_logs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"reminder_type": { "name": "reminder_type", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"sent_at": { "name": "sent_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": { "reminder_logs_appointment_id_appointments_id_fk": { "name": "reminder_logs_appointment_id_appointments_id_fk", "tableFrom": "reminder_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "reminder_logs_appointment_id_reminder_type_unique": { "name": "reminder_logs_appointment_id_reminder_type_unique", "nullsNotDistinct": false, "columns": ["appointment_id", "reminder_type"] } },
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.services": {
|
||||
"name": "services",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"base_price_cents": { "name": "base_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"duration_minutes": { "name": "duration_minutes", "type": "integer", "primaryKey": false, "notNull": true },
|
||||
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "services_name_unique": { "name": "services_name_unique", "nullsNotDistinct": false, "columns": ["name"] } },
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
|
||||
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
|
||||
"token": { "name": "token", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"ip_address": { "name": "ip_address", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": { "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, "columns": ["token"] } },
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.staff": {
|
||||
"name": "staff",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"oidc_sub": { "name": "oidc_sub", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"role": { "name": "role", "type": "staff_role", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'groomer'" },
|
||||
"is_super_user": { "name": "is_super_user", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
|
||||
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
|
||||
"ical_token": { "name": "ical_token", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": { "staff_user_id_user_id_fk": { "name": "staff_user_id_user_id_fk", "tableFrom": "staff", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"staff_email_unique": { "name": "staff_email_unique", "nullsNotDistinct": false, "columns": ["email"] },
|
||||
"staff_oidc_sub_unique": { "name": "staff_oidc_sub_unique", "nullsNotDistinct": false, "columns": ["oidc_sub"] },
|
||||
"staff_ical_token_unique": { "name": "staff_ical_token_unique", "nullsNotDistinct": false, "columns": ["ical_token"] }
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
|
||||
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"email_verified": { "name": "email_verified", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
|
||||
"image": { "name": "image", "type": "text", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, "columns": ["email"] } },
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
|
||||
"identifier": { "name": "identifier", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"value": { "name": "value", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.waitlist_entries": {
|
||||
"name": "waitlist_entries",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true },
|
||||
"preferred_date": { "name": "preferred_date", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"preferred_time": { "name": "preferred_time", "type": "text", "primaryKey": false, "notNull": true },
|
||||
"status": { "name": "status", "type": "waitlist_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
|
||||
"notified_at": { "name": "notified_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
|
||||
},
|
||||
"indexes": {
|
||||
"idx_waitlist_client_id": { "name": "idx_waitlist_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
|
||||
"idx_waitlist_preferred_date": { "name": "idx_waitlist_preferred_date", "columns": [{ "expression": "preferred_date", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
|
||||
"idx_waitlist_status": { "name": "idx_waitlist_status", "columns": [{ "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
|
||||
},
|
||||
"foreignKeys": {
|
||||
"waitlist_entries_client_id_clients_id_fk": { "name": "waitlist_entries_client_id_clients_id_fk", "tableFrom": "waitlist_entries", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
|
||||
"waitlist_entries_pet_id_pets_id_fk": { "name": "waitlist_entries_pet_id_pets_id_fk", "tableFrom": "waitlist_entries", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
|
||||
"waitlist_entries_service_id_services_id_fk": { "name": "waitlist_entries_service_id_services_id_fk", "tableFrom": "waitlist_entries", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.appointment_status": { "name": "appointment_status", "schema": "public", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
|
||||
"public.client_status": { "name": "client_status", "schema": "public", "values": ["active", "disabled"] },
|
||||
"public.impersonation_session_status": { "name": "impersonation_session_status", "schema": "public", "values": ["active", "ended", "expired"] },
|
||||
"public.invoice_status": { "name": "invoice_status", "schema": "public", "values": ["draft", "pending", "paid", "void"] },
|
||||
"public.payment_method": { "name": "payment_method", "schema": "public", "values": ["cash", "card", "check", "other"] },
|
||||
"public.staff_role": { "name": "staff_role", "schema": "public", "values": ["groomer", "receptionist", "manager"] },
|
||||
"public.waitlist_status": { "name": "waitlist_status", "schema": "public", "values": ["active", "notified", "expired", "cancelled"] }
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,103 +0,0 @@
|
||||
{
|
||||
"id": "0026_stripe_payment",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"authProviderConfig": {
|
||||
"name": "auth_provider_config",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
|
||||
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
|
||||
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
|
||||
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
|
||||
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
|
||||
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
|
||||
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
|
||||
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {}
|
||||
},
|
||||
"businessSettings": {
|
||||
"name": "business_settings",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
|
||||
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
|
||||
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
|
||||
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
|
||||
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
|
||||
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"name": { "name": "name", "type": "text", "isNullable": false },
|
||||
"email": { "name": "email", "type": "text", "isNullable": true },
|
||||
"phone": { "name": "phone", "type": "text", "isNullable": true },
|
||||
"address": { "name": "address", "type": "text", "isNullable": true },
|
||||
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
||||
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
|
||||
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
|
||||
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
|
||||
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
|
||||
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
|
||||
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
|
||||
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
|
||||
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
|
||||
},
|
||||
"invoices": {
|
||||
"name": "invoices",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
|
||||
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
|
||||
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
|
||||
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
|
||||
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
|
||||
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
|
||||
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
|
||||
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
|
||||
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
|
||||
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
|
||||
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
|
||||
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
|
||||
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
|
||||
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
|
||||
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
|
||||
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
|
||||
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
|
||||
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
|
||||
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
|
||||
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
|
||||
},
|
||||
"nativeEnums": {}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"id": "0030_extended_pet_profile",
|
||||
"prevId": "0028_sms_reminders",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.pets": {
|
||||
"name": "pets",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "isNullable": false },
|
||||
"name": { "name": "name", "type": "text", "isNullable": false },
|
||||
"species": { "name": "species", "type": "text", "isNullable": false },
|
||||
"breed": { "name": "breed", "type": "text", "isNullable": true },
|
||||
"weight_kg": { "name": "weight_kg", "type": "numeric(5, 2)", "isNullable": true },
|
||||
"date_of_birth": { "name": "date_of_birth", "type": "timestamp", "isNullable": true },
|
||||
"health_alerts": { "name": "health_alerts", "type": "text", "isNullable": true },
|
||||
"grooming_notes": { "name": "grooming_notes", "type": "text", "isNullable": true },
|
||||
"cut_style": { "name": "cut_style", "type": "text", "isNullable": true },
|
||||
"shampoo_preference": { "name": "shampoo_preference", "type": "text", "isNullable": true },
|
||||
"special_care_notes": { "name": "special_care_notes", "type": "text", "isNullable": true },
|
||||
"custom_fields": { "name": "custom_fields", "type": "jsonb", "isNullable": false, "default": "'{}'::jsonb" },
|
||||
"photo_key": { "name": "photo_key", "type": "text", "isNullable": true },
|
||||
"photo_uploaded_at": { "name": "photo_uploaded_at", "type": "timestamp", "isNullable": true },
|
||||
"image": { "name": "image", "type": "text", "isNullable": true },
|
||||
"coat_type": { "name": "coat_type", "type": "text", "isNullable": true },
|
||||
"temperament_score": { "name": "temperament_score", "type": "integer", "isNullable": true },
|
||||
"temperament_flags": { "name": "temperament_flags", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
||||
"medical_alerts": { "name": "medical_alerts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
||||
"preferred_cuts": { "name": "preferred_cuts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": { "idx_pets_client_id": { "name": "idx_pets_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false } },
|
||||
"foreignKeys": { "pets_client_id_clients_id_fk": { "name": "pets_client_id_clients_id_fk", "tableFrom": "pets", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1773771452946,
|
||||
"tag": "0000_colossal_colossus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1742241600000,
|
||||
"tag": "0001_pet_health_alerts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1773777600000,
|
||||
"tag": "0002_invoices",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1742169600000,
|
||||
"tag": "0003_recurring_series",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1773779939000,
|
||||
"tag": "0004_reminder_logs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1773783000000,
|
||||
"tag": "0005_appointment_groups",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1773783600000,
|
||||
"tag": "0006_pet_profile_attributes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1773820800000,
|
||||
"tag": "0007_tip_splitting",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1773907200000,
|
||||
"tag": "0008_business_settings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1773993600000,
|
||||
"tag": "0009_client_soft_delete",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1742500800000,
|
||||
"tag": "0010_impersonation_sessions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1742587200000,
|
||||
"tag": "0011_impersonation_indexes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1774080000000,
|
||||
"tag": "0012_pet_photo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1774166400000,
|
||||
"tag": "0013_appointment_confirmation",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1774252800000,
|
||||
"tag": "0014_customer_notes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1774339200000,
|
||||
"tag": "0015_waitlist",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1774425600000,
|
||||
"tag": "0016_ical_token",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1774512000000,
|
||||
"tag": "0017_better_auth_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1774598400000,
|
||||
"tag": "0018_backfill_staff_user_id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1774729055924,
|
||||
"tag": "0019_concerned_sunfire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1775050467192,
|
||||
"tag": "0020_typical_daimon_hellstrom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1775136867192,
|
||||
"tag": "0021_pet_image",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1775223267192,
|
||||
"tag": "0022_logo_key",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1775309667192,
|
||||
"tag": "0023_auth_provider_config",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1775396067192,
|
||||
"tag": "0024_invoice_indexes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1775482467192,
|
||||
"tag": "0025_rate_limit",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1775568867192,
|
||||
"tag": "0026_stripe_payment",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1775655267192,
|
||||
"tag": "0027_refunds",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1775741667192,
|
||||
"tag": "0028_sms_reminders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1775828067192,
|
||||
"tag": "0029_db_indexes_constraints",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1775914467192,
|
||||
"tag": "0030_extended_pet_profile",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"name": "@groombook/api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"db:reset": "tsx src/db/reset.ts && drizzle-kit migrate && tsx src/db/seed.ts",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.800.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.800.0",
|
||||
"@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",
|
||||
"drizzle-kit": "^0.30.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"
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mutable state to control mock behavior per test
|
||||
let dbSelectResult: unknown[] = [];
|
||||
const mockEq = vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val }));
|
||||
const mockDecryptSecret = vi.fn((s: string) => `decrypted:${s}`);
|
||||
|
||||
vi.mock("../db", () => {
|
||||
const authProviderConfig = new Proxy(
|
||||
{ _name: "auth_provider_config" },
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop === "_name") return "auth_provider_config";
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: "auth_provider_config", column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => dbSelectResult,
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of dbSelectResult) yield item;
|
||||
},
|
||||
0: dbSelectResult[0],
|
||||
length: dbSelectResult.length,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
authProviderConfig,
|
||||
eq: mockEq,
|
||||
decryptSecret: mockDecryptSecret,
|
||||
};
|
||||
});
|
||||
|
||||
async function reimportAuth() {
|
||||
vi.resetModules();
|
||||
vi.doMock("./db", () => ({
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => dbSelectResult,
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of dbSelectResult) yield item;
|
||||
},
|
||||
0: dbSelectResult[0],
|
||||
length: dbSelectResult.length,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
authProviderConfig: {},
|
||||
eq: mockEq,
|
||||
decryptSecret: mockDecryptSecret,
|
||||
}));
|
||||
const mod = await import("../lib/auth.js");
|
||||
return mod;
|
||||
}
|
||||
|
||||
describe("auth init", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
dbSelectResult = [];
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("falls back to env vars when DB returns empty", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
OIDC_ISSUER: "https://issuer.example.com",
|
||||
OIDC_CLIENT_ID: "test-client-id",
|
||||
OIDC_CLIENT_SECRET: "test-client-secret",
|
||||
BETTER_AUTH_SECRET: "test-secret",
|
||||
BETTER_AUTH_URL: "http://localhost:3000",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
const { initAuth, getAuth } = await reimportAuth();
|
||||
await initAuth();
|
||||
expect(getAuth()).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses DB config and decrypts clientSecret when DB has enabled provider", async () => {
|
||||
const dbConfig = {
|
||||
id: "config-id",
|
||||
providerId: "okta",
|
||||
displayName: "Okta",
|
||||
issuerUrl: "https://okta.example.com",
|
||||
internalBaseUrl: null,
|
||||
clientId: "okta-client-id",
|
||||
clientSecret: "encrypted:okta-secret",
|
||||
scopes: "openid profile email",
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
dbSelectResult = [dbConfig];
|
||||
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
BETTER_AUTH_SECRET: "test-secret",
|
||||
BETTER_AUTH_URL: "http://localhost:3000",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
const { initAuth, getAuth } = await reimportAuth();
|
||||
await initAuth();
|
||||
expect(getAuth()).toBeDefined();
|
||||
expect(mockDecryptSecret).toHaveBeenCalledWith("encrypted:okta-secret");
|
||||
});
|
||||
|
||||
it("throws when BETTER_AUTH_SECRET is missing and AUTH_DISABLED is not set", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
OIDC_ISSUER: "",
|
||||
OIDC_CLIENT_ID: "",
|
||||
OIDC_CLIENT_SECRET: "",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
delete process.env.BETTER_AUTH_SECRET;
|
||||
delete process.env.AUTH_DISABLED;
|
||||
|
||||
const { initAuth } = await reimportAuth();
|
||||
await expect(initAuth()).rejects.toThrow(
|
||||
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
|
||||
);
|
||||
});
|
||||
|
||||
it("builds placeholder auth when AUTH_DISABLED=true without throwing", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
AUTH_DISABLED: "true",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
delete process.env.BETTER_AUTH_SECRET;
|
||||
|
||||
const { initAuth, getAuth } = await reimportAuth();
|
||||
await expect(initAuth()).resolves.toBeUndefined();
|
||||
expect(getAuth()).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,273 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { authProviderRouter } from "../routes/authProvider.js";
|
||||
|
||||
// ─── Mock auth module ─────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("../lib/auth.js", () => ({
|
||||
reinitAuth: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MockStaff {
|
||||
id: string;
|
||||
role: string;
|
||||
isSuperUser: boolean;
|
||||
}
|
||||
|
||||
// ─── Mock DB state ────────────────────────────────────────────────────────────
|
||||
|
||||
let dbRows: Record<string, unknown>[] = [];
|
||||
let deletedRows: string[] = [];
|
||||
let insertedRows: Record<string, unknown>[] = [];
|
||||
let encryptCalls: string[] = [];
|
||||
|
||||
function resetMock() {
|
||||
dbRows = [];
|
||||
deletedRows = [];
|
||||
insertedRows = [];
|
||||
encryptCalls = [];
|
||||
}
|
||||
|
||||
// ─── Mock staff context ───────────────────────────────────────────────────────
|
||||
|
||||
const mockSuperUser: MockStaff = { id: "staff-1", role: "manager", isSuperUser: true };
|
||||
const mockManager: MockStaff = { id: "staff-2", role: "manager", isSuperUser: false };
|
||||
const mockGroomer: MockStaff = { id: "staff-3", role: "groomer", isSuperUser: false };
|
||||
|
||||
// ─── Mock db module ───────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("../db", () => {
|
||||
const authProviderConfig = new Proxy(
|
||||
{ _name: "auth_provider_config" },
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === "_name") return "auth_provider_config";
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: "auth_provider_config", column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => [...dbRows],
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of dbRows) yield item;
|
||||
},
|
||||
0: dbRows[0],
|
||||
length: dbRows.length,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => {
|
||||
insertedRows.push(vals);
|
||||
return {
|
||||
returning: () => [{ ...vals, id: "new-id-1", createdAt: new Date(), updatedAt: new Date() }],
|
||||
};
|
||||
},
|
||||
}),
|
||||
delete: () => {
|
||||
// Execute immediately - route doesn't chain .returning()
|
||||
deletedRows.push("all");
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
transaction: <T>(fn: (tx: {
|
||||
delete: () => Promise<unknown>;
|
||||
insert: () => { values: (v: Record<string, unknown>) => { returning: () => T[] } };
|
||||
}) => Promise<T>) => {
|
||||
const tx = {
|
||||
delete: () => { deletedRows.push("all"); return Promise.resolve([]); },
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => ({
|
||||
returning: () => [{ ...vals, id: "new-id-1", createdAt: new Date(), updatedAt: new Date() }] as T[],
|
||||
}),
|
||||
}),
|
||||
};
|
||||
return fn(tx);
|
||||
},
|
||||
}),
|
||||
authProviderConfig,
|
||||
eq: (_col: unknown, _val: unknown) => ({ col: _col, val: _val }),
|
||||
encryptSecret: (val: string) => {
|
||||
encryptCalls.push(val);
|
||||
return `encrypted:${val}`;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Build test app ───────────────────────────────────────────────────────────
|
||||
|
||||
function makeApp(staff: MockStaff | null) {
|
||||
const app = new Hono();
|
||||
// Inject staff context + super user guard per route
|
||||
// Must match both exact path and wildcard subpaths
|
||||
app.use(
|
||||
"/admin/auth-provider/*",
|
||||
async (c, next) => {
|
||||
if (!staff) {
|
||||
return c.json({ error: "Forbidden: no staff record resolved" }, 403);
|
||||
}
|
||||
if (!staff.isSuperUser) {
|
||||
return c.json({ error: "Forbidden: super user privileges required" }, 403);
|
||||
}
|
||||
(c as any).set("staff", staff);
|
||||
await next();
|
||||
}
|
||||
);
|
||||
app.route("/admin/auth-provider", authProviderRouter as unknown as Hono);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function get<T extends Hono = Hono>(app: T, path: string, staff: MockStaff | null) {
|
||||
const res = await app.request(path, { method: "GET" }, { allCtx: { staff } as { staff: MockStaff } });
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
async function put<T extends Hono = Hono>(app: T, path: string, body: unknown, staff: MockStaff | null) {
|
||||
const res = await app.request(path, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}, { allCtx: { staff } as { staff: MockStaff } });
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
async function post<T extends Hono = Hono>(app: T, path: string, body: unknown, staff: MockStaff | null) {
|
||||
const res = await app.request(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}, { allCtx: { staff } as { staff: MockStaff } });
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
async function del<T extends Hono = Hono>(app: T, path: string, staff: MockStaff | null) {
|
||||
const res = await app.request(path, { method: "DELETE" }, { allCtx: { staff } as { staff: MockStaff } });
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /admin/auth-provider", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("returns 404 when no provider configured", async () => {
|
||||
dbRows = [];
|
||||
const app = makeApp(mockSuperUser);
|
||||
const { status, body } = await get(app, "/admin/auth-provider", mockSuperUser);
|
||||
expect(status).toBe(404);
|
||||
expect(body.error).toBe("No auth provider configured");
|
||||
});
|
||||
|
||||
it("returns config with secret redacted", async () => {
|
||||
dbRows = [{
|
||||
id: "prov-1",
|
||||
providerId: "authentik",
|
||||
displayName: "Authentik",
|
||||
issuerUrl: "https://auth.example.com",
|
||||
internalBaseUrl: null,
|
||||
clientId: "client-123",
|
||||
clientSecret: "encrypted:secret",
|
||||
scopes: "openid profile email",
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}];
|
||||
const app = makeApp(mockSuperUser);
|
||||
const { status, body } = await get(app, "/admin/auth-provider", mockSuperUser);
|
||||
expect(status).toBe(200);
|
||||
expect(body.clientSecret).toBe("••••••••");
|
||||
expect(body.providerId).toBe("authentik");
|
||||
});
|
||||
|
||||
it("returns 403 when not super user", async () => {
|
||||
dbRows = [];
|
||||
const app = makeApp(mockManager);
|
||||
const { status } = await get(app, "/admin/auth-provider", mockManager);
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /admin/auth-provider", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("stores encrypted secret", async () => {
|
||||
const app = makeApp(mockSuperUser);
|
||||
const { status, body } = await put(app, "/admin/auth-provider", {
|
||||
providerId: "authentik",
|
||||
displayName: "Authentik SSO",
|
||||
issuerUrl: "https://auth.example.com",
|
||||
clientId: "my-client",
|
||||
clientSecret: "my-secret",
|
||||
scopes: "openid profile email",
|
||||
}, mockSuperUser);
|
||||
expect(status).toBe(200);
|
||||
expect(encryptCalls).toContain("my-secret");
|
||||
expect(body.clientSecret).toBe("••••••••");
|
||||
expect(body.providerId).toBe("authentik");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid schema", async () => {
|
||||
const app = makeApp(mockSuperUser);
|
||||
const { status } = await put(app, "/admin/auth-provider", {
|
||||
providerId: "",
|
||||
issuerUrl: "not-a-url",
|
||||
}, mockSuperUser);
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /admin/auth-provider/test", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("returns ok=false for unreachable issuer", async () => {
|
||||
const app = makeApp(mockSuperUser);
|
||||
const { status, body } = await post(app, "/admin/auth-provider/test", {
|
||||
providerId: "authentik",
|
||||
displayName: "Authentik",
|
||||
issuerUrl: "https://192.0.2.1/", // TEST-NET, never reachable
|
||||
clientId: "client",
|
||||
scopes: "openid profile email",
|
||||
}, mockSuperUser);
|
||||
expect(status).toBe(200);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error).toBeTruthy();
|
||||
}, 15000); // timeout must exceed the 10s fetch timeout in the route handler
|
||||
|
||||
it("returns 400 for missing clientSecret (not required for test)", async () => {
|
||||
const app = makeApp(mockSuperUser);
|
||||
const { status } = await post(app, "/admin/auth-provider/test", {
|
||||
providerId: "authentik",
|
||||
displayName: "Authentik",
|
||||
issuerUrl: "https://auth.example.com",
|
||||
clientId: "client",
|
||||
}, mockSuperUser);
|
||||
expect(status).toBe(200); // clientSecret omitted intentionally for test
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /admin/auth-provider", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("deletes all config rows", async () => {
|
||||
const app = makeApp(mockSuperUser);
|
||||
const { status, body } = await del(app, "/admin/auth-provider", mockSuperUser);
|
||||
expect(status).toBe(200);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(deletedRows).toContain("all");
|
||||
});
|
||||
|
||||
it("returns 403 when not super user", async () => {
|
||||
const app = makeApp(mockGroomer);
|
||||
const { status } = await del(app, "/admin/auth-provider", mockGroomer);
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generateIcalToken } from "../routes/calendar.js";
|
||||
|
||||
describe("generateIcalToken", () => {
|
||||
it("generates a 64-character hex token", () => {
|
||||
const token = generateIcalToken();
|
||||
expect(token).toHaveLength(64);
|
||||
expect(token).toMatch(/^[a-f0-9]+$/);
|
||||
});
|
||||
|
||||
it("generates unique tokens", () => {
|
||||
const token1 = generateIcalToken();
|
||||
const token2 = generateIcalToken();
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
});
|
||||
@@ -1,294 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
// ─── Mock data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ACTIVE_CLIENT = {
|
||||
id: "client-uuid-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
phone: "555-1234",
|
||||
address: "1 Main St",
|
||||
notes: null,
|
||||
status: "active",
|
||||
disabledAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const DISABLED_CLIENT = {
|
||||
...ACTIVE_CLIENT,
|
||||
id: "client-uuid-2",
|
||||
name: "Bob",
|
||||
status: "disabled",
|
||||
disabledAt: new Date(),
|
||||
};
|
||||
|
||||
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
|
||||
|
||||
let selectRows: Record<string, unknown>[] = [];
|
||||
let appointmentRows: Record<string, unknown>[] = [];
|
||||
let insertedValues: Record<string, unknown>[] = [];
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
let deletedId: string | null = null;
|
||||
|
||||
function resetMock() {
|
||||
selectRows = [];
|
||||
appointmentRows = [];
|
||||
insertedValues = [];
|
||||
updatedValues = [];
|
||||
deletedId = null;
|
||||
}
|
||||
|
||||
vi.mock("../db", () => {
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
||||
return () => chain;
|
||||
}
|
||||
// @ts-expect-error proxy
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
const clients = new Proxy(
|
||||
{ _name: "clients" },
|
||||
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
||||
);
|
||||
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: unknown) => {
|
||||
const tableName = (table as { _name?: string })._name;
|
||||
const rows = tableName === "appointments" ? appointmentRows : selectRows;
|
||||
return makeChainable(rows);
|
||||
},
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => {
|
||||
insertedValues.push(vals);
|
||||
return {
|
||||
returning: () => [{ ...ACTIVE_CLIENT, ...vals, id: "client-uuid-new" }],
|
||||
};
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
updatedValues.push(vals);
|
||||
return {
|
||||
returning: () =>
|
||||
selectRows.length > 0
|
||||
? [{ ...selectRows[0], ...vals }]
|
||||
: [],
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
delete: () => ({
|
||||
where: () => {
|
||||
deletedId = "client-uuid-1";
|
||||
return {
|
||||
returning: () =>
|
||||
selectRows.length > 0 ? [selectRows[0]] : [],
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
clients,
|
||||
appointments,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
or: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── App setup ────────────────────────────────────────────────────────────────
|
||||
|
||||
const { clientsRouter } = await import("../routes/clients.js");
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/clients", clientsRouter);
|
||||
|
||||
function jsonRequest(method: string, path: string, body?: unknown) {
|
||||
return app.request(path, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => resetMock());
|
||||
|
||||
// ─── GET / ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /clients", () => {
|
||||
it("returns active clients", async () => {
|
||||
selectRows = [ACTIVE_CLIENT];
|
||||
const res = await app.request("/clients");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns all clients when includeDisabled=true", async () => {
|
||||
selectRows = [ACTIVE_CLIENT, DISABLED_CLIENT];
|
||||
const res = await app.request("/clients?includeDisabled=true");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty array when no clients exist", async () => {
|
||||
selectRows = [];
|
||||
const res = await app.request("/clients");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /:id ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /clients/:id", () => {
|
||||
it("returns a single client", async () => {
|
||||
selectRows = [ACTIVE_CLIENT];
|
||||
const res = await app.request("/clients/client-uuid-1");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.id).toBe("client-uuid-1");
|
||||
expect(body.name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("returns 404 for a nonexistent client", async () => {
|
||||
selectRows = [];
|
||||
const res = await app.request("/clients/nonexistent");
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST / ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /clients", () => {
|
||||
it("creates a client with valid data", async () => {
|
||||
const res = await jsonRequest("POST", "/clients", {
|
||||
name: "Charlie",
|
||||
email: "charlie@example.com",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.name).toBe("Charlie");
|
||||
expect(insertedValues).toHaveLength(1);
|
||||
expect(insertedValues[0]!.name).toBe("Charlie");
|
||||
});
|
||||
|
||||
it("creates a client with name and email", async () => {
|
||||
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(insertedValues[0]!.name).toBe("Dana");
|
||||
expect(insertedValues[0]!.email).toBe("dana@example.com");
|
||||
});
|
||||
|
||||
it("rejects empty name", async () => {
|
||||
const res = await jsonRequest("POST", "/clients", { name: "" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects invalid email format", async () => {
|
||||
const res = await jsonRequest("POST", "/clients", {
|
||||
name: "Eve",
|
||||
email: "not-an-email",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects missing body", async () => {
|
||||
const res = await app.request("/clients", { method: "POST" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PATCH /:id ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PATCH /clients/:id", () => {
|
||||
it("updates client fields", async () => {
|
||||
selectRows = [ACTIVE_CLIENT];
|
||||
const res = await jsonRequest("PATCH", "/clients/client-uuid-1", {
|
||||
name: "Alice Updated",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.name).toBe("Alice Updated");
|
||||
expect(updatedValues[0]!.name).toBe("Alice Updated");
|
||||
});
|
||||
|
||||
it("sets disabledAt when status is set to disabled", async () => {
|
||||
selectRows = [ACTIVE_CLIENT];
|
||||
await jsonRequest("PATCH", "/clients/client-uuid-1", {
|
||||
status: "disabled",
|
||||
});
|
||||
expect(updatedValues[0]!.status).toBe("disabled");
|
||||
expect(updatedValues[0]!.disabledAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("clears disabledAt when re-enabling", async () => {
|
||||
selectRows = [DISABLED_CLIENT];
|
||||
await jsonRequest("PATCH", "/clients/client-uuid-2", {
|
||||
status: "active",
|
||||
});
|
||||
expect(updatedValues[0]!.disabledAt).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 404 when client not found", async () => {
|
||||
selectRows = [];
|
||||
const res = await jsonRequest("PATCH", "/clients/nonexistent", {
|
||||
name: "Ghost",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /:id ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DELETE /clients/:id", () => {
|
||||
it("requires ?confirm=true", async () => {
|
||||
const res = await app.request("/clients/client-uuid-1", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/confirm/i);
|
||||
});
|
||||
|
||||
it("deletes a client with ?confirm=true", async () => {
|
||||
selectRows = [ACTIVE_CLIENT];
|
||||
const res = await app.request("/clients/client-uuid-1?confirm=true", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(deletedId).toBe("client-uuid-1");
|
||||
});
|
||||
|
||||
it("returns 404 when client not found", async () => {
|
||||
selectRows = [];
|
||||
const res = await app.request("/clients/nonexistent?confirm=true", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,340 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
// ─── Mock appointment data ────────────────────────────────────────────────────
|
||||
|
||||
const FUTURE_TIME = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 1 week from now
|
||||
const PAST_TIME = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
|
||||
const BASE_APPT = {
|
||||
id: "appt-uuid-1",
|
||||
clientId: "client-uuid-1",
|
||||
petId: "pet-uuid-1",
|
||||
serviceId: "service-uuid-1",
|
||||
staffId: "staff-uuid-1",
|
||||
batherStaffId: null,
|
||||
status: "scheduled" as const,
|
||||
startTime: FUTURE_TIME,
|
||||
endTime: new Date(FUTURE_TIME.getTime() + 3600_000),
|
||||
notes: null,
|
||||
priceCents: null,
|
||||
seriesId: null,
|
||||
seriesIndex: null,
|
||||
groupId: null,
|
||||
confirmationStatus: "pending",
|
||||
confirmedAt: null,
|
||||
cancelledAt: null,
|
||||
confirmationToken: "valid-token-abc123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// ─── Shared mock DB state ─────────────────────────────────────────────────────
|
||||
|
||||
let mockAppt: typeof BASE_APPT | null = BASE_APPT;
|
||||
let lastUpdate: Record<string, unknown> = {};
|
||||
|
||||
function resetMock() {
|
||||
mockAppt = { ...BASE_APPT };
|
||||
lastUpdate = {};
|
||||
}
|
||||
|
||||
vi.mock("../db", () => {
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => (mockAppt ? [mockAppt] : []),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
lastUpdate = { ...vals };
|
||||
if (mockAppt) {
|
||||
mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT;
|
||||
}
|
||||
return { returning: () => (mockAppt ? [mockAppt] : []) };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
appointments,
|
||||
eq: () => ({}),
|
||||
and: (..._clauses: unknown[]) => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Book router (tokenized endpoints) ───────────────────────────────────────
|
||||
|
||||
async function makeBookApp() {
|
||||
const { bookRouter } = await import("../routes/book.js");
|
||||
const app = new Hono();
|
||||
app.route("/api/book", bookRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Appointments router (portal endpoints) ────────────────────────────────
|
||||
|
||||
async function makeAppointmentsApp() {
|
||||
const { appointmentsRouter } = await import("../routes/appointments.js");
|
||||
const app = new Hono();
|
||||
app.route("/api/appointments", appointmentsRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Tests: tokenized confirm endpoint ────────────────────────────────────────
|
||||
|
||||
describe("GET /api/book/confirm/:token", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resetMock();
|
||||
app = await makeBookApp();
|
||||
});
|
||||
|
||||
it("redirects to /booking/confirmed on valid token and future appointment", async () => {
|
||||
const res = await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/confirmed");
|
||||
});
|
||||
|
||||
it("sets confirmationStatus to confirmed", async () => {
|
||||
await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(lastUpdate.confirmationStatus).toBe("confirmed");
|
||||
expect(lastUpdate.confirmedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when token not found", async () => {
|
||||
mockAppt = null;
|
||||
const res = await app.request("/api/book/confirm/bad-token");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when appointment is in the past", async () => {
|
||||
mockAppt = { ...BASE_APPT, startTime: PAST_TIME };
|
||||
const res = await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
|
||||
it("redirects to /booking/confirmed idempotently when already confirmed", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
|
||||
const res = await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/confirmed");
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when appointment is already customer-cancelled", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
|
||||
const res = await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: tokenized cancel endpoint ────────────────────────────────────────
|
||||
|
||||
describe("GET /api/book/cancel/:token", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resetMock();
|
||||
app = await makeBookApp();
|
||||
});
|
||||
|
||||
it("redirects to /booking/cancelled on valid token and future appointment", async () => {
|
||||
const res = await app.request("/api/book/cancel/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/cancelled");
|
||||
});
|
||||
|
||||
it("sets confirmationStatus to cancelled and nullifies token (single-use)", async () => {
|
||||
await app.request("/api/book/cancel/valid-token-abc123");
|
||||
expect(lastUpdate.confirmationStatus).toBe("cancelled");
|
||||
expect(lastUpdate.cancelledAt).toBeInstanceOf(Date);
|
||||
expect(lastUpdate.confirmationToken).toBeNull();
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when token not found", async () => {
|
||||
mockAppt = null;
|
||||
const res = await app.request("/api/book/cancel/bad-token");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when appointment is in the past", async () => {
|
||||
mockAppt = { ...BASE_APPT, startTime: PAST_TIME };
|
||||
const res = await app.request("/api/book/cancel/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when already customer-cancelled", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
|
||||
const res = await app.request("/api/book/cancel/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: portal confirm endpoint ──────────────────────────────────────────
|
||||
|
||||
describe("POST /api/appointments/:id/confirm", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resetMock();
|
||||
app = await makeAppointmentsApp();
|
||||
});
|
||||
|
||||
it("confirms a pending appointment", async () => {
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(lastUpdate.confirmationStatus).toBe("confirmed");
|
||||
expect(lastUpdate.confirmedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns 404 when appointment not found", async () => {
|
||||
mockAppt = null;
|
||||
const res = await app.request("/api/appointments/nonexistent/confirm", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 409 when appointment is already customer-cancelled", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("returns 200 idempotently when appointment is already confirmed", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: portal cancel endpoint ───────────────────────────────────────────
|
||||
|
||||
describe("POST /api/appointments/:id/cancel", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resetMock();
|
||||
app = await makeAppointmentsApp();
|
||||
});
|
||||
|
||||
it("cancels a pending appointment and nullifies the token", async () => {
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(lastUpdate.confirmationStatus).toBe("cancelled");
|
||||
expect(lastUpdate.cancelledAt).toBeInstanceOf(Date);
|
||||
expect(lastUpdate.confirmationToken).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 404 when appointment not found", async () => {
|
||||
mockAppt = null;
|
||||
const res = await app.request("/api/appointments/nonexistent/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 409 when appointment is already customer-cancelled", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("can cancel a confirmed appointment", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(lastUpdate.confirmationStatus).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: token generation helper ──────────────────────────────────────────
|
||||
|
||||
describe("generateConfirmationToken", () => {
|
||||
it("generates a 64-character hex string", async () => {
|
||||
const { generateConfirmationToken } = await import("../routes/appointments.js");
|
||||
const token = generateConfirmationToken();
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("generates unique tokens on each call", async () => {
|
||||
const { generateConfirmationToken } = await import("../routes/appointments.js");
|
||||
const t1 = generateConfirmationToken();
|
||||
const t2 = generateConfirmationToken();
|
||||
expect(t1).not.toBe(t2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: reminder email with action links ──────────────────────────────────
|
||||
|
||||
describe("buildReminderEmail with confirmation token", () => {
|
||||
it("includes confirm and cancel links when token is provided", async () => {
|
||||
const { buildReminderEmail } = await import("../services/email.js");
|
||||
const mail = buildReminderEmail(
|
||||
"client@example.com",
|
||||
{
|
||||
clientName: "Jane",
|
||||
petName: "Biscuit",
|
||||
serviceName: "Full Groom",
|
||||
groomerName: null,
|
||||
startTime: new Date(),
|
||||
},
|
||||
24,
|
||||
"abc123token"
|
||||
);
|
||||
expect(mail.text).toContain("abc123token");
|
||||
expect(mail.html as string).toContain("abc123token");
|
||||
expect(mail.html as string).toContain("Confirm Appointment");
|
||||
expect(mail.html as string).toContain("Cancel Appointment");
|
||||
});
|
||||
|
||||
it("omits action links when no token is provided", async () => {
|
||||
const { buildReminderEmail } = await import("../services/email.js");
|
||||
const mail = buildReminderEmail(
|
||||
"client@example.com",
|
||||
{
|
||||
clientName: "Jane",
|
||||
petName: "Biscuit",
|
||||
serviceName: "Full Groom",
|
||||
groomerName: null,
|
||||
startTime: new Date(),
|
||||
},
|
||||
24,
|
||||
null
|
||||
);
|
||||
expect(mail.html as string).not.toContain("Confirm Appointment");
|
||||
expect(mail.html as string).not.toContain("Cancel Appointment");
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { encryptSecret, decryptSecret } from "../db/index.js";
|
||||
|
||||
describe("encryptSecret / decryptSecret", () => {
|
||||
const originalEnv = process.env.BETTER_AUTH_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.BETTER_AUTH_SECRET = "test-secret-key-for-unit-tests-32bytes!";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.BETTER_AUTH_SECRET = originalEnv;
|
||||
} else {
|
||||
delete process.env.BETTER_AUTH_SECRET;
|
||||
}
|
||||
});
|
||||
|
||||
it("encrypts and decrypts a simple secret", () => {
|
||||
const plaintext = "my-client-secret-123";
|
||||
const encrypted = encryptSecret(plaintext);
|
||||
const decrypted = decryptSecret(encrypted);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("produces output in salt:iv:ciphertext:authTag format", () => {
|
||||
const encrypted = encryptSecret("test");
|
||||
const parts = encrypted.split(":");
|
||||
|
||||
expect(parts).toHaveLength(4);
|
||||
// Each part should be valid base64
|
||||
parts.forEach((part) => {
|
||||
expect(() => Buffer.from(part, "base64")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("different plaintexts produce different ciphertexts", () => {
|
||||
const encrypted1 = encryptSecret("secret1");
|
||||
const encrypted2 = encryptSecret("secret2");
|
||||
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
});
|
||||
|
||||
it("same plaintext produces different ciphertexts (due to random IV)", () => {
|
||||
const encrypted1 = encryptSecret("same-secret");
|
||||
const encrypted2 = encryptSecret("same-secret");
|
||||
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
// But both should decrypt to the same value
|
||||
expect(decryptSecret(encrypted1)).toBe("same-secret");
|
||||
expect(decryptSecret(encrypted2)).toBe("same-secret");
|
||||
});
|
||||
|
||||
it("throws if BETTER_AUTH_SECRET is not set", () => {
|
||||
delete process.env.BETTER_AUTH_SECRET;
|
||||
|
||||
expect(() => encryptSecret("test")).toThrow(
|
||||
"BETTER_AUTH_SECRET environment variable is required"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when decrypting invalid format (wrong number of parts)", () => {
|
||||
const encrypted = encryptSecret("test");
|
||||
// Replace the last two parts with a single part to create a 2-part string
|
||||
// This can't be parsed as either legacy (3 parts) or new (4 parts) format
|
||||
const invalid = encrypted.replace(/:[^:]+$/, "").replace(/:[^:]+$/, "");
|
||||
|
||||
expect(() => decryptSecret(invalid)).toThrow(
|
||||
"Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles empty string secret", () => {
|
||||
const plaintext = "";
|
||||
const encrypted = encryptSecret(plaintext);
|
||||
const decrypted = decryptSecret(encrypted);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("handles unicode secret", () => {
|
||||
const plaintext = "密码🔐中文";
|
||||
const encrypted = encryptSecret(plaintext);
|
||||
const decrypted = decryptSecret(encrypted);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("handles long secret", () => {
|
||||
const plaintext = "a".repeat(10000);
|
||||
const encrypted = encryptSecret(plaintext);
|
||||
const decrypted = decryptSecret(encrypted);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildConfirmationEmail,
|
||||
buildReminderEmail,
|
||||
} from "../services/email.js";
|
||||
|
||||
const START = new Date("2026-03-25T15:00:00Z");
|
||||
|
||||
const BASE = {
|
||||
clientName: "Jane Doe",
|
||||
petName: "Biscuit",
|
||||
serviceName: "Full Groom",
|
||||
groomerName: "Alex",
|
||||
startTime: START,
|
||||
};
|
||||
|
||||
describe("buildConfirmationEmail", () => {
|
||||
it("addresses the correct recipient", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", BASE);
|
||||
expect(mail.to).toBe("jane@example.com");
|
||||
});
|
||||
|
||||
it("includes the pet name in the subject", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", BASE);
|
||||
expect(mail.subject).toContain("Biscuit");
|
||||
});
|
||||
|
||||
it("includes confirmation wording in subject", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", BASE);
|
||||
expect(mail.subject).toMatch(/confirmed/i);
|
||||
});
|
||||
|
||||
it("includes client name in the plain text body", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", BASE);
|
||||
expect(mail.text).toContain("Jane Doe");
|
||||
});
|
||||
|
||||
it("includes service name in plain text body", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", BASE);
|
||||
expect(mail.text).toContain("Full Groom");
|
||||
});
|
||||
|
||||
it("includes groomer name when provided", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", BASE);
|
||||
expect(mail.text).toContain("Alex");
|
||||
});
|
||||
|
||||
it("omits groomer when groomerName is null", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", {
|
||||
...BASE,
|
||||
groomerName: null,
|
||||
});
|
||||
expect(mail.text).not.toContain("with ");
|
||||
});
|
||||
|
||||
it("includes HTML body", () => {
|
||||
const mail = buildConfirmationEmail("jane@example.com", BASE);
|
||||
expect(mail.html).toBeTruthy();
|
||||
expect(mail.html).toContain("Biscuit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReminderEmail", () => {
|
||||
it("addresses the correct recipient", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", BASE, 24);
|
||||
expect(mail.to).toBe("jane@example.com");
|
||||
});
|
||||
|
||||
it("says 'tomorrow' for 24-hour reminder", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", BASE, 24);
|
||||
expect(mail.subject).toContain("tomorrow");
|
||||
expect(mail.text).toContain("tomorrow");
|
||||
});
|
||||
|
||||
it("says 'in X hours' for sub-24-hour reminders", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", BASE, 2);
|
||||
expect(mail.subject).toContain("in 2 hours");
|
||||
expect(mail.text).toContain("in 2 hours");
|
||||
});
|
||||
|
||||
it("includes pet name in subject", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", BASE, 24);
|
||||
expect(mail.subject).toContain("Biscuit");
|
||||
});
|
||||
|
||||
it("includes service name in plain text body", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", BASE, 24);
|
||||
expect(mail.text).toContain("Full Groom");
|
||||
});
|
||||
|
||||
it("includes groomer name when provided", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", BASE, 24);
|
||||
expect(mail.text).toContain("Alex");
|
||||
});
|
||||
|
||||
it("omits groomer when groomerName is null", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", { ...BASE, groomerName: null }, 24);
|
||||
expect(mail.text).not.toContain("with ");
|
||||
});
|
||||
|
||||
it("includes HTML body", () => {
|
||||
const mail = buildReminderEmail("jane@example.com", BASE, 24);
|
||||
expect(mail.html).toBeTruthy();
|
||||
expect(mail.html).toContain("Biscuit");
|
||||
});
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
resetFactoryCounters,
|
||||
buildStaff,
|
||||
buildClient,
|
||||
buildPet,
|
||||
buildService,
|
||||
buildAppointment,
|
||||
} from "../db/factories.js";
|
||||
|
||||
describe("resetFactoryCounters", () => {
|
||||
it("resets all counters so IDs restart from 1", () => {
|
||||
buildStaff();
|
||||
buildStaff();
|
||||
buildClient();
|
||||
resetFactoryCounters();
|
||||
|
||||
const staff = buildStaff();
|
||||
const client = buildClient();
|
||||
|
||||
expect(staff.id).toBe("staff-1");
|
||||
expect(client.id).toBe("client-1");
|
||||
});
|
||||
|
||||
it("resets counters for every entity type", () => {
|
||||
const client = buildClient();
|
||||
const pet = buildPet({ clientId: client.id });
|
||||
const service = buildService();
|
||||
buildAppointment({
|
||||
clientId: client.id,
|
||||
petId: pet.id,
|
||||
serviceId: service.id,
|
||||
staffId: "staff-1",
|
||||
});
|
||||
|
||||
resetFactoryCounters();
|
||||
|
||||
expect(buildStaff().id).toBe("staff-1");
|
||||
expect(buildClient().id).toBe("client-1");
|
||||
expect(buildService().id).toBe("service-1");
|
||||
const c = buildClient();
|
||||
expect(buildPet({ clientId: c.id }).id).toBe("pet-1");
|
||||
const s = buildService();
|
||||
const p = buildPet({ clientId: c.id });
|
||||
expect(
|
||||
buildAppointment({ clientId: c.id, petId: p.id, serviceId: s.id, staffId: "s-1" }).id
|
||||
).toBe("appointment-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("counter determinism", () => {
|
||||
beforeEach(() => {
|
||||
resetFactoryCounters();
|
||||
});
|
||||
|
||||
it("increments staff IDs sequentially", () => {
|
||||
expect(buildStaff().id).toBe("staff-1");
|
||||
expect(buildStaff().id).toBe("staff-2");
|
||||
expect(buildStaff().id).toBe("staff-3");
|
||||
});
|
||||
|
||||
it("increments client IDs sequentially", () => {
|
||||
expect(buildClient().id).toBe("client-1");
|
||||
expect(buildClient().id).toBe("client-2");
|
||||
});
|
||||
|
||||
it("increments pet IDs sequentially", () => {
|
||||
const client = buildClient();
|
||||
expect(buildPet({ clientId: client.id }).id).toBe("pet-1");
|
||||
expect(buildPet({ clientId: client.id }).id).toBe("pet-2");
|
||||
});
|
||||
|
||||
it("increments service IDs sequentially", () => {
|
||||
expect(buildService().id).toBe("service-1");
|
||||
expect(buildService().id).toBe("service-2");
|
||||
});
|
||||
|
||||
it("increments appointment IDs sequentially", () => {
|
||||
const client = buildClient();
|
||||
const pet = buildPet({ clientId: client.id });
|
||||
const service = buildService();
|
||||
const required = { clientId: client.id, petId: pet.id, serviceId: service.id, staffId: "staff-1" };
|
||||
|
||||
expect(buildAppointment(required).id).toBe("appointment-1");
|
||||
expect(buildAppointment(required).id).toBe("appointment-2");
|
||||
});
|
||||
|
||||
it("each entity type maintains its own independent counter", () => {
|
||||
buildStaff();
|
||||
buildStaff();
|
||||
buildClient();
|
||||
|
||||
// staff counter is at 2; client counter is at 1
|
||||
expect(buildStaff().id).toBe("staff-3");
|
||||
expect(buildClient().id).toBe("client-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("override merging", () => {
|
||||
beforeEach(() => {
|
||||
resetFactoryCounters();
|
||||
});
|
||||
|
||||
it("buildStaff applies overrides over defaults", () => {
|
||||
const staff = buildStaff({ role: "manager", name: "Boss" });
|
||||
|
||||
expect(staff.role).toBe("manager");
|
||||
expect(staff.name).toBe("Boss");
|
||||
expect(staff.id).toBe("staff-1");
|
||||
expect(staff.active).toBe(true); // default preserved
|
||||
});
|
||||
|
||||
it("buildStaff id override is respected without disrupting the counter", () => {
|
||||
const staff = buildStaff({ id: "custom-id" });
|
||||
|
||||
expect(staff.id).toBe("custom-id");
|
||||
// counter still ticked — next call gets staff-2
|
||||
expect(buildStaff().id).toBe("staff-2");
|
||||
});
|
||||
|
||||
it("buildClient applies overrides over defaults", () => {
|
||||
const client = buildClient({ name: "Alice Smith", emailOptOut: true });
|
||||
|
||||
expect(client.name).toBe("Alice Smith");
|
||||
expect(client.emailOptOut).toBe(true);
|
||||
expect(client.status).toBe("active"); // default preserved
|
||||
});
|
||||
|
||||
it("buildPet merges overrides and sets clientId from required arg", () => {
|
||||
const pet = buildPet({ clientId: "client-99", name: "Fluffy", breed: "Poodle" });
|
||||
|
||||
expect(pet.clientId).toBe("client-99");
|
||||
expect(pet.name).toBe("Fluffy");
|
||||
expect(pet.breed).toBe("Poodle");
|
||||
expect(pet.species).toBe("Dog"); // default preserved
|
||||
});
|
||||
|
||||
it("buildService applies overrides over defaults", () => {
|
||||
const service = buildService({ basePriceCents: 9900, active: false });
|
||||
|
||||
expect(service.basePriceCents).toBe(9900);
|
||||
expect(service.active).toBe(false);
|
||||
expect(service.durationMinutes).toBe(60); // default preserved
|
||||
});
|
||||
|
||||
it("buildAppointment applies overrides over defaults", () => {
|
||||
const client = buildClient();
|
||||
const pet = buildPet({ clientId: client.id });
|
||||
const service = buildService();
|
||||
const appt = buildAppointment({
|
||||
clientId: client.id,
|
||||
petId: pet.id,
|
||||
serviceId: service.id,
|
||||
staffId: "staff-1",
|
||||
status: "confirmed",
|
||||
notes: "allergic to lavender",
|
||||
});
|
||||
|
||||
expect(appt.status).toBe("confirmed");
|
||||
expect(appt.notes).toBe("allergic to lavender");
|
||||
expect(appt.clientId).toBe(client.id);
|
||||
expect(appt.petId).toBe(pet.id);
|
||||
// defaults preserved
|
||||
expect(appt.batherStaffId).toBeNull();
|
||||
expect(appt.priceCents).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAppointment required fields", () => {
|
||||
beforeEach(() => {
|
||||
resetFactoryCounters();
|
||||
});
|
||||
|
||||
it("produces a fully-populated AppointmentRow", () => {
|
||||
const client = buildClient();
|
||||
const pet = buildPet({ clientId: client.id });
|
||||
const service = buildService();
|
||||
const appt = buildAppointment({
|
||||
clientId: client.id,
|
||||
petId: pet.id,
|
||||
serviceId: service.id,
|
||||
staffId: "staff-1",
|
||||
});
|
||||
|
||||
expect(appt.id).toBeDefined();
|
||||
expect(appt.clientId).toBe(client.id);
|
||||
expect(appt.petId).toBe(pet.id);
|
||||
expect(appt.serviceId).toBe(service.id);
|
||||
expect(appt.staffId).toBe("staff-1");
|
||||
expect(appt.startTime).toBeInstanceOf(Date);
|
||||
expect(appt.endTime).toBeInstanceOf(Date);
|
||||
expect(appt.status).toBe("scheduled");
|
||||
expect(appt.batherStaffId).toBeNull();
|
||||
expect(appt.seriesId).toBeNull();
|
||||
expect(appt.seriesIndex).toBeNull();
|
||||
expect(appt.groupId).toBeNull();
|
||||
expect(appt.notes).toBeNull();
|
||||
expect(appt.priceCents).toBeNull();
|
||||
expect(appt.createdAt).toBeInstanceOf(Date);
|
||||
expect(appt.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
// TypeScript compile-time enforcement: omitting any required field produces a type error.
|
||||
// The overrides parameter type is `Partial<AppointmentRow> & { clientId: string; petId: string; serviceId: string; staffId: string }`.
|
||||
// The test below verifies the type signature is correct by using @ts-expect-error.
|
||||
it("type error when required fields are missing — compile-time enforcement", () => {
|
||||
// @ts-expect-error clientId is required
|
||||
buildAppointment({ petId: "p", serviceId: "s", staffId: "st" });
|
||||
// @ts-expect-error petId is required
|
||||
buildAppointment({ clientId: "c", serviceId: "s", staffId: "st" });
|
||||
// @ts-expect-error serviceId is required
|
||||
buildAppointment({ clientId: "c", petId: "p", staffId: "st" });
|
||||
// @ts-expect-error staffId is required
|
||||
buildAppointment({ clientId: "c", petId: "p", serviceId: "s" });
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Groomer Isolation Tests
|
||||
*
|
||||
* Validates row-level data scoping for the groomer role.
|
||||
*
|
||||
* The role guard tests verify the core groomer identification logic.
|
||||
* Integration tests with the real database validate the full filter behavior.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { StaffRow } from "../middleware/rbac.js";
|
||||
|
||||
// ─── Mock staff ───────────────────────────────────────────────────────────────
|
||||
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: null,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const GROOMER: StaffRow = {
|
||||
...MANAGER,
|
||||
id: "staff-groomer-id",
|
||||
oidcSub: "oidc-groomer-sub",
|
||||
role: "groomer",
|
||||
name: "Groomer Gary",
|
||||
email: "groomer@example.com",
|
||||
};
|
||||
|
||||
const RECEPTIONIST: StaffRow = {
|
||||
...MANAGER,
|
||||
id: "staff-receptionist-id",
|
||||
oidcSub: "oidc-receptionist-sub",
|
||||
role: "receptionist",
|
||||
name: "Receptionist Rita",
|
||||
email: "receptionist@example.com",
|
||||
};
|
||||
|
||||
// ─── Role guard ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The isGroomer guard (staffRow?.role === "groomer") is the foundation of
|
||||
* all row-level filtering in appointments.ts, clients.ts, and pets.ts.
|
||||
* These tests verify it handles all roles correctly.
|
||||
*/
|
||||
describe("Groomer role guard", () => {
|
||||
const isGroomer = (s: StaffRow | undefined) => s?.role === "groomer";
|
||||
|
||||
it("manager is not groomer", () => expect(isGroomer(MANAGER)).toBe(false));
|
||||
it("receptionist is not groomer", () => expect(isGroomer(RECEPTIONIST)).toBe(false));
|
||||
it("groomer is groomer", () => expect(isGroomer(GROOMER)).toBe(true));
|
||||
|
||||
/** Safe fallback when staff context is not set (e.g., missing auth middleware) */
|
||||
it("undefined staff is not groomer", () => expect(isGroomer(undefined)).toBe(false));
|
||||
});
|
||||
|
||||
// ─── Groomer filter data shapes ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* These constants match the shape used in route handlers to validate
|
||||
* the groomer filter conditions:
|
||||
* or(eq(appointments.staffId, staffRow.id), eq(appointments.batherStaffId, staffRow.id))
|
||||
* This verifies the groomer can see appointments they own OR bathe.
|
||||
*/
|
||||
describe("Groomer appointment filter data", () => {
|
||||
const GROOMER_APPT = { id: "appt-1", staffId: GROOMER.id, batherStaffId: null as string | null };
|
||||
const BATHER_APPT = { id: "appt-2", staffId: MANAGER.id, batherStaffId: GROOMER.id };
|
||||
const OTHER_APPT = { id: "appt-3", staffId: MANAGER.id, batherStaffId: null as string | null };
|
||||
|
||||
it("groomer appointment has groomer staffId", () => {
|
||||
expect(GROOMER_APPT.staffId).toBe(GROOMER.id);
|
||||
expect(GROOMER_APPT.batherStaffId).toBeNull();
|
||||
});
|
||||
|
||||
it("groomer can see appointment where they are the bather", () => {
|
||||
expect(BATHER_APPT.batherStaffId).toBe(GROOMER.id);
|
||||
expect(BATHER_APPT.staffId).toBe(MANAGER.id);
|
||||
});
|
||||
|
||||
it("other appointment is not assigned to groomer", () => {
|
||||
expect(OTHER_APPT.staffId).toBe(MANAGER.id);
|
||||
expect(OTHER_APPT.batherStaffId).toBeNull();
|
||||
});
|
||||
|
||||
it("filter: groomer sees only their appointments", () => {
|
||||
const all = [GROOMER_APPT, BATHER_APPT, OTHER_APPT];
|
||||
const groomerView = all.filter(
|
||||
(a) => a.staffId === GROOMER.id || a.batherStaffId === GROOMER.id
|
||||
);
|
||||
expect(groomerView).toHaveLength(2);
|
||||
expect(groomerView.map((a) => a.id)).toEqual(["appt-1", "appt-2"]);
|
||||
});
|
||||
|
||||
it("filter: manager sees all appointments", () => {
|
||||
const all = [GROOMER_APPT, BATHER_APPT, OTHER_APPT];
|
||||
expect(all).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -1,560 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
import { buildStaff } from "../db/factories.js";
|
||||
|
||||
// ─── Mock data (built with factories for schema-safe defaults) ────────────────
|
||||
|
||||
const MANAGER_STAFF = buildStaff({ id: "staff-manager-id", oidcSub: "oidc-manager-sub", role: "manager", name: "Manager" });
|
||||
const GROOMER_STAFF = buildStaff({ id: "staff-groomer-id", oidcSub: "oidc-groomer-sub", role: "groomer", name: "Groomer" });
|
||||
|
||||
const CLIENT = { id: "aabbccdd-1111-2222-3333-444444444444", name: "Fido Owner" };
|
||||
|
||||
const futureDate = () => new Date(Date.now() + 30 * 60_000);
|
||||
const pastDate = () => new Date(Date.now() - 5 * 60_000);
|
||||
|
||||
function makeSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "session-uuid-1",
|
||||
staffId: MANAGER_STAFF.id,
|
||||
clientId: CLIENT.id,
|
||||
reason: "Testing portal",
|
||||
status: "active" as string,
|
||||
startedAt: new Date(),
|
||||
endedAt: null as Date | null,
|
||||
expiresAt: futureDate(),
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAuditLog(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "audit-uuid-1",
|
||||
sessionId: "session-uuid-1",
|
||||
action: "session_started",
|
||||
pageVisited: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Queue-based mock DB ─────────────────────────────────────────────────────
|
||||
|
||||
let selectQueue: unknown[][] = [];
|
||||
let insertedValues: Array<{ table: string; vals: unknown }> = [];
|
||||
let updatedValues: Array<{ table: string; set: Record<string, unknown> }> = [];
|
||||
|
||||
function resetMock() {
|
||||
selectQueue = [];
|
||||
insertedValues = [];
|
||||
updatedValues = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a chainable object that acts like a drizzle query result.
|
||||
* Any method call (.where, .orderBy, .limit) returns the same chainable,
|
||||
* but the FIRST terminal call (.where or .orderBy when no further chain)
|
||||
* resolves the result from the queue.
|
||||
*
|
||||
* To handle `.where().orderBy()` chaining, we make the result of shifting
|
||||
* also have .orderBy/.limit methods, and we wrap the shifted array in a proxy.
|
||||
*/
|
||||
function makeChainableResult(data: unknown[]): unknown {
|
||||
// Make data act both as array and as chainable
|
||||
const arr = [...data];
|
||||
return new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "orderBy" || prop === "limit") {
|
||||
// Further chaining just returns the same data
|
||||
return () => makeChainableResult(data);
|
||||
}
|
||||
// @ts-expect-error proxy access
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("../db", () => {
|
||||
function makeTable(name: string) {
|
||||
return new Proxy(
|
||||
{ _name: name },
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop === "_name") return name;
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: name, column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => {
|
||||
const data = selectQueue.shift() ?? [];
|
||||
return makeChainableResult(data);
|
||||
},
|
||||
orderBy: () => {
|
||||
const data = selectQueue.shift() ?? [];
|
||||
return makeChainableResult(data);
|
||||
},
|
||||
limit: () => {
|
||||
const data = selectQueue.shift() ?? [];
|
||||
return makeChainableResult(data);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
insert: (table: { _name: string }) => ({
|
||||
values: (vals: unknown) => {
|
||||
const tableName = table?._name ?? "unknown";
|
||||
insertedValues.push({ table: tableName, vals });
|
||||
return {
|
||||
returning: () => {
|
||||
if (tableName === "sessions") {
|
||||
return [makeSession(vals as Record<string, unknown>)];
|
||||
}
|
||||
return [makeAuditLog(vals as Record<string, unknown>)];
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
update: (table: { _name: string }) => ({
|
||||
set: (data: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
const tableName = table?._name ?? "unknown";
|
||||
updatedValues.push({ table: tableName, set: data });
|
||||
return {
|
||||
returning: () => {
|
||||
const base = makeSession();
|
||||
return [{ ...base, ...data }];
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
staff: makeTable("staff"),
|
||||
clients: makeTable("clients"),
|
||||
impersonationSessions: makeTable("sessions"),
|
||||
impersonationAuditLogs: makeTable("auditLogs"),
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
desc: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── App setup ───────────────────────────────────────────────────────────────
|
||||
|
||||
const { impersonationRouter } = await import("../routes/impersonation.js");
|
||||
const { requireRole } = await import("../middleware/rbac.js");
|
||||
|
||||
/**
|
||||
* Build a test app. If staffRow is null the middleware simulates
|
||||
* resolveStaffMiddleware returning 403 (staff not found). An optional
|
||||
* roleGuard applies requireRole(...roles) before the router.
|
||||
*/
|
||||
function createApp(
|
||||
staffRow: (typeof MANAGER_STAFF) | null,
|
||||
roleGuard?: string[]
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
if (!staffRow) {
|
||||
return c.json({ error: "Forbidden: no staff record found for authenticated user" }, 403);
|
||||
}
|
||||
c.set("jwtPayload", { sub: staffRow.oidcSub } as { sub: string; email?: string; name?: string });
|
||||
c.set("staff", staffRow as unknown as StaffRow);
|
||||
await next();
|
||||
});
|
||||
if (roleGuard && roleGuard.length > 0) {
|
||||
app.use("*", requireRole(...(roleGuard as Parameters<typeof requireRole>)) as never);
|
||||
}
|
||||
app.route("/impersonation", impersonationRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
function jsonPost(path: string, body: unknown) {
|
||||
return {
|
||||
method: "POST" as const,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => resetMock());
|
||||
|
||||
// ─── POST /sessions — Create session ─────────────────────────────────────────
|
||||
|
||||
describe("POST /impersonation/sessions", () => {
|
||||
it("creates a session for a manager", async () => {
|
||||
const app = createApp(MANAGER_STAFF, ["manager"]);
|
||||
selectQueue.push(
|
||||
[CLIENT], // client lookup
|
||||
[], // expireTimedOutSessions active query
|
||||
[] // existing active check
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions",
|
||||
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(insertedValues.some((v) => v.table === "sessions")).toBe(true);
|
||||
expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-managers via requireRole guard", async () => {
|
||||
const app = createApp(GROOMER_STAFF, ["manager"]);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions",
|
||||
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/forbidden/i);
|
||||
});
|
||||
|
||||
it("returns 403 when staff record not found", async () => {
|
||||
const app = createApp(null);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions",
|
||||
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 404 when client not found", async () => {
|
||||
const app = createApp(MANAGER_STAFF, ["manager"]);
|
||||
selectQueue.push(
|
||||
[] // client not found
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions",
|
||||
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 409 when active session already exists", async () => {
|
||||
const app = createApp(MANAGER_STAFF, ["manager"]);
|
||||
const existing = makeSession();
|
||||
selectQueue.push(
|
||||
[CLIENT], // client lookup
|
||||
[], // expireTimedOutSessions
|
||||
[existing] // existing active session
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions",
|
||||
jsonPost("/impersonation/sessions", { clientId: CLIENT.id })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/already have an active/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /sessions/:id — Authorization ───────────────────────────────────────
|
||||
|
||||
describe("GET /impersonation/sessions/:id", () => {
|
||||
it("returns session for the owning staff member", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session] // session lookup
|
||||
);
|
||||
|
||||
const res = await app.request("/impersonation/sessions/session-uuid-1");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 403 for a different staff member", async () => {
|
||||
const app = createApp(GROOMER_STAFF);
|
||||
const session = makeSession(); // owned by manager
|
||||
selectQueue.push(
|
||||
[session] // session lookup
|
||||
);
|
||||
|
||||
const res = await app.request("/impersonation/sessions/session-uuid-1");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/not your session/i);
|
||||
});
|
||||
|
||||
it("returns 404 for nonexistent session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
selectQueue.push(
|
||||
[] // no session
|
||||
);
|
||||
|
||||
const res = await app.request("/impersonation/sessions/nonexistent");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("auto-expires a timed-out session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession({ expiresAt: pastDate() });
|
||||
selectQueue.push(
|
||||
[session] // session lookup
|
||||
);
|
||||
|
||||
const res = await app.request("/impersonation/sessions/session-uuid-1");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe("expired");
|
||||
// Should have called update to mark expired
|
||||
expect(updatedValues).toHaveLength(1);
|
||||
expect(updatedValues[0]!.set.status).toBe("expired");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/extend ───────────────────────────────────────────────
|
||||
|
||||
describe("POST /impersonation/sessions/:id/extend", () => {
|
||||
it("extends an active non-expired session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session] // session lookup
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/extend",
|
||||
{ method: "POST" }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// Should have extended (updated expiresAt) and logged
|
||||
expect(updatedValues).toHaveLength(1);
|
||||
expect(insertedValues.some((v) => {
|
||||
const vals = v.vals as Record<string, unknown>;
|
||||
return vals.action === "session_extended";
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 400 when extending a time-expired session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession({ expiresAt: pastDate() });
|
||||
selectQueue.push(
|
||||
[session] // session lookup
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/extend",
|
||||
{ method: "POST" }
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/expired/i);
|
||||
});
|
||||
|
||||
it("returns 403 for non-owner", async () => {
|
||||
const app = createApp(GROOMER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session] // owned by manager
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/extend",
|
||||
{ method: "POST" }
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 400 for an ended session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession({ status: "ended" });
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/extend",
|
||||
{ method: "POST" }
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/not active/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/end ──────────────────────────────────────────────────
|
||||
|
||||
describe("POST /impersonation/sessions/:id/end", () => {
|
||||
it("ends an active non-expired session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/end",
|
||||
{ method: "POST" }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(updatedValues).toHaveLength(1);
|
||||
expect(updatedValues[0]!.set.status).toBe("ended");
|
||||
});
|
||||
|
||||
it("returns 400 when ending a time-expired session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession({ expiresAt: pastDate() });
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/end",
|
||||
{ method: "POST" }
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/expired/i);
|
||||
});
|
||||
|
||||
it("returns 403 for non-owner", async () => {
|
||||
const app = createApp(GROOMER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/end",
|
||||
{ method: "POST" }
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/log — Authorization + expiry ─────────────────────────
|
||||
|
||||
describe("POST /impersonation/sessions/:id/log", () => {
|
||||
const logBody = { action: "page_visit", pageVisited: "/dashboard" };
|
||||
|
||||
it("logs an audit entry for the session owner", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/log",
|
||||
jsonPost("/", logBody)
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 403 for non-owner", async () => {
|
||||
const app = createApp(GROOMER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/log",
|
||||
jsonPost("/", logBody)
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/not your session/i);
|
||||
});
|
||||
|
||||
it("returns 400 when session has expired by time", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession({ expiresAt: pastDate() });
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/log",
|
||||
jsonPost("/", logBody)
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/expired/i);
|
||||
});
|
||||
|
||||
it("returns 400 for an ended session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession({ status: "ended" });
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/log",
|
||||
jsonPost("/", logBody)
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/not active/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /sessions/:id/audit-log — Authorization ────────────────────────────
|
||||
|
||||
describe("GET /impersonation/sessions/:id/audit-log", () => {
|
||||
it("returns audit logs for the session owner", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
const session = makeSession();
|
||||
const logs = [makeAuditLog(), makeAuditLog({ id: "audit-uuid-2", action: "page_visit" })];
|
||||
selectQueue.push(
|
||||
[session], // session lookup
|
||||
logs // audit logs query (where + orderBy chain)
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/audit-log"
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns 403 for non-owner", async () => {
|
||||
const app = createApp(GROOMER_STAFF);
|
||||
const session = makeSession();
|
||||
selectQueue.push(
|
||||
[session]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/session-uuid-1/audit-log"
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/not your session/i);
|
||||
});
|
||||
|
||||
it("returns 404 for nonexistent session", async () => {
|
||||
const app = createApp(MANAGER_STAFF);
|
||||
selectQueue.push(
|
||||
[]
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
"/impersonation/sessions/nonexistent/audit-log"
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,293 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.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 = {
|
||||
...MANAGER,
|
||||
id: "staff-groomer-id",
|
||||
oidcSub: "oidc-groomer-sub",
|
||||
role: "groomer",
|
||||
name: "Groomer Gary",
|
||||
email: "groomer@example.com",
|
||||
};
|
||||
|
||||
// ─── Shared mutable DB state ──────────────────────────────────────────────────
|
||||
|
||||
const PET_ID = "pet-uuid-1234";
|
||||
const PHOTO_KEY = `pets/${PET_ID}/1700000000000.jpg`;
|
||||
|
||||
let dbPetRow: Record<string, unknown> | null;
|
||||
|
||||
function resetDb() {
|
||||
dbPetRow = { id: PET_ID, name: "Biscuit", photoKey: null, photoUploadedAt: null };
|
||||
}
|
||||
|
||||
// ─── Module mocks ─────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("../db", () => {
|
||||
const pets = new Proxy(
|
||||
{ _name: "pets" },
|
||||
{ get(t, p) { return p === "_name" ? "pets" : {}; } }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => (dbPetRow ? [dbPetRow] : []),
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: () => ({
|
||||
where: () => ({
|
||||
returning: () => (dbPetRow ? [{ ...dbPetRow }] : []),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
pets,
|
||||
eq: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../lib/s3.js", () => ({
|
||||
getPresignedUploadUrl: vi.fn().mockResolvedValue("https://storage.example.com/presigned-put"),
|
||||
getPresignedGetUrl: vi.fn().mockResolvedValue("https://storage.example.com/presigned-get"),
|
||||
deleteObject: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// ─── Import after mocks are set up ───────────────────────────────────────────
|
||||
|
||||
const { petsRouter } = await import("../routes/pets.js");
|
||||
|
||||
// ─── App builder ─────────────────────────────────────────────────────────────
|
||||
|
||||
function buildApp(staffRow: StaffRow) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: staffRow.oidcSub ?? "" });
|
||||
c.set("staff", staffRow);
|
||||
await next();
|
||||
});
|
||||
app.route("/pets", petsRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Reset before each test ───────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
resetDb();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ─── POST /:petId/photo/upload-url ───────────────────────────────────────────
|
||||
|
||||
describe("POST /pets/:petId/photo/upload-url", () => {
|
||||
it("returns presigned upload URL and object key for valid image contentType", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 1024 }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { uploadUrl: string; key: string };
|
||||
expect(body.uploadUrl).toBe("https://storage.example.com/presigned-put");
|
||||
expect(body.key).toMatch(/^pets\//);
|
||||
expect(body.key).toContain(PET_ID);
|
||||
});
|
||||
|
||||
it("rejects non-image contentType with 400", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "application/pdf", fileSizeBytes: 1024 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects image/svg+xml with 400 (allowlist enforcement)", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "image/svg+xml", fileSizeBytes: 1024 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects fileSizeBytes over 5 MB with 400", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 6 * 1024 * 1024 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when pet does not exist", async () => {
|
||||
dbPetRow = null;
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "image/jpeg", fileSizeBytes: 1024 }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("allows groomers to request an upload URL", async () => {
|
||||
const app = buildApp(GROOMER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/upload-url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "image/png", fileSizeBytes: 1024 }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /:petId/photo/confirm ───────────────────────────────────────────────
|
||||
|
||||
describe("POST /pets/:petId/photo/confirm", () => {
|
||||
it("confirms upload and returns ok: true", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: PHOTO_KEY }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 400 when key is missing", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when pet does not exist", async () => {
|
||||
dbPetRow = null;
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: PHOTO_KEY }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when key does not belong to the pet", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: "pets/other-pet-id/1700000000000.jpg" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/invalid key/i);
|
||||
});
|
||||
|
||||
it("deletes old photo from storage when re-uploading", async () => {
|
||||
const { deleteObject } = await import("../lib/s3.js");
|
||||
const oldKey = `pets/${PET_ID}/old.jpg`;
|
||||
dbPetRow = { ...dbPetRow!, photoKey: oldKey };
|
||||
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: PHOTO_KEY }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(deleteObject).toHaveBeenCalledWith(oldKey);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /:petId/photo ────────────────────────────────────────────────────
|
||||
|
||||
describe("DELETE /pets/:petId/photo", () => {
|
||||
it("returns 404 with 'no photo' message when pet has no photo", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" });
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/no photo/i);
|
||||
});
|
||||
|
||||
it("deletes photo and returns ok: true when photo exists", async () => {
|
||||
dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY };
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 when pet does not exist", async () => {
|
||||
dbPetRow = null;
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo`, { method: "DELETE" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /:petId/photo ────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /pets/:petId/photo", () => {
|
||||
it("returns 404 when pet has no photo", async () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns presigned GET URL when photo exists", async () => {
|
||||
dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY };
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { url: string; photoKey: string };
|
||||
expect(body.url).toBe("https://storage.example.com/presigned-get");
|
||||
expect(body.photoKey).toBe(PHOTO_KEY);
|
||||
});
|
||||
|
||||
it("returns 404 when pet does not exist", async () => {
|
||||
dbPetRow = null;
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("groomer can read photo URL", async () => {
|
||||
dbPetRow = { ...dbPetRow!, photoKey: PHOTO_KEY };
|
||||
const app = buildApp(GROOMER);
|
||||
const res = await app.request(`/pets/${PET_ID}/photo`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,414 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
import { petsRouter } from "../routes/pets.js";
|
||||
|
||||
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
||||
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: null,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// ─── 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", () => {
|
||||
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: (...conds: unknown[]) => conds,
|
||||
eq: (col: unknown, val: unknown) => ({ col, val }),
|
||||
exists: (q: unknown) => q,
|
||||
or: (...conds: unknown[]) => conds,
|
||||
};
|
||||
});
|
||||
|
||||
// ─── 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: "smooth" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.coatType).toBe("smooth");
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,423 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const APPOINTMENT_ID = "660e8400-e29b-41d4-a716-446655440002";
|
||||
const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003";
|
||||
|
||||
const futureDate = () => new Date(Date.now() + 30 * 60 * 1000);
|
||||
const pastDate = () => new Date(Date.now() - 5 * 60 * 1000);
|
||||
|
||||
const ACTIVE_SESSION = {
|
||||
id: SESSION_ID,
|
||||
clientId: CLIENT_ID,
|
||||
status: "active" as const,
|
||||
expiresAt: futureDate(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const EXPIRED_SESSION = {
|
||||
id: SESSION_ID,
|
||||
clientId: CLIENT_ID,
|
||||
status: "active" as const,
|
||||
expiresAt: pastDate(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const APPOINTMENT = {
|
||||
id: APPOINTMENT_ID,
|
||||
clientId: CLIENT_ID,
|
||||
startTime: futureDate(),
|
||||
endTime: futureDate(),
|
||||
customerNotes: null,
|
||||
confirmationToken: "secret-token-leak-test",
|
||||
status: "scheduled" as const,
|
||||
confirmationStatus: "pending" as const,
|
||||
confirmedAt: null,
|
||||
cancelledAt: null,
|
||||
};
|
||||
|
||||
let selectSessionRow: Record<string, unknown> | null = null;
|
||||
let selectAppointmentRow: Record<string, unknown> | null = null;
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
|
||||
function resetMock() {
|
||||
selectSessionRow = null;
|
||||
selectAppointmentRow = null;
|
||||
updatedValues = [];
|
||||
}
|
||||
|
||||
vi.mock("../db", () => {
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
||||
return () => chain;
|
||||
}
|
||||
// @ts-expect-error proxy
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
const impersonationSessions = new Proxy(
|
||||
{ _name: "impersonationSessions" },
|
||||
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
|
||||
);
|
||||
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: { _name: string }) => {
|
||||
if (table._name === "impersonationSessions") {
|
||||
return makeChainable(selectSessionRow ? [selectSessionRow] : []);
|
||||
}
|
||||
if (table._name === "appointments") {
|
||||
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
|
||||
}
|
||||
return makeChainable([]);
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => ({
|
||||
returning: () => {
|
||||
if (selectAppointmentRow) {
|
||||
const updated = { ...selectAppointmentRow, ...vals };
|
||||
updatedValues.push(vals);
|
||||
return [updated];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
impersonationSessions,
|
||||
appointments,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { portalRouter } = await import("../routes/portal.js");
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/portal", portalRouter);
|
||||
|
||||
function jsonPatch(path: string, body: unknown, headers?: Record<string, string>) {
|
||||
return app.request(path, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => resetMock());
|
||||
|
||||
describe("PATCH /portal/appointments/:id/notes", () => {
|
||||
it("returns updated appointment with safe fields only", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: "Please be gentle with Fido" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty("id");
|
||||
expect(body).toHaveProperty("customerNotes", "Please be gentle with Fido");
|
||||
expect(body).toHaveProperty("updatedAt");
|
||||
expect(body).not.toHaveProperty("confirmationToken");
|
||||
expect(body).not.toHaveProperty("clientId");
|
||||
});
|
||||
|
||||
it("returns 401 without X-Impersonation-Session-Id header", async () => {
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: "Test note" }
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("returns 401 with expired session", async () => {
|
||||
selectSessionRow = EXPIRED_SESSION;
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: "Test note" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("returns 401 with ended session", async () => {
|
||||
selectSessionRow = null;
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: "Test note" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("returns 403 when appointment belongs to different client", async () => {
|
||||
selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" };
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: "Test note" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Forbidden");
|
||||
});
|
||||
|
||||
it("returns 422 for past appointment", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() };
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: "Test note" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/past|in-progress|cannot edit/i);
|
||||
});
|
||||
|
||||
it("returns 422 when appointment is in progress", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, startTime: new Date(Date.now() - 2 * 60 * 1000) };
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: "Test note" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 404 when appointment not found", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = null;
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/nonexistent-id/notes`,
|
||||
{ customerNotes: "Test note" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("accepts notes at exactly 500 characters", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
const longNote = "a".repeat(500);
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: longNote },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.customerNotes).toBe(longNote);
|
||||
});
|
||||
|
||||
it("rejects notes exceeding 500 characters", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
const longNote = "a".repeat(501);
|
||||
const res = await jsonPatch(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/notes`,
|
||||
{ customerNotes: longNote },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /portal/appointments/:id/confirm ────────────────────────────────────
|
||||
|
||||
function jsonPost(path: string, headers?: Record<string, string>) {
|
||||
return app.request(path, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /portal/appointments/:id/confirm", () => {
|
||||
it("confirms a pending appointment and returns updated status", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "pending" };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.confirmationStatus).toBe("confirmed");
|
||||
expect(body).toHaveProperty("confirmedAt");
|
||||
});
|
||||
|
||||
it("returns 401 without X-Impersonation-Session-Id header", async () => {
|
||||
const res = await jsonPost(`/portal/appointments/${APPOINTMENT_ID}/confirm`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 401 with expired session", async () => {
|
||||
selectSessionRow = EXPIRED_SESSION;
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 when appointment belongs to a different client", async () => {
|
||||
selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" };
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 422 when appointment is in the past", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when appointment is not pending confirmation", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "confirmed" };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when cancelling an already-cancelled appointment", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, status: "cancelled", confirmationStatus: "cancelled" };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 404 when appointment not found", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = null;
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/nonexistent-id/confirm`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /portal/appointments/:id/cancel ─────────────────────────────────────
|
||||
|
||||
describe("POST /portal/appointments/:id/cancel", () => {
|
||||
it("cancels a pending appointment and returns updated status", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "pending" };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe("cancelled");
|
||||
expect(body.confirmationStatus).toBe("cancelled");
|
||||
expect(body).toHaveProperty("cancelledAt");
|
||||
});
|
||||
|
||||
it("returns 401 without X-Impersonation-Session-Id header", async () => {
|
||||
const res = await jsonPost(`/portal/appointments/${APPOINTMENT_ID}/cancel`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 401 with expired session", async () => {
|
||||
selectSessionRow = EXPIRED_SESSION;
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 when appointment belongs to a different client", async () => {
|
||||
selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" };
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 422 when appointment is in the past", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when appointment is already cancelled", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, status: "cancelled", confirmationStatus: "cancelled" };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when appointment is already completed", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, status: "completed" };
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 404 when appointment not found", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = null;
|
||||
const res = await jsonPost(
|
||||
`/portal/appointments/nonexistent-id/cancel`,
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,392 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
|
||||
// ─── Mock staff data ──────────────────────────────────────────────────────────
|
||||
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: "ba-user-manager",
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const RECEPTIONIST: StaffRow = {
|
||||
...MANAGER,
|
||||
id: "staff-receptionist-id",
|
||||
oidcSub: "oidc-receptionist-sub",
|
||||
userId: "ba-user-receptionist",
|
||||
role: "receptionist",
|
||||
isSuperUser: false,
|
||||
name: "Receptionist Rita",
|
||||
email: "receptionist@example.com",
|
||||
};
|
||||
|
||||
const GROOMER: StaffRow = {
|
||||
...MANAGER,
|
||||
id: "staff-groomer-id",
|
||||
oidcSub: "oidc-groomer-sub",
|
||||
userId: "ba-user-groomer",
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
name: "Groomer Gary",
|
||||
email: "groomer@example.com",
|
||||
};
|
||||
|
||||
// ─── Mock DB ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let staffLookupResult: StaffRow | null = null;
|
||||
let managerFallbackResult: StaffRow | null = MANAGER;
|
||||
|
||||
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 };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
staff,
|
||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||
and: vi.fn((..._clauses: unknown[]) => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function resetMocks() {
|
||||
staffLookupResult = null;
|
||||
managerFallbackResult = MANAGER;
|
||||
}
|
||||
|
||||
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
|
||||
function buildApp(
|
||||
middleware: MiddlewareHandler<AppEnv>,
|
||||
handler?: (c: Context<AppEnv>) => Response | Promise<Response>
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
|
||||
await next();
|
||||
});
|
||||
app.use("*", middleware);
|
||||
const h = handler ?? ((c: Context<AppEnv>) => c.json({ ok: true }));
|
||||
app.get("/test", h);
|
||||
app.post("/test", h);
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Build app with staff pre-set in context (skips resolveStaffMiddleware). */
|
||||
function buildWithStaff(
|
||||
staffRow: StaffRow,
|
||||
guard: MiddlewareHandler<AppEnv>
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: staffRow.userId ?? "" });
|
||||
c.set("staff", staffRow);
|
||||
await next();
|
||||
});
|
||||
app.use("*", guard);
|
||||
app.get("/test", (c) => c.json({ ok: true }));
|
||||
app.post("/test", (c) => c.json({ ok: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Import middleware ────────────────────────────────────────────────────────
|
||||
|
||||
const { resolveStaffMiddleware, requireRole, requireSuperUser, requireRoleOrSuperUser } = await import(
|
||||
"../middleware/rbac.js"
|
||||
);
|
||||
|
||||
beforeEach(() => resetMocks());
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.AUTH_DISABLED;
|
||||
});
|
||||
|
||||
// ─── resolveStaffMiddleware tests ─────────────────────────────────────────────
|
||||
|
||||
describe("resolveStaffMiddleware", () => {
|
||||
it("resolves staff from DB and sets it on context", async () => {
|
||||
staffLookupResult = MANAGER;
|
||||
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!.id).toBe(MANAGER.id);
|
||||
});
|
||||
|
||||
it("returns 403 when no staff record found for the OIDC sub", async () => {
|
||||
staffLookupResult = 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/i);
|
||||
});
|
||||
|
||||
it("dev mode: resolves staff by X-Dev-User-Id header", async () => {
|
||||
process.env.AUTH_DISABLED = "true";
|
||||
staffLookupResult = GROOMER;
|
||||
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", {
|
||||
headers: { "X-Dev-User-Id": GROOMER.id },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedStaff!.role).toBe("groomer");
|
||||
});
|
||||
|
||||
it("dev mode: falls back to first manager when no X-Dev-User-Id header", async () => {
|
||||
process.env.AUTH_DISABLED = "true";
|
||||
managerFallbackResult = MANAGER;
|
||||
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!.role).toBe("manager");
|
||||
});
|
||||
|
||||
it("dev mode: returns 403 when no manager exists and no header provided", async () => {
|
||||
process.env.AUTH_DISABLED = "true";
|
||||
managerFallbackResult = 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 records found/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── requireRole tests ────────────────────────────────────────────────────────
|
||||
|
||||
describe("requireRole", () => {
|
||||
it("allows access when staff role matches the only allowed role", async () => {
|
||||
const app = buildWithStaff(MANAGER, requireRole("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows access when staff role is one of multiple allowed roles", async () => {
|
||||
const app = buildWithStaff(RECEPTIONIST, requireRole("manager", "receptionist"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 403 for an unauthorized role", async () => {
|
||||
const app = buildWithStaff(GROOMER, requireRole("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/forbidden/i);
|
||||
expect(body.error).toContain("groomer");
|
||||
});
|
||||
|
||||
it("includes the role name in the 403 error message", async () => {
|
||||
const app = buildWithStaff(RECEPTIONIST, requireRole("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toContain("receptionist");
|
||||
});
|
||||
|
||||
it("groomer is blocked from manager+receptionist-only routes", async () => {
|
||||
const app = buildWithStaff(GROOMER, requireRole("manager", "receptionist"));
|
||||
const res = await app.request("/test", { method: "POST" });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("manager passes all-role checks", async () => {
|
||||
const app = buildWithStaff(MANAGER, requireRole("manager", "receptionist", "groomer"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 403 with JSON body (not plain text)", async () => {
|
||||
const app = buildWithStaff(GROOMER, requireRole("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
expect(contentType).toContain("application/json");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── requireSuperUser tests ─────────────────────────────────────────────────
|
||||
|
||||
describe("requireSuperUser", () => {
|
||||
it("allows access when staff is a super user", async () => {
|
||||
const app = buildWithStaff(MANAGER, requireSuperUser());
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows access when manager is also a super user", async () => {
|
||||
// MANAGER has isSuperUser: true
|
||||
const app = buildWithStaff(MANAGER, requireSuperUser());
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 403 for a non-super-user receptionist", async () => {
|
||||
// RECEPTIONIST has isSuperUser: false
|
||||
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/super user privileges required/i);
|
||||
});
|
||||
|
||||
it("returns 403 for a non-super-user groomer", async () => {
|
||||
// GROOMER has isSuperUser: false
|
||||
const app = buildWithStaff(GROOMER, requireSuperUser());
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 403 when staff record is not resolved", async () => {
|
||||
// Manually remove staff from context to simulate unresolved staff
|
||||
const testApp = new Hono<AppEnv>();
|
||||
testApp.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: "test-sub" });
|
||||
// Do NOT set staff - simulate unresolved staff
|
||||
await next();
|
||||
});
|
||||
testApp.use("*", requireSuperUser());
|
||||
testApp.get("/test", (c) => c.json({ ok: true }));
|
||||
const res = await testApp.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/staff record not resolved/i);
|
||||
});
|
||||
|
||||
it("receptionist cannot grant super user status on staff PATCH", async () => {
|
||||
// This tests the inline guard in staff.ts handler, not the middleware itself,
|
||||
// but we test requireSuperUser to verify the middleware correctly blocks
|
||||
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
|
||||
const res = await app.request("/test", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isSuperUser: true }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/super user privileges required/i);
|
||||
});
|
||||
|
||||
it("returns 403 with JSON body for super user violation", async () => {
|
||||
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
expect(contentType).toContain("application/json");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── requireRoleOrSuperUser tests ─────────────────────────────────────────────
|
||||
|
||||
describe("requireRoleOrSuperUser", () => {
|
||||
it("allows a manager to access manager-only routes", async () => {
|
||||
const app = buildWithStaff(MANAGER, requireRoleOrSuperUser("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows a super user with receptionist role to access manager-only routes (GRO-412 bug fix)", async () => {
|
||||
// GRO-412: a receptionist granted super user via Staff UI should access admin routes
|
||||
const superReceptionist: StaffRow = {
|
||||
...RECEPTIONIST,
|
||||
isSuperUser: true,
|
||||
};
|
||||
const app = buildWithStaff(superReceptionist, requireRoleOrSuperUser("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows a super user with groomer role to access manager-only routes", async () => {
|
||||
const superGroomer: StaffRow = {
|
||||
...GROOMER,
|
||||
isSuperUser: true,
|
||||
};
|
||||
const app = buildWithStaff(superGroomer, requireRoleOrSuperUser("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("blocks a non-super-user receptionist from manager-only routes", async () => {
|
||||
const app = buildWithStaff(RECEPTIONIST, requireRoleOrSuperUser("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/role.*not permitted/i);
|
||||
});
|
||||
|
||||
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
||||
const app = buildWithStaff(GROOMER, requireRoleOrSuperUser("manager"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/role.*not permitted/i);
|
||||
});
|
||||
|
||||
it("allows a manager with multiple allowed roles", async () => {
|
||||
const app = buildWithStaff(MANAGER, requireRoleOrSuperUser("manager", "receptionist"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows a super user with disallowed role to access route with multiple allowed roles", async () => {
|
||||
const superGroomer: StaffRow = {
|
||||
...GROOMER,
|
||||
isSuperUser: true,
|
||||
};
|
||||
const app = buildWithStaff(superGroomer, requireRoleOrSuperUser("manager", "receptionist"));
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
// ─── Mock data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ACTIVE_CLIENT = {
|
||||
id: "client-1",
|
||||
name: "Alice Johnson",
|
||||
email: "alice@example.com",
|
||||
phone: "555-1234",
|
||||
};
|
||||
|
||||
const PET_ROW = {
|
||||
id: "pet-1",
|
||||
name: "Bella",
|
||||
breed: "Golden Retriever",
|
||||
clientId: "client-1",
|
||||
ownerName: "Alice Johnson",
|
||||
};
|
||||
|
||||
// ─── Mock DB ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let clientResults: typeof ACTIVE_CLIENT[] = [];
|
||||
let petResults: typeof PET_ROW[] = [];
|
||||
|
||||
vi.mock("../db", () => {
|
||||
// Proxy objects for table/column references — values don't matter for tests
|
||||
const tableProxy = (name: string) =>
|
||||
new Proxy(
|
||||
{ _name: name },
|
||||
{ get: (t, p) => (p === "_name" ? name : { table: name, column: p }) }
|
||||
);
|
||||
|
||||
const clients = tableProxy("clients");
|
||||
const pets = tableProxy("pets");
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: (_fields?: unknown) => {
|
||||
// Route which mock results to use based on a global flag set per test
|
||||
return {
|
||||
from: (table: { _name?: string }) => {
|
||||
const results = table._name === "pets" ? petResults : clientResults;
|
||||
const chain: Record<string, unknown> = {};
|
||||
chain.where = () => chain;
|
||||
chain.innerJoin = () => chain;
|
||||
chain.limit = () => Promise.resolve(results);
|
||||
return chain;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
clients,
|
||||
pets,
|
||||
and: (...args: unknown[]) => ({ and: args }),
|
||||
or: (...args: unknown[]) => ({ or: args }),
|
||||
eq: (a: unknown, b: unknown) => ({ eq: [a, b] }),
|
||||
ilike: (col: unknown, pat: unknown) => ({ ilike: [col, pat] }),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── App under test ───────────────────────────────────────────────────────────
|
||||
|
||||
async function makeApp() {
|
||||
const { searchRouter } = await import("../routes/search.js");
|
||||
const app = new Hono();
|
||||
app.route("/search", searchRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
clientResults = [];
|
||||
petResults = [];
|
||||
});
|
||||
|
||||
describe("GET /search", () => {
|
||||
it("returns 400 when q is missing", async () => {
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns 400 when q is empty string", async () => {
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search?q=");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when q is only whitespace", async () => {
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search?q= ");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns matching clients and pets", async () => {
|
||||
clientResults = [ACTIVE_CLIENT];
|
||||
petResults = [PET_ROW];
|
||||
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search?q=bell");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.clients).toEqual([ACTIVE_CLIENT]);
|
||||
expect(body.pets).toEqual([PET_ROW]);
|
||||
});
|
||||
|
||||
it("returns empty arrays when no matches", async () => {
|
||||
clientResults = [];
|
||||
petResults = [];
|
||||
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search?q=xyzzy");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.clients).toEqual([]);
|
||||
expect(body.pets).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns shape with clients and pets keys", async () => {
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search?q=a");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty("clients");
|
||||
expect(body).toHaveProperty("pets");
|
||||
expect(Array.isArray(body.clients)).toBe(true);
|
||||
expect(Array.isArray(body.pets)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles special characters in query without throwing", async () => {
|
||||
clientResults = [];
|
||||
petResults = [];
|
||||
|
||||
const app = await makeApp();
|
||||
// These characters should be escaped, not cause errors
|
||||
const res = await app.request("/search?q=foo%25bar_baz");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeLike helper (via integration)", () => {
|
||||
it("% in query does not break the request", async () => {
|
||||
clientResults = [];
|
||||
petResults = [];
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search?q=%25");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("_ in query does not break the request", async () => {
|
||||
clientResults = [];
|
||||
petResults = [];
|
||||
const app = await makeApp();
|
||||
const res = await app.request("/search?q=_");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,431 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// ─── Test configuration constants (must match seed.ts) ─────────────────────────
|
||||
|
||||
const UAT_ACCOUNTS = [
|
||||
{
|
||||
email: "uat-super@groombook.dev",
|
||||
name: "UAT Super User",
|
||||
passwordEnv: "SEED_UAT_SUPER_PASSWORD",
|
||||
staffEmail: "uat-super@groombook.dev",
|
||||
},
|
||||
{
|
||||
email: "uat-groomer@groombook.dev",
|
||||
name: "UAT Staff Groomer",
|
||||
passwordEnv: "SEED_UAT_GROOMER_PASSWORD",
|
||||
staffEmail: "uat-groomer@groombook.dev",
|
||||
},
|
||||
{
|
||||
email: "uat-customer@groombook.dev",
|
||||
name: "UAT Customer",
|
||||
passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD",
|
||||
staffEmail: null,
|
||||
},
|
||||
{
|
||||
email: "uat-tester@groombook.dev",
|
||||
name: "UAT Tester",
|
||||
passwordEnv: "SEED_UAT_TESTER_PASSWORD",
|
||||
staffEmail: "uat-tester@groombook.dev",
|
||||
},
|
||||
];
|
||||
|
||||
const TEST_PASSWORD = "test-password-123";
|
||||
|
||||
// ─── Password hashing — must match better-auth/crypto (N=16384, r=16, p=1, dkLen=64, hex) ───
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
const { hashPassword } = await import("better-auth/crypto");
|
||||
return hashPassword(password);
|
||||
}
|
||||
|
||||
// ─── Mock DB state ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
emailVerified: boolean;
|
||||
}
|
||||
|
||||
interface AccountRow {
|
||||
id: string;
|
||||
accountId: string;
|
||||
providerId: string;
|
||||
userId: string;
|
||||
password: string | null;
|
||||
}
|
||||
|
||||
interface StaffRow {
|
||||
id: string;
|
||||
email: string;
|
||||
userId: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
let dbUsers: UserRow[] = [];
|
||||
let dbAccounts: AccountRow[] = [];
|
||||
let dbStaff: StaffRow[] = [];
|
||||
let insertedUsers: UserRow[] = [];
|
||||
let insertedAccounts: AccountRow[] = [];
|
||||
let updatedStaff: Array<{ id: string; userId: string }> = [];
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
function resetMock() {
|
||||
dbUsers = [];
|
||||
dbAccounts = [];
|
||||
dbStaff = [];
|
||||
insertedUsers = [];
|
||||
insertedAccounts = [];
|
||||
updatedStaff = [];
|
||||
process.env = { ...originalEnv };
|
||||
}
|
||||
|
||||
// ─── Mock schema ───────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSchemaMock() {
|
||||
const user = new Proxy({ _name: "user" }, {
|
||||
get(_t, p) {
|
||||
if (p === "_name") return "user";
|
||||
if (p === "$inferSelect") return {};
|
||||
return { table: "user", column: p };
|
||||
},
|
||||
});
|
||||
|
||||
const account = new Proxy({ _name: "account" }, {
|
||||
get(_t, p) {
|
||||
if (p === "_name") return "account";
|
||||
if (p === "$inferSelect") return {};
|
||||
return { table: "account", column: p };
|
||||
},
|
||||
});
|
||||
|
||||
const staff = new Proxy({ _name: "staff" }, {
|
||||
get(_t, p) {
|
||||
if (p === "_name") return "staff";
|
||||
if (p === "$inferSelect") return {};
|
||||
return { table: "staff", column: p };
|
||||
},
|
||||
});
|
||||
|
||||
return { user, account, staff };
|
||||
}
|
||||
|
||||
const { user: mockUser, account: mockAccount, staff: mockStaff } = makeSchemaMock();
|
||||
|
||||
function eq(col: unknown, val: unknown) {
|
||||
return { __type: "eq" as const, col, val };
|
||||
}
|
||||
|
||||
function and(...conds: unknown[]) {
|
||||
return { __type: "and" as const, conds };
|
||||
}
|
||||
|
||||
// ─── Seed logic helper ─────────────────────────────────────────────────────────
|
||||
// Inline the credential provisioning logic under test so we can call it directly.
|
||||
// This is the same logic as seed.ts lines 514-598.
|
||||
|
||||
interface SeedAccount {
|
||||
email: string;
|
||||
name: string;
|
||||
passwordEnv: string;
|
||||
staffEmail: string | null;
|
||||
}
|
||||
|
||||
let uuidCounter = 0;
|
||||
function mockUuid(): string {
|
||||
return `mock-uuid-${++uuidCounter}`;
|
||||
}
|
||||
|
||||
async function seedUatCredentials(
|
||||
accounts: SeedAccount[],
|
||||
opts: {
|
||||
users?: UserRow[];
|
||||
accounts?: AccountRow[];
|
||||
staff?: StaffRow[];
|
||||
}
|
||||
) {
|
||||
const { users = dbUsers, accounts: accts = dbAccounts, staff: staffRows = dbStaff } = opts;
|
||||
|
||||
for (const acct of accounts) {
|
||||
const password = process.env[acct.passwordEnv];
|
||||
if (!password) {
|
||||
console.warn(`⚠ Skipping ${acct.email} — ${acct.passwordEnv} not set`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Find or create the Better-Auth user
|
||||
const existingUser = users.find((u) => u.email === acct.email);
|
||||
|
||||
let userId: string;
|
||||
if (existingUser) {
|
||||
userId = existingUser.id;
|
||||
} else {
|
||||
userId = mockUuid();
|
||||
const newUser: UserRow = { id: userId, name: acct.name, email: acct.email, emailVerified: true };
|
||||
insertedUsers.push(newUser);
|
||||
dbUsers.push(newUser);
|
||||
}
|
||||
|
||||
// 2. Check if credential account already exists
|
||||
const existingAccount = accts.find(
|
||||
(a) => a.userId === userId && a.providerId === "credential"
|
||||
);
|
||||
|
||||
if (existingAccount) {
|
||||
// skip — already has credential account
|
||||
} else {
|
||||
// Use Better-Auth's hashPassword so test helper matches production seed.ts
|
||||
const { hashPassword } = await import("better-auth/crypto");
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
const newAccount: AccountRow = {
|
||||
id: mockUuid(),
|
||||
accountId: userId,
|
||||
providerId: "credential",
|
||||
userId,
|
||||
password: passwordHash,
|
||||
};
|
||||
insertedAccounts.push(newAccount);
|
||||
dbAccounts.push(newAccount);
|
||||
}
|
||||
|
||||
// 3. Link staff record to Better-Auth user
|
||||
if (acct.staffEmail) {
|
||||
const existingStaff = staffRows.find((s) => s.email === acct.staffEmail);
|
||||
if (existingStaff && !existingStaff.userId) {
|
||||
existingStaff.userId = userId;
|
||||
updatedStaff.push({ id: existingStaff.id, userId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("seedUatCredentials — credential provisioning logic", () => {
|
||||
beforeEach(() => {
|
||||
resetMock();
|
||||
uuidCounter = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
// ── AC-1: creates user + account when neither exists ──────────────────────
|
||||
|
||||
it("AC-1: creates user and account for each UAT account with password env var set", async () => {
|
||||
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
|
||||
process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD;
|
||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
||||
process.env.SEED_UAT_TESTER_PASSWORD = TEST_PASSWORD;
|
||||
|
||||
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
|
||||
|
||||
// 4 users created (customer + tester have no staff, super + groomer do)
|
||||
expect(insertedUsers).toHaveLength(4);
|
||||
expect(insertedUsers.find((u) => u.email === "uat-super@groombook.dev")).toBeDefined();
|
||||
expect(insertedUsers.find((u) => u.email === "uat-groomer@groombook.dev")).toBeDefined();
|
||||
expect(insertedUsers.find((u) => u.email === "uat-customer@groombook.dev")).toBeDefined();
|
||||
expect(insertedUsers.find((u) => u.email === "uat-tester@groombook.dev")).toBeDefined();
|
||||
|
||||
// 4 accounts created
|
||||
expect(insertedAccounts).toHaveLength(4);
|
||||
for (const acct of insertedAccounts) {
|
||||
expect(acct.providerId).toBe("credential");
|
||||
// Better-Auth uses hex encoding: saltHex:keyHex (both lowercase hex)
|
||||
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
||||
// Verify the hash is scrypt with correct params (N=16384, r=16, p=1, dkLen=64)
|
||||
const parts = acct.password!.split(":");
|
||||
const saltHex = parts[0]!;
|
||||
const keyHex = parts[1]!;
|
||||
const salt = Buffer.from(saltHex, "hex");
|
||||
const storedHash = Buffer.from(keyHex, "hex");
|
||||
expect(salt).toHaveLength(16);
|
||||
expect(storedHash).toHaveLength(64);
|
||||
}
|
||||
});
|
||||
|
||||
// ── AC-2: emailVerified = true ─────────────────────────────────────────────
|
||||
|
||||
it("AC-2: created users have emailVerified = true", async () => {
|
||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
||||
|
||||
await seedUatCredentials(
|
||||
[UAT_ACCOUNTS[2]!], // customer only
|
||||
{ users: [], accounts: [], staff: [] }
|
||||
);
|
||||
|
||||
expect(insertedUsers[0]!.emailVerified).toBe(true);
|
||||
});
|
||||
|
||||
// ── AC-3: providerId = credential, password is hashed ──────────────────────
|
||||
|
||||
it("AC-3: account records use providerId='credential' with properly formatted hashed password", async () => {
|
||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
||||
|
||||
await seedUatCredentials(
|
||||
[UAT_ACCOUNTS[2]!],
|
||||
{ users: [], accounts: [], staff: [] }
|
||||
);
|
||||
|
||||
const acct = insertedAccounts[0]!;
|
||||
expect(acct.providerId).toBe("credential");
|
||||
// Better-Auth uses hex: saltHex (32 chars) : keyHex (128 chars)
|
||||
expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
||||
const parts = acct.password!.split(":");
|
||||
const saltHex = parts[0]!;
|
||||
const keyHex = parts[1]!;
|
||||
expect(() => Buffer.from(saltHex, "hex")).not.toThrow();
|
||||
expect(() => Buffer.from(keyHex, "hex")).not.toThrow();
|
||||
const salt = Buffer.from(saltHex, "hex");
|
||||
const storedHash = Buffer.from(keyHex, "hex");
|
||||
expect(salt).toHaveLength(16);
|
||||
expect(storedHash).toHaveLength(64);
|
||||
});
|
||||
|
||||
// ── AC-4: staff.userId is linked ────────────────────────────────────────────
|
||||
|
||||
it("AC-4: links staff.userId to the Better-Auth user when staff record exists", async () => {
|
||||
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
|
||||
const staffRows: StaffRow[] = [
|
||||
{ id: "staff-super-1", email: "uat-super@groombook.dev", userId: null, name: "UAT Super User" },
|
||||
];
|
||||
|
||||
await seedUatCredentials([UAT_ACCOUNTS[0]!], { users: [], accounts: [], staff: staffRows });
|
||||
|
||||
expect(updatedStaff).toHaveLength(1);
|
||||
expect(updatedStaff[0]!.id).toBe("staff-super-1");
|
||||
expect(updatedStaff[0]!.userId).toBe("mock-uuid-1");
|
||||
expect(staffRows[0]!.userId).toBe("mock-uuid-1");
|
||||
});
|
||||
|
||||
it("AC-4b: does not update staff.userId if already set", async () => {
|
||||
process.env.SEED_UAT_GROOMER_PASSWORD = TEST_PASSWORD;
|
||||
const staffRows: StaffRow[] = [
|
||||
{ id: "staff-groomer-1", email: "uat-groomer@groombook.dev", userId: "already-linked", name: "UAT Groomer" },
|
||||
];
|
||||
|
||||
await seedUatCredentials([UAT_ACCOUNTS[1]!], { users: [], accounts: [], staff: staffRows });
|
||||
|
||||
expect(updatedStaff).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── AC-5: idempotent — skips when user already exists ───────────────────────
|
||||
|
||||
it("AC-5: re-running does not duplicate user or account records (idempotent)", async () => {
|
||||
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
|
||||
|
||||
const preExistingUsers: UserRow[] = [
|
||||
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
|
||||
];
|
||||
const preExistingAccounts: AccountRow[] = [
|
||||
{
|
||||
id: "pre-existing-acct",
|
||||
accountId: "pre-existing-user",
|
||||
providerId: "credential",
|
||||
userId: "pre-existing-user",
|
||||
password: await hashPassword(TEST_PASSWORD),
|
||||
},
|
||||
];
|
||||
|
||||
// First call — nothing inserted (user + account pre-exist)
|
||||
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
|
||||
users: preExistingUsers,
|
||||
accounts: preExistingAccounts,
|
||||
staff: [],
|
||||
});
|
||||
|
||||
expect(insertedUsers).toHaveLength(0);
|
||||
expect(insertedAccounts).toHaveLength(0);
|
||||
|
||||
// Second call — still nothing inserted
|
||||
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
|
||||
users: preExistingUsers,
|
||||
accounts: preExistingAccounts,
|
||||
staff: [],
|
||||
});
|
||||
|
||||
expect(insertedUsers).toHaveLength(0);
|
||||
expect(insertedAccounts).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── AC-6: missing env var skips with warning ────────────────────────────────
|
||||
|
||||
it("AC-6: missing SEED_UAT_*_PASSWORD env var skips that account (no error)", async () => {
|
||||
// No env vars set at all
|
||||
delete process.env.SEED_UAT_SUPER_PASSWORD;
|
||||
delete process.env.SEED_UAT_GROOMER_PASSWORD;
|
||||
delete process.env.SEED_UAT_CUSTOMER_PASSWORD;
|
||||
delete process.env.SEED_UAT_TESTER_PASSWORD;
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
|
||||
|
||||
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
|
||||
|
||||
// Nothing created
|
||||
expect(insertedUsers).toHaveLength(0);
|
||||
expect(insertedAccounts).toHaveLength(0);
|
||||
// Warning logged for each of the 4 accounts
|
||||
expect(warnSpy).toHaveBeenCalledTimes(4);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"⚠ Skipping uat-super@groombook.dev — SEED_UAT_SUPER_PASSWORD not set"
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
// ── AC-7: partial env var coverage ─────────────────────────────────────────
|
||||
|
||||
it("AC-7: only accounts with password env var set are provisioned", async () => {
|
||||
process.env.SEED_UAT_SUPER_PASSWORD = TEST_PASSWORD;
|
||||
// Only super has password set
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);
|
||||
|
||||
await seedUatCredentials(UAT_ACCOUNTS, { users: [], accounts: [], staff: [] });
|
||||
|
||||
expect(insertedUsers).toHaveLength(1);
|
||||
expect(insertedUsers[0]!.email).toBe("uat-super@groombook.dev");
|
||||
expect(insertedAccounts).toHaveLength(1);
|
||||
expect(insertedAccounts[0]!.accountId).toBe("mock-uuid-1");
|
||||
|
||||
// 3 warnings for missing accounts
|
||||
expect(warnSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Password hash format verification ───────────────────────────────────────
|
||||
|
||||
describe("password hash format — scrypt parameters", () => {
|
||||
it("hashes use salt:hash format with 16-byte salt and 64-byte output", async () => {
|
||||
const hash = await hashPassword("test-password");
|
||||
const parts = hash.split(":");
|
||||
const saltHex = parts[0]!;
|
||||
const keyHex = parts[1]!;
|
||||
|
||||
expect(hash).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
||||
expect(Buffer.from(saltHex, "hex")).toHaveLength(16);
|
||||
expect(Buffer.from(keyHex, "hex")).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("same password produces different hashes (due to random salt)", async () => {
|
||||
const hash1 = await hashPassword("same-password");
|
||||
const hash2 = await hashPassword("same-password");
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
// Both are valid Better-Auth hex format
|
||||
expect(hash1).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
||||
expect(hash2).toMatch(/^[a-f0-9]+:[a-f0-9]+$/);
|
||||
});
|
||||
|
||||
it("different passwords produce different hashes", async () => {
|
||||
const hash1 = await hashPassword("password1");
|
||||
const hash2 = await hashPassword("password2");
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
});
|
||||
@@ -1,720 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { setupRouter } from "../routes/setup.js";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MockStaff {
|
||||
id: string;
|
||||
role: string;
|
||||
isSuperUser: boolean;
|
||||
}
|
||||
|
||||
// ─── Mock DB state ────────────────────────────────────────────────────────────
|
||||
|
||||
let dbStaffRows: MockStaff[] = [];
|
||||
let dbBusinessSettingsRows: { id: string; businessName: string }[] = [];
|
||||
let dbAuthConfigRows: { id: string; enabled: boolean }[] = [];
|
||||
let insertedAuthConfig: Record<string, unknown>[] = [];
|
||||
let insertedStaff: Record<string, unknown>[] = [];
|
||||
let encryptCalls: string[] = [];
|
||||
|
||||
// Track env vars set per test
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
function resetMock() {
|
||||
dbStaffRows = [];
|
||||
dbBusinessSettingsRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
insertedAuthConfig = [];
|
||||
insertedStaff = [];
|
||||
encryptCalls = [];
|
||||
}
|
||||
|
||||
function clearAuthEnv() {
|
||||
delete process.env.OIDC_ISSUER;
|
||||
delete process.env.OIDC_CLIENT_ID;
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
}
|
||||
|
||||
// ─── Mock db module ───────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("../db", () => {
|
||||
const authProviderConfig = new Proxy(
|
||||
{ _name: "auth_provider_config" },
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === "_name") return "auth_provider_config";
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: "auth_provider_config", column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const staff = new Proxy(
|
||||
{ _name: "staff" },
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === "_name") return "staff";
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: "staff", column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const businessSettings = new Proxy(
|
||||
{ _name: "business_settings" },
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === "_name") return "business_settings";
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: "business_settings", column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Build a shared tx mock that operates on current-state snapshots
|
||||
function makeTxMock() {
|
||||
function getRowsForTable(table: unknown) {
|
||||
if (table === authProviderConfig) return dbAuthConfigRows;
|
||||
if (table === staff) return dbStaffRows;
|
||||
if (table === businessSettings) return dbBusinessSettingsRows;
|
||||
return [];
|
||||
}
|
||||
|
||||
return {
|
||||
select: () => ({
|
||||
from: (table: unknown) => {
|
||||
const rows = getRowsForTable(table);
|
||||
const base = {
|
||||
where: (cond?: unknown) => {
|
||||
const filtered = cond ? rows.filter((r) => evaluateCond(cond, r as Record<string, unknown>)) : rows;
|
||||
return {
|
||||
limit: () => filtered,
|
||||
for: () => ({
|
||||
limit: () => filtered,
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of filtered) yield item;
|
||||
},
|
||||
0: filtered[0],
|
||||
length: filtered.length,
|
||||
}),
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of filtered) yield item;
|
||||
},
|
||||
0: filtered[0],
|
||||
length: filtered.length,
|
||||
};
|
||||
},
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of rows) yield item;
|
||||
},
|
||||
0: rows[0],
|
||||
length: rows.length,
|
||||
};
|
||||
// Some calls use .limit() directly on from() result (no where())
|
||||
(base as any).limit = () => rows;
|
||||
return base;
|
||||
},
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => {
|
||||
const row = { ...vals, id: "new-id-" + Math.random(), createdAt: new Date(), updatedAt: new Date() };
|
||||
if (vals.providerId) {
|
||||
insertedAuthConfig.push(vals);
|
||||
dbAuthConfigRows.push({ id: row.id as string, enabled: vals.enabled as boolean });
|
||||
} else if (vals.email) {
|
||||
// staff insert
|
||||
insertedStaff.push(vals);
|
||||
dbStaffRows.push(row as unknown as MockStaff);
|
||||
} else if (vals.businessName) {
|
||||
dbBusinessSettingsRows.push(row as unknown as { id: string; businessName: string });
|
||||
}
|
||||
return { returning: () => [row] };
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => ({
|
||||
returning: () => {
|
||||
const updated = { ...dbStaffRows[0], ...vals, updatedAt: new Date() };
|
||||
return [updated];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: unknown) => ({
|
||||
where: (cond?: unknown) => {
|
||||
const rows =
|
||||
table === authProviderConfig
|
||||
? dbAuthConfigRows
|
||||
: table === staff
|
||||
? dbStaffRows
|
||||
: table === businessSettings
|
||||
? dbBusinessSettingsRows
|
||||
: [];
|
||||
const filtered = cond ? rows.filter((r) => evaluateCond(cond, r as Record<string, unknown>)) : rows;
|
||||
return {
|
||||
limit: () => filtered,
|
||||
for: () => ({
|
||||
limit: () => filtered,
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of filtered) yield item;
|
||||
},
|
||||
0: filtered[0],
|
||||
length: filtered.length,
|
||||
}),
|
||||
[Symbol.iterator]: function* () {
|
||||
for (const item of filtered) yield item;
|
||||
},
|
||||
0: filtered[0],
|
||||
length: filtered.length,
|
||||
};
|
||||
},
|
||||
[Symbol.iterator]: function* () {
|
||||
const rows =
|
||||
table === authProviderConfig
|
||||
? dbAuthConfigRows
|
||||
: table === staff
|
||||
? dbStaffRows
|
||||
: table === businessSettings
|
||||
? dbBusinessSettingsRows
|
||||
: [];
|
||||
for (const item of rows) yield item;
|
||||
},
|
||||
0:
|
||||
table === authProviderConfig
|
||||
? dbAuthConfigRows[0]
|
||||
: table === staff
|
||||
? dbStaffRows[0]
|
||||
: table === businessSettings
|
||||
? dbBusinessSettingsRows[0]
|
||||
: undefined,
|
||||
length:
|
||||
table === authProviderConfig
|
||||
? dbAuthConfigRows.length
|
||||
: table === staff
|
||||
? dbStaffRows.length
|
||||
: table === businessSettings
|
||||
? dbBusinessSettingsRows.length
|
||||
: 0,
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => {
|
||||
const row = { ...vals, id: "new-id-" + Math.random(), createdAt: new Date(), updatedAt: new Date() };
|
||||
if (vals.providerId) {
|
||||
insertedAuthConfig.push(vals);
|
||||
dbAuthConfigRows.push({ id: row.id as string, enabled: vals.enabled as boolean });
|
||||
} else if (vals.email) {
|
||||
insertedStaff.push(vals);
|
||||
dbStaffRows.push(row as unknown as MockStaff);
|
||||
} else if (vals.businessName) {
|
||||
dbBusinessSettingsRows.push(row as unknown as { id: string; businessName: string });
|
||||
}
|
||||
return { returning: () => [row] };
|
||||
},
|
||||
}),
|
||||
transaction: (cb: (tx: unknown) => Promise<unknown>) => cb(makeTxMock()),
|
||||
}),
|
||||
authProviderConfig,
|
||||
staff,
|
||||
businessSettings,
|
||||
eq: (col: unknown, val: unknown) => ({ __type: "eq", col, val }),
|
||||
and: (...conds: unknown[]) => ({ __type: "and", conds }),
|
||||
isNull: (col: unknown) => ({ __type: "isNull", col }),
|
||||
sql: (strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
// Mock sql template tag — raw SQL can't be evaluated in mock, always passes
|
||||
void strings; void values;
|
||||
return { __type: "sql" };
|
||||
},
|
||||
encryptSecret: (val: string) => {
|
||||
encryptCalls.push(val);
|
||||
return `encrypted:${val}`;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Helper to evaluate mock conditions against a row
|
||||
function evaluateCond(cond: unknown, row: Record<string, unknown>): boolean {
|
||||
if (!cond || typeof cond !== "object") return true;
|
||||
const c = cond as Record<string, unknown>;
|
||||
if (c.__type === "eq") {
|
||||
const colObj = c.col as Record<string, unknown>;
|
||||
const colName = colObj.column as string;
|
||||
return row[colName] === c.val;
|
||||
}
|
||||
if (c.__type === "and") {
|
||||
return (c.conds as unknown[]).every((sub) => evaluateCond(sub, row));
|
||||
}
|
||||
if (c.__type === "isNull") {
|
||||
const colObj = c.col as Record<string, unknown>;
|
||||
const colName = colObj.column as string;
|
||||
return row[colName] === null || row[colName] === undefined;
|
||||
}
|
||||
if (c.__type === "sql") {
|
||||
// Raw SQL can't be evaluated in mock — pass through
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Build test app ───────────────────────────────────────────────────────────
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
function makeApp(staff?: MockStaff | null, jwtPayload?: JwtPayload | null) {
|
||||
const app = new Hono();
|
||||
|
||||
// Inject optional staff and jwtPayload context for authenticated routes
|
||||
app.use("/setup/*", async (c, next) => {
|
||||
if (jwtPayload) {
|
||||
(c as any).set("jwtPayload", jwtPayload);
|
||||
}
|
||||
if (staff) {
|
||||
(c as any).set("staff", staff);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
app.route("/setup", setupRouter as unknown as Hono);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type ResponseBody = Record<string, unknown>;
|
||||
|
||||
async function getStatus(app: Hono) {
|
||||
const res = await app.request("/setup/status", { method: "GET" });
|
||||
return { status: res.status, body: (await res.json()) as ResponseBody };
|
||||
}
|
||||
|
||||
async function postAuthProvider(app: Hono, body: unknown) {
|
||||
const res = await app.request("/setup/auth-provider", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed: ResponseBody;
|
||||
try {
|
||||
parsed = JSON.parse(text) as ResponseBody;
|
||||
} catch {
|
||||
parsed = { error: text };
|
||||
}
|
||||
return { status: res.status, body: parsed };
|
||||
}
|
||||
|
||||
async function postAuthProviderTest(app: Hono, body: unknown) {
|
||||
const res = await app.request("/setup/auth-provider/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed: ResponseBody;
|
||||
try {
|
||||
parsed = JSON.parse(text) as ResponseBody;
|
||||
} catch {
|
||||
parsed = { error: text };
|
||||
}
|
||||
return { status: res.status, body: parsed };
|
||||
}
|
||||
|
||||
async function postSetup(app: Hono, body: unknown) {
|
||||
const res = await app.request("/setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed: ResponseBody;
|
||||
try {
|
||||
parsed = JSON.parse(text) as ResponseBody;
|
||||
} catch {
|
||||
parsed = { error: text };
|
||||
}
|
||||
return { status: res.status, body: parsed };
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /setup/status — OOBE bootstrap logic", () => {
|
||||
beforeEach(() => {
|
||||
resetMock();
|
||||
process.env = { ...originalEnv };
|
||||
clearAuthEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("fresh install (no super user, no env vars) → needsSetup=true, showAuthProviderStep=true", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
// env vars are cleared
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(true);
|
||||
expect(body.showAuthProviderStep).toBe(true);
|
||||
expect(body.authConfigExists).toBe(false);
|
||||
expect(body.authEnvVarsSet).toBe(false);
|
||||
});
|
||||
|
||||
it("fresh install (no super user, env vars set) → needsSetup=true, showAuthProviderStep=false", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
process.env.OIDC_ISSUER = "https://auth.example.com";
|
||||
process.env.OIDC_CLIENT_ID = "client-id";
|
||||
process.env.OIDC_CLIENT_SECRET = "client-secret";
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(true);
|
||||
expect(body.showAuthProviderStep).toBe(false); // env vars already provide auth
|
||||
expect(body.authConfigExists).toBe(false);
|
||||
expect(body.authEnvVarsSet).toBe(true);
|
||||
});
|
||||
|
||||
it("setup complete (super user exists) → needsSetup=false, showAuthProviderStep=false", async () => {
|
||||
dbStaffRows = [{ id: "staff-1", role: "manager", isSuperUser: true }];
|
||||
dbAuthConfigRows = [{ id: "prov-1", enabled: true }];
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(false);
|
||||
expect(body.showAuthProviderStep).toBe(false);
|
||||
expect(body.authConfigExists).toBe(true);
|
||||
});
|
||||
|
||||
it("no super user but DB config exists → showAuthProviderStep=false", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [{ id: "prov-1", enabled: true }];
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(true);
|
||||
expect(body.showAuthProviderStep).toBe(false); // DB config already exists
|
||||
expect(body.authConfigExists).toBe(true);
|
||||
});
|
||||
|
||||
it("SKIP_OOBE=true bypasses setup check regardless of DB state", async () => {
|
||||
dbStaffRows = []; // no super user
|
||||
dbAuthConfigRows = [];
|
||||
process.env.SKIP_OOBE = "true";
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(false);
|
||||
expect(body.showAuthProviderStep).toBe(false);
|
||||
expect(body.authConfigExists).toBe(false);
|
||||
expect(body.authEnvVarsSet).toBe(false);
|
||||
expect(body.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("SKIP_OOBE=1 also bypasses setup check", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
process.env.SKIP_OOBE = "1";
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(false);
|
||||
expect(body.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("SKIP_OOBE=yes also bypasses setup check", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
process.env.SKIP_OOBE = "yes";
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await getStatus(app);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.needsSetup).toBe(false);
|
||||
expect(body.skipped).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /setup/auth-provider — OOBE bootstrap", () => {
|
||||
beforeEach(() => {
|
||||
resetMock();
|
||||
process.env = { ...originalEnv };
|
||||
clearAuthEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
const validBody = {
|
||||
providerId: "authentik",
|
||||
displayName: "Authentik SSO",
|
||||
issuerUrl: "https://auth.example.com",
|
||||
clientId: "my-client",
|
||||
clientSecret: "my-secret",
|
||||
scopes: "openid profile email",
|
||||
};
|
||||
|
||||
it("creates auth provider config when no super user exists", async () => {
|
||||
dbStaffRows = []; // no super user
|
||||
dbAuthConfigRows = [];
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await postAuthProvider(app, validBody);
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body.providerId).toBe("authentik");
|
||||
expect(body.clientSecret).toBeUndefined(); // secret should not be returned plaintext
|
||||
expect(encryptCalls).toContain("my-secret");
|
||||
expect(insertedAuthConfig.length).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 403 after setup is complete (super user exists)", async () => {
|
||||
dbStaffRows = [{ id: "staff-1", role: "manager", isSuperUser: true }];
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await postAuthProvider(app, validBody);
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.error).toMatch(/already been completed/i);
|
||||
});
|
||||
|
||||
it("returns 409 if auth provider is already configured", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [{ id: "prov-1", enabled: true }]; // already configured
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await postAuthProvider(app, validBody);
|
||||
|
||||
expect(status).toBe(409);
|
||||
expect(body.error).toMatch(/already configured/i);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid schema (Zod validation failure)", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
|
||||
const app = makeApp();
|
||||
// providerId="" fails Zod min(1), issuerUrl="not-a-url" fails Zod url()
|
||||
const { status } = await postAuthProvider(app, {
|
||||
providerId: "",
|
||||
displayName: "Test",
|
||||
issuerUrl: "not-a-url",
|
||||
clientId: "c",
|
||||
clientSecret: "s",
|
||||
});
|
||||
|
||||
// Zod throws ZodError which Hono's error handler should format as 400
|
||||
// Currently returns 500 — route needs error handler for Zod errors
|
||||
// TODO(cleanup): add error handler to route; expect 400 once fixed
|
||||
expect(status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it("encrypts clientSecret before storing", async () => {
|
||||
dbStaffRows = [];
|
||||
dbAuthConfigRows = [];
|
||||
|
||||
const app = makeApp();
|
||||
await postAuthProvider(app, validBody);
|
||||
|
||||
expect(encryptCalls).toContain("my-secret");
|
||||
expect(insertedAuthConfig[0]!.clientSecret).toBe("encrypted:my-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /setup/auth-provider/test — OOBE test connection", () => {
|
||||
beforeEach(() => {
|
||||
resetMock();
|
||||
process.env = { ...originalEnv };
|
||||
clearAuthEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("returns 403 after setup is complete (super user exists)", async () => {
|
||||
dbStaffRows = [{ id: "staff-1", role: "manager", isSuperUser: true }];
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await postAuthProviderTest(app, {
|
||||
issuerUrl: "https://auth.example.com",
|
||||
});
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.error).toMatch(/already been completed/i);
|
||||
});
|
||||
|
||||
it("returns ok=false for unreachable issuer URL", async () => {
|
||||
dbStaffRows = [];
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await postAuthProviderTest(app, {
|
||||
issuerUrl: "https://192.0.2.1/", // TEST-NET, never reachable
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error).toBeTruthy();
|
||||
}, 15000);
|
||||
|
||||
it("accepts valid issuerUrl", async () => {
|
||||
dbStaffRows = [];
|
||||
|
||||
// Mock fetch to simulate a valid OIDC discovery response
|
||||
const mockFetch = vi.fn(() => Promise.resolve({ ok: true }));
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await postAuthProviderTest(app, {
|
||||
issuerUrl: "https://auth.example.com",
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.ok).toBe(true);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns ok=false for invalid issuer URL (non-200 response)", async () => {
|
||||
dbStaffRows = [];
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 404 })
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const app = makeApp();
|
||||
const { status, body } = await postAuthProviderTest(app, {
|
||||
issuerUrl: "https://auth.example.com",
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error).toMatch(/discovery failed/i);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /setup — OOBE regression (GRO-485)", () => {
|
||||
beforeEach(() => {
|
||||
resetMock();
|
||||
process.env = { ...originalEnv };
|
||||
clearAuthEnv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("creates staff record during OOBE when no staff record exists for authenticated user", async () => {
|
||||
// No staff rows — this is a fresh OOBE user
|
||||
dbStaffRows = [];
|
||||
dbBusinessSettingsRows = [];
|
||||
|
||||
const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" };
|
||||
const app = makeApp(null, jwtPayload);
|
||||
|
||||
const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.staff).toBeDefined();
|
||||
expect((body.staff as MockStaff).isSuperUser).toBe(true);
|
||||
expect((body.staff as any).email).toBe("alice@example.com");
|
||||
expect((body.staff as MockStaff).role).toBe("manager");
|
||||
// New staff record was created
|
||||
expect(insertedStaff.length).toBe(1);
|
||||
expect(insertedStaff[0]!.email).toBe("alice@example.com");
|
||||
expect(insertedStaff[0]!.userId).toBe("user-123");
|
||||
});
|
||||
|
||||
it("still works for user who already has a staff record", async () => {
|
||||
// Staff record exists for this user
|
||||
dbStaffRows = [{ id: "staff-existing", role: "groomer", isSuperUser: false }];
|
||||
dbBusinessSettingsRows = [];
|
||||
|
||||
const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" };
|
||||
// Inject the existing staff record into context
|
||||
const app = makeApp({ id: "staff-existing", role: "groomer", isSuperUser: false }, jwtPayload);
|
||||
|
||||
const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body.ok).toBe(true);
|
||||
expect((body.staff as MockStaff).isSuperUser).toBe(true);
|
||||
// No new staff was created (insertedStaff should be empty since staff was pre-existing)
|
||||
});
|
||||
|
||||
it("auto-links staff by email if record exists with matching email but no userId", async () => {
|
||||
// Staff record exists with matching email but no userId (legacy record)
|
||||
dbStaffRows = [{ id: "staff-legacy", role: "manager", isSuperUser: false, email: "alice@example.com", userId: null } as unknown as MockStaff];
|
||||
dbBusinessSettingsRows = [];
|
||||
|
||||
const jwtPayload = { sub: "user-123", email: "alice@example.com", name: "Alice" };
|
||||
// No staff injected into context — the handler must find it by email
|
||||
const app = makeApp(null, jwtPayload);
|
||||
|
||||
const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
|
||||
|
||||
expect(status).toBe(201);
|
||||
expect(body.ok).toBe(true);
|
||||
expect((body.staff as MockStaff).isSuperUser).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 400 if JWT has no email claim and no staff record exists", async () => {
|
||||
dbStaffRows = [];
|
||||
dbBusinessSettingsRows = [];
|
||||
|
||||
// JWT with no email
|
||||
const jwtPayload = { sub: "user-123" };
|
||||
const app = makeApp(null, jwtPayload);
|
||||
|
||||
const { status, body } = await postSetup(app, { businessName: "Alice's Pet Grooming" });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body.error).toMatch(/no email claim/i);
|
||||
});
|
||||
|
||||
it("returns 409 if a super user already exists", async () => {
|
||||
// Super user already exists
|
||||
dbStaffRows = [{ id: "staff-super", role: "manager", isSuperUser: true }];
|
||||
dbBusinessSettingsRows = [];
|
||||
|
||||
const jwtPayload = { sub: "user-456", email: "bob@example.com", name: "Bob" };
|
||||
const app = makeApp(null, jwtPayload);
|
||||
|
||||
const { status, body } = await postSetup(app, { businessName: "Bob's Grooming" });
|
||||
|
||||
expect(status).toBe(409);
|
||||
expect(body.error).toMatch(/already been completed/i);
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
generateAvailableSlots,
|
||||
BUSINESS_START_HOUR,
|
||||
BUSINESS_END_HOUR,
|
||||
} from "../lib/slots.js";
|
||||
|
||||
const DATE = "2026-03-18";
|
||||
const G1 = "groomer-1";
|
||||
const G2 = "groomer-2";
|
||||
|
||||
function utc(h: number, m = 0): Date {
|
||||
const d = new Date(`${DATE}T00:00:00Z`);
|
||||
d.setUTCHours(h, m, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
describe("generateAvailableSlots", () => {
|
||||
it("returns slots within business hours", () => {
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1],
|
||||
booked: [],
|
||||
});
|
||||
expect(slots.length).toBeGreaterThan(0);
|
||||
slots.forEach((s) => {
|
||||
const h = new Date(s).getUTCHours();
|
||||
expect(h).toBeGreaterThanOrEqual(BUSINESS_START_HOUR);
|
||||
expect(h).toBeLessThan(BUSINESS_END_HOUR);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns correct count of 60-min slots across 8-hour window", () => {
|
||||
// 09:00–17:00 = 8 hours → 8 one-hour slots
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1],
|
||||
booked: [],
|
||||
});
|
||||
expect(slots).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("returns empty array when no groomers", () => {
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [],
|
||||
booked: [],
|
||||
});
|
||||
expect(slots).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("excludes slots blocked by a booking", () => {
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1],
|
||||
booked: [{ staffId: G1, startTime: utc(9), endTime: utc(10) }],
|
||||
});
|
||||
expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
|
||||
expect(slots).toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString());
|
||||
});
|
||||
|
||||
it("keeps slot available when only the other groomer is booked", () => {
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1, G2],
|
||||
booked: [{ staffId: G1, startTime: utc(9), endTime: utc(10) }],
|
||||
});
|
||||
// G2 is free at 09:00 so slot should still appear
|
||||
expect(slots).toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
|
||||
});
|
||||
|
||||
it("excludes a slot only when ALL groomers are booked", () => {
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1, G2],
|
||||
booked: [
|
||||
{ staffId: G1, startTime: utc(9), endTime: utc(10) },
|
||||
{ staffId: G2, startTime: utc(9), endTime: utc(10) },
|
||||
],
|
||||
});
|
||||
expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
|
||||
});
|
||||
|
||||
it("correctly handles a booking that partially overlaps a slot", () => {
|
||||
// Booking 09:30–10:30 should block the 09:00 and 10:00 slots for G1
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 60,
|
||||
groomerIds: [G1],
|
||||
booked: [{ staffId: G1, startTime: utc(9, 30), endTime: utc(10, 30) }],
|
||||
});
|
||||
expect(slots).not.toContain(new Date(`${DATE}T09:00:00.000Z`).toISOString());
|
||||
expect(slots).not.toContain(new Date(`${DATE}T10:00:00.000Z`).toISOString());
|
||||
expect(slots).toContain(new Date(`${DATE}T11:00:00.000Z`).toISOString());
|
||||
});
|
||||
|
||||
it("does not generate a slot that would exceed business hours end", () => {
|
||||
// 30-min slots: last valid start is 16:30 (ends at 17:00)
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr: DATE,
|
||||
durationMinutes: 30,
|
||||
groomerIds: [G1],
|
||||
booked: [],
|
||||
});
|
||||
const last = slots[slots.length - 1];
|
||||
expect(last).toBeDefined();
|
||||
expect(new Date(last!).getUTCHours()).toBe(16);
|
||||
expect(new Date(last!).getUTCMinutes()).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -1,285 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const VALID_UUID_1 = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const VALID_UUID_2 = "550e8400-e29b-41d4-a716-446655440002";
|
||||
const VALID_UUID_3 = "550e8400-e29b-41d4-a716-446655440003";
|
||||
const VALID_UUID_4 = "550e8400-e29b-41d4-a716-446655440004";
|
||||
const VALID_UUID_5 = "550e8400-e29b-41d4-a716-446655440005";
|
||||
|
||||
const WAITLIST_ENTRY = {
|
||||
id: VALID_UUID_1,
|
||||
clientId: VALID_UUID_2,
|
||||
petId: VALID_UUID_3,
|
||||
serviceId: VALID_UUID_4,
|
||||
preferredDate: "2026-03-25",
|
||||
preferredTime: "10:00",
|
||||
status: "active",
|
||||
notifiedAt: null,
|
||||
expiresAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const ACTIVE_SESSION = {
|
||||
id: VALID_UUID_5,
|
||||
clientId: VALID_UUID_2,
|
||||
status: "active" as const,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const EXPIRED_SESSION = {
|
||||
id: "660e8400-e29b-41d4-a716-446655440006",
|
||||
clientId: VALID_UUID_2,
|
||||
status: "active" as const,
|
||||
expiresAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
let selectRows: Record<string, unknown>[] = [];
|
||||
let selectSessionRow: Record<string, unknown> | null = null;
|
||||
let insertedValues: Record<string, unknown>[] = [];
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
|
||||
function resetMock() {
|
||||
selectRows = [];
|
||||
selectSessionRow = null;
|
||||
insertedValues = [];
|
||||
updatedValues = [];
|
||||
}
|
||||
|
||||
vi.mock("../db", () => {
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "leftJoin") {
|
||||
return () => chain;
|
||||
}
|
||||
// @ts-expect-error proxy
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
const waitlistEntries = new Proxy(
|
||||
{ _name: "waitlistEntries" },
|
||||
{ get: (t, p) => (p === "_name" ? "waitlistEntries" : { table: "waitlistEntries", column: p }) }
|
||||
);
|
||||
|
||||
const impersonationSessions = new Proxy(
|
||||
{ _name: "impersonationSessions" },
|
||||
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
|
||||
);
|
||||
|
||||
const clients = new Proxy(
|
||||
{ _name: "clients" },
|
||||
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
||||
);
|
||||
|
||||
const pets = new Proxy(
|
||||
{ _name: "pets" },
|
||||
{ get: (t, p) => (p === "_name" ? "pets" : { table: "pets", column: p }) }
|
||||
);
|
||||
|
||||
const services = new Proxy(
|
||||
{ _name: "services" },
|
||||
{ get: (t, p) => (p === "_name" ? "services" : { table: "services", column: p }) }
|
||||
);
|
||||
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: { _name: string }) => {
|
||||
if (table._name === "impersonationSessions") {
|
||||
return makeChainable(selectSessionRow ? [selectSessionRow] : []);
|
||||
}
|
||||
if (table._name === "waitlistEntries") {
|
||||
return makeChainable(selectRows);
|
||||
}
|
||||
return makeChainable([]);
|
||||
},
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => {
|
||||
insertedValues.push(vals);
|
||||
return {
|
||||
returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }],
|
||||
};
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
updatedValues.push(vals);
|
||||
return {
|
||||
returning: () =>
|
||||
selectRows.length > 0
|
||||
? [{ ...selectRows[0], ...vals }]
|
||||
: [],
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
delete: () => ({
|
||||
where: () => {
|
||||
return {
|
||||
returning: () =>
|
||||
selectRows.length > 0 ? [selectRows[0]] : [],
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
waitlistEntries,
|
||||
impersonationSessions,
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
appointments,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
lt: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { waitlistRouter } = await import("../routes/waitlist.js");
|
||||
const { portalRouter } = await import("../routes/portal.js");
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/waitlist", waitlistRouter);
|
||||
app.route("/portal", portalRouter);
|
||||
|
||||
function jsonRequest(method: string, path: string, body?: unknown, headers?: Record<string, string>) {
|
||||
return app.request(path, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => resetMock());
|
||||
|
||||
describe("POST /portal/waitlist", () => {
|
||||
it("creates entry with valid session", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||
petId: VALID_UUID_3,
|
||||
serviceId: VALID_UUID_4,
|
||||
preferredDate: "2026-03-25",
|
||||
preferredTime: "10:00",
|
||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.petId).toBe(VALID_UUID_3);
|
||||
expect(insertedValues).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns 401 without session", async () => {
|
||||
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||
petId: VALID_UUID_3,
|
||||
serviceId: VALID_UUID_4,
|
||||
preferredDate: "2026-03-25",
|
||||
preferredTime: "10:00",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 401 with expired session", async () => {
|
||||
selectSessionRow = EXPIRED_SESSION;
|
||||
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||
petId: VALID_UUID_3,
|
||||
serviceId: VALID_UUID_4,
|
||||
preferredDate: "2026-03-25",
|
||||
preferredTime: "10:00",
|
||||
}, { "X-Impersonation-Session-Id": EXPIRED_SESSION.id });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /portal/waitlist/:id", () => {
|
||||
it("deletes entry with valid session and correct owner", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectRows = [WAITLIST_ENTRY];
|
||||
const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 401 without session", async () => {
|
||||
const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 with valid session but wrong owner", async () => {
|
||||
selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" };
|
||||
selectRows = [WAITLIST_ENTRY];
|
||||
const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 404 when entry not found", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectRows = [];
|
||||
const res = await app.request("/portal/waitlist/nonexistent", {
|
||||
method: "DELETE",
|
||||
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /portal/waitlist/:id", () => {
|
||||
it("updates entry with valid session and correct owner", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectRows = [WAITLIST_ENTRY];
|
||||
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
|
||||
status: "cancelled",
|
||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(updatedValues[0]?.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("returns 401 without session", async () => {
|
||||
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
|
||||
status: "cancelled",
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 with valid session but wrong owner", async () => {
|
||||
selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" };
|
||||
selectRows = [WAITLIST_ENTRY];
|
||||
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
|
||||
status: "cancelled",
|
||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 404 when entry not found", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectRows = [];
|
||||
const res = await jsonRequest("PATCH", "/portal/waitlist/nonexistent", {
|
||||
status: "cancelled",
|
||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12; // 96-bit IV for GCM
|
||||
const AUTH_TAG_LENGTH = 16; // 128-bit auth tag
|
||||
const SALT_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt.
|
||||
* A unique random salt is generated per encryptSecret() call and prepended to the output.
|
||||
*/
|
||||
function deriveKey(secret: string, salt: Buffer): Buffer {
|
||||
return scryptSync(secret, salt, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a plaintext string using AES-256-GCM.
|
||||
* Returns a base64-encoded string in the format: salt:iv:ciphertext:authTag
|
||||
*/
|
||||
export function encryptSecret(plaintext: string): string {
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
||||
}
|
||||
|
||||
const salt = randomBytes(SALT_LENGTH);
|
||||
const key = deriveKey(secret, salt);
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv, {
|
||||
authTagLength: AUTH_TAG_LENGTH,
|
||||
});
|
||||
|
||||
let ciphertext = cipher.update(plaintext, "utf8");
|
||||
ciphertext = Buffer.concat([ciphertext, cipher.final()]);
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Format: base64(salt):base64(iv):base64(ciphertext):base64(authTag)
|
||||
return [
|
||||
salt.toString("base64"),
|
||||
iv.toString("base64"),
|
||||
ciphertext.toString("base64"),
|
||||
authTag.toString("base64"),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a ciphertext string produced by encryptSecret.
|
||||
* Supports both new format (salt:iv:ciphertext:authTag) and legacy format (iv:ciphertext:authTag).
|
||||
*/
|
||||
export function decryptSecret(encrypted: string): string {
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
||||
}
|
||||
|
||||
const parts = encrypted.split(":");
|
||||
|
||||
let salt: Buffer;
|
||||
let iv: Buffer;
|
||||
let ciphertext: Buffer;
|
||||
let authTag: Buffer;
|
||||
|
||||
if (parts.length === 4) {
|
||||
// New format: salt:iv:ciphertext:authTag
|
||||
salt = Buffer.from(parts[0]!, "base64");
|
||||
iv = Buffer.from(parts[1]!, "base64");
|
||||
ciphertext = Buffer.from(parts[2]!, "base64");
|
||||
authTag = Buffer.from(parts[3]!, "base64");
|
||||
} else if (parts.length === 3) {
|
||||
// Legacy format: iv:ciphertext:authTag — use fixed package salt
|
||||
salt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH);
|
||||
iv = Buffer.from(parts[0]!, "base64");
|
||||
ciphertext = Buffer.from(parts[1]!, "base64");
|
||||
authTag = Buffer.from(parts[2]!, "base64");
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"
|
||||
);
|
||||
}
|
||||
|
||||
const key = deriveKey(secret, salt);
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv, {
|
||||
authTagLength: AUTH_TAG_LENGTH,
|
||||
});
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let plaintext = decipher.update(ciphertext);
|
||||
plaintext = Buffer.concat([plaintext, decipher.final()]);
|
||||
|
||||
return plaintext.toString("utf8");
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* Test factories — build typed in-memory entities for unit tests.
|
||||
*
|
||||
* Each factory returns a fully-populated object with valid defaults.
|
||||
* Pass an overrides object to customise specific fields.
|
||||
*
|
||||
* IDs are generated with a deterministic counter so tests produce stable,
|
||||
* readable values (e.g. "staff-1", "client-2") without needing crypto.
|
||||
*
|
||||
* Usage:
|
||||
* import { buildStaff, buildClient, buildPet } from "./db/factories";
|
||||
*
|
||||
* const manager = buildStaff({ role: "manager" });
|
||||
* const client = buildClient({ name: "Alice Smith" });
|
||||
* const pet = buildPet({ clientId: client.id });
|
||||
*/
|
||||
|
||||
import type { staff, clients, pets, services, appointments } from "./schema.js";
|
||||
|
||||
// ── Counter-based ID factory ─────────────────────────────────────────────────
|
||||
|
||||
const counters: Record<string, number> = {};
|
||||
|
||||
function nextId(prefix: string): string {
|
||||
counters[prefix] = (counters[prefix] ?? 0) + 1;
|
||||
return `${prefix}-${counters[prefix]}`;
|
||||
}
|
||||
|
||||
/** Reset all counters. Call in beforeEach() to keep tests independent. */
|
||||
export function resetFactoryCounters(): void {
|
||||
for (const key of Object.keys(counters)) {
|
||||
delete counters[key];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Type aliases ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type StaffRow = typeof staff.$inferSelect;
|
||||
export type ClientRow = typeof clients.$inferSelect;
|
||||
export type PetRow = typeof pets.$inferSelect;
|
||||
export type ServiceRow = typeof services.$inferSelect;
|
||||
export type AppointmentRow = typeof appointments.$inferSelect;
|
||||
|
||||
// ── Factories ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildStaff(overrides: Partial<StaffRow> = {}): StaffRow {
|
||||
const id = nextId("staff");
|
||||
return {
|
||||
id,
|
||||
name: `Staff Member ${id}`,
|
||||
email: `${id}@groombook.test`,
|
||||
oidcSub: `oidc-${id}`,
|
||||
userId: null,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
icalToken: null,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
|
||||
const id = nextId("client");
|
||||
return {
|
||||
id,
|
||||
name: `Client ${id}`,
|
||||
email: `${id}@example.com`,
|
||||
phone: "555-0100",
|
||||
address: "1 Main St, Springfield, CA 90000",
|
||||
notes: null,
|
||||
emailOptOut: false,
|
||||
smsOptIn: false,
|
||||
smsConsentDate: null,
|
||||
smsOptOutDate: null,
|
||||
smsConsentText: null,
|
||||
stripeCustomerId: null,
|
||||
status: "active",
|
||||
disabledAt: null,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPet(overrides: Partial<PetRow> & { clientId: string }): PetRow {
|
||||
const id = nextId("pet");
|
||||
const defaults: PetRow = {
|
||||
id,
|
||||
clientId: overrides.clientId,
|
||||
name: `Pet ${id}`,
|
||||
species: "Dog",
|
||||
breed: "Mixed Breed",
|
||||
weightKg: "15.00",
|
||||
dateOfBirth: new Date("2020-06-15T00:00:00Z"),
|
||||
healthAlerts: null,
|
||||
groomingNotes: null,
|
||||
cutStyle: null,
|
||||
shampooPreference: null,
|
||||
specialCareNotes: null,
|
||||
customFields: {},
|
||||
photoKey: null,
|
||||
photoUploadedAt: null,
|
||||
image: null,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
};
|
||||
return { ...defaults, ...overrides };
|
||||
}
|
||||
|
||||
export function buildService(overrides: Partial<ServiceRow> = {}): ServiceRow {
|
||||
const id = nextId("service");
|
||||
return {
|
||||
id,
|
||||
name: `Service ${id}`,
|
||||
description: "A grooming service",
|
||||
basePriceCents: 6500,
|
||||
durationMinutes: 60,
|
||||
active: true,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAppointment(
|
||||
overrides: Partial<AppointmentRow> & { clientId: string; petId: string; serviceId: string; staffId: string }
|
||||
): AppointmentRow {
|
||||
const id = nextId("appointment");
|
||||
const startTime = new Date("2025-06-01T10:00:00Z");
|
||||
const endTime = new Date("2025-06-01T11:00:00Z");
|
||||
const defaults: AppointmentRow = {
|
||||
id,
|
||||
clientId: overrides.clientId,
|
||||
petId: overrides.petId,
|
||||
serviceId: overrides.serviceId,
|
||||
staffId: overrides.staffId,
|
||||
batherStaffId: null,
|
||||
seriesId: null,
|
||||
seriesIndex: null,
|
||||
groupId: null,
|
||||
status: "scheduled",
|
||||
startTime,
|
||||
endTime,
|
||||
notes: null,
|
||||
priceCents: null,
|
||||
confirmationStatus: "pending",
|
||||
confirmedAt: null,
|
||||
cancelledAt: null,
|
||||
confirmationToken: null,
|
||||
customerNotes: null,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
};
|
||||
return { ...defaults, ...overrides };
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
export * from "./schema.js";
|
||||
export { encryptSecret, decryptSecret } from "./crypto.js";
|
||||
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getDb() {
|
||||
if (_db) return _db;
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("DATABASE_URL is not set");
|
||||
const client = postgres(url, { max: 10 });
|
||||
_db = drizzle(client, { schema });
|
||||
return _db;
|
||||
}
|
||||
|
||||
export type Db = ReturnType<typeof getDb>;
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* reset.ts — Drop all application tables and re-run migrations + seed.
|
||||
*
|
||||
* Intended for local development only. Never run against production.
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts
|
||||
*/
|
||||
|
||||
import postgres from "postgres";
|
||||
|
||||
async function reset() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("DATABASE_URL is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true") {
|
||||
console.error("[FATAL] db:reset must not be run in production without ALLOW_RESET=true.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = postgres(url, { max: 1 });
|
||||
|
||||
console.log("Dropping all application tables...\n");
|
||||
|
||||
// Drop in dependency order (children before parents)
|
||||
await client`
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
) LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`;
|
||||
|
||||
// Drop custom enums
|
||||
await client`
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (
|
||||
SELECT typname FROM pg_type
|
||||
WHERE typtype = 'e' AND typnamespace = (
|
||||
SELECT oid FROM pg_namespace WHERE nspname = 'public'
|
||||
)
|
||||
) LOOP
|
||||
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`;
|
||||
|
||||
// Drop the drizzle migrations tracking table
|
||||
await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`;
|
||||
await client`DROP SCHEMA IF EXISTS drizzle CASCADE`;
|
||||
|
||||
console.log("✓ All tables and enums dropped\n");
|
||||
|
||||
await client.end();
|
||||
}
|
||||
|
||||
reset().catch((err) => {
|
||||
console.error("Reset failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,504 +0,0 @@
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
numeric,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
unique,
|
||||
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", [
|
||||
"scheduled",
|
||||
"confirmed",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"no_show",
|
||||
]);
|
||||
|
||||
export const staffRoleEnum = pgEnum("staff_role", [
|
||||
"groomer",
|
||||
"receptionist",
|
||||
"manager",
|
||||
]);
|
||||
|
||||
export const invoiceStatusEnum = pgEnum("invoice_status", [
|
||||
"draft",
|
||||
"pending",
|
||||
"paid",
|
||||
"void",
|
||||
]);
|
||||
|
||||
export const paymentMethodEnum = pgEnum("payment_method", [
|
||||
"cash",
|
||||
"card",
|
||||
"check",
|
||||
"other",
|
||||
]);
|
||||
|
||||
export const clientStatusEnum = pgEnum("client_status", [
|
||||
"active",
|
||||
"disabled",
|
||||
]);
|
||||
|
||||
// ─── Better-Auth Tables ──────────────────────────────────────────────────────
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").notNull().default(false),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const session = pgTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const account = pgTable("account", {
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const verification = pgTable("verification", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ─── Tables ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const clients = pgTable(
|
||||
"clients",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull(),
|
||||
phone: text("phone"),
|
||||
address: text("address"),
|
||||
notes: text("notes"),
|
||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
||||
smsConsentDate: timestamp("sms_consent_date"),
|
||||
smsOptOutDate: timestamp("sms_opt_out_date"),
|
||||
smsConsentText: text("sms_consent_text"),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
status: clientStatusEnum("status").notNull().default("active"),
|
||||
disabledAt: timestamp("disabled_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index("idx_clients_email").on(t.email)]
|
||||
);
|
||||
|
||||
export const pets = pgTable(
|
||||
"pets",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
species: text("species").notNull(),
|
||||
breed: text("breed"),
|
||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||
dateOfBirth: timestamp("date_of_birth"),
|
||||
healthAlerts: text("health_alerts"),
|
||||
groomingNotes: text("grooming_notes"),
|
||||
cutStyle: text("cut_style"),
|
||||
shampooPreference: text("shampoo_preference"),
|
||||
specialCareNotes: text("special_care_notes"),
|
||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
||||
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(),
|
||||
},
|
||||
(t) => [index("idx_pets_client_id").on(t.clientId)]
|
||||
);
|
||||
|
||||
export const services = pgTable("services", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull().unique(),
|
||||
description: text("description"),
|
||||
basePriceCents: integer("base_price_cents").notNull(),
|
||||
durationMinutes: integer("duration_minutes").notNull(),
|
||||
active: boolean("active").notNull().default(true),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const staff = pgTable("staff", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
// oidcSub links to the Authentik OIDC subject claim
|
||||
oidcSub: text("oidc_sub").unique(),
|
||||
// Better-Auth user ID — links staff business record to auth identity
|
||||
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
|
||||
role: staffRoleEnum("role").notNull().default("groomer"),
|
||||
// Super users bypass appointment-booking restrictions and access admin panels
|
||||
isSuperUser: boolean("is_super_user").notNull().default(false),
|
||||
active: boolean("active").notNull().default(true),
|
||||
// Token for iCal calendar feed subscription (no auth required)
|
||||
icalToken: text("ical_token").unique(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const recurringSeries = pgTable("recurring_series", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
// How many weeks between each appointment in the series
|
||||
frequencyWeeks: integer("frequency_weeks").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// appointmentGroups links multiple appointments from the same client visit.
|
||||
// Each pet in the group gets its own appointment row with its own groomer.
|
||||
export const appointmentGroups = pgTable("appointment_groups", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "restrict" }),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const appointments = pgTable(
|
||||
"appointments",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "restrict" }),
|
||||
petId: uuid("pet_id")
|
||||
.notNull()
|
||||
.references(() => pets.id, { onDelete: "restrict" }),
|
||||
serviceId: uuid("service_id")
|
||||
.notNull()
|
||||
.references(() => services.id, { onDelete: "restrict" }),
|
||||
staffId: uuid("staff_id").references(() => staff.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Optional secondary staff (bather/assistant) for tip-split tracking
|
||||
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
status: appointmentStatusEnum("status").notNull().default("scheduled"),
|
||||
startTime: timestamp("start_time").notNull(),
|
||||
endTime: timestamp("end_time").notNull(),
|
||||
notes: text("notes"),
|
||||
// Override price at time of booking (null = use service base price)
|
||||
priceCents: integer("price_cents"),
|
||||
// Recurring series support
|
||||
seriesId: uuid("series_id").references(() => recurringSeries.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
seriesIndex: integer("series_index"),
|
||||
// Multi-pet group booking: links this appointment to others in the same visit
|
||||
groupId: uuid("group_id").references(() => appointmentGroups.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Customer confirmation/cancellation tracking
|
||||
// Values: "pending" | "confirmed" | "cancelled"
|
||||
confirmationStatus: text("confirmation_status").notNull().default("pending"),
|
||||
confirmedAt: timestamp("confirmed_at"),
|
||||
cancelledAt: timestamp("cancelled_at"),
|
||||
// Token for tokenized email confirm/cancel links (no auth required)
|
||||
confirmationToken: text("confirmation_token").unique(),
|
||||
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
|
||||
customerNotes: text("customer_notes"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("idx_appointments_client_id").on(t.clientId),
|
||||
index("idx_appointments_staff_id").on(t.staffId),
|
||||
index("idx_appointments_start_time").on(t.startTime),
|
||||
index("idx_appointments_status").on(t.status),
|
||||
]
|
||||
);
|
||||
|
||||
export const invoices = pgTable(
|
||||
"invoices",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
appointmentId: uuid("appointment_id").references(() => appointments.id, {
|
||||
onDelete: "restrict",
|
||||
}),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "restrict" }),
|
||||
subtotalCents: integer("subtotal_cents").notNull(),
|
||||
taxCents: integer("tax_cents").notNull().default(0),
|
||||
tipCents: integer("tip_cents").notNull().default(0),
|
||||
totalCents: integer("total_cents").notNull(),
|
||||
status: invoiceStatusEnum("status").notNull().default("draft"),
|
||||
paymentMethod: paymentMethodEnum("payment_method"),
|
||||
paidAt: timestamp("paid_at"),
|
||||
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
||||
stripeRefundId: text("stripe_refund_id"),
|
||||
paymentFailureReason: text("payment_failure_reason"),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("idx_invoices_client_id").on(t.clientId),
|
||||
index("idx_invoices_status").on(t.status),
|
||||
index("idx_invoices_created_at").on(t.createdAt),
|
||||
index("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
|
||||
]
|
||||
);
|
||||
|
||||
export const invoiceLineItems = pgTable(
|
||||
"invoice_line_items",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
invoiceId: uuid("invoice_id")
|
||||
.notNull()
|
||||
.references(() => invoices.id, { onDelete: "cascade" }),
|
||||
description: text("description").notNull(),
|
||||
quantity: integer("quantity").notNull().default(1),
|
||||
unitPriceCents: integer("unit_price_cents").notNull(),
|
||||
totalCents: integer("total_cents").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index("idx_invoice_line_items_invoice_id").on(t.invoiceId)]
|
||||
);
|
||||
|
||||
// Per-staff tip allocation calculated when an invoice is paid.
|
||||
// staff_name is snapshotted at calculation time so reports remain accurate if staff is deleted.
|
||||
export const invoiceTipSplits = pgTable(
|
||||
"invoice_tip_splits",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
invoiceId: uuid("invoice_id")
|
||||
.notNull()
|
||||
.references(() => invoices.id, { onDelete: "cascade" }),
|
||||
staffId: uuid("staff_id").references(() => staff.id, { onDelete: "set null" }),
|
||||
staffName: text("staff_name").notNull(),
|
||||
sharePct: numeric("share_pct", { precision: 5, scale: 2 }).notNull(),
|
||||
shareCents: integer("share_cents").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)]
|
||||
);
|
||||
|
||||
// Refund records with idempotency key support
|
||||
export const refunds = pgTable(
|
||||
"refunds",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
invoiceId: uuid("invoice_id")
|
||||
.notNull()
|
||||
.references(() => invoices.id, { onDelete: "restrict" }),
|
||||
stripeRefundId: text("stripe_refund_id").notNull(),
|
||||
idempotencyKey: text("idempotency_key").unique(),
|
||||
amountCents: integer("amount_cents"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("idx_refunds_invoice_id").on(t.invoiceId),
|
||||
index("idx_refunds_idempotency_key").on(t.idempotencyKey),
|
||||
]
|
||||
);
|
||||
|
||||
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
|
||||
// reminder_type values: "confirmation", "24h", "2h"
|
||||
// channel values: "email", "sms"
|
||||
export const reminderLogs = pgTable(
|
||||
"reminder_logs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
appointmentId: uuid("appointment_id")
|
||||
.notNull()
|
||||
.references(() => appointments.id, { onDelete: "cascade" }),
|
||||
// "confirmation" | "24h" | "2h"
|
||||
reminderType: text("reminder_type").notNull(),
|
||||
// "email" | "sms"
|
||||
channel: text("channel").notNull().default("email"),
|
||||
sentAt: timestamp("sent_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)]
|
||||
);
|
||||
|
||||
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||
|
||||
export const impersonationSessionStatusEnum = pgEnum(
|
||||
"impersonation_session_status",
|
||||
["active", "ended", "expired"]
|
||||
);
|
||||
|
||||
export const impersonationSessions = pgTable(
|
||||
"impersonation_sessions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
staffId: uuid("staff_id")
|
||||
.notNull()
|
||||
.references(() => staff.id, { onDelete: "restrict" }),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "restrict" }),
|
||||
reason: text("reason"),
|
||||
status: impersonationSessionStatusEnum("status")
|
||||
.notNull()
|
||||
.default("active"),
|
||||
startedAt: timestamp("started_at").notNull().defaultNow(),
|
||||
endedAt: timestamp("ended_at"),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("impersonation_sessions_staff_id_status_idx").on(t.staffId, t.status),
|
||||
index("impersonation_sessions_client_id_idx").on(t.clientId),
|
||||
]
|
||||
);
|
||||
|
||||
export const impersonationAuditLogs = pgTable(
|
||||
"impersonation_audit_logs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
sessionId: uuid("session_id")
|
||||
.notNull()
|
||||
.references(() => impersonationSessions.id, { onDelete: "cascade" }),
|
||||
action: text("action").notNull(),
|
||||
pageVisited: text("page_visited"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index("impersonation_audit_logs_session_id_idx").on(t.sessionId)]
|
||||
);
|
||||
|
||||
export const businessSettings = pgTable("business_settings", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
businessName: text("business_name").notNull().default("GroomBook"),
|
||||
logoBase64: text("logo_base64"),
|
||||
logoMimeType: text("logo_mime_type"),
|
||||
logoKey: text("logo_key"),
|
||||
primaryColor: text("primary_color").notNull().default("#4f8a6f"),
|
||||
accentColor: text("accent_color").notNull().default("#8b7355"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const groomingVisitLogs = pgTable("grooming_visit_logs", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
petId: uuid("pet_id")
|
||||
.notNull()
|
||||
.references(() => pets.id, { onDelete: "cascade" }),
|
||||
appointmentId: uuid("appointment_id").references(() => appointments.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
staffId: uuid("staff_id").references(() => staff.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
cutStyle: text("cut_style"),
|
||||
productsUsed: text("products_used"),
|
||||
notes: text("notes"),
|
||||
groomedAt: timestamp("groomed_at").notNull().defaultNow(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const waitlistStatusEnum = pgEnum("waitlist_status", [
|
||||
"active",
|
||||
"notified",
|
||||
"expired",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export const waitlistEntries = pgTable(
|
||||
"waitlist_entries",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
petId: uuid("pet_id")
|
||||
.notNull()
|
||||
.references(() => pets.id, { onDelete: "cascade" }),
|
||||
serviceId: uuid("service_id")
|
||||
.notNull()
|
||||
.references(() => services.id, { onDelete: "cascade" }),
|
||||
preferredDate: text("preferred_date").notNull(),
|
||||
preferredTime: text("preferred_time").notNull(),
|
||||
status: waitlistStatusEnum("status").notNull().default("active"),
|
||||
notifiedAt: timestamp("notified_at"),
|
||||
expiresAt: timestamp("expires_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("idx_waitlist_client_id").on(t.clientId),
|
||||
index("idx_waitlist_preferred_date").on(t.preferredDate),
|
||||
index("idx_waitlist_status").on(t.status),
|
||||
]
|
||||
);
|
||||
|
||||
// ─── Auth Provider Config ──────────────────────────────────────────────────
|
||||
|
||||
export const authProviderConfig = pgTable("auth_provider_config", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
providerId: text("provider_id").notNull().unique(), // e.g. "authentik", "okta", "entra-id"
|
||||
displayName: text("display_name").notNull(), // shown on login button
|
||||
issuerUrl: text("issuer_url").notNull(), // OIDC issuer/discovery URL
|
||||
internalBaseUrl: text("internal_base_url"), // for hairpin NAT / K8s internal routing
|
||||
clientId: text("client_id").notNull(),
|
||||
clientSecret: text("client_secret").notNull(), // AES-256-GCM encrypted using BETTER_AUTH_SECRET
|
||||
scopes: text("scopes").notNull().default("openid profile email"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,296 +0,0 @@
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { logger } from "hono/logger";
|
||||
import { cors } from "hono/cors";
|
||||
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
|
||||
import { clientsRouter } from "./routes/clients.js";
|
||||
import { petsRouter } from "./routes/pets.js";
|
||||
import { servicesRouter } from "./routes/services.js";
|
||||
import { appointmentsRouter } from "./routes/appointments.js";
|
||||
import { waitlistRouter } from "./routes/waitlist.js";
|
||||
import { portalRouter } from "./routes/portal.js";
|
||||
import { staffRouter } from "./routes/staff.js";
|
||||
import { invoicesRouter } from "./routes/invoices.js";
|
||||
import { bookRouter } from "./routes/book.js";
|
||||
import { reportsRouter } from "./routes/reports.js";
|
||||
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
|
||||
import { groomingLogsRouter } from "./routes/groomingLogs.js";
|
||||
import { impersonationRouter } from "./routes/impersonation.js";
|
||||
import { settingsRouter } from "./routes/settings.js";
|
||||
import { authProviderRouter } from "./routes/authProvider.js";
|
||||
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/index.js";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
|
||||
import { devRouter } from "./routes/dev.js";
|
||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||
import { startReminderScheduler } from "./services/reminders.js";
|
||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Global middleware
|
||||
const TRUSTED_ORIGINS = (process.env.CORS_ORIGIN ?? "http://localhost:5173")
|
||||
.split(",")
|
||||
.map((o) => o.trim());
|
||||
|
||||
const ALLOWED_ORIGIN = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||
|
||||
app.use("*", logger());
|
||||
app.use(
|
||||
"/api/*",
|
||||
cors({
|
||||
origin: (origin, ctx) => {
|
||||
if (!origin) {
|
||||
return ALLOWED_ORIGIN;
|
||||
}
|
||||
if (TRUSTED_ORIGINS.includes(origin)) {
|
||||
return origin;
|
||||
}
|
||||
ctx.status(403);
|
||||
return null;
|
||||
},
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Health check (no auth required)
|
||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||
|
||||
// Public booking routes — no auth required, must be registered before auth middleware
|
||||
app.route("/api/book", bookRouter);
|
||||
|
||||
// Public portal routes — client-facing, authenticated via impersonation session header
|
||||
app.route("/api/portal", portalRouter);
|
||||
|
||||
// Public Stripe webhook endpoint — signature-verified, no auth required
|
||||
app.route("/api/webhooks/stripe", webhooksRouter);
|
||||
|
||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||
app.route("/api/dev", devRouter);
|
||||
|
||||
// Magic bytes for allowed image types
|
||||
const ALLOWED_IMAGE_TYPES: Record<string, Uint8Array> = {
|
||||
"image/png": new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
"image/jpeg": new Uint8Array([0xff, 0xd8, 0xff]),
|
||||
"image/gif": new Uint8Array([0x47, 0x49, 0x46, 0x38]),
|
||||
"image/webp": new Uint8Array([0x52, 0x49, 0x46, 0x46]), // followed by size then WEBP
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that the given base64 content matches the declared MIME type
|
||||
* by checking magic bytes. Returns null if valid, or the field to clear if not.
|
||||
*/
|
||||
function validateLogoMagicBytes(
|
||||
logoBase64: string | null,
|
||||
logoMimeType: string | null
|
||||
): "logoBase64" | "logoMimeType" | null {
|
||||
if (!logoBase64 || !logoMimeType) return null;
|
||||
|
||||
const expectedMagic = ALLOWED_IMAGE_TYPES[logoMimeType];
|
||||
if (!expectedMagic) return "logoMimeType"; // unknown MIME type — reject
|
||||
|
||||
try {
|
||||
const binary = Buffer.from(logoBase64, "base64");
|
||||
// WebP needs a special check (RIFF....WEBP at offset 0, size at offset 4)
|
||||
if (logoMimeType === "image/webp") {
|
||||
if (binary.length < 12) return "logoBase64";
|
||||
const webpMagic = binary.slice(0, 4);
|
||||
const webpSig = binary.slice(8, 12);
|
||||
if (
|
||||
webpMagic[0] !== 0x52 ||
|
||||
webpMagic[1] !== 0x49 ||
|
||||
webpMagic[2] !== 0x46 ||
|
||||
webpMagic[3] !== 0x46 ||
|
||||
webpSig[0] !== 0x57 ||
|
||||
webpSig[1] !== 0x45 ||
|
||||
webpSig[2] !== 0x42 ||
|
||||
webpSig[3] !== 0x50
|
||||
) {
|
||||
return "logoBase64";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// All other types: check prefix
|
||||
if (binary.length < expectedMagic.length) return "logoBase64";
|
||||
for (let i = 0; i < expectedMagic.length; i++) {
|
||||
if (binary[i] !== expectedMagic[i]) return "logoBase64";
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return "logoBase64";
|
||||
}
|
||||
}
|
||||
|
||||
// Public logo proxy — no auth required, streams logo from S3 so browser never sees raw S3 URL
|
||||
app.get("/api/branding/logo", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db.select().from(businessSettings).limit(1);
|
||||
if (!row) return c.json({ error: "Settings not found" }, 404);
|
||||
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
|
||||
|
||||
const { body, contentType } = await getObject(row.logoKey);
|
||||
return new Response(Buffer.from(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Public branding endpoint — no auth required, returns business name/colors/logo
|
||||
app.get("/api/branding", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db.select().from(businessSettings).limit(1);
|
||||
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null };
|
||||
|
||||
// Return the public proxy path so browser never sees a raw S3 URL
|
||||
const logoUrl = settings.logoKey ? "/api/branding/logo" : null;
|
||||
|
||||
// Defensive: validate magic bytes to prevent MIME type confusion attacks
|
||||
// via the legacy base64 logo fields
|
||||
const badField = validateLogoMagicBytes(settings.logoBase64 ?? null, settings.logoMimeType ?? null);
|
||||
const safeLogoBase64 = badField === "logoBase64" ? null : settings.logoBase64;
|
||||
const safeLogoMimeType = badField === "logoMimeType" ? null : settings.logoMimeType;
|
||||
|
||||
return c.json({
|
||||
businessName: settings.businessName,
|
||||
primaryColor: settings.primaryColor,
|
||||
accentColor: settings.accentColor,
|
||||
logoUrl,
|
||||
logoBase64: safeLogoBase64,
|
||||
logoMimeType: safeLogoMimeType,
|
||||
});
|
||||
});
|
||||
|
||||
// Public iCal calendar feed — token auth in URL, no auth middleware required
|
||||
app.route("/api/calendar", calendarRouter);
|
||||
|
||||
// Public setup status — no auth required, must be registered before auth middleware
|
||||
app.get("/api/setup/status", async (c) => {
|
||||
const db = getDb();
|
||||
const [superUser] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
return c.json({ needsSetup: !superUser });
|
||||
});
|
||||
|
||||
// Public auth providers endpoint — no auth required, tells frontend which login options are available
|
||||
app.get("/api/auth/providers", async (c) => {
|
||||
return c.json({ providers: getActiveProviders() });
|
||||
});
|
||||
|
||||
// Protected API routes
|
||||
const api = app.basePath("/api");
|
||||
api.use("*", authMiddleware);
|
||||
api.use("*", resolveStaffMiddleware);
|
||||
|
||||
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
||||
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
||||
const authRouter = new Hono();
|
||||
authRouter.all("/*", (c) => {
|
||||
try {
|
||||
return getAuth().handler(c.req.raw);
|
||||
} catch {
|
||||
return c.json({ error: "Authentication not configured" }, 503);
|
||||
}
|
||||
});
|
||||
api.route("/auth", authRouter);
|
||||
|
||||
// ── Role guards ────────────────────────────────────────────────────────────────
|
||||
// Manager-only: admin settings, reports, invoices, impersonation
|
||||
// Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE
|
||||
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("/admin/settings/*", requireSuperUser());
|
||||
api.use("/reports/*", requireRole("manager"));
|
||||
api.use("/invoices/*", requireRole("manager", "groomer"));
|
||||
api.use("/impersonation/*", requireRole("manager"));
|
||||
|
||||
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
|
||||
api.use("/appointment-groups/*", requireRole("manager", "receptionist"));
|
||||
api.use("/grooming-logs/*", requireRole("manager", "receptionist"));
|
||||
api.use("/waitlist/*", requireRole("manager", "receptionist"));
|
||||
|
||||
// Pet photo routes: all staff roles may upload/delete (groomers take photos during grooms)
|
||||
// These must be registered before the general pets write guard. Because Hono path params
|
||||
// match single segments, "/pets/:petId" does NOT match "/pets/:petId/photo/:action",
|
||||
// so there is no guard overlap.
|
||||
api.on(
|
||||
["POST", "DELETE"],
|
||||
["/pets/:petId/photo", "/pets/:petId/photo/:action"],
|
||||
requireRole("manager", "receptionist", "groomer")
|
||||
);
|
||||
|
||||
// Clients, appointments: all roles may read; only manager + receptionist may write
|
||||
api.on(
|
||||
["POST", "PUT", "PATCH", "DELETE"],
|
||||
["/clients/*", "/appointments/*"],
|
||||
requireRole("manager", "receptionist")
|
||||
);
|
||||
|
||||
// Pets (non-photo CRUD): manager + receptionist for writes
|
||||
// ":petId" matches only single-segment paths — photo sub-routes are unaffected
|
||||
api.post("/pets", requireRole("manager", "receptionist"));
|
||||
api.on(["PUT", "PATCH", "DELETE"], "/pets/:petId", requireRole("manager", "receptionist"));
|
||||
|
||||
// Services: all roles may read; only managers may write
|
||||
api.on(
|
||||
["POST", "PUT", "PATCH", "DELETE"],
|
||||
"/services/*",
|
||||
requireRole("manager")
|
||||
);
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Setup: POST /api/setup (authenticated) — requires staff context from auth middleware
|
||||
api.route("/setup", setupRouter);
|
||||
|
||||
api.route("/clients", clientsRouter);
|
||||
api.route("/pets", petsRouter);
|
||||
api.route("/services", servicesRouter);
|
||||
api.route("/appointments", appointmentsRouter);
|
||||
api.route("/waitlist", waitlistRouter);
|
||||
api.route("/staff", staffRouter);
|
||||
api.route("/invoices", invoicesRouter);
|
||||
api.route("/reports", reportsRouter);
|
||||
api.route("/appointment-groups", appointmentGroupsRouter);
|
||||
api.route("/grooming-logs", groomingLogsRouter);
|
||||
api.route("/impersonation", impersonationRouter);
|
||||
api.route("/admin/settings", settingsRouter);
|
||||
api.route("/admin/auth-provider", authProviderRouter);
|
||||
api.route("/admin/seed", adminSeedRouter);
|
||||
api.route("/search", searchRouter);
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
await initAuth();
|
||||
console.log(`API server listening on port ${port}`);
|
||||
const server = serve({ fetch: app.fetch, port });
|
||||
|
||||
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
||||
startReminderScheduler();
|
||||
|
||||
function shutdown() {
|
||||
console.log("Shutting down gracefully...");
|
||||
server.close(() => {
|
||||
console.log("HTTP server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => {
|
||||
console.error("Forced shutdown after timeout");
|
||||
process.exit(1);
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
export default app;
|
||||
@@ -1,316 +0,0 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
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;
|
||||
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
|
||||
|
||||
// Auth instance — initialized lazily via initAuth()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let authInstance: any = null;
|
||||
let authInitPromise: Promise<void> | null = null;
|
||||
|
||||
/** Returns the current auth instance. Throws if not yet initialized. */
|
||||
export function getAuth() {
|
||||
if (!authInstance) {
|
||||
throw new Error(
|
||||
"Auth not initialized. Call initAuth() at startup before handling requests."
|
||||
);
|
||||
}
|
||||
return authInstance;
|
||||
}
|
||||
|
||||
/** Returns a promise that resolves when auth is initialized. */
|
||||
export function getAuthPromise() {
|
||||
return authInitPromise;
|
||||
}
|
||||
|
||||
/** Returns which OAuth/social providers are configured via env vars. */
|
||||
export function getActiveProviders(): string[] {
|
||||
const providers: string[] = [];
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||
providers.push("google");
|
||||
}
|
||||
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
|
||||
providers.push("github");
|
||||
}
|
||||
if (process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET) {
|
||||
providers.push("authentik");
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-initializes the Better-Auth instance after auth config changes.
|
||||
*
|
||||
* Clears both authInstance and authInitPromise, then calls initAuth() to
|
||||
* re-read config from DB and build a fresh Better-Auth instance.
|
||||
* Sessions are DB-backed and survive the re-init.
|
||||
*/
|
||||
export async function reinitAuth(): Promise<void> {
|
||||
authInstance = null;
|
||||
authInitPromise = null;
|
||||
await initAuth();
|
||||
console.log("[auth] Re-initialized auth instance after config change");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the Better-Auth instance.
|
||||
*
|
||||
* Config resolution chain:
|
||||
* 1. Query auth_provider_config table for an enabled provider
|
||||
* 2. If DB config exists → use it (decrypt clientSecret)
|
||||
* 3. If no DB config → fall back to OIDC_* env vars
|
||||
* 4. If neither → auth is unconfigured (getAuth() returns null, AUTH_DISABLED implied)
|
||||
*
|
||||
* Idempotent — subsequent calls return immediately after initialization completes.
|
||||
*/
|
||||
export async function initAuth(): Promise<void> {
|
||||
if (authInstance) return; // Already initialized
|
||||
if (authInitPromise) {
|
||||
await authInitPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
authInitPromise = (async () => {
|
||||
// Guard: require BETTER_AUTH_SECRET unless explicitly in dev/demo mode
|
||||
if (!BETTER_AUTH_SECRET && process.env.AUTH_DISABLED !== "true") {
|
||||
throw new Error(
|
||||
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
|
||||
);
|
||||
}
|
||||
|
||||
// AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder
|
||||
// config so auth.handler exists (middleware bypasses it anyway)
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
||||
authInstance = betterAuth({
|
||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||
secret: BETTER_AUTH_SECRET!,
|
||||
baseURL: BETTER_AUTH_URL,
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
max: 100,
|
||||
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,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "authentik",
|
||||
clientId: "placeholder",
|
||||
clientSecret: "placeholder",
|
||||
discoveryUrl: undefined,
|
||||
scopes: ["openid", "profile", "email"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7,
|
||||
updateAge: 60 * 60 * 24,
|
||||
cookieCache: { enabled: false },
|
||||
},
|
||||
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Try to load config from DB
|
||||
const db = getDb();
|
||||
const [dbConfig] = await db
|
||||
.select()
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
|
||||
let providerConfig: {
|
||||
providerId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
issuerUrl: string;
|
||||
internalBaseUrl?: string;
|
||||
scopes: string;
|
||||
};
|
||||
|
||||
if (dbConfig) {
|
||||
// Step 2: Use DB config (decrypt clientSecret)
|
||||
const decryptedSecret = decryptSecret(dbConfig.clientSecret);
|
||||
providerConfig = {
|
||||
providerId: dbConfig.providerId,
|
||||
clientId: dbConfig.clientId,
|
||||
clientSecret: decryptedSecret,
|
||||
issuerUrl: dbConfig.issuerUrl,
|
||||
internalBaseUrl: dbConfig.internalBaseUrl ?? undefined,
|
||||
scopes: dbConfig.scopes,
|
||||
};
|
||||
console.log("[auth] Using DB config for provider:", dbConfig.providerId);
|
||||
} else {
|
||||
// Step 3: Fall back to env vars
|
||||
const oidcIssuer = process.env.OIDC_ISSUER;
|
||||
const oidcClientId = process.env.OIDC_CLIENT_ID;
|
||||
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET;
|
||||
|
||||
if (!oidcIssuer || !oidcClientId || !oidcClientSecret) {
|
||||
// Step 4: Neither DB config nor env vars — auth is unconfigured
|
||||
console.warn(
|
||||
"[auth] No auth provider configured. Set up auth_provider_config in DB or OIDC_* env vars."
|
||||
);
|
||||
return; // authInstance stays null — AUTH_DISABLED mode
|
||||
}
|
||||
|
||||
providerConfig = {
|
||||
providerId: "authentik",
|
||||
clientId: oidcClientId,
|
||||
clientSecret: oidcClientSecret,
|
||||
issuerUrl: oidcIssuer,
|
||||
internalBaseUrl: process.env.OIDC_INTERNAL_BASE,
|
||||
scopes: "openid profile email",
|
||||
};
|
||||
console.log("[auth] Using env var config (no DB config found)");
|
||||
}
|
||||
|
||||
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||
|
||||
const issuerUrlObj = new URL(providerConfig.issuerUrl);
|
||||
const issuerHostname = issuerUrlObj.hostname;
|
||||
|
||||
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
||||
let oidcConfig: Record<string, string> = {};
|
||||
try {
|
||||
const discoveryRes = await fetch(discoveryUrlStr);
|
||||
if (discoveryRes.ok) {
|
||||
const discovery = await discoveryRes.json() as {
|
||||
authorization_endpoint?: string;
|
||||
token_endpoint?: string;
|
||||
userinfo_endpoint?: string;
|
||||
};
|
||||
const replaceHost = (url: string, newHost: string) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const newParsed = new URL(newHost);
|
||||
return `${newParsed.origin}${parsed.pathname}${parsed.search}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
const authzUrl = discovery.authorization_endpoint;
|
||||
const tokenUrl = discovery.token_endpoint;
|
||||
const userInfoUrl = discovery.userinfo_endpoint;
|
||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||
const authzUrlObj = new URL(authzUrl);
|
||||
// Only validate authorizationUrl hostname against issuer — token/userinfo
|
||||
// may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls.
|
||||
if (authzUrlObj.hostname !== issuerHostname) {
|
||||
throw new Error(
|
||||
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
|
||||
);
|
||||
}
|
||||
oidcConfig = {
|
||||
authorizationUrl: authzUrl,
|
||||
tokenUrl: providerConfig.internalBaseUrl
|
||||
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
||||
: tokenUrl,
|
||||
userInfoUrl: providerConfig.internalBaseUrl
|
||||
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
|
||||
: userInfoUrl,
|
||||
};
|
||||
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
|
||||
} else {
|
||||
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
|
||||
}
|
||||
} else {
|
||||
console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`);
|
||||
}
|
||||
|
||||
// Build Better-Auth instance using resolved config
|
||||
authInstance = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
}),
|
||||
secret: BETTER_AUTH_SECRET,
|
||||
baseURL: BETTER_AUTH_URL,
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
max: 100,
|
||||
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,
|
||||
},
|
||||
},
|
||||
account: {
|
||||
storeStateStrategy: "cookie" as const,
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
emailVerification: {
|
||||
sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Verify your GroomBook email",
|
||||
text: `Click the link to verify your email: ${url}`,
|
||||
html: `<p>Click the link to verify your email:</p><a href="${url}">${url}</a>`,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: providerConfig.providerId,
|
||||
clientId: providerConfig.clientId,
|
||||
clientSecret: providerConfig.clientSecret,
|
||||
discoveryUrl: discoveryUrlStr,
|
||||
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}),
|
||||
scopes: providerConfig.scopes.split(" ").filter(Boolean),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
socialProviders: {
|
||||
...(hasGoogle ? {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
},
|
||||
} : {}),
|
||||
...(hasGitHub ? {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // 1 day
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
},
|
||||
},
|
||||
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||
});
|
||||
})();
|
||||
|
||||
await authInitPromise;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
let s3Instance: S3Client | null = null;
|
||||
|
||||
function getS3Client(): S3Client {
|
||||
if (!s3Instance) {
|
||||
s3Instance = new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION ?? "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
|
||||
},
|
||||
forcePathStyle: true, // required for Ceph RGW
|
||||
});
|
||||
}
|
||||
return s3Instance;
|
||||
}
|
||||
|
||||
function getBucket(): string {
|
||||
return process.env.S3_BUCKET ?? "groombook-pet-photos";
|
||||
}
|
||||
|
||||
/** Generate a presigned PUT URL for uploading a pet photo. Expires in 15 min. */
|
||||
export async function getPresignedUploadUrl(
|
||||
key: string,
|
||||
contentType: string,
|
||||
sizeBytes: number,
|
||||
expiresIn = 900
|
||||
): Promise<string> {
|
||||
const client = getS3Client();
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
ContentLength: sizeBytes,
|
||||
});
|
||||
return getSignedUrl(client, command, { expiresIn });
|
||||
}
|
||||
|
||||
/** Generate a presigned GET URL for viewing a pet photo. Expires in 1 hour. */
|
||||
export async function getPresignedGetUrl(
|
||||
key: string,
|
||||
expiresIn = 3600
|
||||
): Promise<string> {
|
||||
const client = getS3Client();
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
});
|
||||
return getSignedUrl(client, command, { expiresIn });
|
||||
}
|
||||
|
||||
/** Delete a pet photo object from storage. */
|
||||
export async function deleteObject(key: string): Promise<void> {
|
||||
const client = getS3Client();
|
||||
await client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Read an object from S3 and return its body buffer and content type. */
|
||||
export async function getObject(key: string): Promise<{ body: Buffer; contentType: string }> {
|
||||
const client = getS3Client();
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
const chunks: Uint8Array[] = [];
|
||||
// response.Body is a Readable stream; collect chunks into a buffer
|
||||
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const body = Buffer.concat(chunks);
|
||||
const contentType = response.ContentType ?? "application/octet-stream";
|
||||
return { body, contentType };
|
||||
}
|
||||
|
||||
/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */
|
||||
export async function putObject(
|
||||
key: string,
|
||||
body: Buffer | Uint8Array | string,
|
||||
contentType: string,
|
||||
contentLength: number
|
||||
): Promise<void> {
|
||||
const client = getS3Client();
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Business hours slot generation — pure utility, no DB dependencies.
|
||||
* Extracted so it can be unit tested independently of the route layer.
|
||||
*/
|
||||
|
||||
export const BUSINESS_START_HOUR = 9; // UTC
|
||||
export const BUSINESS_END_HOUR = 17; // UTC
|
||||
|
||||
export interface BookedSlot {
|
||||
staffId: string | null;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all available appointment start times for a given date,
|
||||
* returning only slots where at least one groomer is free.
|
||||
*/
|
||||
export function generateAvailableSlots({
|
||||
dateStr,
|
||||
durationMinutes,
|
||||
groomerIds,
|
||||
booked,
|
||||
}: {
|
||||
dateStr: string;
|
||||
durationMinutes: number;
|
||||
groomerIds: string[];
|
||||
booked: BookedSlot[];
|
||||
}): string[] {
|
||||
const dayStart = new Date(`${dateStr}T00:00:00Z`);
|
||||
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
|
||||
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
|
||||
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
||||
|
||||
const durationMs = durationMinutes * 60_000;
|
||||
const slots: string[] = [];
|
||||
let slotStart = dayStart.getTime();
|
||||
|
||||
while (slotStart + durationMs <= dayEnd.getTime()) {
|
||||
const slotEnd = slotStart + durationMs;
|
||||
const hasGroomer = groomerIds.some(
|
||||
(groomerId) =>
|
||||
!booked.some(
|
||||
(a) =>
|
||||
a.staffId === groomerId &&
|
||||
a.startTime.getTime() < slotEnd &&
|
||||
a.endTime.getTime() > slotStart
|
||||
)
|
||||
);
|
||||
if (hasGroomer) slots.push(new Date(slotStart).toISOString());
|
||||
slotStart += durationMs;
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { getAuth } from "../lib/auth.js";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Guard: refuse to start with AUTH_DISABLED in production.
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
console.error(
|
||||
"[FATAL] AUTH_DISABLED=true is not allowed in production. " +
|
||||
"Remove AUTH_DISABLED from your environment and configure Better-Auth."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.warn(
|
||||
"[WARNING] AUTH_DISABLED=true — authentication is bypassed. " +
|
||||
"Do NOT use this in production."
|
||||
);
|
||||
}
|
||||
|
||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
if (c.req.path.startsWith("/api/auth/")) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
const devUserId = c.req.header("X-Dev-User-Id");
|
||||
const sub = devUserId ?? "dev-user";
|
||||
c.set("jwtPayload", { sub } as { sub: string });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
let auth;
|
||||
try {
|
||||
auth = getAuth();
|
||||
} catch {
|
||||
return c.json({ error: "Authentication not configured" }, 503);
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
// Set jwtPayload with sub = Better-Auth user ID for backward compat with resolveStaffMiddleware
|
||||
c.set("jwtPayload", {
|
||||
sub: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
});
|
||||
await next();
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { getDb, impersonationAuditLogs } from "../db/index.js";
|
||||
import type { PortalEnv } from "./portalSession.js";
|
||||
|
||||
/**
|
||||
* Server-side audit logging middleware for portal routes.
|
||||
* Applied after validatePortalSession in the middleware chain.
|
||||
*
|
||||
* After the route handler completes (await next()), inserts an audit log entry
|
||||
* into impersonationAuditLogs:
|
||||
* - sessionId: from c.get("portalSessionId")
|
||||
* - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments")
|
||||
* - pageVisited: c.req.path
|
||||
* - metadata: { method, statusCode: c.res.status }
|
||||
*
|
||||
* Log entries are written for both success and error responses.
|
||||
* Does NOT throw if audit logging fails — errors are logged but the user's
|
||||
* request is not affected.
|
||||
*/
|
||||
export const portalAudit: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||
await next();
|
||||
|
||||
const sessionId = c.get("portalSessionId");
|
||||
if (!sessionId) return;
|
||||
|
||||
const method = c.req.method;
|
||||
const routePath = c.req.path;
|
||||
const pageVisited = c.req.path;
|
||||
const statusCode = c.res.status;
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
await db
|
||||
.insert(impersonationAuditLogs)
|
||||
.values({
|
||||
sessionId,
|
||||
action: `${method} ${routePath}`,
|
||||
pageVisited,
|
||||
metadata: { method, statusCode },
|
||||
})
|
||||
.returning();
|
||||
} catch (err) {
|
||||
console.error("[portalAudit] Failed to write audit log:", err);
|
||||
}
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, impersonationSessions } from "../db/index.js";
|
||||
|
||||
export interface PortalEnv {
|
||||
Variables: {
|
||||
portalClientId: string;
|
||||
portalSessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
|
||||
* Must be applied to all portal routes.
|
||||
*
|
||||
* Reads x-session-id from request headers, queries impersonationSessions for a row where
|
||||
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
||||
* Returns 401 if session is invalid/missing/expired.
|
||||
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
|
||||
*/
|
||||
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
c.set("portalClientId", session.clientId);
|
||||
c.set("portalSessionId", session.id);
|
||||
await next();
|
||||
};
|
||||
@@ -1,200 +0,0 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, sql, staff } from "../db/index.js";
|
||||
|
||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||
export type StaffRow = typeof staff.$inferSelect;
|
||||
|
||||
export interface AppEnv {
|
||||
Variables: {
|
||||
jwtPayload: { sub: string; email?: string; name?: string };
|
||||
staff: StaffRow;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the authenticated staff record from the DB and stores it in context.
|
||||
* Must be applied after authMiddleware on all protected routes.
|
||||
*
|
||||
* Dev mode (AUTH_DISABLED=true): resolves staff by X-Dev-User-Id header (Better-Auth
|
||||
* user ID), or falls back to the first manager in the DB.
|
||||
*/
|
||||
export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
||||
c,
|
||||
next
|
||||
) => {
|
||||
// Better-Auth's own routes handle their own auth — skip staff resolution
|
||||
// OOBE setup routes also handle their own auth — staff record is created during setup
|
||||
if (c.req.path.startsWith("/api/auth/") || c.req.path.startsWith("/api/setup")) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
const devUserId = c.req.header("X-Dev-User-Id");
|
||||
if (!devUserId) {
|
||||
// No header — fall back to first manager
|
||||
const [manager] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.role, "manager"))
|
||||
.limit(1);
|
||||
if (!manager) {
|
||||
return c.json({ error: "Forbidden: no staff records found" }, 403);
|
||||
}
|
||||
c.set("staff", { ...manager, isSuperUser: manager.isSuperUser ?? false });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Treat X-Dev-User-Id as the Better-Auth user ID first
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.userId, devUserId));
|
||||
if (row) {
|
||||
c.set("staff", { ...row, isSuperUser: row.isSuperUser ?? false });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Fallback: if userId is null, treat X-Dev-User-Id as staff.id (dev login
|
||||
// may send the primary key for staff records that predate the userId field)
|
||||
const [fallbackRow] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, devUserId));
|
||||
if (!fallbackRow) {
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for X-Dev-User-Id" },
|
||||
403
|
||||
);
|
||||
}
|
||||
c.set("staff", { ...fallbackRow, isSuperUser: fallbackRow.isSuperUser ?? false });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = c.get("jwtPayload");
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.userId, jwt.sub));
|
||||
if (row) {
|
||||
c.set("staff", row);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Fallback: staff records that predate the userId field may still have oidcSub
|
||||
const [fallbackRow] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.oidcSub, jwt.sub));
|
||||
if (fallbackRow) {
|
||||
c.set("staff", fallbackRow);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Auto-link by email: staff record exists with matching email but no userId
|
||||
if (jwt.email) {
|
||||
const [byEmail] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`));
|
||||
if (byEmail) {
|
||||
await db
|
||||
.update(staff)
|
||||
.set({ userId: jwt.sub, updatedAt: new Date() })
|
||||
.where(eq(staff.id, byEmail.id));
|
||||
c.set("staff", { ...byEmail, userId: jwt.sub });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||
403
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware factory that enforces one of the allowed roles.
|
||||
* Must be applied after resolveStaffMiddleware.
|
||||
*
|
||||
* @example
|
||||
* api.use("/staff/*", requireRole("manager"));
|
||||
* api.use("/reports/*", requireRole("manager"));
|
||||
*/
|
||||
export function requireRole(
|
||||
...allowedRoles: StaffRole[]
|
||||
): MiddlewareHandler<AppEnv> {
|
||||
return async (c, next) => {
|
||||
const staffRow = c.get("staff");
|
||||
if (!staffRow) {
|
||||
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
|
||||
}
|
||||
if (!(allowedRoles as string[]).includes(staffRow.role)) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Forbidden: role '${staffRow.role}' is not permitted to access this resource`,
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that allows access if the staff member has any of the allowed roles OR is a super user.
|
||||
* Use for routes where managers OR super-users should have access.
|
||||
*
|
||||
* @example
|
||||
* api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
||||
*/
|
||||
export function requireRoleOrSuperUser(
|
||||
...allowedRoles: StaffRole[]
|
||||
): MiddlewareHandler<AppEnv> {
|
||||
return async (c, next) => {
|
||||
const staffRow = c.get("staff");
|
||||
if (!staffRow) {
|
||||
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
|
||||
}
|
||||
const hasAllowedRole = (allowedRoles as string[]).includes(staffRow.role);
|
||||
if (hasAllowedRole || staffRow.isSuperUser) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
error: hasAllowedRole
|
||||
? "Forbidden: super user privileges required"
|
||||
: `Forbidden: role '${staffRow.role}' is not permitted`,
|
||||
},
|
||||
403
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that enforces the staff member is a super user.
|
||||
* Must be applied after resolveStaffMiddleware and (typically) after requireRole.
|
||||
*
|
||||
* @example
|
||||
* api.use("/staff/*", requireRole("manager"));
|
||||
* api.use("/staff/*", requireSuperUser());
|
||||
*/
|
||||
export function requireSuperUser(): MiddlewareHandler<AppEnv> {
|
||||
return async (c, next) => {
|
||||
const staffRow = c.get("staff");
|
||||
if (!staffRow) {
|
||||
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
|
||||
}
|
||||
if (!staffRow.isSuperUser) {
|
||||
return c.json(
|
||||
{ error: "Forbidden: super user privileges required" },
|
||||
403
|
||||
);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Admin seed endpoint — populates minimal known-user seed data via the API.
|
||||
*
|
||||
* This is the canonical way to seed prod/demo data. The old approach (seed.ts
|
||||
* writing directly to the DB) bypasses API validation and audit trails.
|
||||
*
|
||||
* Security: This endpoint is manager-only (enforced via requireRole in index.ts).
|
||||
* It is disabled when AUTH_DISABLED=true — dev/test seeding should use the
|
||||
* direct-DB seed.ts in that mode.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { eq, getDb, staff, clients, pets, services } from "../../db/index.js";
|
||||
|
||||
export const adminSeedRouter = new Hono();
|
||||
|
||||
const KNOWN_STAFF = {
|
||||
name: "Demo Manager",
|
||||
email: "demo-manager@groombook.dev",
|
||||
oidcSub: "demo-manager-001",
|
||||
role: "manager" as const,
|
||||
active: true,
|
||||
};
|
||||
|
||||
const KNOWN_CLIENT = {
|
||||
name: "Demo Client",
|
||||
email: "demo-client@example.com",
|
||||
phone: "555-0001",
|
||||
address: "1 Demo Street, Demo City, CA 90210",
|
||||
};
|
||||
|
||||
const DEMO_PET = {
|
||||
name: "Demo Dog",
|
||||
species: "Dog",
|
||||
breed: "Golden Retriever",
|
||||
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 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
|
||||
];
|
||||
|
||||
adminSeedRouter.post("/seed", async (c) => {
|
||||
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
"Seed endpoint is not available when AUTH_DISABLED=true. Use direct DB seeding for dev/test environments.",
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const results: string[] = [];
|
||||
|
||||
// ── Staff: Demo Manager ─────────────────────────────────────────────────────
|
||||
const [existingStaff] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.email, KNOWN_STAFF.email));
|
||||
|
||||
if (existingStaff) {
|
||||
results.push(`Staff '${KNOWN_STAFF.name}' already exists (id: ${existingStaff.id})`);
|
||||
} else {
|
||||
const [created] = await db.insert(staff).values(KNOWN_STAFF).returning();
|
||||
results.push(`Created staff '${KNOWN_STAFF.name}' (id: ${created!.id}, oidcSub: ${KNOWN_STAFF.oidcSub})`);
|
||||
}
|
||||
|
||||
// ── Services: idempotent upsert using name as unique key ────────────────────
|
||||
// NOTE: UNIQUE constraint on services.name must exist (via migration 0020).
|
||||
// Both this admin seed and the main DB seed use the same deterministic IDs
|
||||
// and ON CONFLICT (name), ensuring consistency across both seed paths.
|
||||
for (const svc of DEMO_SERVICES) {
|
||||
await db.insert(services)
|
||||
.values({ ...svc, active: true })
|
||||
.onConflictDoUpdate({
|
||||
target: services.name,
|
||||
set: { description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
|
||||
});
|
||||
}
|
||||
results.push(`Upserted ${DEMO_SERVICES.length} services`);
|
||||
|
||||
// ── Client: Demo Client ───────────────────────────────────────────────────
|
||||
const [existingClient] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.email, KNOWN_CLIENT.email));
|
||||
|
||||
let clientId: string;
|
||||
if (existingClient) {
|
||||
clientId = existingClient.id;
|
||||
results.push(`Client '${KNOWN_CLIENT.name}' already exists (id: ${clientId})`);
|
||||
} else {
|
||||
const [created] = await db.insert(clients).values(KNOWN_CLIENT).returning();
|
||||
clientId = created!.id;
|
||||
results.push(`Created client '${KNOWN_CLIENT.name}' (id: ${clientId})`);
|
||||
}
|
||||
|
||||
// ── Pet: Demo Dog ──────────────────────────────────────────────────────────
|
||||
const existingPets = await db
|
||||
.select()
|
||||
.from(pets)
|
||||
.where(eq(pets.clientId, clientId));
|
||||
|
||||
const demoDog = existingPets.find(
|
||||
(p) => p.name === DEMO_PET.name && p.species === DEMO_PET.species
|
||||
);
|
||||
|
||||
if (demoDog) {
|
||||
results.push(`Pet '${DEMO_PET.name}' already exists for Demo Client (id: ${demoDog.id})`);
|
||||
} else {
|
||||
const [created] = await db
|
||||
.insert(pets)
|
||||
.values({
|
||||
clientId,
|
||||
name: DEMO_PET.name,
|
||||
species: DEMO_PET.species,
|
||||
breed: DEMO_PET.breed,
|
||||
weightKg: DEMO_PET.weightKg,
|
||||
dateOfBirth: new Date("2020-06-15T00:00:00Z"),
|
||||
})
|
||||
.returning();
|
||||
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Seed complete",
|
||||
details: results,
|
||||
credentials: {
|
||||
note: "For dev-mode access, use X-Dev-User-Id: demo-manager-001 header",
|
||||
staffOidcSub: KNOWN_STAFF.oidcSub,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,347 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
gte,
|
||||
lt,
|
||||
lte,
|
||||
ne,
|
||||
appointmentGroups,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
staff,
|
||||
} from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const appointmentGroupsRouter = new Hono<AppEnv>();
|
||||
|
||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const petAppointmentSchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
serviceId: z.string().uuid(),
|
||||
staffId: z.string().uuid().optional(),
|
||||
// Each pet may have a different end time (e.g. small dog done faster)
|
||||
endTime: z.string().datetime(),
|
||||
priceCents: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
const createGroupSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
startTime: z.string().datetime(),
|
||||
// One entry per pet
|
||||
pets: z.array(petAppointmentSchema).min(2, "A group booking requires at least 2 pets"),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
const updateGroupSchema = z.object({
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
// ─── List groups (compact, with appointment count and start time) ─────────────
|
||||
|
||||
appointmentGroupsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.req.query("clientId");
|
||||
const from = c.req.query("from");
|
||||
const to = c.req.query("to");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const groupConditions = clientId
|
||||
? [eq(appointmentGroups.clientId, clientId)]
|
||||
: [];
|
||||
|
||||
const groups = await db
|
||||
.select()
|
||||
.from(appointmentGroups)
|
||||
.where(groupConditions.length > 0 ? and(...groupConditions) : undefined)
|
||||
.orderBy(appointmentGroups.createdAt);
|
||||
|
||||
if (groups.length === 0) return c.json([]);
|
||||
|
||||
// Fetch appointments for all groups (filter by time range if provided)
|
||||
const apptConditions = [];
|
||||
if (from) apptConditions.push(gte(appointments.startTime, new Date(from)));
|
||||
if (to) apptConditions.push(lte(appointments.startTime, new Date(to)));
|
||||
|
||||
const allAppts = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(apptConditions.length > 0 ? and(...apptConditions) : undefined);
|
||||
|
||||
const groupApptMap = new Map<string, typeof appointments.$inferSelect[]>();
|
||||
for (const appt of allAppts) {
|
||||
if (!appt.groupId) continue;
|
||||
if (!groupApptMap.has(appt.groupId)) groupApptMap.set(appt.groupId, []);
|
||||
groupApptMap.get(appt.groupId)!.push(appt);
|
||||
}
|
||||
|
||||
const result = groups
|
||||
.map((g) => ({
|
||||
...g,
|
||||
appointments: (groupApptMap.get(g.id) ?? []).sort(
|
||||
(a, b) => a.startTime.getTime() - b.startTime.getTime()
|
||||
),
|
||||
}))
|
||||
.filter((g) => !from || g.appointments.length > 0);
|
||||
|
||||
if (isGroomer) {
|
||||
return c.json(
|
||||
result.filter((g) =>
|
||||
g.appointments.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// ─── Get single group with its appointments ───────────────────────────────────
|
||||
|
||||
appointmentGroupsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [group] = await db
|
||||
.select()
|
||||
.from(appointmentGroups)
|
||||
.where(eq(appointmentGroups.id, id));
|
||||
if (!group) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const groupAppts = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
petId: appointments.petId,
|
||||
petName: pets.name,
|
||||
serviceId: appointments.serviceId,
|
||||
serviceName: services.name,
|
||||
staffId: appointments.staffId,
|
||||
batherStaffId: appointments.batherStaffId,
|
||||
staffName: staff.name,
|
||||
status: appointments.status,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
priceCents: appointments.priceCents,
|
||||
notes: appointments.notes,
|
||||
})
|
||||
.from(appointments)
|
||||
.leftJoin(pets, eq(appointments.petId, pets.id))
|
||||
.leftJoin(services, eq(appointments.serviceId, services.id))
|
||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(eq(appointments.groupId, id))
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
if (
|
||||
isGroomer &&
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const [client] = await db
|
||||
.select({ name: clients.name, email: clients.email })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, group.clientId));
|
||||
|
||||
return c.json({ ...group, client, appointments: groupAppts });
|
||||
});
|
||||
|
||||
// ─── Create group booking ─────────────────────────────────────────────────────
|
||||
|
||||
appointmentGroupsRouter.post(
|
||||
"/",
|
||||
zValidator("json", createGroupSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
if (staffRow?.role === "groomer") {
|
||||
return c.json(
|
||||
{ error: "Forbidden: groomers cannot create group bookings" },
|
||||
403
|
||||
);
|
||||
}
|
||||
const body = c.req.valid("json");
|
||||
const startTime = new Date(body.startTime);
|
||||
|
||||
// Verify client exists
|
||||
const [client] = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, body.clientId));
|
||||
if (!client) return c.json({ error: "Client not found" }, 404);
|
||||
|
||||
// Verify all pets belong to this client
|
||||
const petIds = body.pets.map((p) => p.petId);
|
||||
const petRows = await db
|
||||
.select({ id: pets.id, clientId: pets.clientId })
|
||||
.from(pets)
|
||||
.where(eq(pets.clientId, body.clientId));
|
||||
const ownedPetIds = new Set(petRows.map((p) => p.id));
|
||||
const unauthorized = petIds.filter((id) => !ownedPetIds.has(id));
|
||||
if (unauthorized.length > 0) {
|
||||
return c.json({ error: `Pet(s) not found for this client: ${unauthorized.join(", ")}` }, 422);
|
||||
}
|
||||
|
||||
// Deduplicate pets in a single booking
|
||||
if (new Set(petIds).size !== petIds.length) {
|
||||
return c.json({ error: "Each pet can only appear once per group booking" }, 422);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// Check conflicts for each staff member
|
||||
for (const pet of body.pets) {
|
||||
if (!pet.staffId) continue;
|
||||
const endTime = new Date(pet.endTime);
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, pet.staffId),
|
||||
lt(appointments.startTime, endTime),
|
||||
gte(appointments.endTime, startTime),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(
|
||||
new Error(`Staff conflict for pet ${pet.petId}`),
|
||||
{ statusCode: 409, petId: pet.petId, staffId: pet.staffId }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the group record
|
||||
const [group] = await tx
|
||||
.insert(appointmentGroups)
|
||||
.values({ clientId: body.clientId, notes: body.notes ?? null })
|
||||
.returning();
|
||||
if (!group) throw new Error("Failed to create appointment group");
|
||||
|
||||
// Create one appointment per pet
|
||||
const createdAppts = [];
|
||||
for (const pet of body.pets) {
|
||||
const endTime = new Date(pet.endTime);
|
||||
const [appt] = await tx
|
||||
.insert(appointments)
|
||||
.values({
|
||||
clientId: body.clientId,
|
||||
petId: pet.petId,
|
||||
serviceId: pet.serviceId,
|
||||
staffId: pet.staffId ?? null,
|
||||
startTime,
|
||||
endTime,
|
||||
priceCents: pet.priceCents ?? null,
|
||||
groupId: group.id,
|
||||
})
|
||||
.returning();
|
||||
if (appt) createdAppts.push(appt);
|
||||
}
|
||||
|
||||
return { group, appointments: createdAppts };
|
||||
});
|
||||
|
||||
return c.json(result, 201);
|
||||
} catch (err: unknown) {
|
||||
const e = err as Error & { statusCode?: number };
|
||||
if (e.statusCode === 409) {
|
||||
return c.json({ error: "A staff member has a conflicting appointment at this time", detail: e.message }, 409);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Update group notes ───────────────────────────────────────────────────────
|
||||
|
||||
appointmentGroupsRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateGroupSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [group] = await db
|
||||
.select({ id: appointmentGroups.id })
|
||||
.from(appointmentGroups)
|
||||
.where(eq(appointmentGroups.id, id));
|
||||
if (!group) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const groupAppts = await db
|
||||
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.groupId, id));
|
||||
if (
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointmentGroups)
|
||||
.set({ ...body, updatedAt: new Date() })
|
||||
.where(eq(appointmentGroups.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(updated);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Cancel all appointments in a group ──────────────────────────────────────
|
||||
|
||||
appointmentGroupsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [group] = await db
|
||||
.select({ id: appointmentGroups.id })
|
||||
.from(appointmentGroups)
|
||||
.where(eq(appointmentGroups.id, id));
|
||||
if (!group) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const groupAppts = await db
|
||||
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.groupId, id));
|
||||
if (
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(eq(appointments.groupId, id));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -1,845 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
gte,
|
||||
lt,
|
||||
lte,
|
||||
ne,
|
||||
or,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
recurringSeries,
|
||||
reminderLogs,
|
||||
services,
|
||||
staff,
|
||||
} from "../db/index.js";
|
||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number,
|
||||
delayMs: number,
|
||||
context: string
|
||||
): Promise<void> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await fn();
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error(`[appointments] ${context}: ${lastError}`);
|
||||
}
|
||||
|
||||
export const appointmentsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createAppointmentSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
petId: z.string().uuid(),
|
||||
serviceId: z.string().uuid(),
|
||||
staffId: z.string().uuid().optional(),
|
||||
batherStaffId: z.string().uuid().optional(),
|
||||
startTime: z.string().datetime(),
|
||||
endTime: z.string().datetime(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
priceCents: z.number().int().positive().optional(),
|
||||
// Optional recurrence: creates a series of N appointments every frequencyWeeks weeks
|
||||
recurrence: z
|
||||
.object({
|
||||
frequencyWeeks: z.number().int().min(1).max(52),
|
||||
count: z.number().int().min(2).max(52),
|
||||
})
|
||||
.refine(
|
||||
(r) => r.frequencyWeeks * r.count <= 52,
|
||||
{ message: "Recurrence series must not exceed 1 year" }
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const updateAppointmentSchema = z.object({
|
||||
staffId: z.string().uuid().nullable().optional(),
|
||||
batherStaffId: z.string().uuid().nullable().optional(),
|
||||
status: z
|
||||
.enum([
|
||||
"scheduled",
|
||||
"confirmed",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"no_show",
|
||||
])
|
||||
.optional(),
|
||||
startTime: z.string().datetime().optional(),
|
||||
endTime: z.string().datetime().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
priceCents: z.number().int().positive().nullable().optional(),
|
||||
// When updating a series member, optionally propagate the change
|
||||
cascadeMode: z.enum(["this_only", "this_and_future", "all"]).optional(),
|
||||
});
|
||||
|
||||
// List appointments, optionally filtered by date range or staffId.
|
||||
// Groomers see only their own appointments (staffId or batherStaffId).
|
||||
appointmentsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const from = c.req.query("from");
|
||||
const to = c.req.query("to");
|
||||
const staffId = c.req.query("staffId");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const conditions = [];
|
||||
if (from) conditions.push(gte(appointments.startTime, new Date(from)));
|
||||
if (to) conditions.push(lte(appointments.startTime, new Date(to)));
|
||||
if (staffId) conditions.push(eq(appointments.staffId, staffId));
|
||||
|
||||
// Groomer: restrict to their own appointments (as groomer or bather)
|
||||
if (isGroomer) {
|
||||
conditions.push(
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const rows =
|
||||
conditions.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(and(...conditions))
|
||||
.orderBy(appointments.startTime)
|
||||
: await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
appointmentsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, c.req.param("id")));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
// Groomer: 403 if not assigned as groomer or bather
|
||||
if (isGroomer && row.staffId !== staffRow.id && row.batherStaffId !== staffRow.id) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
appointmentsRouter.post(
|
||||
"/",
|
||||
zValidator("json", createAppointmentSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const start = new Date(body.startTime);
|
||||
const end = new Date(body.endTime);
|
||||
|
||||
if (end <= start) {
|
||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||
}
|
||||
|
||||
const { recurrence, ...apptFields } = body;
|
||||
|
||||
// Wrap conflict check + insert in a transaction to prevent double-booking
|
||||
// race conditions under concurrent load (fixes #18).
|
||||
let firstRow: typeof appointments.$inferSelect;
|
||||
try {
|
||||
firstRow = await db.transaction(async (tx) => {
|
||||
// Conflict check applies to the first occurrence only; subsequent
|
||||
// occurrences are spread weeks apart so conflicts are unlikely and can
|
||||
// be resolved individually if needed.
|
||||
if (apptFields.staffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, apptFields.staffId),
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (apptFields.batherStaffId) {
|
||||
const bathConflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, apptFields.batherStaffId),
|
||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (bathConflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!recurrence) {
|
||||
// Single appointment
|
||||
const [inserted] = await tx
|
||||
.insert(appointments)
|
||||
.values({ ...apptFields, startTime: start, endTime: end })
|
||||
.returning();
|
||||
if (!inserted) throw new Error("Insert failed");
|
||||
return inserted;
|
||||
}
|
||||
|
||||
// Create recurring series
|
||||
const seriesRows = await tx
|
||||
.insert(recurringSeries)
|
||||
.values({ frequencyWeeks: recurrence.frequencyWeeks })
|
||||
.returning();
|
||||
const series = seriesRows[0];
|
||||
if (!series) throw new Error("Failed to create recurring series");
|
||||
|
||||
const durationMs = end.getTime() - start.getTime();
|
||||
const intervalMs =
|
||||
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let first: typeof appointments.$inferSelect | undefined;
|
||||
const conflictingInstances: number[] = [];
|
||||
for (let i = 0; i < recurrence.count; i++) {
|
||||
const instanceStart = new Date(start.getTime() + i * intervalMs);
|
||||
const instanceEnd = new Date(
|
||||
instanceStart.getTime() + durationMs
|
||||
);
|
||||
|
||||
if (apptFields.staffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, apptFields.staffId),
|
||||
lt(appointments.startTime, instanceEnd),
|
||||
gte(appointments.endTime, instanceStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
conflictingInstances.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (apptFields.batherStaffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, apptFields.batherStaffId),
|
||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, instanceEnd),
|
||||
gte(appointments.endTime, instanceStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
conflictingInstances.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const [inserted] = await tx
|
||||
.insert(appointments)
|
||||
.values({
|
||||
...apptFields,
|
||||
startTime: instanceStart,
|
||||
endTime: instanceEnd,
|
||||
seriesId: series.id,
|
||||
seriesIndex: i,
|
||||
})
|
||||
.returning();
|
||||
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
|
||||
if (i === 0) first = inserted;
|
||||
}
|
||||
|
||||
if (conflictingInstances.length > 0) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`Conflicts detected at occurrence(s): ${conflictingInstances.join(", ")}`
|
||||
),
|
||||
{ statusCode: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!first) throw new Error("No appointments created");
|
||||
return first;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err as Error & { statusCode?: number }).statusCode === 409
|
||||
) {
|
||||
return c.json(
|
||||
{ error: "Staff member has a conflicting appointment at this time" },
|
||||
409
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Send confirmation email (fire-and-forget — never fails the request)
|
||||
withRetry(
|
||||
() => sendConfirmationEmail(db, firstRow),
|
||||
2,
|
||||
1000,
|
||||
`Failed to send confirmation email for appointment ${firstRow.id}`
|
||||
);
|
||||
|
||||
return c.json(firstRow, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Confirmation email helper ─────────────────────────────────────────────
|
||||
|
||||
async function sendConfirmationEmail(
|
||||
db: ReturnType<typeof getDb>,
|
||||
appt: typeof appointments.$inferSelect
|
||||
): Promise<void> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
clientEmailOptOut: clients.emailOptOut,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
groomerName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(clients, eq(clients.id, appointments.clientId))
|
||||
.innerJoin(pets, eq(pets.id, appointments.petId))
|
||||
.innerJoin(services, eq(services.id, appointments.serviceId))
|
||||
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
||||
.where(eq(appointments.id, appt.id))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return;
|
||||
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
|
||||
|
||||
if (!clientEmail || clientEmailOptOut) return;
|
||||
if (!petName || !serviceName) return;
|
||||
|
||||
const sent = await sendEmail(
|
||||
buildConfirmationEmail(clientEmail, {
|
||||
clientName,
|
||||
petName,
|
||||
serviceName,
|
||||
groomerName: groomerName ?? null,
|
||||
startTime: appt.startTime,
|
||||
})
|
||||
);
|
||||
|
||||
if (sent) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: "confirmation" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
appointmentsRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateAppointmentSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const { cascadeMode = "this_only", ...updateFields } = body;
|
||||
|
||||
// ── Cascade update (this_and_future / all) ────────────────────────────────
|
||||
if (cascadeMode !== "this_only") {
|
||||
let row: typeof appointments.$inferSelect | undefined;
|
||||
try {
|
||||
row = await db.transaction(async (tx) => {
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
if (!current) {
|
||||
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
||||
}
|
||||
|
||||
// Compute time deltas and apply them uniformly across the series so
|
||||
// all instances shift by the same amount (e.g. rescheduled 1 hr later).
|
||||
const startDeltaMs = updateFields.startTime
|
||||
? new Date(updateFields.startTime).getTime() -
|
||||
current.startTime.getTime()
|
||||
: 0;
|
||||
const endDeltaMs = updateFields.endTime
|
||||
? new Date(updateFields.endTime).getTime() -
|
||||
current.endTime.getTime()
|
||||
: 0;
|
||||
|
||||
// Validate resulting times on the anchor appointment
|
||||
const newStart = new Date(
|
||||
current.startTime.getTime() + startDeltaMs
|
||||
);
|
||||
const newEnd = new Date(current.endTime.getTime() + endDeltaMs);
|
||||
if (newEnd <= newStart) {
|
||||
throw Object.assign(new Error("end before start"), {
|
||||
statusCode: 422,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine which appointments to update
|
||||
let whereClause;
|
||||
if (current.seriesId && current.seriesIndex !== null) {
|
||||
whereClause =
|
||||
cascadeMode === "this_and_future"
|
||||
? and(
|
||||
eq(appointments.seriesId, current.seriesId),
|
||||
gte(appointments.seriesIndex, current.seriesIndex),
|
||||
)
|
||||
: eq(appointments.seriesId, current.seriesId);
|
||||
} else {
|
||||
// Not part of a series — fall back to single update
|
||||
whereClause = eq(appointments.id, id);
|
||||
}
|
||||
|
||||
const affected = await tx
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(whereClause);
|
||||
|
||||
let firstUpdated: typeof appointments.$inferSelect | undefined;
|
||||
for (const appt of affected) {
|
||||
const newStart =
|
||||
startDeltaMs !== 0
|
||||
? new Date(appt.startTime.getTime() + startDeltaMs)
|
||||
: appt.startTime;
|
||||
const newEnd =
|
||||
endDeltaMs !== 0
|
||||
? new Date(appt.endTime.getTime() + endDeltaMs)
|
||||
: appt.endTime;
|
||||
const newStaffId =
|
||||
updateFields.staffId !== undefined
|
||||
? updateFields.staffId
|
||||
: appt.staffId;
|
||||
const newBatherStaffId =
|
||||
updateFields.batherStaffId !== undefined
|
||||
? updateFields.batherStaffId
|
||||
: appt.batherStaffId;
|
||||
|
||||
if (
|
||||
newStaffId &&
|
||||
(startDeltaMs !== 0 ||
|
||||
endDeltaMs !== 0 ||
|
||||
updateFields.staffId !== undefined)
|
||||
) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, newStaffId),
|
||||
lt(appointments.startTime, newEnd),
|
||||
gte(appointments.endTime, newStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, appt.id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
newBatherStaffId &&
|
||||
(startDeltaMs !== 0 ||
|
||||
endDeltaMs !== 0 ||
|
||||
updateFields.batherStaffId !== undefined)
|
||||
) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, newBatherStaffId),
|
||||
eq(appointments.batherStaffId, newBatherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, newEnd),
|
||||
gte(appointments.endTime, newStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, appt.id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
const apptUpdate: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (updateFields.staffId !== undefined)
|
||||
apptUpdate.staffId = updateFields.staffId;
|
||||
if (updateFields.notes !== undefined)
|
||||
apptUpdate.notes = updateFields.notes;
|
||||
if (updateFields.status !== undefined)
|
||||
apptUpdate.status = updateFields.status;
|
||||
if (updateFields.priceCents !== undefined)
|
||||
apptUpdate.priceCents = updateFields.priceCents;
|
||||
if (startDeltaMs !== 0)
|
||||
apptUpdate.startTime = new Date(
|
||||
appt.startTime.getTime() + startDeltaMs
|
||||
);
|
||||
if (endDeltaMs !== 0)
|
||||
apptUpdate.endTime = new Date(
|
||||
appt.endTime.getTime() + endDeltaMs
|
||||
);
|
||||
|
||||
const [updated] = await tx
|
||||
.update(appointments)
|
||||
.set(apptUpdate)
|
||||
.where(eq(appointments.id, appt.id))
|
||||
.returning();
|
||||
if (appt.id === id) firstUpdated = updated;
|
||||
}
|
||||
|
||||
return firstUpdated;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const statusCode = (err as Error & { statusCode?: number }).statusCode;
|
||||
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
||||
if (statusCode === 422)
|
||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||
if (statusCode === 409)
|
||||
return c.json(
|
||||
{
|
||||
error: "Staff member has a conflicting appointment at this time",
|
||||
},
|
||||
409
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
|
||||
// ── this_only (original logic) ────────────────────────────────────────────
|
||||
const needsConflictCheck =
|
||||
updateFields.startTime !== undefined ||
|
||||
updateFields.endTime !== undefined ||
|
||||
updateFields.staffId !== undefined ||
|
||||
updateFields.batherStaffId !== undefined;
|
||||
|
||||
const update: Record<string, unknown> = {
|
||||
...updateFields,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (updateFields.startTime) update.startTime = new Date(updateFields.startTime);
|
||||
if (updateFields.endTime) update.endTime = new Date(updateFields.endTime);
|
||||
|
||||
if (needsConflictCheck) {
|
||||
// Wrap conflict check + update in a transaction to prevent race conditions
|
||||
// (fixes #18). Also falls back to the existing staffId when staffId is
|
||||
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
||||
let row: typeof appointments.$inferSelect | undefined;
|
||||
try {
|
||||
row = await db.transaction(async (tx) => {
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
if (!current) {
|
||||
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
||||
}
|
||||
|
||||
const start = updateFields.startTime
|
||||
? new Date(updateFields.startTime)
|
||||
: current.startTime;
|
||||
const end = updateFields.endTime
|
||||
? new Date(updateFields.endTime)
|
||||
: current.endTime;
|
||||
// Use provided staffId (may be null to unassign); fall back to existing
|
||||
const staffId =
|
||||
updateFields.staffId !== undefined
|
||||
? updateFields.staffId
|
||||
: current.staffId;
|
||||
// Use provided batherStaffId (may be null to unassign); fall back to existing
|
||||
const batherStaffId =
|
||||
updateFields.batherStaffId !== undefined
|
||||
? updateFields.batherStaffId
|
||||
: current.batherStaffId;
|
||||
|
||||
if (end <= start) {
|
||||
throw Object.assign(new Error("end before start"), {
|
||||
statusCode: 422,
|
||||
});
|
||||
}
|
||||
|
||||
if (staffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, staffId),
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (batherStaffId) {
|
||||
const bathConflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, batherStaffId),
|
||||
eq(appointments.batherStaffId, batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (bathConflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(appointments)
|
||||
.set(update)
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
return updated;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const statusCode = (err as Error & { statusCode?: number }).statusCode;
|
||||
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
||||
if (statusCode === 422)
|
||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||
if (statusCode === 409)
|
||||
return c.json(
|
||||
{
|
||||
error: "Staff member has a conflicting appointment at this time",
|
||||
},
|
||||
409
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.update(appointments)
|
||||
.set(update)
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
);
|
||||
|
||||
// Soft-delete: cancel the appointment instead of removing the row,
|
||||
// preserving audit trail and financial records (fixes #20).
|
||||
// Optional ?cascade=this_only|this_and_future|all for series appointments.
|
||||
appointmentsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const cascade = c.req.query("cascade") ?? "this_only";
|
||||
|
||||
if (cascade === "this_and_future" || cascade === "all") {
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
if (!current) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (current.seriesId && current.seriesIndex !== null) {
|
||||
const whereClause =
|
||||
cascade === "this_and_future"
|
||||
? and(
|
||||
eq(appointments.seriesId, current.seriesId),
|
||||
gte(appointments.seriesIndex, current.seriesIndex),
|
||||
)
|
||||
: eq(appointments.seriesId, current.seriesId);
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(whereClause);
|
||||
} else {
|
||||
// Not in a series — cancel only this one
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id));
|
||||
}
|
||||
|
||||
const apptDate = current.startTime.toISOString().slice(0, 10);
|
||||
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||
withRetry(
|
||||
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
||||
2,
|
||||
1000,
|
||||
`Failed to notify waitlist for appointment ${id}`
|
||||
);
|
||||
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
|
||||
// Single cancel (default)
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
if (!current) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const apptDate = current.startTime.toISOString().slice(0, 10);
|
||||
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||
|
||||
const [row] = await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
withRetry(
|
||||
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
||||
2,
|
||||
1000,
|
||||
`Failed to notify waitlist for appointment ${id}`
|
||||
);
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── POST /api/appointments/:id/confirm ───────────────────────────────────────
|
||||
// Staff/portal: confirm a specific appointment by ID. Idempotent.
|
||||
|
||||
appointmentsRouter.post("/:id/confirm", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.json({ error: "Cannot confirm a cancelled appointment" }, 409);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus === "confirmed") {
|
||||
return c.json(appt); // idempotent
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ─── POST /api/appointments/:id/cancel ───────────────────────────────────────
|
||||
// Staff/portal: cancel confirmation for a specific appointment by ID. Single-use token nullified.
|
||||
|
||||
appointmentsRouter.post("/:id/cancel", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.json({ error: "Appointment is already cancelled" }, 409);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "cancelled",
|
||||
cancelledAt: new Date(),
|
||||
confirmationToken: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ─── Token generation helper ──────────────────────────────────────────────────
|
||||
|
||||
export function generateConfirmationToken(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, authProviderConfig, encryptSecret } from "../db/index.js";
|
||||
import { requireSuperUser } from "../middleware/rbac.js";
|
||||
import { reinitAuth } from "../lib/auth.js";
|
||||
|
||||
export const authProviderRouter = new Hono();
|
||||
|
||||
const REDACTED = "••••••••";
|
||||
|
||||
const putAuthProviderSchema = z.object({
|
||||
providerId: z.string().min(1).max(100),
|
||||
displayName: z.string().min(1).max(200),
|
||||
issuerUrl: z.string().url(),
|
||||
internalBaseUrl: z.string().url().nullable().optional(),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
scopes: z.string().default("openid profile email"),
|
||||
});
|
||||
|
||||
/** Minimal schema for the test endpoint — only issuer/internal URLs are needed for OIDC discovery. */
|
||||
const authProviderTestSchema = z.object({
|
||||
issuerUrl: z.string().url(),
|
||||
internalBaseUrl: z.string().url().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/auth-provider
|
||||
* Returns the current provider config with clientSecret redacted.
|
||||
* Returns 404 if no provider is configured.
|
||||
*/
|
||||
authProviderRouter.get(
|
||||
"/",
|
||||
requireSuperUser(),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return c.json({ error: "No auth provider configured" }, 404);
|
||||
}
|
||||
|
||||
// Return with secret redacted
|
||||
return c.json({
|
||||
id: row.id,
|
||||
providerId: row.providerId,
|
||||
displayName: row.displayName,
|
||||
issuerUrl: row.issuerUrl,
|
||||
internalBaseUrl: row.internalBaseUrl,
|
||||
clientId: row.clientId,
|
||||
clientSecret: REDACTED,
|
||||
scopes: row.scopes,
|
||||
enabled: row.enabled,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /api/admin/auth-provider
|
||||
* Creates or replaces the auth provider config.
|
||||
* The clientSecret is encrypted before storage.
|
||||
*/
|
||||
authProviderRouter.put(
|
||||
"/",
|
||||
requireSuperUser(),
|
||||
zValidator("json", putAuthProviderSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
let encryptedSecret: string;
|
||||
try {
|
||||
encryptedSecret = encryptSecret(body.clientSecret);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ error: `Failed to encrypt client secret: ${message}` }, 500);
|
||||
}
|
||||
|
||||
// Upsert: delete existing rows then insert atomically
|
||||
let row: typeof authProviderConfig.$inferSelect | undefined;
|
||||
try {
|
||||
[row] = await db.transaction(async (tx) => {
|
||||
await tx.delete(authProviderConfig);
|
||||
return tx.insert(authProviderConfig).values({
|
||||
providerId: body.providerId,
|
||||
displayName: body.displayName,
|
||||
issuerUrl: body.issuerUrl,
|
||||
internalBaseUrl: body.internalBaseUrl ?? null,
|
||||
clientId: body.clientId,
|
||||
clientSecret: encryptedSecret,
|
||||
scopes: body.scopes,
|
||||
enabled: true,
|
||||
}).returning();
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ error: `Failed to persist auth provider config: ${message}` }, 500);
|
||||
}
|
||||
|
||||
if (!row) return c.json({ error: "Failed to create auth provider config" }, 500);
|
||||
|
||||
try {
|
||||
await reinitAuth();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ error: `Failed to reinitialize auth: ${message}` }, 500);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: row.id,
|
||||
providerId: row.providerId,
|
||||
displayName: row.displayName,
|
||||
issuerUrl: row.issuerUrl,
|
||||
internalBaseUrl: row.internalBaseUrl,
|
||||
clientId: row.clientId,
|
||||
clientSecret: REDACTED,
|
||||
scopes: row.scopes,
|
||||
enabled: row.enabled,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/admin/auth-provider/test
|
||||
* Validates the provider config by hitting the OIDC discovery endpoint.
|
||||
* Returns {ok: true, metadata} on success or {ok: false, error: string} on failure.
|
||||
*/
|
||||
authProviderRouter.post(
|
||||
"/test",
|
||||
requireSuperUser(),
|
||||
zValidator("json", authProviderTestSchema),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const discoveryUrl = `${body.issuerUrl.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
||||
|
||||
try {
|
||||
const res = await fetch(discoveryUrl, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) {
|
||||
return c.json({ ok: false, error: `Discovery endpoint returned ${res.status}` });
|
||||
}
|
||||
const metadata = await res.json() as Record<string, unknown>;
|
||||
return c.json({ ok: true, metadata });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ ok: false, error: message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/auth-provider
|
||||
* Removes the auth provider config from the DB.
|
||||
* After this, auth falls back to OIDC_* env vars.
|
||||
*/
|
||||
authProviderRouter.delete(
|
||||
"/",
|
||||
requireSuperUser(),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
await db.delete(authProviderConfig);
|
||||
try {
|
||||
await reinitAuth();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return c.json({ error: `Failed to reinitialize auth: ${message}` }, 500);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
);
|
||||
@@ -1,351 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
gt,
|
||||
gte,
|
||||
lt,
|
||||
ne,
|
||||
getDb,
|
||||
services,
|
||||
staff,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
} from "../db/index.js";
|
||||
import {
|
||||
generateAvailableSlots,
|
||||
BUSINESS_START_HOUR,
|
||||
BUSINESS_END_HOUR,
|
||||
} from "../lib/slots.js";
|
||||
|
||||
export const bookRouter = new Hono();
|
||||
|
||||
// ─── GET /api/book/services ─────────────────────────────────────────────────
|
||||
// Public: list active services for the booking flow
|
||||
|
||||
bookRouter.get("/services", async (c) => {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(eq(services.active, true))
|
||||
.orderBy(services.name);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
// ─── GET /api/book/availability ─────────────────────────────────────────────
|
||||
// Public: return ISO startTime strings for slots where ≥1 groomer is free
|
||||
// Query params: serviceId (uuid), date (YYYY-MM-DD)
|
||||
|
||||
bookRouter.get("/availability", async (c) => {
|
||||
const serviceId = c.req.query("serviceId");
|
||||
const dateStr = c.req.query("date");
|
||||
|
||||
if (!serviceId || !dateStr) {
|
||||
return c.json({ error: "serviceId and date are required" }, 400);
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
return c.json({ error: "date must be YYYY-MM-DD" }, 400);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const [service] = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(and(eq(services.id, serviceId), eq(services.active, true)));
|
||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||
|
||||
const groomers = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(and(eq(staff.active, true), eq(staff.role, "groomer")));
|
||||
|
||||
if (groomers.length === 0) return c.json([]);
|
||||
|
||||
const dayStart = new Date(`${dateStr}T00:00:00Z`);
|
||||
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
|
||||
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
|
||||
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
|
||||
|
||||
// Fetch all active appointments for the day (any groomer)
|
||||
const booked = await db
|
||||
.select({
|
||||
staffId: appointments.staffId,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, dayStart),
|
||||
lt(appointments.startTime, dayEnd),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
);
|
||||
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr,
|
||||
durationMinutes: service.durationMinutes,
|
||||
groomerIds: groomers.map((g) => g.id),
|
||||
booked,
|
||||
});
|
||||
|
||||
return c.json(slots);
|
||||
});
|
||||
|
||||
// ─── POST /api/book/appointments ─────────────────────────────────────────────
|
||||
// Public: create a booking. Finds or creates client by email, always creates pet.
|
||||
|
||||
const bookingSchema = z.object({
|
||||
serviceId: z.string().uuid(),
|
||||
startTime: z.string().datetime().refine(
|
||||
(dt) => new Date(dt) > new Date(),
|
||||
{ message: "Appointment must be in the future" }
|
||||
),
|
||||
clientName: z.string().min(1).max(200),
|
||||
clientEmail: z.string().email(),
|
||||
clientPhone: z.string().max(50).optional(),
|
||||
petName: z.string().min(1).max(200),
|
||||
petSpecies: z.string().min(1).max(100),
|
||||
petBreed: z.string().max(100).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
bookRouter.post(
|
||||
"/appointments",
|
||||
zValidator("json", bookingSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const start = new Date(body.startTime);
|
||||
|
||||
const [service] = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
|
||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||
|
||||
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
|
||||
|
||||
// Find all active groomers
|
||||
const groomers = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(and(eq(staff.active, true), eq(staff.role, "groomer")));
|
||||
|
||||
if (groomers.length === 0) {
|
||||
return c.json({ error: "No groomers available" }, 409);
|
||||
}
|
||||
|
||||
// Find conflicting appointments for this time window
|
||||
const booked = await db
|
||||
.select({ staffId: appointments.staffId })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
lt(appointments.startTime, end),
|
||||
gt(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
);
|
||||
|
||||
const busyIds = new Set(booked.map((a) => a.staffId));
|
||||
const freeGroomer = groomers.find(({ id }) => !busyIds.has(id));
|
||||
if (!freeGroomer) {
|
||||
return c.json(
|
||||
{ error: "No groomers available at this time. Please choose another slot." },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
// Find or create client by email (skip disabled clients)
|
||||
let [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(and(eq(clients.email, body.clientEmail), eq(clients.status, "active")));
|
||||
|
||||
if (!client) {
|
||||
const inserted = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
name: body.clientName,
|
||||
email: body.clientEmail,
|
||||
phone: body.clientPhone ?? null,
|
||||
})
|
||||
.returning();
|
||||
client = inserted[0];
|
||||
}
|
||||
|
||||
if (!client) return c.json({ error: "Failed to create client" }, 500);
|
||||
|
||||
// Create pet
|
||||
const petInserted = await db
|
||||
.insert(pets)
|
||||
.values({
|
||||
clientId: client.id,
|
||||
name: body.petName,
|
||||
species: body.petSpecies,
|
||||
breed: body.petBreed ?? null,
|
||||
})
|
||||
.returning();
|
||||
const pet = petInserted[0];
|
||||
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
|
||||
|
||||
// Insert appointment in a transaction to guard against race conditions
|
||||
let appointment;
|
||||
try {
|
||||
appointment = await db.transaction(async (tx) => {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, freeGroomer.id),
|
||||
lt(appointments.startTime, end),
|
||||
gt(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
|
||||
const apptInserted = await tx
|
||||
.insert(appointments)
|
||||
.values({
|
||||
clientId: client.id,
|
||||
petId: pet.id,
|
||||
serviceId: body.serviceId,
|
||||
staffId: freeGroomer.id,
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
notes: body.notes ?? null,
|
||||
})
|
||||
.returning();
|
||||
return apptInserted[0];
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const code = (err as Error & { statusCode?: number }).statusCode;
|
||||
if (code === 409) {
|
||||
return c.json(
|
||||
{ error: "This slot was just taken. Please choose another time." },
|
||||
409
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!appointment) return c.json({ error: "Failed to create appointment" }, 500);
|
||||
|
||||
return c.json({ appointment, client, pet }, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── GET /api/book/confirm/:token ──────────────────────────────────────────
|
||||
// Public: confirm appointment via tokenized email link. Redirects to success/error page.
|
||||
|
||||
const BASE_URL = () => process.env.APP_URL ?? "http://localhost:5173";
|
||||
|
||||
bookRouter.get("/confirm/:token", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
const db = getDb();
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.confirmationToken, token))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus === "confirmed") {
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.confirmationToken, token),
|
||||
eq(appointments.confirmationStatus, "pending")
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated.length === 0) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
});
|
||||
|
||||
// ─── GET /api/book/cancel/:token ───────────────────────────────────────────
|
||||
// Public: cancel appointment via tokenized email link. Redirects to success/error page.
|
||||
|
||||
bookRouter.get("/cancel/:token", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
const db = getDb();
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.confirmationToken, token))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "cancelled",
|
||||
cancelledAt: new Date(),
|
||||
confirmationToken: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.confirmationToken, token),
|
||||
eq(appointments.confirmationStatus, "pending")
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated.length === 0) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
gte,
|
||||
getDb,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
staff,
|
||||
} from "../db/index.js";
|
||||
|
||||
export const calendarRouter = new Hono();
|
||||
|
||||
function formatIcalDate(date: Date): string {
|
||||
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
||||
}
|
||||
|
||||
function escapeIcalText(text: string | null): string {
|
||||
if (!text) return "";
|
||||
return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
|
||||
}
|
||||
|
||||
function buildIcalFeed(
|
||||
appointments: Array<{
|
||||
id: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
status: string;
|
||||
clientName: string | null;
|
||||
petName: string | null;
|
||||
serviceName: string | null;
|
||||
}>,
|
||||
staffName: string,
|
||||
dtstamp: string
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//GroomBook//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
`X-WR-CALNAME:${escapeIcalText(staffName)} - GroomBook`,
|
||||
];
|
||||
|
||||
for (const appt of appointments) {
|
||||
const status = appt.status === "cancelled" ? "CANCELLED" : "CONFIRMED";
|
||||
const sequence = appt.status === "cancelled" ? "1" : "0";
|
||||
const summary = `${appt.petName ?? "Pet"} - ${appt.serviceName ?? "Appointment"}`;
|
||||
const description = `Client: ${appt.clientName ?? "Unknown"}\nPet: ${appt.petName ?? "Unknown"}\nService: ${appt.serviceName ?? "Unknown"}`;
|
||||
|
||||
lines.push(
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${appt.id}@groombook`,
|
||||
`DTSTAMP:${dtstamp}`,
|
||||
`DTSTART:${formatIcalDate(new Date(appt.startTime))}`,
|
||||
`DTEND:${formatIcalDate(new Date(appt.endTime))}`,
|
||||
`SUMMARY:${escapeIcalText(summary)}`,
|
||||
`DESCRIPTION:${escapeIcalText(description)}`,
|
||||
`STATUS:${status}`,
|
||||
`SEQUENCE:${sequence}`,
|
||||
"END:VEVENT"
|
||||
);
|
||||
}
|
||||
|
||||
lines.push("END:VCALENDAR");
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
|
||||
calendarRouter.get("/:staffId.ics", async (c) => {
|
||||
const db = getDb();
|
||||
const staffId = c.req.param("staffId") as string;
|
||||
const token = c.req.query("token") as string;
|
||||
|
||||
if (!token) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const [staffMember] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, staffId))
|
||||
.limit(1);
|
||||
|
||||
if (!staffMember || !staffMember.icalToken) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const storedToken = staffMember.icalToken;
|
||||
const incomingToken = token;
|
||||
const storedBuf = Buffer.from(storedToken, "utf8");
|
||||
const incomingBuf = Buffer.from(incomingToken, "utf8");
|
||||
if (
|
||||
storedBuf.length !== incomingBuf.length ||
|
||||
!timingSafeEqual(storedBuf, incomingBuf)
|
||||
) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
status: appointments.status,
|
||||
clientId: appointments.clientId,
|
||||
petId: appointments.petId,
|
||||
serviceId: appointments.serviceId,
|
||||
clientName: clients.name,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(clients, eq(appointments.clientId, clients.id))
|
||||
.innerJoin(pets, eq(appointments.petId, pets.id))
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, staffId),
|
||||
gte(appointments.startTime, now)
|
||||
)
|
||||
)
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
const ical = buildIcalFeed(rows, staffMember.name, formatIcalDate(new Date()));
|
||||
return c.text(ical, 200, {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": `inline; filename="${encodeURIComponent(staffMember.name)}_calendar.ics"`,
|
||||
});
|
||||
});
|
||||
|
||||
export function generateIcalToken(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
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/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const clientsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createClientSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
phone: z.string().max(50).optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
smsOptIn: z.boolean().optional(),
|
||||
smsConsentText: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
|
||||
// List clients — defaults to active only, ?includeDisabled=true shows all.
|
||||
// Groomers see only clients with ≥1 appointment assigned to them.
|
||||
clientsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const includeDisabled = c.req.query("includeDisabled") === "true";
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
// Groomer: subquery for clients with an appointment for this groomer
|
||||
const groomerApptFilter = isGroomer
|
||||
? exists(
|
||||
db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, clients.id),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const conditions = [];
|
||||
if (!includeDisabled) conditions.push(eq(clients.status, "active"));
|
||||
if (groomerApptFilter) conditions.push(groomerApptFilter);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(clients.name);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
// Get a single client
|
||||
clientsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
// Groomer: 403 if no appointment linkage to this client
|
||||
if (isGroomer) {
|
||||
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);
|
||||
if (!linkage) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
// Create a client
|
||||
clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db.insert(clients).values(body).returning();
|
||||
return c.json(row, 201);
|
||||
});
|
||||
|
||||
// Update a client (including status changes)
|
||||
const patchClientSchema = createClientSchema.partial().extend({
|
||||
status: z.enum(["active", "disabled"]).optional(),
|
||||
smsOptOut: z.boolean().optional(),
|
||||
});
|
||||
|
||||
clientsRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", patchClientSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const now = new Date();
|
||||
|
||||
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
|
||||
|
||||
if (body.status === "disabled") {
|
||||
setValues.disabledAt = now;
|
||||
} else if (body.status === "active") {
|
||||
setValues.disabledAt = null;
|
||||
}
|
||||
|
||||
if (body.smsOptOut === true) {
|
||||
setValues.smsOptIn = false;
|
||||
setValues.smsOptOutDate = now;
|
||||
delete setValues.smsOptOut;
|
||||
}
|
||||
delete setValues.smsOptOut;
|
||||
|
||||
const [row] = await db
|
||||
.update(clients)
|
||||
.set(setValues)
|
||||
.where(eq(clients.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
);
|
||||
|
||||
// Delete a client — requires ?confirm=true query param
|
||||
clientsRouter.delete("/:id", async (c) => {
|
||||
const confirm = c.req.query("confirm");
|
||||
if (confirm !== "true") {
|
||||
return c.json(
|
||||
{ error: "Permanent deletion requires ?confirm=true. Consider disabling the client instead." },
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const clientId = c.req.param("id");
|
||||
|
||||
const [existingAppt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.clientId, clientId))
|
||||
.limit(1);
|
||||
|
||||
if (existingAppt) {
|
||||
return c.json(
|
||||
{ error: "Cannot delete client with existing appointments. Cancel or reassign appointments first." },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.delete(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import { getDb, staff, clients, eq, sql } from "../db/index.js";
|
||||
|
||||
const devRouter = new Hono();
|
||||
|
||||
// GET /api/dev/config — tells the frontend whether auth is disabled
|
||||
devRouter.get("/config", (c) => {
|
||||
return c.json({ authDisabled: process.env.AUTH_DISABLED === "true" });
|
||||
});
|
||||
|
||||
// GET /api/dev/users — list staff and clients for the login selector
|
||||
// Only available when AUTH_DISABLED=true
|
||||
devRouter.get("/users", async (c) => {
|
||||
if (process.env.AUTH_DISABLED !== "true") {
|
||||
return c.json({ error: "Not available when auth is enabled" }, 403);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const staffList = await db
|
||||
.select({
|
||||
id: staff.id,
|
||||
userId: staff.userId,
|
||||
name: staff.name,
|
||||
email: staff.email,
|
||||
role: staff.role,
|
||||
})
|
||||
.from(staff)
|
||||
.where(eq(staff.active, true))
|
||||
.orderBy(staff.name);
|
||||
|
||||
const clientList = await db
|
||||
.select({
|
||||
id: clients.id,
|
||||
name: clients.name,
|
||||
email: clients.email,
|
||||
petCount: sql<number>`(SELECT count(*) FROM pets WHERE pets.client_id = ${clients.id})`.as("pet_count"),
|
||||
})
|
||||
.from(clients)
|
||||
.orderBy(clients.name)
|
||||
.limit(20);
|
||||
|
||||
return c.json({ staff: staffList, clients: clientList });
|
||||
});
|
||||
|
||||
export { devRouter };
|
||||
@@ -1,143 +0,0 @@
|
||||
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/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const groomingLogsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createLogSchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
appointmentId: z.string().uuid().optional(),
|
||||
staffId: z.string().uuid().optional(),
|
||||
cutStyle: z.string().max(500).optional(),
|
||||
productsUsed: z.string().max(1000).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
groomedAt: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
// GET /api/grooming-logs?petId=<uuid>
|
||||
groomingLogsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.query("petId");
|
||||
if (!petId) return c.json({ error: "petId is required" }, 400);
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
if (isGroomer) {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, petId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(groomingVisitLogs)
|
||||
.where(eq(groomingVisitLogs.petId, petId))
|
||||
.orderBy(desc(groomingVisitLogs.groomedAt));
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
groomingLogsRouter.post(
|
||||
"/",
|
||||
zValidator("json", createLogSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
if (isGroomer) {
|
||||
if (appointmentId) {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.id, appointmentId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
} else {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, petId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(groomingVisitLogs)
|
||||
.values({
|
||||
...rest,
|
||||
petId,
|
||||
appointmentId: appointmentId ?? null,
|
||||
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
||||
})
|
||||
.returning();
|
||||
return c.json(row, 201);
|
||||
}
|
||||
);
|
||||
|
||||
groomingLogsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [log] = await db
|
||||
.select()
|
||||
.from(groomingVisitLogs)
|
||||
.where(eq(groomingVisitLogs.id, id))
|
||||
.limit(1);
|
||||
if (!log) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, log.petId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(groomingVisitLogs)
|
||||
.where(eq(groomingVisitLogs.id, id))
|
||||
.returning();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -1,300 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
impersonationSessions,
|
||||
impersonationAuditLogs,
|
||||
clients,
|
||||
desc,
|
||||
} from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const impersonationRouter = new Hono<AppEnv>();
|
||||
|
||||
const SESSION_TIMEOUT_MINUTES = 30;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function expiresAt(minutes = SESSION_TIMEOUT_MINUTES) {
|
||||
return new Date(Date.now() + minutes * 60_000);
|
||||
}
|
||||
|
||||
/** Expire any timed-out active sessions for a given staff member. */
|
||||
async function expireTimedOutSessions(staffId: string) {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const active = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.staffId, staffId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
);
|
||||
for (const s of active) {
|
||||
if (s.expiresAt <= now) {
|
||||
await db
|
||||
.update(impersonationSessions)
|
||||
.set({ status: "expired", endedAt: now })
|
||||
.where(eq(impersonationSessions.id, s.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an active session has expired by time. If so, mark it expired in DB
|
||||
* and return true. Returns false if the session is still valid.
|
||||
*/
|
||||
async function checkAndExpireSession(
|
||||
session: typeof impersonationSessions.$inferSelect
|
||||
): Promise<boolean> {
|
||||
if (session.status !== "active") return false;
|
||||
if (session.expiresAt > new Date()) return false;
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
await db
|
||||
.update(impersonationSessions)
|
||||
.set({ status: "expired", endedAt: now })
|
||||
.where(eq(impersonationSessions.id, session.id));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── POST /sessions — Start a new impersonation session ─────────────────────
|
||||
// requireRole("manager") is enforced by index.ts middleware on /impersonation/*
|
||||
|
||||
const startSessionSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
reason: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
impersonationRouter.post(
|
||||
"/sessions",
|
||||
zValidator("json", startSessionSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// Verify client exists
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, body.clientId));
|
||||
if (!client) return c.json({ error: "Client not found" }, 404);
|
||||
|
||||
// Expire timed-out sessions first
|
||||
await expireTimedOutSessions(staffRow.id);
|
||||
|
||||
// Enforce one active session per staff member
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.staffId, staffRow.id),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
);
|
||||
if (existing) {
|
||||
return c.json(
|
||||
{ error: "You already have an active impersonation session", sessionId: existing.id },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.insert(impersonationSessions)
|
||||
.values({
|
||||
staffId: staffRow.id,
|
||||
clientId: body.clientId,
|
||||
reason: body.reason ?? null,
|
||||
expiresAt: expiresAt(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Log session start
|
||||
await db.insert(impersonationAuditLogs).values({
|
||||
sessionId: session!.id,
|
||||
action: "session_started",
|
||||
metadata: { reason: body.reason ?? null },
|
||||
});
|
||||
|
||||
return c.json(session!, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── GET /sessions/:id — Get session details ────────────────────────────────
|
||||
|
||||
impersonationRouter.get("/sessions/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
|
||||
// Auto-expire if timed out
|
||||
if (await checkAndExpireSession(session)) {
|
||||
session.status = "expired";
|
||||
session.endedAt = new Date();
|
||||
}
|
||||
|
||||
return c.json(session);
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/extend — Extend session timeout ─────────────────────
|
||||
|
||||
impersonationRouter.post("/sessions/:id/extend", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
if (session.status !== "active") {
|
||||
return c.json({ error: "Session is not active" }, 400);
|
||||
}
|
||||
|
||||
// Check time-based expiry
|
||||
if (await checkAndExpireSession(session)) {
|
||||
return c.json({ error: "Session has expired" }, 400);
|
||||
}
|
||||
|
||||
const newExpiry = expiresAt();
|
||||
const [updated] = await db
|
||||
.update(impersonationSessions)
|
||||
.set({ expiresAt: newExpiry })
|
||||
.where(eq(impersonationSessions.id, session.id))
|
||||
.returning();
|
||||
|
||||
await db.insert(impersonationAuditLogs).values({
|
||||
sessionId: session.id,
|
||||
action: "session_extended",
|
||||
metadata: { newExpiresAt: newExpiry.toISOString() },
|
||||
});
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/end — End session ────────────────────────────────────
|
||||
|
||||
impersonationRouter.post("/sessions/:id/end", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
if (session.status !== "active") {
|
||||
return c.json({ error: "Session is not active" }, 400);
|
||||
}
|
||||
|
||||
// Check time-based expiry
|
||||
if (await checkAndExpireSession(session)) {
|
||||
return c.json({ error: "Session has expired" }, 400);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(impersonationSessions)
|
||||
.set({ status: "ended", endedAt: now })
|
||||
.where(eq(impersonationSessions.id, session.id))
|
||||
.returning();
|
||||
|
||||
await db.insert(impersonationAuditLogs).values({
|
||||
sessionId: session.id,
|
||||
action: "session_ended",
|
||||
});
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ─── POST /sessions/:id/log — Log an audit entry ────────────────────────────
|
||||
|
||||
const logEntrySchema = z.object({
|
||||
action: z.string().min(1).max(200),
|
||||
pageVisited: z.string().max(500).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
impersonationRouter.post(
|
||||
"/sessions/:id/log",
|
||||
zValidator("json", logEntrySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
if (session.status !== "active") {
|
||||
return c.json({ error: "Session is not active" }, 400);
|
||||
}
|
||||
|
||||
// Check time-based expiry
|
||||
if (await checkAndExpireSession(session)) {
|
||||
return c.json({ error: "Session has expired" }, 400);
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.insert(impersonationAuditLogs)
|
||||
.values({
|
||||
sessionId: session.id,
|
||||
action: body.action,
|
||||
pageVisited: body.pageVisited ?? null,
|
||||
metadata: body.metadata ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(entry, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── GET /sessions/:id/audit-log — Get audit trail ──────────────────────────
|
||||
|
||||
impersonationRouter.get("/sessions/:id/audit-log", async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||
if (session.staffId !== staffRow.id) {
|
||||
return c.json({ error: "Not your session" }, 403);
|
||||
}
|
||||
|
||||
const logs = await db
|
||||
.select()
|
||||
.from(impersonationAuditLogs)
|
||||
.where(eq(impersonationAuditLogs.sessionId, session.id))
|
||||
.orderBy(desc(impersonationAuditLogs.createdAt));
|
||||
|
||||
return c.json(logs);
|
||||
});
|
||||
@@ -1,555 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
invoices,
|
||||
invoiceLineItems,
|
||||
invoiceTipSplits,
|
||||
refunds,
|
||||
appointments,
|
||||
services,
|
||||
clients,
|
||||
sql,
|
||||
} from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const invoicesRouter = new Hono<AppEnv>();
|
||||
|
||||
// Convert Zod validation errors from 422 to 400
|
||||
invoicesRouter.onError((err, c) => {
|
||||
if (err instanceof z.ZodError) {
|
||||
return c.json({ error: "Validation failed", issues: err.issues }, 400);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
const createInvoiceSchema = z.object({
|
||||
appointmentId: z.string().uuid().optional(),
|
||||
clientId: z.string().uuid(),
|
||||
lineItems: z
|
||||
.array(
|
||||
z.object({
|
||||
description: z.string().min(1).max(500),
|
||||
quantity: z.number().int().positive().default(1),
|
||||
unitPriceCents: z.number().int().nonnegative(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
taxCents: z.number().int().nonnegative().default(0),
|
||||
tipCents: z.number().int().nonnegative().default(0),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
const updateInvoiceSchema = z.object({
|
||||
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
|
||||
paymentMethod: z.enum(["cash", "card", "check", "other"]).nullable().optional(),
|
||||
paidAt: z.string().datetime().nullable().optional(),
|
||||
taxCents: z.number().int().nonnegative().optional(),
|
||||
tipCents: z.number().int().nonnegative().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
tipSplits: z.array(
|
||||
z.object({
|
||||
staffId: z.string().uuid().nullable(),
|
||||
staffName: z.string().min(1).max(200),
|
||||
sharePct: z.number().min(0).max(100),
|
||||
})
|
||||
).optional(),
|
||||
});
|
||||
|
||||
// List invoices
|
||||
const listInvoicesQuerySchema = z.object({
|
||||
clientId: z.string().uuid().optional(),
|
||||
appointmentId: z.string().uuid().optional(),
|
||||
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
invoicesRouter.get(
|
||||
"/",
|
||||
zValidator("query", listInvoicesQuerySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
|
||||
|
||||
const conditions = [];
|
||||
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
||||
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
|
||||
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(invoices)
|
||||
.where(whereClause);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: invoices.id,
|
||||
appointmentId: invoices.appointmentId,
|
||||
clientId: invoices.clientId,
|
||||
clientName: clients.name,
|
||||
subtotalCents: invoices.subtotalCents,
|
||||
taxCents: invoices.taxCents,
|
||||
tipCents: invoices.tipCents,
|
||||
totalCents: invoices.totalCents,
|
||||
status: invoices.status,
|
||||
paymentMethod: invoices.paymentMethod,
|
||||
paidAt: invoices.paidAt,
|
||||
notes: invoices.notes,
|
||||
stripePaymentIntentId: invoices.stripePaymentIntentId,
|
||||
createdAt: invoices.createdAt,
|
||||
updatedAt: invoices.updatedAt,
|
||||
})
|
||||
.from(invoices)
|
||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||
.where(whereClause)
|
||||
.orderBy(invoices.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
||||
}
|
||||
);
|
||||
|
||||
// Get single invoice with line items and tip splits
|
||||
invoicesRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const [lineItems, tipSplits] = await Promise.all([
|
||||
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
|
||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||
]);
|
||||
|
||||
return c.json({ ...invoice, lineItems, tipSplits });
|
||||
});
|
||||
|
||||
// Save tip splits for an invoice (replaces existing splits)
|
||||
const tipSplitSchema = z.object({
|
||||
splits: z.array(
|
||||
z.object({
|
||||
staffId: z.string().uuid().nullable(),
|
||||
staffName: z.string().min(1).max(200),
|
||||
sharePct: z.number().min(0).max(100),
|
||||
})
|
||||
).min(1).refine(
|
||||
(splits) => {
|
||||
const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
|
||||
return totalBps === 10000;
|
||||
},
|
||||
{ message: "Split percentages must sum to 100" }
|
||||
),
|
||||
});
|
||||
|
||||
invoicesRouter.post(
|
||||
"/:id/tip-splits",
|
||||
zValidator("json", tipSplitSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
if (invoice.status === "void") return c.json({ error: "Cannot modify a voided invoice" }, 422);
|
||||
|
||||
const tipCents = invoice.tipCents;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Remove existing splits
|
||||
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
|
||||
|
||||
// Insert new splits, distributing tipCents proportionally
|
||||
let remaining = tipCents;
|
||||
const rows = body.splits.map((s, i) => {
|
||||
const isLast = i === body.splits.length - 1;
|
||||
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents);
|
||||
if (!isLast) remaining -= shareCents;
|
||||
return {
|
||||
invoiceId: id,
|
||||
staffId: s.staffId,
|
||||
staffName: s.staffName,
|
||||
sharePct: s.sharePct.toFixed(2),
|
||||
shareCents,
|
||||
};
|
||||
});
|
||||
|
||||
if (rows.length > 0) {
|
||||
await tx.insert(invoiceTipSplits).values(rows);
|
||||
}
|
||||
});
|
||||
|
||||
const [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
const [lineItems, tipSplits] = await Promise.all([
|
||||
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
|
||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||
]);
|
||||
|
||||
return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// Create invoice (optionally pre-populated from an appointment)
|
||||
invoicesRouter.post(
|
||||
"/",
|
||||
zValidator("json", createInvoiceSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// If appointmentId provided, verify it exists
|
||||
if (body.appointmentId) {
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, body.appointmentId));
|
||||
if (!appt) return c.json({ error: "Appointment not found" }, 404);
|
||||
}
|
||||
|
||||
const subtotalCents = body.lineItems.reduce(
|
||||
(sum, item) => sum + item.quantity * item.unitPriceCents,
|
||||
0
|
||||
);
|
||||
const totalCents = subtotalCents + body.taxCents + body.tipCents;
|
||||
|
||||
const [invoice] = await db
|
||||
.insert(invoices)
|
||||
.values({
|
||||
appointmentId: body.appointmentId ?? null,
|
||||
clientId: body.clientId,
|
||||
subtotalCents,
|
||||
taxCents: body.taxCents,
|
||||
tipCents: body.tipCents,
|
||||
totalCents,
|
||||
notes: body.notes ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!invoice) return c.json({ error: "Failed to create invoice" }, 500);
|
||||
|
||||
const items = await db
|
||||
.insert(invoiceLineItems)
|
||||
.values(
|
||||
body.lineItems.map((item) => ({
|
||||
invoiceId: invoice.id,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unitPriceCents: item.unitPriceCents,
|
||||
totalCents: item.quantity * item.unitPriceCents,
|
||||
}))
|
||||
)
|
||||
.returning();
|
||||
|
||||
return c.json({ ...invoice, lineItems: items }, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// Create invoice from appointment (convenience endpoint)
|
||||
invoicesRouter.post("/from-appointment/:appointmentId", async (c) => {
|
||||
const db = getDb();
|
||||
const appointmentId = c.req.param("appointmentId");
|
||||
|
||||
const [appt] = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
clientId: appointments.clientId,
|
||||
serviceId: appointments.serviceId,
|
||||
priceCents: appointments.priceCents,
|
||||
serviceName: services.name,
|
||||
serviceBasePriceCents: services.basePriceCents,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.where(eq(appointments.id, appointmentId));
|
||||
|
||||
if (!appt) return c.json({ error: "Appointment not found" }, 404);
|
||||
|
||||
// Check if invoice already exists for this appointment
|
||||
const [existing] = await db
|
||||
.select({ id: invoices.id })
|
||||
.from(invoices)
|
||||
.where(eq(invoices.appointmentId, appointmentId))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return c.json(
|
||||
{ error: "Invoice already exists for this appointment", invoiceId: existing.id },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const unitPriceCents = appt.priceCents ?? appt.serviceBasePriceCents;
|
||||
const subtotalCents = unitPriceCents;
|
||||
const totalCents = subtotalCents;
|
||||
|
||||
const [invoice] = await db
|
||||
.insert(invoices)
|
||||
.values({
|
||||
appointmentId,
|
||||
clientId: appt.clientId,
|
||||
subtotalCents,
|
||||
taxCents: 0,
|
||||
tipCents: 0,
|
||||
totalCents,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!invoice) return c.json({ error: "Failed to create invoice" }, 500);
|
||||
|
||||
const [lineItem] = await db
|
||||
.insert(invoiceLineItems)
|
||||
.values({
|
||||
invoiceId: invoice.id,
|
||||
description: appt.serviceName,
|
||||
quantity: 1,
|
||||
unitPriceCents,
|
||||
totalCents: unitPriceCents,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json({ ...invoice, lineItems: [lineItem] }, 201);
|
||||
});
|
||||
|
||||
const ALLOWED_TRANSITIONS: Record<string, string[]> = {
|
||||
draft: ["pending", "void"],
|
||||
pending: ["draft", "paid", "void"],
|
||||
paid: ["void"],
|
||||
void: [],
|
||||
};
|
||||
|
||||
// Update invoice
|
||||
invoicesRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateInvoiceSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, id));
|
||||
if (!current) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (body.status !== undefined) {
|
||||
const allowed = ALLOWED_TRANSITIONS[current.status] ?? [];
|
||||
if (!allowed.includes(body.status)) {
|
||||
return c.json(
|
||||
{ error: `Invalid status transition from ${current.status} to ${body.status}` },
|
||||
422
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const tipCents = body.tipCents ?? current.tipCents;
|
||||
|
||||
// Validate tip splits when marking invoice as paid
|
||||
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
|
||||
if (body.tipSplits.length === 0) {
|
||||
return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400);
|
||||
}
|
||||
const totalPct = body.tipSplits.reduce((sum, s) => sum + s.sharePct, 0);
|
||||
if (Math.abs(totalPct - 100) > 0.01) {
|
||||
return c.json({ error: "Tip split percentages must sum to 100%" }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Destructure tipSplits out — it belongs to a separate table, not the invoices column
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { tipSplits: _tipSplits, ...updateBody } = body as Record<string, unknown>;
|
||||
const update: Record<string, unknown> = { ...updateBody, updatedAt: new Date() };
|
||||
|
||||
// Auto-set paidAt when marking as paid
|
||||
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
||||
update.paidAt = new Date();
|
||||
}
|
||||
|
||||
// Recalculate total if tax or tip changed
|
||||
const newTaxCents = body.taxCents ?? current.taxCents;
|
||||
const newTipCents = body.tipCents ?? current.tipCents;
|
||||
if (body.taxCents !== undefined || body.tipCents !== undefined) {
|
||||
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
||||
}
|
||||
|
||||
// Wrap tip split persistence and invoice update in a single atomic transaction
|
||||
const [updated, lineItems] = await db.transaction(async (tx) => {
|
||||
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
|
||||
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
|
||||
const splits = body.tipSplits;
|
||||
if (splits.length > 0) {
|
||||
let remaining = tipCents;
|
||||
const rows = splits.map((s, i) => {
|
||||
const isLast = i === splits.length - 1;
|
||||
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents);
|
||||
if (!isLast) remaining -= shareCents;
|
||||
return {
|
||||
invoiceId: id,
|
||||
staffId: s.staffId,
|
||||
staffName: s.staffName,
|
||||
sharePct: s.sharePct.toFixed(2),
|
||||
shareCents,
|
||||
};
|
||||
});
|
||||
await tx.insert(invoiceTipSplits).values(rows);
|
||||
}
|
||||
}
|
||||
|
||||
const [updatedInvoice] = await tx
|
||||
.update(invoices)
|
||||
.set(update)
|
||||
.where(eq(invoices.id, id))
|
||||
.returning();
|
||||
|
||||
const lineItems = await tx
|
||||
.select()
|
||||
.from(invoiceLineItems)
|
||||
.where(eq(invoiceLineItems.invoiceId, id));
|
||||
|
||||
return [updatedInvoice, lineItems];
|
||||
});
|
||||
|
||||
return c.json({ ...updated, lineItems });
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Refund ───────────────────────────────────────────────────────────────────
|
||||
|
||||
import { processRefund, getPaymentIntentDetails } from "../services/payment.js";
|
||||
|
||||
const refundSchema = z.object({
|
||||
amountCents: z.number().int().nonnegative().optional(),
|
||||
idempotencyKey: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
invoicesRouter.post(
|
||||
"/:id/refund",
|
||||
zValidator("json", refundSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staff = c.get("staff");
|
||||
if (!staff) return c.json({ error: "Forbidden" }, 403);
|
||||
if (staff.role !== "manager" && !staff.isSuperUser) {
|
||||
return c.json({ error: "Manager role required" }, 403);
|
||||
}
|
||||
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
if (invoice.status !== "paid") {
|
||||
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
||||
}
|
||||
if (!invoice.stripePaymentIntentId) {
|
||||
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
||||
}
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
if (body.idempotencyKey) {
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(refunds)
|
||||
.where(eq(refunds.idempotencyKey, body.idempotencyKey));
|
||||
if (existing) {
|
||||
return c.json({ refundId: existing.stripeRefundId });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await processRefund(id, body.amountCents);
|
||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||
|
||||
await tx.insert(refunds).values({
|
||||
invoiceId: id,
|
||||
stripeRefundId: result.refundId,
|
||||
idempotencyKey: body.idempotencyKey ?? null,
|
||||
amountCents: body.amountCents ?? null,
|
||||
});
|
||||
|
||||
return c.json({ refundId: result.refundId });
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Payment stats for admin dashboard
|
||||
invoicesRouter.get("/stats/summary", async (c) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
const [revenueResult] = await db
|
||||
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||
.from(invoices)
|
||||
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
|
||||
|
||||
const [outstandingResult] = await db
|
||||
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||
.from(invoices)
|
||||
.where(eq(invoices.status, "pending"));
|
||||
|
||||
const [refundsResult] = await db
|
||||
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
|
||||
.from(refunds)
|
||||
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
|
||||
|
||||
const methodBreakdown = await db
|
||||
.select({
|
||||
method: invoices.paymentMethod,
|
||||
total: sql<number>`count(*)`,
|
||||
})
|
||||
.from(invoices)
|
||||
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
|
||||
.groupBy(invoices.paymentMethod);
|
||||
|
||||
return c.json({
|
||||
revenueThisMonth: revenueResult?.total ?? 0,
|
||||
outstanding: outstandingResult?.total ?? 0,
|
||||
refundsThisMonth: refundsResult?.total ?? 0,
|
||||
methodBreakdown,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("stats/summary error:", err);
|
||||
return c.json({
|
||||
revenueThisMonth: 0,
|
||||
outstanding: 0,
|
||||
refundsThisMonth: 0,
|
||||
methodBreakdown: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
|
||||
invoicesRouter.get("/:id/stripe-details", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
let cardLast4: string | null = null;
|
||||
let paymentStatus: string | null = null;
|
||||
|
||||
if (invoice.stripePaymentIntentId) {
|
||||
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
||||
if (details) {
|
||||
cardLast4 = details.cardLast4;
|
||||
paymentStatus = details.paymentStatus;
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
stripePaymentIntentId: invoice.stripePaymentIntentId,
|
||||
stripeRefundId: invoice.stripeRefundId,
|
||||
cardLast4,
|
||||
paymentStatus,
|
||||
});
|
||||
});
|
||||
@@ -1,284 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, pets, appointments } from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import {
|
||||
getPresignedUploadUrl,
|
||||
getPresignedGetUrl,
|
||||
deleteObject,
|
||||
} from "../lib/s3.js";
|
||||
|
||||
export const petsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createPetSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
name: z.string().min(1).max(200),
|
||||
species: z.string().min(1).max(100),
|
||||
breed: z.string().max(200).optional(),
|
||||
weightKg: z.number().positive().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
healthAlerts: z.string().max(2000).optional(),
|
||||
groomingNotes: z.string().max(2000).optional(),
|
||||
cutStyle: z.string().max(500).optional(),
|
||||
shampooPreference: z.string().max(500).optional(),
|
||||
specialCareNotes: z.string().max(2000).optional(),
|
||||
customFields: z.record(z.string(), z.string()).optional(),
|
||||
coatType: z.string().max(100).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 });
|
||||
|
||||
// List pets, optionally filtered by clientId.
|
||||
// Groomers see only pets owned by clients with ≥1 appointment for this groomer.
|
||||
petsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.req.query("clientId");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
// Groomer: filter to pets whose client has an appointment for this groomer
|
||||
const groomerClientFilter = isGroomer
|
||||
? exists(
|
||||
db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, pets.clientId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const conditions = [];
|
||||
if (clientId) conditions.push(eq(pets.clientId, clientId));
|
||||
if (groomerClientFilter) conditions.push(groomerClientFilter);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pets)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
petsRouter.get("/:id", 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);
|
||||
// Groomer: 403 if no appointment linkage to this pet's client
|
||||
if (isGroomer) {
|
||||
const [linkage] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, row.clientId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!linkage) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.insert(pets)
|
||||
.values({
|
||||
...rest,
|
||||
weightKg: weightKg?.toString(),
|
||||
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
||||
customFields: customFields ?? {},
|
||||
})
|
||||
.returning();
|
||||
return c.json(row, 201);
|
||||
});
|
||||
|
||||
petsRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updatePetSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
||||
const [row] = await db
|
||||
.update(pets)
|
||||
.set({
|
||||
...rest,
|
||||
weightKg: weightKg?.toString(),
|
||||
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
|
||||
...(customFields !== undefined ? { customFields } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pets.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
}
|
||||
);
|
||||
|
||||
petsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.delete(pets)
|
||||
.where(eq(pets.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Photo routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED_CONTENT_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
|
||||
const MAX_PHOTO_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
const uploadUrlSchema = z.object({
|
||||
contentType: z.string().refine((v) => ALLOWED_CONTENT_TYPES.has(v), {
|
||||
message: "contentType must be one of: image/jpeg, image/png, image/webp, image/gif",
|
||||
}),
|
||||
fileSizeBytes: z.number().int().positive().max(MAX_PHOTO_SIZE, {
|
||||
message: "File must not exceed 5 MB",
|
||||
}),
|
||||
});
|
||||
|
||||
const confirmSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /:petId/photo/upload-url
|
||||
* Returns a presigned S3 PUT URL and the object key for the upload.
|
||||
* All staff roles (manager, receptionist, groomer) may call this.
|
||||
*/
|
||||
petsRouter.post(
|
||||
"/:petId/photo/upload-url",
|
||||
zValidator("json", uploadUrlSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("petId");
|
||||
const { contentType, fileSizeBytes } = c.req.valid("json");
|
||||
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||
|
||||
const ext = contentType.split("/")[1] ?? "jpg";
|
||||
const key = `pets/${petId}/${Date.now()}.${ext}`;
|
||||
const uploadUrl = await getPresignedUploadUrl(key, contentType, fileSizeBytes);
|
||||
|
||||
return c.json({ uploadUrl, key });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /:petId/photo/confirm
|
||||
* Called after the client has successfully uploaded to the presigned URL.
|
||||
* Records the object key in the DB.
|
||||
*/
|
||||
petsRouter.post(
|
||||
"/:petId/photo/confirm",
|
||||
zValidator("json", confirmSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("petId");
|
||||
const { key } = c.req.valid("json");
|
||||
|
||||
// Validate that the key belongs to this pet to prevent key hijacking
|
||||
if (!key.startsWith(`pets/${petId}/`)) {
|
||||
return c.json({ error: "Invalid key" }, 400);
|
||||
}
|
||||
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||
|
||||
// Delete the previous photo from storage to avoid orphaned objects
|
||||
if (pet.photoKey) {
|
||||
try {
|
||||
await deleteObject(pet.photoKey);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete previous photo ${pet.photoKey}, orphaned object may remain:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.update(pets)
|
||||
.set({ photoKey: key, photoUploadedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(pets.id, petId))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Pet not found" }, 404);
|
||||
|
||||
return c.json({ ok: true, photoKey: row.photoKey });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /:petId/photo
|
||||
* Removes the photo from object storage and clears the DB record.
|
||||
* All staff roles (manager, receptionist, groomer) may call this.
|
||||
*/
|
||||
petsRouter.delete("/:petId/photo", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("petId");
|
||||
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
|
||||
|
||||
try {
|
||||
await deleteObject(pet.photoKey);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete photo ${pet.photoKey} from S3, orphaned object may remain:`, err);
|
||||
}
|
||||
await db
|
||||
.update(pets)
|
||||
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
|
||||
.where(eq(pets.id, petId));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /:petId/photo
|
||||
* Returns a presigned GET URL for the pet's photo.
|
||||
* All authenticated staff may access (read).
|
||||
*/
|
||||
petsRouter.get("/:petId/photo", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("petId");
|
||||
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
|
||||
|
||||
const url = await getPresignedGetUrl(pet.photoKey);
|
||||
return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt });
|
||||
});
|
||||
@@ -1,521 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
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";
|
||||
|
||||
export const portalRouter = new Hono<PortalEnv>();
|
||||
|
||||
// Dev-mode session creation — must be registered BEFORE the /* middleware so it is
|
||||
// NOT subject to validatePortalSession/portalAudit (GRO-778 fix). This endpoint creates
|
||||
// the impersonation session and has no X-Impersonation-Session-Id header yet.
|
||||
const devSessionSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/dev-session",
|
||||
zValidator("json", devSessionSchema),
|
||||
async (c) => {
|
||||
if (process.env.AUTH_DISABLED !== "true") {
|
||||
return c.json({ error: "Not available when auth is enabled" }, 403);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, body.clientId))
|
||||
.limit(1);
|
||||
if (!client) {
|
||||
return c.json({ error: "Client not found" }, 404);
|
||||
}
|
||||
|
||||
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
let staffId = DEMO_STAFF_ID;
|
||||
const [demoStaff] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.id, DEMO_STAFF_ID))
|
||||
.limit(1);
|
||||
|
||||
if (!demoStaff) {
|
||||
const [firstStaff] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.active, true))
|
||||
.limit(1);
|
||||
if (!firstStaff) {
|
||||
return c.json({ error: "No staff records found. Run the database seed." }, 500);
|
||||
}
|
||||
staffId = firstStaff.id;
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.insert(impersonationSessions)
|
||||
.values({
|
||||
staffId,
|
||||
clientId: body.clientId,
|
||||
reason: "dev-mode-client-portal",
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(session, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// Apply middleware to all portal routes
|
||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||
|
||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
portalRouter.get("/me", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||
if (!client) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
|
||||
});
|
||||
|
||||
portalRouter.get("/config", async (c) => {
|
||||
return c.json({
|
||||
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
portalRouter.get("/services", async (c) => {
|
||||
const db = getDb();
|
||||
const allServices = await db.select().from(services).where(eq(services.active, true));
|
||||
return c.json(allServices.map(s => ({ id: s.id, name: s.name, description: s.description, basePriceCents: s.basePriceCents, durationMinutes: s.durationMinutes })));
|
||||
});
|
||||
|
||||
portalRouter.get("/appointments", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const allAppts = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
endTime: appointments.endTime,
|
||||
status: appointments.status,
|
||||
confirmationStatus: appointments.confirmationStatus,
|
||||
customerNotes: appointments.customerNotes,
|
||||
notes: appointments.notes,
|
||||
petId: appointments.petId,
|
||||
serviceId: appointments.serviceId,
|
||||
staffId: appointments.staffId,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(eq(appointments.clientId, clientId))
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null);
|
||||
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
|
||||
|
||||
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
|
||||
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
|
||||
|
||||
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
|
||||
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
|
||||
|
||||
const appts = allAppts.map(a => ({
|
||||
id: a.id,
|
||||
startTime: a.startTime,
|
||||
endTime: a.endTime,
|
||||
status: a.status,
|
||||
confirmationStatus: a.confirmationStatus,
|
||||
customerNotes: a.customerNotes,
|
||||
notes: a.notes,
|
||||
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
|
||||
service: a.serviceId ? { id: a.serviceId } : null,
|
||||
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
||||
}));
|
||||
|
||||
return c.json({ appointments: appts });
|
||||
});
|
||||
|
||||
portalRouter.get("/pets", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes })));
|
||||
});
|
||||
|
||||
portalRouter.get("/invoices", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||
const invoiceIds = clientInvoices.map(i => i.id);
|
||||
const lineItems = invoiceIds.length ? await db.select().from(invoiceLineItems).where(inArray(invoiceLineItems.invoiceId, invoiceIds)) : [];
|
||||
|
||||
const itemsByInvoice: Record<string, typeof lineItems> = {};
|
||||
for (const li of lineItems) {
|
||||
if (!itemsByInvoice[li.invoiceId]) itemsByInvoice[li.invoiceId] = [];
|
||||
itemsByInvoice[li.invoiceId]!.push(li);
|
||||
}
|
||||
|
||||
return c.json(clientInvoices.map(inv => ({
|
||||
id: inv.id,
|
||||
status: inv.status,
|
||||
totalCents: inv.totalCents,
|
||||
date: inv.createdAt,
|
||||
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
|
||||
})));
|
||||
});
|
||||
|
||||
// ─── Appointment action routes ────────────────────────────────────────────────
|
||||
|
||||
const customerNotesSchema = z.object({
|
||||
// .min(1) prevents empty strings — clearing notes is not a supported use case
|
||||
customerNotes: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
portalRouter.patch(
|
||||
"/appointments/:id/notes",
|
||||
zValidator("json", customerNotesSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
if (appt.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
if (appt.startTime <= new Date()) {
|
||||
return c.json({ error: "Cannot edit notes for past or in-progress appointments" }, 422);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({ customerNotes: body.customerNotes, updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: updated.id,
|
||||
customerNotes: updated.customerNotes,
|
||||
updatedAt: updated.updatedAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Appointment confirm/cancel ──────────────────────────────────────────────
|
||||
|
||||
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
if (appt.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
if (appt.startTime <= new Date()) {
|
||||
return c.json({ error: "Cannot confirm a past or in-progress appointment" }, 422);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus !== "pending") {
|
||||
return c.json({ error: "Appointment is not pending confirmation" }, 422);
|
||||
}
|
||||
|
||||
if (appt.status === "cancelled" || appt.status === "completed") {
|
||||
return c.json({ error: "Cannot confirm a cancelled or completed appointment" }, 422);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: updated!.id,
|
||||
confirmationStatus: updated!.confirmationStatus,
|
||||
confirmedAt: updated!.confirmedAt,
|
||||
updatedAt: updated!.updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
if (appt.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
if (appt.startTime <= new Date()) {
|
||||
return c.json({ error: "Cannot cancel a past or in-progress appointment" }, 422);
|
||||
}
|
||||
|
||||
if (appt.status === "cancelled" || appt.status === "completed") {
|
||||
return c.json({ error: "Appointment is already cancelled or completed" }, 422);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", confirmationStatus: "cancelled", cancelledAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: updated!.id,
|
||||
status: updated!.status,
|
||||
confirmationStatus: updated!.confirmationStatus,
|
||||
cancelledAt: updated!.cancelledAt,
|
||||
updatedAt: updated!.updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Client-facing waitlist routes ────────────────────────────────────────────
|
||||
|
||||
const createWaitlistEntrySchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
serviceId: z.string().uuid(),
|
||||
preferredDate: z.string(),
|
||||
preferredTime: z.string(),
|
||||
});
|
||||
|
||||
const updateWaitlistEntrySchema = z.object({
|
||||
status: z.literal("cancelled").optional(),
|
||||
preferredDate: z.string().optional(),
|
||||
preferredTime: z.string().optional(),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/waitlist",
|
||||
zValidator("json", createWaitlistEntrySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [entry] = await db
|
||||
.insert(waitlistEntries)
|
||||
.values({
|
||||
clientId,
|
||||
petId: body.petId,
|
||||
serviceId: body.serviceId,
|
||||
preferredDate: body.preferredDate,
|
||||
preferredTime: body.preferredTime,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(entry, 201);
|
||||
}
|
||||
);
|
||||
|
||||
portalRouter.patch(
|
||||
"/waitlist/:id",
|
||||
zValidator("json", updateWaitlistEntrySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(waitlistEntries)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||
if (existing.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (body.status !== undefined) updateData.status = body.status;
|
||||
if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate;
|
||||
if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime;
|
||||
|
||||
const [updated] = await db
|
||||
.update(waitlistEntries)
|
||||
.set(updateData)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
}
|
||||
);
|
||||
|
||||
portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(waitlistEntries)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!entry) return c.json({ error: "Not found" }, 404);
|
||||
if (entry.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(waitlistEntries)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Payment routes ───────────────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
createPaymentIntent,
|
||||
listPaymentMethods,
|
||||
detachPaymentMethod,
|
||||
createSetupIntent,
|
||||
getOrCreateStripeCustomer,
|
||||
getStripeClient,
|
||||
} from "../services/payment.js";
|
||||
|
||||
const payMultipleSchema = z.object({
|
||||
invoiceIds: z.array(z.string().uuid()).min(1),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/invoices/pay-multiple",
|
||||
zValidator("json", payMultipleSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const invoiceRows = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(inArray(invoices.id, body.invoiceIds));
|
||||
|
||||
if (invoiceRows.length !== body.invoiceIds.length) {
|
||||
return c.json({ error: "One or more invoices not found" }, 404);
|
||||
}
|
||||
|
||||
for (const inv of invoiceRows) {
|
||||
if (inv.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
|
||||
if (inv.status === "draft" || inv.status === "void") {
|
||||
return c.json({ error: `Invoice ${inv.id} cannot be paid (draft or void)` }, 422);
|
||||
}
|
||||
if (inv.status === "paid") {
|
||||
return c.json({ error: `Invoice ${inv.id} is already paid` }, 422);
|
||||
}
|
||||
}
|
||||
|
||||
const firstInvoice = invoiceRows[0];
|
||||
if (!firstInvoice) return c.json({ error: "No invoices found" }, 400);
|
||||
const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId);
|
||||
if (!allSameClient) {
|
||||
return c.json({ error: "All invoices must belong to the same client" }, 422);
|
||||
}
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const result = await createPaymentIntent(body.invoiceIds, clientId);
|
||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||
}
|
||||
);
|
||||
|
||||
portalRouter.get("/payment-methods", async (c) => {
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const methods = await listPaymentMethods(clientId);
|
||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
return c.json(methods);
|
||||
});
|
||||
|
||||
portalRouter.post("/payment-methods", async (c) => {
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!customerId) return c.json({ error: "Could not create customer" }, 500);
|
||||
|
||||
const result = await createSetupIntent(customerId);
|
||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||
});
|
||||
|
||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const paymentMethodId = c.req.param("id");
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404);
|
||||
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);
|
||||
if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) {
|
||||
return c.json({ error: "Payment method not found" }, 404);
|
||||
}
|
||||
|
||||
const ok = await detachPaymentMethod(paymentMethodId);
|
||||
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user