Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b54bbae65 | |||
| ce9fcfb362 | |||
| 59893908e2 | |||
| 2b78fcf731 | |||
| f12ec4f8d3 | |||
| 2c928ca4d7 | |||
| af75fecb66 | |||
| 2d4df6fe1e | |||
| db10320c8f | |||
| 40a4023c65 | |||
| d598511b75 | |||
| 434c7b94e2 | |||
| 70af9da338 | |||
| e714200b71 | |||
| 1e70e01046 | |||
| 83d7fecdd3 | |||
| 2448887924 | |||
| f4995d987d | |||
| c9b699527c | |||
| 54a6b047fb | |||
| 1855b374b5 | |||
| 004725ae6e | |||
| 51f95e0fd6 |
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.git
|
||||
*.md
|
||||
.github
|
||||
apps/e2e
|
||||
apps/web/dist
|
||||
apps/api/dist
|
||||
packages/db/dist
|
||||
packages/types/dist
|
||||
.turbo
|
||||
screenshots/
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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
|
||||
@@ -0,0 +1,161 @@
|
||||
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: 22
|
||||
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: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm 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: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
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: ${{ gitea.token }}
|
||||
|
||||
- name: Build and push API image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: runner
|
||||
push: true
|
||||
tags: |
|
||||
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
||||
|
||||
- name: Build and push Migrate image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: migrate
|
||||
push: true
|
||||
tags: |
|
||||
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
|
||||
|
||||
- name: Build and push Seed image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: seed
|
||||
push: true
|
||||
tags: |
|
||||
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:seed
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max
|
||||
|
||||
- name: Build and push Reset image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: reset
|
||||
push: true
|
||||
tags: |
|
||||
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
|
||||
+162
-8
@@ -25,17 +25,17 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
run: pnpm --filter @groombook/api typecheck
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
run: pnpm --filter @groombook/api lint
|
||||
|
||||
test:
|
||||
name: Test
|
||||
@@ -49,23 +49,46 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
run: pnpm --filter @groombook/api test
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Image
|
||||
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: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm --filter @groombook/api build
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -95,9 +118,140 @@ 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
|
||||
|
||||
+19
-2
@@ -1,6 +1,23 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
.DS_Store
|
||||
*.log
|
||||
.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/
|
||||
|
||||
+13
-27
@@ -1,52 +1,38 @@
|
||||
FROM node:20-alpine AS base
|
||||
FROM node:22-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/types/package.json packages/types/
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build
|
||||
FROM deps AS builder
|
||||
RUN mkdir -p /home/node/.cache/node/corepack
|
||||
COPY packages/ packages/
|
||||
COPY src/ src/
|
||||
RUN pnpm --filter @groombook/types build && \
|
||||
pnpm --filter @groombook/db build && \
|
||||
pnpm build
|
||||
COPY apps/api/ apps/api/
|
||||
RUN pnpm --filter @groombook/api build
|
||||
|
||||
# Runtime
|
||||
FROM node:20-alpine AS runner
|
||||
FROM node:22-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/dist dist/
|
||||
COPY --from=builder /app/packages/db/package.json packages/db/
|
||||
COPY --from=builder /app/packages/db/dist packages/db/dist
|
||||
COPY --from=builder /app/packages/types/package.json packages/types/
|
||||
COPY --from=builder /app/packages/types/dist packages/types/dist
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY --from=builder /app/apps/api/package.json apps/api/
|
||||
COPY --from=builder /app/apps/api/dist apps/api/dist
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
EXPOSE 3000
|
||||
RUN apk add --no-cache curl
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/health || exit 1
|
||||
CMD ["node", "dist/index.js"]
|
||||
CMD ["node", "apps/api/dist/index.js"]
|
||||
|
||||
# Migrate stage — runs drizzle-kit migrate against the database
|
||||
FROM builder AS migrate
|
||||
CMD ["pnpm", "db:migrate"]
|
||||
CMD ["pnpm", "--filter", "@groombook/api", "db:migrate"]
|
||||
|
||||
# Seed stage — populates the database with test data
|
||||
FROM builder AS seed
|
||||
CMD ["pnpm", "db:seed"]
|
||||
CMD ["pnpm", "--filter", "@groombook/api", "db:seed"]
|
||||
|
||||
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
||||
FROM builder AS reset
|
||||
CMD ["pnpm", "db:reset"]
|
||||
CMD ["pnpm", "--filter", "@groombook/api", "db:reset"]
|
||||
|
||||
@@ -1,2 +1,38 @@
|
||||
# api
|
||||
GroomBook API service (extracted from groombook/app monorepo)
|
||||
# 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
|
||||
|
||||
```
|
||||
apps/api/ # 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
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
# 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 |
|
||||
|
||||
### 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 |
|
||||
|
||||
## 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,7 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/schema.ts",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Migration: 0030_extended_pet_profile
|
||||
-- Adds extended profile fields to the pets table
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE pets ADD COLUMN coat_type text;
|
||||
ALTER TABLE pets ADD COLUMN temperament_score integer;
|
||||
ALTER TABLE pets ADD COLUMN temperament_flags jsonb DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE pets ADD COLUMN medical_alerts jsonb DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE pets ADD COLUMN preferred_cuts jsonb DEFAULT '[]'::jsonb;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"id": "0030_extended_pet_profile",
|
||||
"prevId": "0028_sms_reminders",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.pets": {
|
||||
"name": "pets",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"client_id": { "name": "client_id", "type": "uuid", "isNullable": false },
|
||||
"name": { "name": "name", "type": "text", "isNullable": false },
|
||||
"species": { "name": "species", "type": "text", "isNullable": false },
|
||||
"breed": { "name": "breed", "type": "text", "isNullable": true },
|
||||
"weight_kg": { "name": "weight_kg", "type": "numeric(5, 2)", "isNullable": true },
|
||||
"date_of_birth": { "name": "date_of_birth", "type": "timestamp", "isNullable": true },
|
||||
"health_alerts": { "name": "health_alerts", "type": "text", "isNullable": true },
|
||||
"grooming_notes": { "name": "grooming_notes", "type": "text", "isNullable": true },
|
||||
"cut_style": { "name": "cut_style", "type": "text", "isNullable": true },
|
||||
"shampoo_preference": { "name": "shampoo_preference", "type": "text", "isNullable": true },
|
||||
"special_care_notes": { "name": "special_care_notes", "type": "text", "isNullable": true },
|
||||
"custom_fields": { "name": "custom_fields", "type": "jsonb", "isNullable": false, "default": "'{}'::jsonb" },
|
||||
"photo_key": { "name": "photo_key", "type": "text", "isNullable": true },
|
||||
"photo_uploaded_at": { "name": "photo_uploaded_at", "type": "timestamp", "isNullable": true },
|
||||
"image": { "name": "image", "type": "text", "isNullable": true },
|
||||
"coat_type": { "name": "coat_type", "type": "text", "isNullable": true },
|
||||
"temperament_score": { "name": "temperament_score", "type": "integer", "isNullable": true },
|
||||
"temperament_flags": { "name": "temperament_flags", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
||||
"medical_alerts": { "name": "medical_alerts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
||||
"preferred_cuts": { "name": "preferred_cuts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" },
|
||||
"created_at": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updated_at": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": { "idx_pets_client_id": { "name": "idx_pets_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false } },
|
||||
"foreignKeys": { "pets_client_id_clients_id_fk": { "name": "pets_client_id_clients_id_fk", "tableFrom": "pets", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade" } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
|
||||
}
|
||||
@@ -208,15 +208,15 @@
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1775784467192,
|
||||
"when": 1775828067192,
|
||||
"tag": "0029_db_indexes_constraints",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1775828067192,
|
||||
"tag": "0030_messaging",
|
||||
"when": 1775914467192,
|
||||
"tag": "0030_extended_pet_profile",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -5,7 +5,7 @@ let dbSelectResult: unknown[] = [];
|
||||
const mockEq = vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val }));
|
||||
const mockDecryptSecret = vi.fn((s: string) => `decrypted:${s}`);
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
vi.mock("../db", () => {
|
||||
const authProviderConfig = new Proxy(
|
||||
{ _name: "auth_provider_config" },
|
||||
{
|
||||
@@ -40,7 +40,7 @@ vi.mock("@groombook/db", () => {
|
||||
|
||||
async function reimportAuth() {
|
||||
vi.resetModules();
|
||||
vi.doMock("@groombook/db", () => ({
|
||||
vi.doMock("./db", () => ({
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
@@ -38,7 +38,7 @@ const mockGroomer: MockStaff = { id: "staff-3", role: "groomer", isSuperUser: fa
|
||||
|
||||
// ─── Mock db module ───────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
vi.mock("../db", () => {
|
||||
const authProviderConfig = new Proxy(
|
||||
{ _name: "auth_provider_config" },
|
||||
{
|
||||
@@ -40,7 +40,7 @@ function resetMock() {
|
||||
deletedId = null;
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
vi.mock("../db", () => {
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
@@ -39,7 +39,7 @@ function resetMock() {
|
||||
lastUpdate = {};
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
vi.mock("../db", () => {
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { encryptSecret, decryptSecret } from "@groombook/db";
|
||||
import { encryptSecret, decryptSecret } from "../db/index.js";
|
||||
|
||||
describe("encryptSecret / decryptSecret", () => {
|
||||
const originalEnv = process.env.BETTER_AUTH_SECRET;
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
buildPet,
|
||||
buildService,
|
||||
buildAppointment,
|
||||
} from "@groombook/db/factories";
|
||||
} from "../db/factories.js";
|
||||
|
||||
describe("resetFactoryCounters", () => {
|
||||
it("resets all counters so IDs restart from 1", () => {
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
import { buildStaff } from "@groombook/db/factories";
|
||||
import { buildStaff } from "../db/factories.js";
|
||||
|
||||
// ─── Mock data (built with factories for schema-safe defaults) ────────────────
|
||||
|
||||
@@ -76,7 +76,7 @@ function makeChainableResult(data: unknown[]): unknown {
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
vi.mock("../db", () => {
|
||||
function makeTable(name: string) {
|
||||
return new Proxy(
|
||||
{ _name: name },
|
||||
@@ -40,7 +40,7 @@ function resetDb() {
|
||||
|
||||
// ─── Module mocks ─────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
vi.mock("../db", () => {
|
||||
const pets = new Proxy(
|
||||
{ _name: "pets" },
|
||||
{ get(t, p) { return p === "_name" ? "pets" : {}; } }
|
||||
@@ -0,0 +1,414 @@
|
||||
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 = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const PET_ID = "660e8400-e29b-41d4-a716-446655440002";
|
||||
|
||||
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: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
or: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeApp(staff: StaffRow = MANAGER) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("staff", staff);
|
||||
await next();
|
||||
});
|
||||
return app.route("/pets", petsRouter);
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
const app = makeApp(MANAGER);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Extended pet profile fields — validation", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("rejects temperamentScore of 0 (below min)", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 0 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects temperamentScore of 6 (above max)", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 6 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-integer temperamentScore", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 3.5 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects invalid medicalAlert severity", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: CLIENT_ID,
|
||||
name: "Test",
|
||||
species: "dog",
|
||||
medicalAlerts: [{ type: "seizure", description: "xyz", severity: "critical" }],
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("accepts valid temperamentScore 1–5", async () => {
|
||||
const app = createApp();
|
||||
for (const score of [1, 2, 3, 4, 5]) {
|
||||
resetMock();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: score }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts all valid medicalAlert severity values", async () => {
|
||||
const app = createApp();
|
||||
for (const severity of ["low", "medium", "high"] as const) {
|
||||
resetMock();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: CLIENT_ID,
|
||||
name: "Test",
|
||||
species: "dog",
|
||||
medicalAlerts: [{ type: "allergy", description: "Sensitive to chicken", severity }],
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Extended pet profile fields — create", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("accepts all extended fields on create", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: CLIENT_ID,
|
||||
name: "Biscuit",
|
||||
species: "dog",
|
||||
breed: "Golden Retriever",
|
||||
coatType: "double",
|
||||
temperamentScore: 4,
|
||||
temperamentFlags: ["anxious_with_dryers", "gentle"],
|
||||
medicalAlerts: [
|
||||
{ type: "seizure", description: "Occasional episodes", severity: "medium" },
|
||||
],
|
||||
preferredCuts: ["puppy cut", "teddy bear"],
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.coatType).toBe("double");
|
||||
expect(body.temperamentScore).toBe(4);
|
||||
expect(body.temperamentFlags).toEqual(["anxious_with_dryers", "gentle"]);
|
||||
expect(body.medicalAlerts).toEqual([{ type: "seizure", description: "Occasional episodes", severity: "medium" }]);
|
||||
expect(body.preferredCuts).toEqual(["puppy cut", "teddy bear"]);
|
||||
});
|
||||
|
||||
it("create without extended fields works (all optional)", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request("/pets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Basil", species: "cat" }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Extended pet profile fields — update", () => {
|
||||
beforeEach(resetMock);
|
||||
|
||||
it("updates coatType", async () => {
|
||||
const app = createApp();
|
||||
const res = await app.request(`/pets/${PET_ID}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ coatType: "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"]);
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ function resetMock() {
|
||||
updatedValues = [];
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
vi.mock("../db", () => {
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
@@ -67,6 +67,11 @@ vi.mock("@groombook/db", () => {
|
||||
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
|
||||
);
|
||||
|
||||
const impersonationAuditLogs = new Proxy(
|
||||
{ _name: "impersonationAuditLogs" },
|
||||
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
|
||||
);
|
||||
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
@@ -99,8 +104,12 @@ vi.mock("@groombook/db", () => {
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: () => ({ returning: () => [{}] }),
|
||||
}),
|
||||
}),
|
||||
impersonationSessions,
|
||||
impersonationAuditLogs,
|
||||
appointments,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
@@ -46,7 +46,7 @@ const GROOMER: StaffRow = {
|
||||
let staffLookupResult: StaffRow | null = null;
|
||||
let managerFallbackResult: StaffRow | null = MANAGER;
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
vi.mock("../db", () => {
|
||||
const staff = new Proxy(
|
||||
{ _name: "staff" },
|
||||
{
|
||||
@@ -23,7 +23,7 @@ const PET_ROW = {
|
||||
let clientResults: typeof ACTIVE_CLIENT[] = [];
|
||||
let petResults: typeof PET_ROW[] = [];
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
vi.mock("../db", () => {
|
||||
// Proxy objects for table/column references — values don't matter for tests
|
||||
const tableProxy = (name: string) =>
|
||||
new Proxy(
|
||||
@@ -39,7 +39,7 @@ function clearAuthEnv() {
|
||||
|
||||
// ─── Mock db module ───────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
vi.mock("../db", () => {
|
||||
const authProviderConfig = new Proxy(
|
||||
{ _name: "auth_provider_config" },
|
||||
{
|
||||
@@ -49,7 +49,7 @@ function resetMock() {
|
||||
updatedValues = [];
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
vi.mock("../db", () => {
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
@@ -8,7 +8,7 @@
|
||||
* readable values (e.g. "staff-1", "client-2") without needing crypto.
|
||||
*
|
||||
* Usage:
|
||||
* import { buildStaff, buildClient, buildPet } from "@groombook/db/factories";
|
||||
* import { buildStaff, buildClient, buildPet } from "./db/factories";
|
||||
*
|
||||
* const manager = buildStaff({ role: "manager" });
|
||||
* const client = buildClient({ name: "Alice Smith" });
|
||||
@@ -103,6 +103,11 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
|
||||
photoKey: null,
|
||||
photoUploadedAt: null,
|
||||
image: null,
|
||||
coatType: null,
|
||||
temperamentScore: null,
|
||||
temperamentFlags: [],
|
||||
medicalAlerts: [],
|
||||
preferredCuts: [],
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||
};
|
||||
@@ -12,6 +12,16 @@ import {
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// ─── Shared types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type MedicalAlertSeverity = "low" | "medium" | "high";
|
||||
|
||||
export interface MedicalAlert {
|
||||
type: string;
|
||||
description: string;
|
||||
severity: MedicalAlertSeverity;
|
||||
}
|
||||
|
||||
// ─── Enums ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const appointmentStatusEnum = pgEnum("appointment_status", [
|
||||
@@ -146,6 +156,12 @@ export const pets = pgTable(
|
||||
photoKey: text("photo_key"),
|
||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||
image: text("image"),
|
||||
// Extended profile fields
|
||||
coatType: text("coat_type"),
|
||||
temperamentScore: integer("temperament_score"),
|
||||
temperamentFlags: jsonb("temperament_flags").$type<string[]>().default([]),
|
||||
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().default([]),
|
||||
preferredCuts: jsonb("preferred_cuts").$type<string[]>().default([]),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
@@ -406,117 +422,6 @@ export const impersonationAuditLogs = pgTable(
|
||||
(t) => [index("impersonation_audit_logs_session_id_idx").on(t.sessionId)]
|
||||
);
|
||||
|
||||
// ─── Messaging ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const messagingChannelEnum = pgEnum("messaging_channel", ["sms", "mms"]);
|
||||
|
||||
export const messageDirectionEnum = pgEnum("message_direction", [
|
||||
"inbound",
|
||||
"outbound",
|
||||
]);
|
||||
|
||||
export const messageStatusEnum = pgEnum("message_status", [
|
||||
"queued",
|
||||
"sent",
|
||||
"delivered",
|
||||
"failed",
|
||||
"received",
|
||||
]);
|
||||
|
||||
export const messageConsentKindEnum = pgEnum("message_consent_kind", [
|
||||
"opt_in",
|
||||
"opt_out",
|
||||
"help",
|
||||
]);
|
||||
|
||||
export const conversations = pgTable(
|
||||
"conversations",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
businessId: uuid("business_id").notNull(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
channel: messagingChannelEnum("channel").notNull(),
|
||||
externalNumber: text("external_number").notNull(),
|
||||
businessNumber: text("business_number").notNull(),
|
||||
lastMessageAt: timestamp("last_message_at"),
|
||||
status: text("status").notNull().default("active"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("idx_conversations_business_id_last_message_at").on(
|
||||
t.businessId,
|
||||
t.lastMessageAt.desc()
|
||||
),
|
||||
unique("uq_conversations_business_client_number").on(
|
||||
t.businessId,
|
||||
t.clientId,
|
||||
t.businessNumber
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
export const messages = pgTable(
|
||||
"messages",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
conversationId: uuid("conversation_id")
|
||||
.notNull()
|
||||
.references(() => conversations.id, { onDelete: "cascade" }),
|
||||
direction: messageDirectionEnum("direction").notNull(),
|
||||
body: text("body"),
|
||||
status: messageStatusEnum("status").notNull().default("queued"),
|
||||
providerMessageId: text("provider_message_id"),
|
||||
errorCode: text("error_code"),
|
||||
errorMessage: text("error_message"),
|
||||
sentByStaffId: uuid("sent_by_staff_id").references(() => staff.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
deliveredAt: timestamp("delivered_at"),
|
||||
readByClientAt: timestamp("read_by_client_at"),
|
||||
},
|
||||
(t) => [
|
||||
index("idx_messages_conversation_id_created_at").on(
|
||||
t.conversationId,
|
||||
t.createdAt.desc()
|
||||
),
|
||||
unique("uq_messages_provider_message_id").on(t.providerMessageId),
|
||||
]
|
||||
);
|
||||
|
||||
export const messageAttachments = pgTable(
|
||||
"message_attachments",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
messageId: uuid("message_id")
|
||||
.notNull()
|
||||
.references(() => messages.id, { onDelete: "cascade" }),
|
||||
contentType: text("content_type").notNull(),
|
||||
url: text("url").notNull(),
|
||||
size: integer("size").notNull(),
|
||||
providerMediaId: text("provider_media_id"),
|
||||
},
|
||||
(t) => [index("idx_message_attachments_message_id").on(t.messageId)]
|
||||
);
|
||||
|
||||
export const messageConsentEvents = pgTable(
|
||||
"message_consent_events",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
businessId: uuid("business_id").notNull(),
|
||||
kind: messageConsentKindEnum("kind").notNull(),
|
||||
source: text("source"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index("idx_message_consent_events_client_id").on(t.clientId)]
|
||||
);
|
||||
|
||||
export const businessSettings = pgTable("business_settings", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
businessName: text("business_name").notNull().default("GroomBook"),
|
||||
@@ -525,8 +430,6 @@ export const businessSettings = pgTable("business_settings", {
|
||||
logoKey: text("logo_key"),
|
||||
primaryColor: text("primary_color").notNull().default("#4f8a6f"),
|
||||
accentColor: text("accent_color").notNull().default("#8b7355"),
|
||||
messagingPhoneNumber: text("messaging_phone_number"),
|
||||
telnyxMessagingProfileId: text("telnyx_messaging_profile_id"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
@@ -94,11 +94,6 @@ function pick<T>(arr: T[]): T {
|
||||
return arr[Math.floor(rand() * arr.length)]!;
|
||||
}
|
||||
|
||||
/** Return n distinct random elements from an array. */
|
||||
function pickN<T>(arr: T[], n: number): T[] {
|
||||
const shuffled = [...arr].sort(() => rand() - 0.5);
|
||||
return shuffled.slice(0, n);
|
||||
}
|
||||
|
||||
function randInt(min: number, max: number): number {
|
||||
return Math.floor(rand() * (max - min + 1)) + min;
|
||||
@@ -459,6 +454,32 @@ async function seedKnownUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff: UAT Tester (oidcSub from SEED_UAT_TESTER_OIDC_SUB env var) ──
|
||||
const uatTesterOidcSub = process.env.SEED_UAT_TESTER_OIDC_SUB;
|
||||
if (uatTesterOidcSub) {
|
||||
const UAT_TESTER_STAFF_ID = "00000000-0000-0000-0000-000000000007";
|
||||
const [existingUatTester] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, "uat-tester@groombook.dev"))
|
||||
.limit(1);
|
||||
|
||||
if (existingUatTester) {
|
||||
console.log(`✓ Staff 'UAT Tester' already exists — skipping`);
|
||||
} else {
|
||||
await db.insert(schema.staff).values({
|
||||
id: UAT_TESTER_STAFF_ID,
|
||||
name: "UAT Tester",
|
||||
email: "uat-tester@groombook.dev",
|
||||
oidcSub: uatTesterOidcSub,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
});
|
||||
console.log(`✓ Created staff 'UAT Tester' (oidcSub: ${uatTesterOidcSub})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
||||
@@ -883,6 +904,7 @@ async function seed() {
|
||||
let appointmentCount = 0;
|
||||
let invoiceCount = 0;
|
||||
let visitLogCount = 0;
|
||||
let paidInvoiceCounter = 0;
|
||||
|
||||
// Process in batches per client to keep memory manageable
|
||||
const apptBatchSize = 100;
|
||||
@@ -977,8 +999,11 @@ async function seed() {
|
||||
|
||||
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
||||
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
||||
paidInvoiceCounter++;
|
||||
const stripePaymentIntentId = invoiceStatus === "paid"
|
||||
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
|
||||
: null;
|
||||
|
||||
const stripePaymentIntentId = invoiceStatus === "paid" && rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
|
||||
invoiceBatch.push({
|
||||
id: invoiceId,
|
||||
appointmentId: apptId,
|
||||
@@ -1075,7 +1100,7 @@ async function seed() {
|
||||
const groomer = pick(groomers);
|
||||
const bather = bathers.length > 0 && rand() < 0.6 ? pick(bathers) : null;
|
||||
|
||||
let startTime = randDate(appointmentsBackDate, now);
|
||||
const startTime = randDate(appointmentsBackDate, now);
|
||||
startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
|
||||
const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000);
|
||||
const effectivePrice = svc.price;
|
||||
@@ -1094,14 +1119,16 @@ async function seed() {
|
||||
const taxCents = Math.round(effectivePrice * 0.08);
|
||||
const totalCents = effectivePrice + taxCents + tipCents;
|
||||
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
||||
const stripePaymentIntentId = rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
|
||||
paidInvoiceCounter++;
|
||||
|
||||
invoiceBatch.push({
|
||||
id: invoiceId, appointmentId: apptId, clientId,
|
||||
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
||||
status: "paid" as const,
|
||||
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
||||
paidAt, stripePaymentIntentId, notes: null,
|
||||
paidAt,
|
||||
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
|
||||
notes: null,
|
||||
});
|
||||
lineItemBatch.push({
|
||||
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
||||
@@ -22,7 +22,7 @@ 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 "@groombook/db";
|
||||
import { getDb, businessSettings, eq, staff } from "./db/index.js";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
|
||||
import { devRouter } from "./routes/dev.js";
|
||||
@@ -1,8 +1,8 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { getDb, authProviderConfig, eq } from "@groombook/db";
|
||||
import { decryptSecret } from "@groombook/db";
|
||||
import { getDb, authProviderConfig, eq } from "../db/index.js";
|
||||
import { decryptSecret } from "../db/index.js";
|
||||
import { sendEmail } from "../services/email.js";
|
||||
|
||||
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
||||
@@ -97,6 +97,9 @@ export async function initAuth(): Promise<void> {
|
||||
window: 10,
|
||||
storage: "memory",
|
||||
customRules: {
|
||||
"/sign-in/social": { max: 10, window: 60 },
|
||||
"/sign-in/email": { max: 10, window: 60 },
|
||||
"/sign-up/email": { max: 5, window: 60 },
|
||||
"/get-session": false,
|
||||
},
|
||||
},
|
||||
@@ -247,6 +250,9 @@ export async function initAuth(): Promise<void> {
|
||||
window: 10,
|
||||
storage: "memory",
|
||||
customRules: {
|
||||
"/sign-in/social": { max: 10, window: 60 },
|
||||
"/sign-in/email": { max: 10, window: 60 },
|
||||
"/sign-up/email": { max: 5, window: 60 },
|
||||
"/get-session": false,
|
||||
},
|
||||
},
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { getDb, impersonationAuditLogs } from "@groombook/db";
|
||||
import { getDb, impersonationAuditLogs } from "../db/index.js";
|
||||
import type { PortalEnv } from "./portalSession.js";
|
||||
|
||||
/**
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, impersonationSessions } from "@groombook/db";
|
||||
import { and, eq, getDb, impersonationSessions } from "../db/index.js";
|
||||
|
||||
export interface PortalEnv {
|
||||
Variables: {
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, sql, staff, user } from "@groombook/db";
|
||||
import { and, eq, getDb, sql, staff } from "../db/index.js";
|
||||
|
||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||
export type StaffRow = typeof staff.$inferSelect;
|
||||
@@ -110,30 +110,6 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Auto-provision: no staff record exists for this user at all, but a valid
|
||||
// Better-Auth user session exists (jwt.sub = user.id from user table).
|
||||
// Create a minimal groomer staff record on first login.
|
||||
const [userRow] = await db
|
||||
.select({ id: user.id, name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, jwt.sub))
|
||||
.limit(1);
|
||||
if (userRow) {
|
||||
const [newStaff] = await db
|
||||
.insert(staff)
|
||||
.values({
|
||||
name: userRow.name ?? jwt.email?.split("@")[0] ?? "Unknown",
|
||||
email: userRow.email ?? jwt.email ?? "",
|
||||
userId: jwt.sub,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
})
|
||||
.returning();
|
||||
c.set("staff", newStaff);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||
403
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { eq, getDb, staff, clients, pets, services } from "@groombook/db";
|
||||
import { eq, getDb, staff, clients, pets, services } from "../../db/index.js";
|
||||
|
||||
export const adminSeedRouter = new Hono();
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
pets,
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
} from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const appointmentGroupsRouter = new Hono<AppEnv>();
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
reminderLogs,
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
} from "../db/index.js";
|
||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, authProviderConfig, encryptSecret } from "@groombook/db";
|
||||
import { eq, getDb, authProviderConfig, encryptSecret } from "../db/index.js";
|
||||
import { requireSuperUser } from "../middleware/rbac.js";
|
||||
import { reinitAuth } from "../lib/auth.js";
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
} from "@groombook/db";
|
||||
} from "../db/index.js";
|
||||
import {
|
||||
generateAvailableSlots,
|
||||
BUSINESS_START_HOUR,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
pets,
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
} from "../db/index.js";
|
||||
|
||||
export const calendarRouter = new Hono();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
|
||||
import { and, eq, exists, getDb, or, clients, appointments } from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const clientsRouter = new Hono<AppEnv>();
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { getDb, staff, clients, eq, sql } from "@groombook/db";
|
||||
import { getDb, staff, clients, eq, sql } from "../db/index.js";
|
||||
|
||||
const devRouter = new Hono();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
|
||||
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const groomingLogsRouter = new Hono<AppEnv>();
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
impersonationAuditLogs,
|
||||
clients,
|
||||
desc,
|
||||
} from "@groombook/db";
|
||||
} from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const impersonationRouter = new Hono<AppEnv>();
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
services,
|
||||
clients,
|
||||
sql,
|
||||
} from "@groombook/db";
|
||||
} from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const invoicesRouter = new Hono<AppEnv>();
|
||||
@@ -102,7 +102,6 @@ invoicesRouter.get(
|
||||
paidAt: invoices.paidAt,
|
||||
notes: invoices.notes,
|
||||
stripePaymentIntentId: invoices.stripePaymentIntentId,
|
||||
stripeRefundId: invoices.stripeRefundId,
|
||||
createdAt: invoices.createdAt,
|
||||
updatedAt: invoices.updatedAt,
|
||||
})
|
||||
@@ -130,17 +129,7 @@ invoicesRouter.get("/:id", async (c) => {
|
||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||
]);
|
||||
|
||||
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({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
|
||||
return c.json({ ...invoice, lineItems, tipSplits });
|
||||
});
|
||||
|
||||
// Save tip splits for an invoice (replaces existing splits)
|
||||
@@ -460,6 +449,9 @@ invoicesRouter.post(
|
||||
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) {
|
||||
@@ -472,25 +464,17 @@ invoicesRouter.post(
|
||||
}
|
||||
}
|
||||
|
||||
let refundId: string;
|
||||
|
||||
if (invoice.stripePaymentIntentId) {
|
||||
const result = await processRefund(id, body.amountCents);
|
||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||
refundId = result.refundId;
|
||||
} else {
|
||||
// Manual refund — no Stripe call needed
|
||||
refundId = `manual_${id}_${Date.now()}`;
|
||||
}
|
||||
const result = await processRefund(id, body.amountCents);
|
||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||
|
||||
await tx.insert(refunds).values({
|
||||
invoiceId: id,
|
||||
stripeRefundId: refundId,
|
||||
stripeRefundId: result.refundId,
|
||||
idempotencyKey: body.idempotencyKey ?? null,
|
||||
amountCents: body.amountCents ?? null,
|
||||
});
|
||||
|
||||
return c.json({ refundId });
|
||||
return c.json({ refundId: result.refundId });
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db";
|
||||
import { and, eq, exists, getDb, or, pets, appointments } from "../db/index.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import {
|
||||
getPresignedUploadUrl,
|
||||
@@ -24,6 +24,15 @@ const createPetSchema = z.object({
|
||||
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 });
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, inArray } from "@groombook/db";
|
||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||
import { eq, inArray } from "../db/index.js";
|
||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "../db/index.js";
|
||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
||||
import { portalAudit } from "../middleware/portalAudit.js";
|
||||
import type { PortalEnv } from "../middleware/portalSession.js";
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
invoiceTipSplits,
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
} from "../db/index.js";
|
||||
|
||||
export const reportsRouter = new Hono();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user