Compare commits

..

18 Commits

Author SHA1 Message Date
Chris Farhood 1674a7df4a fix(GRO-1272): update rbac tests and UAT playbook for auto-provision
CI / Lint & Typecheck (pull_request) Failing after 13s
CI / Test (pull_request) Failing after 20s
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
- Add user table mock and db.insert returning chain to rbac.test.ts
- Add three new tests: happy-path auto-provision, email-prefix fallback,
  and miss-path (no user → 403)
- Add TC-API-1.4 to UAT_PLAYBOOK.md §4.1 for first-login auto-provision

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

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

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

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

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

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

Fixes: GRO-1231

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 16:50:52 +00:00
the-dogfather-cto[bot] db10320c8f fix(auth): override Better Auth sign-in rate limit defaults (#11)
fix(auth): override Better Auth sign-in rate limit defaults
2026-05-14 10:52:31 +00:00
Chris Farhood 40a4023c65 feat(GRO-1202): add sign-in/sign-up rate limit overrides
Port rate limit customRules from groombook/app PR #392 to groombook/api.
Adds per-route limits for /sign-in/social, /sign-in/email, and /sign-up/email
to both AUTH_DISABLED and production better-auth() instances.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 10:34:32 +00:00
groombook-engineer[bot] d598511b75 fix: resolve pre-existing TypeScript errors for CI compliance (#9)
Merge PR #9: fix pre-existing TypeScript errors for CI compliance

All Lint & Typecheck and Test checks pass. Ready to merge.

cc @cpfarhood
2026-05-14 07:50:28 +00:00
the-dogfather-cto[bot] e714200b71 Merge pull request #7 from groombook/fix/uat-tester-oidc-sub
fix(api): add UAT Tester staff creation in seed script
2026-05-12 21:57:44 +00:00
Chris Farhood 1e70e01046 fix(api): add UAT Tester staff creation in seed script
Adds dedicated SEED_UAT_TESTER_OIDC_SUB handling to create the
uat-tester staff record with proper oidcSub mapping to Authentik user PK 237.

Fixes GRO-1151
2026-05-12 21:44:42 +00:00
the-dogfather-cto[bot] 83d7fecdd3 fix: correct test mock paths from "./db" to "../db" (#5)
fix: correct test mock paths from "./db" to "../db"
2026-05-12 21:33:02 +00:00
Chris Farhood 2448887924 fix: regenerate pnpm-lock.yaml to sync with package.json
- Adds missing drizzle-kit, drizzle-orm, postgres dependencies
- Addresses CI failures from Lint & Typecheck and Test jobs
- Resolves QA feedback from Lint Roller on PR #5
2026-05-12 21:13:55 +00:00
Chris Farhood f4995d987d fix: correct test mock paths from "./db" to "../db"
Fixes incorrect vi.mock paths that were causing tests to fail.
The mock path should match the import path in the route files.

This addresses the authProvider test mock path issue on PR #2.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-12 19:54:29 +00:00
the-dogfather-cto[bot] c9b699527c docs: add UAT_PLAYBOOK.md for API service (#3)
docs: add UAT_PLAYBOOK.md for API service
2026-05-11 14:14:31 +00:00
Chris Farhood 54a6b047fb docs: add UAT_PLAYBOOK.md for API service
Created comprehensive UAT playbook covering all 13 route groups with test cases for authentication, client management, pet management, appointment scheduling, services, staff management, invoicing & payments, customer portal, waitlist, search, reports, impersonation, and settings & setup.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-11 13:47:51 +00:00
Hugh Hackman 1855b374b5 refactor: inline packages/db and packages/types into api package
Phase 2 extraction: groombook/api from groombook/app monorepo.

Changes:
- Move packages/db content to apps/api/src/db/
- Move packages/types content to apps/api/src/types/
- Inline database schema and migrations into api package
- Update Dockerfile to build single package
- Update CI workflow for single-package structure
- Fix vitest.config.ts aliases

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-02 21:21:42 +00:00
Hugh Hackman 004725ae6e Add pnpm-lock.yaml
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-02 21:11:55 +00:00
Hugh Hackman 51f95e0fd6 Initial extraction: groombook/api from groombook/app monorepo
Part of GRO-802 monorepo breakdown.

Changes:
- Extract apps/api/ as the main API service
- Inline packages/db/ (database schema, migrations, utilities)
- Inline packages/types/ (shared TypeScript types)
- Add CI workflow for lint, typecheck, test, build, docker
- Port Dockerfile with 4 stages: runner, migrate, seed, reset

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-02 21:10:21 +00:00
123 changed files with 986 additions and 1236 deletions
+11
View File
@@ -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/
+12
View File
@@ -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
+36
View File
@@ -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
-99
View File
@@ -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
+257
View File
@@ -0,0 +1,257 @@
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 --filter @groombook/api typecheck
- name: Lint
run: pnpm --filter @groombook/api lint
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
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
docker:
name: Build & Push Docker Images
runs-on: ubuntu-latest
needs: [build]
outputs:
tag: ${{ steps.version.outputs.tag }}
permissions:
contents: read
packages: write
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 GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push API image
uses: docker/build-push-action@v6
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
View File
@@ -1,6 +1,23 @@
node_modules/ node_modules/
dist/ dist/
.DS_Store
*.log
.env .env
.env.local .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/
+11 -26
View File
@@ -2,52 +2,37 @@ FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app WORKDIR /app
# Install deps
FROM base AS deps FROM base AS deps
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/db/package.json packages/db/ COPY apps/api/package.json apps/api/
COPY packages/types/package.json packages/types/
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# Build
FROM deps AS builder FROM deps AS builder
RUN mkdir -p /home/node/.cache/node/corepack RUN mkdir -p /home/node/.cache/node/corepack
COPY packages/ packages/ COPY apps/api/ apps/api/
COPY src/ src/ RUN pnpm --filter @groombook/api build
COPY tsconfig.json ./
RUN pnpm --filter @groombook/types build && \
pnpm --filter @groombook/db build && \
pnpm build
# Runtime
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY --from=builder /app/package.json ./ COPY --from=builder /app/apps/api/package.json apps/api/
COPY --from=builder /app/dist dist/ COPY --from=builder /app/apps/api/dist apps/api/dist
COPY --from=builder /app/packages/db/package.json packages/db/
COPY --from=builder /app/packages/db/dist packages/db/dist
COPY --from=builder /app/packages/types/package.json packages/types/
COPY --from=builder /app/packages/types/dist packages/types/dist
RUN pnpm install --frozen-lockfile --prod RUN pnpm install --frozen-lockfile --prod
EXPOSE 3000 EXPOSE 3000
RUN apk add --no-cache curl RUN apk add --no-cache curl
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1 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 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 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 FROM builder AS reset
CMD ["pnpm", "db:reset"] CMD ["pnpm", "--filter", "@groombook/api", "db:reset"]
+38 -2
View File
@@ -1,2 +1,38 @@
# api # GroomBook API
GroomBook API service (extracted from groombook/app monorepo)
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
+1 -18
View File
@@ -28,12 +28,7 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-1.1 | Login via OIDC | POST to OIDC provider callback, verify JWT token issued | 200 OK, JWT returned with valid claims | | TC-API-1.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.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.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.4 | Auto-provision on first OIDC login | First login as a Better-Auth user with no existing staff record | 200 OK, access granted; groomer staff record auto-created with name/email from user table |
| 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 ### 4.2 Client Management
@@ -183,18 +178,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-14.4 | Update group notes | PATCH /api/appointment-groups/{id} with notes | 200 OK, notes updated | | TC-API-14.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 | | 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/Fail Criteria
**Pass:** **Pass:**
@@ -204,20 +204,6 @@
"when": 1775741667192, "when": 1775741667192,
"tag": "0028_sms_reminders", "tag": "0028_sms_reminders",
"breakpoints": true "breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1775784467192,
"tag": "0029_db_indexes_constraints",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1775828067192,
"tag": "0030_messaging",
"breakpoints": true
} }
] ]
} }
+47
View File
@@ -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 mockEq = vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val }));
const mockDecryptSecret = vi.fn((s: string) => `decrypted:${s}`); const mockDecryptSecret = vi.fn((s: string) => `decrypted:${s}`);
vi.mock("@groombook/db", () => { vi.mock("../db", () => {
const authProviderConfig = new Proxy( const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" }, { _name: "auth_provider_config" },
{ {
@@ -40,7 +40,7 @@ vi.mock("@groombook/db", () => {
async function reimportAuth() { async function reimportAuth() {
vi.resetModules(); vi.resetModules();
vi.doMock("@groombook/db", () => ({ vi.doMock("./db", () => ({
getDb: () => ({ getDb: () => ({
select: () => ({ select: () => ({
from: () => ({ from: () => ({
@@ -38,7 +38,7 @@ const mockGroomer: MockStaff = { id: "staff-3", role: "groomer", isSuperUser: fa
// ─── Mock db module ─────────────────────────────────────────────────────────── // ─── Mock db module ───────────────────────────────────────────────────────────
vi.mock("@groombook/db", () => { vi.mock("../db", () => {
const authProviderConfig = new Proxy( const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" }, { _name: "auth_provider_config" },
{ {
@@ -40,7 +40,7 @@ function resetMock() {
deletedId = null; deletedId = null;
} }
vi.mock("@groombook/db", () => { vi.mock("../db", () => {
function makeChainable(data: unknown[]): unknown { function makeChainable(data: unknown[]): unknown {
const arr = [...data]; const arr = [...data];
const chain = new Proxy(arr, { const chain = new Proxy(arr, {
@@ -39,7 +39,7 @@ function resetMock() {
lastUpdate = {}; lastUpdate = {};
} }
vi.mock("@groombook/db", () => { vi.mock("../db", () => {
const appointments = new Proxy( const appointments = new Proxy(
{ _name: "appointments" }, { _name: "appointments" },
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) } { get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { encryptSecret, decryptSecret } from "@groombook/db"; import { encryptSecret, decryptSecret } from "../db/index.js";
describe("encryptSecret / decryptSecret", () => { describe("encryptSecret / decryptSecret", () => {
const originalEnv = process.env.BETTER_AUTH_SECRET; const originalEnv = process.env.BETTER_AUTH_SECRET;
@@ -6,7 +6,7 @@ import {
buildPet, buildPet,
buildService, buildService,
buildAppointment, buildAppointment,
} from "@groombook/db/factories"; } from "../db/factories.js";
describe("resetFactoryCounters", () => { describe("resetFactoryCounters", () => {
it("resets all counters so IDs restart from 1", () => { it("resets all counters so IDs restart from 1", () => {
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono"; import { Hono } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js"; 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) ──────────────── // ─── 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) { function makeTable(name: string) {
return new Proxy( return new Proxy(
{ _name: name }, { _name: name },
@@ -40,7 +40,7 @@ function resetDb() {
// ─── Module mocks ───────────────────────────────────────────────────────────── // ─── Module mocks ─────────────────────────────────────────────────────────────
vi.mock("@groombook/db", () => { vi.mock("../db", () => {
const pets = new Proxy( const pets = new Proxy(
{ _name: "pets" }, { _name: "pets" },
{ get(t, p) { return p === "_name" ? "pets" : {}; } } { get(t, p) { return p === "_name" ? "pets" : {}; } }
@@ -47,7 +47,7 @@ function resetMock() {
updatedValues = []; updatedValues = [];
} }
vi.mock("@groombook/db", () => { vi.mock("../db", () => {
function makeChainable(data: unknown[]): unknown { function makeChainable(data: unknown[]): unknown {
const arr = [...data]; const arr = [...data];
const chain = new Proxy(arr, { const chain = new Proxy(arr, {
@@ -45,40 +45,72 @@ const GROOMER: StaffRow = {
let staffLookupResult: StaffRow | null = null; let staffLookupResult: StaffRow | null = null;
let managerFallbackResult: StaffRow | null = MANAGER; let managerFallbackResult: StaffRow | null = MANAGER;
let userLookupResult: { id: string; name: string | null; email: string | null } | null = null;
let insertedStaff: StaffRow | null = null;
vi.mock("@groombook/db", () => { vi.mock("../db", () => {
const staff = new Proxy( const makeTableProxy = (name: string) =>
{ _name: "staff" }, new Proxy(
{ { _name: name },
get(target, prop) { {
if (prop === "_name") return "staff"; get(target, prop) {
if (prop === "$inferSelect") return {}; if (prop === "_name") return name;
return { table: "staff", column: prop }; if (prop === "$inferSelect") return {};
return { table: name, column: prop };
},
}
);
const staff = makeTableProxy("staff");
const user = makeTableProxy("user");
const buildQuery = (result: unknown, fallback: unknown) => ({
limit: () => ({
[Symbol.iterator]: function* () {
if (result) yield result;
}, },
} 0: result,
); length: result ? 1 : 0,
}),
});
return { return {
getDb: () => ({ getDb: () => ({
select: () => ({ select: () => ({
from: () => ({ from: (table: unknown) => ({
where: () => ({ where: () => buildQuery(
limit: () => { table === staff ? staffLookupResult : userLookupResult,
// dev mode fallback to first manager table === staff ? managerFallbackResult : null
return managerFallbackResult ? [managerFallbackResult] : []; ),
}, }),
[Symbol.iterator]: function* () { }),
if (staffLookupResult) yield staffLookupResult; insert: (table: unknown) => ({
}, values: (vals: Record<string, unknown>) => ({
0: staffLookupResult, returning: () => {
length: staffLookupResult ? 1 : 0, const newStaff: StaffRow = {
}), id: "new-staff-id",
oidcSub: null,
userId: vals.userId as string,
role: vals.role as StaffRow["role"],
isSuperUser: false,
name: vals.name as string,
email: vals.email as string,
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
insertedStaff = newStaff;
return [newStaff];
},
}), }),
}), }),
}), }),
staff, staff,
user,
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })), eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
and: vi.fn((..._clauses: unknown[]) => ({})), and: vi.fn((..._clauses: unknown[]) => ({})),
sql: vi.fn((..._args: unknown[]) => ({})),
}; };
}); });
@@ -87,6 +119,8 @@ vi.mock("@groombook/db", () => {
function resetMocks() { function resetMocks() {
staffLookupResult = null; staffLookupResult = null;
managerFallbackResult = MANAGER; managerFallbackResult = MANAGER;
userLookupResult = null;
insertedStaff = null;
} }
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */ /** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
@@ -202,6 +236,50 @@ describe("resolveStaffMiddleware", () => {
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/no staff records found/i); expect(body.error).toMatch(/no staff records found/i);
}); });
it("auto-provision: creates groomer staff record on first login when Better-Auth user exists", async () => {
staffLookupResult = null;
userLookupResult = { id: "ba-user-new", name: "New User", email: "newuser@example.com" };
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff).not.toBeNull();
expect(capturedStaff!.role).toBe("groomer");
expect(capturedStaff!.userId).toBe("ba-user-new");
expect(capturedStaff!.name).toBe("New User");
expect(capturedStaff!.email).toBe("newuser@example.com");
expect(capturedStaff!.isSuperUser).toBe(false);
});
it("auto-provision: falls back to email prefix when user has no name", async () => {
staffLookupResult = null;
userLookupResult = { id: "ba-user-noname", name: null, email: "firstlogin@example.com" };
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff!.name).toBe("firstlogin");
});
it("auto-provision: returns 403 when no staff record and no Better-Auth user exists", async () => {
staffLookupResult = null;
userLookupResult = null;
const app = buildApp(resolveStaffMiddleware);
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/no staff record found for authenticated user/i);
});
}); });
// ─── requireRole tests ──────────────────────────────────────────────────────── // ─── requireRole tests ────────────────────────────────────────────────────────
@@ -23,7 +23,7 @@ const PET_ROW = {
let clientResults: typeof ACTIVE_CLIENT[] = []; let clientResults: typeof ACTIVE_CLIENT[] = [];
let petResults: typeof PET_ROW[] = []; let petResults: typeof PET_ROW[] = [];
vi.mock("@groombook/db", () => { vi.mock("../db", () => {
// Proxy objects for table/column references — values don't matter for tests // Proxy objects for table/column references — values don't matter for tests
const tableProxy = (name: string) => const tableProxy = (name: string) =>
new Proxy( new Proxy(
@@ -39,7 +39,7 @@ function clearAuthEnv() {
// ─── Mock db module ─────────────────────────────────────────────────────────── // ─── Mock db module ───────────────────────────────────────────────────────────
vi.mock("@groombook/db", () => { vi.mock("../db", () => {
const authProviderConfig = new Proxy( const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" }, { _name: "auth_provider_config" },
{ {
@@ -49,7 +49,7 @@ function resetMock() {
updatedValues = []; updatedValues = [];
} }
vi.mock("@groombook/db", () => { vi.mock("../db", () => {
function makeChainable(data: unknown[]): unknown { function makeChainable(data: unknown[]): unknown {
const arr = [...data]; const arr = [...data];
const chain = new Proxy(arr, { const chain = new Proxy(arr, {
@@ -8,7 +8,7 @@
* readable values (e.g. "staff-1", "client-2") without needing crypto. * readable values (e.g. "staff-1", "client-2") without needing crypto.
* *
* Usage: * Usage:
* import { buildStaff, buildClient, buildPet } from "@groombook/db/factories"; * import { buildStaff, buildClient, buildPet } from "./db/factories";
* *
* const manager = buildStaff({ role: "manager" }); * const manager = buildStaff({ role: "manager" });
* const client = buildClient({ name: "Alice Smith" }); * const client = buildClient({ name: "Alice Smith" });
@@ -103,8 +103,6 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
photoKey: null, photoKey: null,
photoUploadedAt: null, photoUploadedAt: null,
image: null, image: null,
coatType: null,
petSizeCategory: null,
createdAt: new Date("2025-01-01T00:00:00Z"), createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"), updatedAt: new Date("2025-01-01T00:00:00Z"),
}; };
@@ -119,7 +117,6 @@ export function buildService(overrides: Partial<ServiceRow> = {}): ServiceRow {
description: "A grooming service", description: "A grooming service",
basePriceCents: 6500, basePriceCents: 6500,
durationMinutes: 60, durationMinutes: 60,
defaultBufferMinutes: null,
active: true, active: true,
createdAt: new Date("2025-01-01T00:00:00Z"), createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"), updatedAt: new Date("2025-01-01T00:00:00Z"),
@@ -48,22 +48,6 @@ export const clientStatusEnum = pgEnum("client_status", [
"disabled", "disabled",
]); ]);
export const petSizeCategoryEnum = pgEnum("pet_size_category", [
"small",
"medium",
"large",
"xlarge",
]);
export const coatTypeEnum = pgEnum("coat_type", [
"smooth",
"double",
"wire",
"curly",
"long",
"hairless",
]);
// ─── Better-Auth Tables ────────────────────────────────────────────────────── // ─── Better-Auth Tables ──────────────────────────────────────────────────────
export const user = pgTable("user", { export const user = pgTable("user", {
@@ -158,8 +142,6 @@ export const pets = pgTable(
cutStyle: text("cut_style"), cutStyle: text("cut_style"),
shampooPreference: text("shampoo_preference"), shampooPreference: text("shampoo_preference"),
specialCareNotes: text("special_care_notes"), specialCareNotes: text("special_care_notes"),
coatType: coatTypeEnum("coat_type"),
petSizeCategory: petSizeCategoryEnum("pet_size_category"),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}), customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
photoKey: text("photo_key"), photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"), photoUploadedAt: timestamp("photo_uploaded_at"),
@@ -176,34 +158,11 @@ export const services = pgTable("services", {
description: text("description"), description: text("description"),
basePriceCents: integer("base_price_cents").notNull(), basePriceCents: integer("base_price_cents").notNull(),
durationMinutes: integer("duration_minutes").notNull(), durationMinutes: integer("duration_minutes").notNull(),
defaultBufferMinutes: integer("default_buffer_minutes"),
active: boolean("active").notNull().default(true), active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
}); });
export const bufferRules = pgTable(
"buffer_rules",
{
id: uuid("id").primaryKey().defaultRandom(),
serviceId: uuid("service_id")
.notNull()
.references(() => services.id, { onDelete: "cascade" }),
sizeCategory: petSizeCategoryEnum("size_category"),
coatType: coatTypeEnum("coat_type"),
bufferMinutes: integer("buffer_minutes").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
unique("uq_buffer_rules_service_size_coat").on(
t.serviceId,
t.sizeCategory,
t.coatType
),
]
);
export const staff = pgTable("staff", { export const staff = pgTable("staff", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(), name: text("name").notNull(),
@@ -447,117 +406,6 @@ export const impersonationAuditLogs = pgTable(
(t) => [index("impersonation_audit_logs_session_id_idx").on(t.sessionId)] (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", { export const businessSettings = pgTable("business_settings", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
businessName: text("business_name").notNull().default("GroomBook"), businessName: text("business_name").notNull().default("GroomBook"),
@@ -566,8 +414,6 @@ export const businessSettings = pgTable("business_settings", {
logoKey: text("logo_key"), logoKey: text("logo_key"),
primaryColor: text("primary_color").notNull().default("#4f8a6f"), primaryColor: text("primary_color").notNull().default("#4f8a6f"),
accentColor: text("accent_color").notNull().default("#8b7355"), accentColor: text("accent_color").notNull().default("#8b7355"),
messagingPhoneNumber: text("messaging_phone_number"),
telnyxMessagingProfileId: text("telnyx_messaging_profile_id"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_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 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 { function randInt(min: number, max: number): number {
return Math.floor(rand() * (max - min + 1)) + min; 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) ── // ── 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 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) ?? []; 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 appointmentCount = 0;
let invoiceCount = 0; let invoiceCount = 0;
let visitLogCount = 0; let visitLogCount = 0;
let paidInvoiceCounter = 0;
// Process in batches per client to keep memory manageable // Process in batches per client to keep memory manageable
const apptBatchSize = 100; const apptBatchSize = 100;
@@ -977,8 +999,11 @@ async function seed() {
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const; 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; 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({ invoiceBatch.push({
id: invoiceId, id: invoiceId,
appointmentId: apptId, appointmentId: apptId,
@@ -1075,7 +1100,7 @@ async function seed() {
const groomer = pick(groomers); const groomer = pick(groomers);
const bather = bathers.length > 0 && rand() < 0.6 ? pick(bathers) : null; 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); startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000); const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000);
const effectivePrice = svc.price; const effectivePrice = svc.price;
@@ -1094,14 +1119,16 @@ async function seed() {
const taxCents = Math.round(effectivePrice * 0.08); const taxCents = Math.round(effectivePrice * 0.08);
const totalCents = effectivePrice + taxCents + tipCents; const totalCents = effectivePrice + taxCents + tipCents;
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000); 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({ invoiceBatch.push({
id: invoiceId, appointmentId: apptId, clientId, id: invoiceId, appointmentId: apptId, clientId,
subtotalCents: effectivePrice, taxCents, tipCents, totalCents, subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
status: "paid" as const, status: "paid" as const,
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check", 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({ lineItemBatch.push({
id: uuid(), invoiceId, description: svc.name, quantity: 1, id: uuid(), invoiceId, description: svc.name, quantity: 1,
+1 -3
View File
@@ -19,11 +19,10 @@ import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js"; import { settingsRouter } from "./routes/settings.js";
import { authProviderRouter } from "./routes/authProvider.js"; import { authProviderRouter } from "./routes/authProvider.js";
import { searchRouter } from "./routes/search.js"; import { searchRouter } from "./routes/search.js";
import { bufferRulesRouter } from "./routes/buffer-rules.js";
import { getObject } from "./lib/s3.js"; import { getObject } from "./lib/s3.js";
import { calendarRouter } from "./routes/calendar.js"; import { calendarRouter } from "./routes/calendar.js";
import { setupRouter } from "./routes/setup.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 { authMiddleware } from "./middleware/auth.js";
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js"; import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
import { devRouter } from "./routes/dev.js"; import { devRouter } from "./routes/dev.js";
@@ -270,7 +269,6 @@ api.route("/admin/settings", settingsRouter);
api.route("/admin/auth-provider", authProviderRouter); api.route("/admin/auth-provider", authProviderRouter);
api.route("/admin/seed", adminSeedRouter); api.route("/admin/seed", adminSeedRouter);
api.route("/search", searchRouter); api.route("/search", searchRouter);
api.route("/buffer-rules", bufferRulesRouter);
const port = Number(process.env.PORT ?? 3000); const port = Number(process.env.PORT ?? 3000);
await initAuth(); await initAuth();
+8 -2
View File
@@ -1,8 +1,8 @@
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins"; import { genericOAuth } from "better-auth/plugins";
import { getDb, authProviderConfig, eq } from "@groombook/db"; import { getDb, authProviderConfig, eq } from "../db/index.js";
import { decryptSecret } from "@groombook/db"; import { decryptSecret } from "../db/index.js";
import { sendEmail } from "../services/email.js"; import { sendEmail } from "../services/email.js";
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET; const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
@@ -97,6 +97,9 @@ export async function initAuth(): Promise<void> {
window: 10, window: 10,
storage: "memory", storage: "memory",
customRules: { 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, "/get-session": false,
}, },
}, },
@@ -247,6 +250,9 @@ export async function initAuth(): Promise<void> {
window: 10, window: 10,
storage: "memory", storage: "memory",
customRules: { 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, "/get-session": false,
}, },
}, },
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { getDb, impersonationAuditLogs } from "@groombook/db"; import { getDb, impersonationAuditLogs } from "../db/index.js";
import type { PortalEnv } from "./portalSession.js"; import type { PortalEnv } from "./portalSession.js";
/** /**
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono"; 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 { export interface PortalEnv {
Variables: { Variables: {
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff } from "@groombook/db"; import { and, eq, getDb, sql, staff, user } from "../db/index.js";
export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect; export type StaffRow = typeof staff.$inferSelect;
@@ -110,6 +110,30 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
return; 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( return c.json(
{ error: "Forbidden: no staff record found for authenticated user" }, { error: "Forbidden: no staff record found for authenticated user" },
403 403
@@ -10,7 +10,7 @@
*/ */
import { Hono } from "hono"; 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(); export const adminSeedRouter = new Hono();
@@ -15,7 +15,7 @@ import {
pets, pets,
services, services,
staff, staff,
} from "@groombook/db"; } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js"; import type { AppEnv } from "../middleware/rbac.js";
export const appointmentGroupsRouter = new Hono<AppEnv>(); export const appointmentGroupsRouter = new Hono<AppEnv>();
@@ -18,7 +18,7 @@ import {
reminderLogs, reminderLogs,
services, services,
staff, staff,
} from "@groombook/db"; } from "../db/index.js";
import { buildConfirmationEmail, sendEmail } from "../services/email.js"; import { buildConfirmationEmail, sendEmail } from "../services/email.js";
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js"; import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
import type { AppEnv } from "../middleware/rbac.js"; import type { AppEnv } from "../middleware/rbac.js";
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3"; 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 { requireSuperUser } from "../middleware/rbac.js";
import { reinitAuth } from "../lib/auth.js"; import { reinitAuth } from "../lib/auth.js";
@@ -14,7 +14,7 @@ import {
appointments, appointments,
clients, clients,
pets, pets,
} from "@groombook/db"; } from "../db/index.js";
import { import {
generateAvailableSlots, generateAvailableSlots,
BUSINESS_START_HOUR, BUSINESS_START_HOUR,
@@ -112,8 +112,6 @@ const bookingSchema = z.object({
petName: z.string().min(1).max(200), petName: z.string().min(1).max(200),
petSpecies: z.string().min(1).max(100), petSpecies: z.string().min(1).max(100),
petBreed: z.string().max(100).optional(), petBreed: z.string().max(100).optional(),
petSizeCategory: z.string().max(50).optional(),
petCoatType: z.string().max(50).optional(),
notes: z.string().max(2000).optional(), notes: z.string().max(2000).optional(),
}); });
@@ -193,8 +191,6 @@ bookRouter.post(
name: body.petName, name: body.petName,
species: body.petSpecies, species: body.petSpecies,
breed: body.petBreed ?? null, breed: body.petBreed ?? null,
coatType: (body.petCoatType ?? null) as "smooth" | "double" | "wire" | "curly" | "long" | "hairless" | null,
petSizeCategory: (body.petSizeCategory ?? null) as "small" | "medium" | "large" | "xlarge" | null,
}) })
.returning(); .returning();
const pet = petInserted[0]; const pet = petInserted[0];
@@ -10,7 +10,7 @@ import {
pets, pets,
services, services,
staff, staff,
} from "@groombook/db"; } from "../db/index.js";
export const calendarRouter = new Hono(); export const calendarRouter = new Hono();
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3"; 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"; import type { AppEnv } from "../middleware/rbac.js";
export const clientsRouter = new Hono<AppEnv>(); export const clientsRouter = new Hono<AppEnv>();
@@ -1,5 +1,5 @@
import { Hono } from "hono"; 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(); const devRouter = new Hono();
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3"; 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"; import type { AppEnv } from "../middleware/rbac.js";
export const groomingLogsRouter = new Hono<AppEnv>(); export const groomingLogsRouter = new Hono<AppEnv>();
@@ -9,7 +9,7 @@ import {
impersonationAuditLogs, impersonationAuditLogs,
clients, clients,
desc, desc,
} from "@groombook/db"; } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js"; import type { AppEnv } from "../middleware/rbac.js";
export const impersonationRouter = new Hono<AppEnv>(); export const impersonationRouter = new Hono<AppEnv>();
@@ -13,7 +13,7 @@ import {
services, services,
clients, clients,
sql, sql,
} from "@groombook/db"; } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js"; import type { AppEnv } from "../middleware/rbac.js";
export const invoicesRouter = new Hono<AppEnv>(); export const invoicesRouter = new Hono<AppEnv>();
@@ -102,7 +102,6 @@ invoicesRouter.get(
paidAt: invoices.paidAt, paidAt: invoices.paidAt,
notes: invoices.notes, notes: invoices.notes,
stripePaymentIntentId: invoices.stripePaymentIntentId, stripePaymentIntentId: invoices.stripePaymentIntentId,
stripeRefundId: invoices.stripeRefundId,
createdAt: invoices.createdAt, createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt, updatedAt: invoices.updatedAt,
}) })
@@ -130,17 +129,7 @@ invoicesRouter.get("/:id", async (c) => {
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)), db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
]); ]);
let cardLast4: string | null = null; return c.json({ ...invoice, lineItems, tipSplits });
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 });
}); });
// Save tip splits for an invoice (replaces existing splits) // Save tip splits for an invoice (replaces existing splits)
@@ -460,6 +449,9 @@ invoicesRouter.post(
if (invoice.status !== "paid") { if (invoice.status !== "paid") {
return c.json({ error: "Refund only allowed on paid invoices" }, 422); 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) => { return await db.transaction(async (tx) => {
if (body.idempotencyKey) { if (body.idempotencyKey) {
@@ -472,25 +464,17 @@ invoicesRouter.post(
} }
} }
let refundId: string; const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
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()}`;
}
await tx.insert(refunds).values({ await tx.insert(refunds).values({
invoiceId: id, invoiceId: id,
stripeRefundId: refundId, stripeRefundId: result.refundId,
idempotencyKey: body.idempotencyKey ?? null, idempotencyKey: body.idempotencyKey ?? null,
amountCents: body.amountCents ?? null, amountCents: body.amountCents ?? null,
}); });
return c.json({ refundId }); return c.json({ refundId: result.refundId });
}); });
} }
); );
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3"; 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 type { AppEnv } from "../middleware/rbac.js";
import { import {
getPresignedUploadUrl, getPresignedUploadUrl,
@@ -24,8 +24,6 @@ const createPetSchema = z.object({
shampooPreference: z.string().max(500).optional(), shampooPreference: z.string().max(500).optional(),
specialCareNotes: z.string().max(2000).optional(), specialCareNotes: z.string().max(2000).optional(),
customFields: z.record(z.string(), z.string()).optional(), customFields: z.record(z.string(), z.string()).optional(),
sizeCategory: z.enum(["small", "medium", "large", "xlarge"]).optional(),
coatType: z.enum(["smooth", "double", "wire", "curly", "long", "hairless"]).optional(),
}); });
const updatePetSchema = createPetSchema.partial().omit({ clientId: true }); const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
@@ -1,8 +1,8 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3"; import { z } from "zod/v3";
import { eq, inArray } from "@groombook/db"; import { eq, inArray } from "../db/index.js";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "../db/index.js";
import { validatePortalSession } from "../middleware/portalSession.js"; import { validatePortalSession } from "../middleware/portalSession.js";
import { portalAudit } from "../middleware/portalAudit.js"; import { portalAudit } from "../middleware/portalAudit.js";
import type { PortalEnv } from "../middleware/portalSession.js"; import type { PortalEnv } from "../middleware/portalSession.js";
@@ -149,89 +149,9 @@ portalRouter.get("/pets", async (c) => {
const clientId = c.get("portalClientId"); const clientId = c.get("portalClientId");
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
return c.json(clientPets.map(p => ({ 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 })));
id: p.id,
name: p.name,
species: p.species,
breed: p.breed,
weightKg: p.weightKg ? Number(p.weightKg) : null,
dateOfBirth: p.dateOfBirth ? new Date(p.dateOfBirth).toISOString() : null,
healthAlerts: p.healthAlerts,
groomingNotes: p.groomingNotes,
cutStyle: p.cutStyle,
shampooPreference: p.shampooPreference,
specialCareNotes: p.specialCareNotes,
coatType: p.coatType,
petSizeCategory: p.petSizeCategory,
photoKey: p.photoKey,
photoUploadedAt: p.photoUploadedAt ? new Date(p.photoUploadedAt).toISOString() : null,
customFields: p.customFields,
})));
}); });
const portalPetUpdateSchema = z.object({
name: z.string().min(1).max(200).optional(),
species: z.string().min(1).max(100).optional(),
breed: z.string().max(200).optional().nullable(),
weightKg: z.number().positive().optional().nullable(),
dateOfBirth: z.string().datetime().optional().nullable(),
healthAlerts: z.string().max(2000).optional().nullable(),
groomingNotes: z.string().max(2000).optional().nullable(),
cutStyle: z.string().max(500).optional().nullable(),
shampooPreference: z.string().max(500).optional().nullable(),
specialCareNotes: z.string().max(2000).optional().nullable(),
coatType: z.enum(["smooth", "double", "wire", "curly", "long", "hairless"]).optional().nullable(),
petSizeCategory: z.enum(["small", "medium", "large", "xlarge"]).optional().nullable(),
customFields: z.record(z.string(), z.string()).optional(),
});
portalRouter.patch("/pets/:id",
zValidator("json", portalPetUpdateSchema),
async (c) => {
const db = getDb();
const petId = c.req.param("id");
const clientId = c.get("portalClientId");
const body = c.req.valid("json");
const [existing] = await db.select().from(pets).where(eq(pets.id, petId)).limit(1);
if (!existing) return c.json({ error: "Not found" }, 404);
if (existing.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
const { weightKg, dateOfBirth, ...rest } = body;
const [updated] = await db
.update(pets)
.set({
...rest,
weightKg: weightKg != null ? String(weightKg) : undefined,
dateOfBirth: dateOfBirth != null ? new Date(dateOfBirth) : undefined,
updatedAt: new Date(),
})
.where(eq(pets.id, petId))
.returning();
if (!updated) return c.json({ error: "Not found" }, 404);
return c.json({
id: updated.id,
name: updated.name,
species: updated.species,
breed: updated.breed,
weightKg: updated.weightKg ? Number(updated.weightKg) : null,
dateOfBirth: updated.dateOfBirth ? new Date(updated.dateOfBirth).toISOString() : null,
healthAlerts: updated.healthAlerts,
groomingNotes: updated.groomingNotes,
cutStyle: updated.cutStyle,
shampooPreference: updated.shampooPreference,
specialCareNotes: updated.specialCareNotes,
coatType: updated.coatType,
petSizeCategory: updated.petSizeCategory,
photoKey: updated.photoKey,
photoUploadedAt: updated.photoUploadedAt ? new Date(updated.photoUploadedAt).toISOString() : null,
customFields: updated.customFields,
});
}
);
portalRouter.get("/invoices", async (c) => { portalRouter.get("/invoices", async (c) => {
const db = getDb(); const db = getDb();
const clientId = c.get("portalClientId"); const clientId = c.get("portalClientId");
@@ -12,7 +12,7 @@ import {
invoiceTipSplits, invoiceTipSplits,
services, services,
staff, staff,
} from "@groombook/db"; } from "../db/index.js";
export const reportsRouter = new Hono(); export const reportsRouter = new Hono();
@@ -1,5 +1,5 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { and, eq, getDb, clients, ilike, or, pets } from "@groombook/db"; import { and, eq, getDb, clients, ilike, or, pets } from "../db/index.js";
export const searchRouter = new Hono(); export const searchRouter = new Hono();
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3"; import { z } from "zod/v3";
import { eq, getDb, services } from "@groombook/db"; import { eq, getDb, services } from "../db/index.js";
export const servicesRouter = new Hono(); export const servicesRouter = new Hono();
@@ -10,7 +10,6 @@ const createServiceSchema = z.object({
description: z.string().max(2000).optional(), description: z.string().max(2000).optional(),
basePriceCents: z.number().int().positive(), basePriceCents: z.number().int().positive(),
durationMinutes: z.number().int().positive().max(480), durationMinutes: z.number().int().positive().max(480),
defaultBufferMinutes: z.number().int().min(0).optional(),
active: z.boolean().default(true), active: z.boolean().default(true),
}); });
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3"; import { z } from "zod/v3";
import { eq, getDb, businessSettings } from "@groombook/db"; import { eq, getDb, businessSettings } from "../db/index.js";
import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js"; import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js";
import { requireSuperUser } from "../middleware/rbac.js"; import { requireSuperUser } from "../middleware/rbac.js";

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