Compare commits

..

15 Commits

Author SHA1 Message Date
Chris Farhood 3604049222 Add .mcp.json
CI / Test (push) Successful in 1m20s
CI / Lint & Typecheck (push) Successful in 1m23s
CI / Build (push) Successful in 31s
CI / E2E Tests (push) Failing after 3m59s
CI / Web E2E (Dev) (push) Has been skipped
CI / Build & Push Docker Images (push) Has been skipped
CI / Deploy PR to groombook-dev (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
2026-05-24 18:14:26 +00:00
Chris Farhood 8f2deaa6d7 Delete opencode.json
CI / Test (push) Successful in 58s
CI / Lint & Typecheck (push) Successful in 55s
CI / Build (push) Successful in 25s
CI / E2E Tests (push) Successful in 2m14s
CI / Build & Push Docker Images (push) Successful in 1m27s
CI / Deploy PR to groombook-dev (push) Has been skipped
CI / Update Infra Image Tags (push) Failing after 0s
CI / Web E2E (Dev) (push) Has been skipped
2026-05-24 18:14:06 +00:00
Scrubs McBarkley 9e8723de57 Merge pull request 'fix(ci): use REGISTRY_TOKEN + E2E DinD networking on main' (#424) from fix/ci-registry-auth-main into main
CI / Deploy PR to groombook-dev (push) Has been skipped
CI / Web E2E (Dev) (push) Has been skipped
CI / Lint & Typecheck (push) Successful in 22s
CI / Test (push) Successful in 24s
CI / Build (push) Successful in 21s
CI / Build & Push Docker Images (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
CI / E2E Tests (push) Failing after 3m32s
fix(ci): use REGISTRY_TOKEN + E2E DinD networking on main

Merging per GRO-1355 org-wide PR backlog cleanup.
2026-05-20 12:10:22 +00:00
Scrubs McBarkley e037748c85 Merge pull request 'docs: archive monorepo and link to successor repos (GRO-1081)' (#411) from archived-readme into main
CI / Lint & Typecheck (push) Successful in 21s
CI / Test (push) Successful in 24s
CI / Deploy PR to groombook-dev (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
CI / Build (push) Successful in 23s
CI / Web E2E (Dev) (push) Has been skipped
CI / E2E Tests (push) Failing after 3m34s
CI / Build & Push Docker Images (push) Has been skipped
docs(GRO-1081): archive monorepo, link to successor repos

Merging per GRO-1355 org-wide PR backlog cleanup.
2026-05-20 12:10:19 +00:00
Chris Farhood 93716afd1a fix(ci): use REGISTRY_TOKEN + E2E DinD networking on main
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Test (pull_request) Successful in 24s
CI / Build (pull_request) Successful in 23s
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Web E2E (Dev) (pull_request) Has been skipped
CI / E2E Tests (pull_request) Failing after 3m27s
CI / Deploy PR to groombook-dev (pull_request) Has been skipped
- Change Docker login password from gitea.token to secrets.REGISTRY_TOKEN
- Add PLAYWRIGHT_BASE_URL env var for E2E DinD networking
- GRO-1345

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 10:56:40 +00:00
The Dogfather 503c3dbdbb Merge pull request 'chore: migrate .github/workflows to .gitea/workflows' (#422) from gitea/migrate-workflows into main
CI / Lint & Typecheck (push) Successful in 21s
CI / Test (push) Successful in 23s
CI / E2E Tests (push) Failing after 4m18s
CI / Deploy PR to groombook-dev (push) Has been skipped
CI / Web E2E (Dev) (push) Has been skipped
CI / Build (push) Successful in 22s
CI / Build & Push Docker Images (push) Has been skipped
CI / Update Infra Image Tags (push) Has been skipped
chore: migrate .github/workflows to .gitea/workflows

Migrate all 4 workflow files from GitHub Actions to Gitea Actions:
- ci.yml, promote-prod.yml, promote-to-uat.yml (renamed)
- helm-release.yml (recreated for Gitea Pages)
- Registry: ghcr.io → git.farh.net
- Auth: github-app-token → gitea.token
- API calls: gh CLI / actions/github-script → Gitea REST API

Part of GRO-1315.
2026-05-20 01:34:07 +00:00
Scrubs McBarkley b3517bf746 chore: remove legacy .github/workflows
CI / Test (pull_request) Successful in 24s
CI / Lint & Typecheck (pull_request) Successful in 23s
CI / Build (pull_request) Successful in 23s
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
CI / E2E Tests (pull_request) Failing after 4m22s
CI / Deploy PR to groombook-dev (pull_request) Has been skipped
CI / Web E2E (Dev) (pull_request) Has been skipped
2026-05-20 01:30:11 +00:00
Scrubs McBarkley 604e79bab4 chore: remove legacy .github/workflows 2026-05-20 01:30:09 +00:00
Scrubs McBarkley 29015cffec chore: remove legacy .github/workflows 2026-05-20 01:30:08 +00:00
Scrubs McBarkley c67f731f69 chore: remove legacy .github/workflows 2026-05-20 01:30:06 +00:00
Scrubs McBarkley db3bcf8094 chore: migrate workflows to .gitea/ 2026-05-20 01:29:57 +00:00
Scrubs McBarkley 7836511baa chore: migrate workflows to .gitea/ 2026-05-20 01:29:54 +00:00
Scrubs McBarkley b69650af15 chore: migrate workflows to .gitea/ 2026-05-20 01:29:52 +00:00
Scrubs McBarkley b0d1a4def4 chore: migrate workflows to .gitea/ 2026-05-20 01:29:50 +00:00
Chris Farhood d407b895be docs: archive monorepo and link to successor repos
GRO-1081: Execute groombook/app monorepo decommission

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 17:39:49 +00:00
69 changed files with 7191 additions and 7296 deletions
-4
View File
@@ -11,10 +11,6 @@ AUTH_DISABLED=false
OIDC_ISSUER=https://authentik.example.com
OIDC_AUDIENCE=groombook
# ── Webhooks ─────────────────────────────────────────────────────────────────
# Telnyx webhook secret for validating inbound message webhooks.
TELNYX_WEBHOOK_SECRET=your-telnyx-webhook-secret-here
# ── 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
@@ -6,6 +6,11 @@ on:
pull_request:
branches: [main, dev]
workflow_dispatch:
inputs:
ref:
description: "Branch or ref to run CI against"
required: false
default: "main"
jobs:
lint-typecheck:
@@ -84,6 +89,14 @@ jobs:
env:
PLAYWRIGHT_BASE_URL: http://host.docker.internal:8080
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: apps/e2e/playwright-report/
retention-days: 7
- name: Stop Docker Compose stack
if: always()
run: docker compose down
@@ -108,8 +121,6 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build all packages
env:
VITE_API_URL: ""
run: pnpm build
docker:
@@ -124,9 +135,6 @@ jobs:
- name: Generate image tag
id: version
run: |
# Always include short SHA so each build is immutable and cache-from can never
# cross-contaminate between commits. For PRs the format is pr-N-sha7; for main
# it is YYYY.MM.DD-sha7.
if [ "${{ github.event_name }}" = "pull_request" ]; then
TAG="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::7}"
else
@@ -211,7 +219,7 @@ jobs:
deploy-dev:
name: Deploy PR to groombook-dev
runs-on: runners-groombook
runs-on: ubuntu-latest
needs: [docker]
if: github.event_name == 'pull_request'
steps:
@@ -266,22 +274,14 @@ jobs:
- name: Comment on PR
env:
PR_NUM: ${{ github.event.pull_request.number }}
GITEA_TOKEN: ${{ gitea.token }}
run: |
PR_NUM="$PR_NUM"
BODY=$(cat <<'EOFBODY'
## Deployed to groombook-dev
**Images:** `pr-'"$PR_NUM"'`
**URL:** https://dev.groombook.farh.net
Ready for UAT validation.
EOFBODY
)
curl -s -X POST "https://git.farh.net/api/v1/repos/groombook/app/issues/${PR_NUM}/comments" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
TAG="pr-${PR_NUM}"
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"body\": $(echo "$BODY" | jq -Rs .)}"
"https://git.farh.net/api/v1/repos/groombook/app/issues/$PR_NUM/comments" \
-d "{\"body\": \"## Deployed to groombook-dev\n\n**Images:** \`${TAG}\`\n**URL:** https://dev.groombook.farh.net\n\nReady for UAT validation.\"}"
web-e2e:
name: Web E2E (Dev)
@@ -310,6 +310,14 @@ jobs:
run: pnpm --filter @groombook/web test:e2e
timeout-minutes: 10
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-web-e2e-report
path: apps/web/playwright-report/
retention-days: 7
cd:
name: Update Infra Image Tags
runs-on: ubuntu-latest
@@ -317,8 +325,10 @@ jobs:
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
steps:
- name: Clone groombook/infra
env:
GITEA_TOKEN: ${{ gitea.token }}
run: |
git clone https://oauth2:${{ secrets.REGISTRY_TOKEN }}@git.farh.net/groombook/infra.git /tmp/infra
git clone https://oauth2:$GITEA_TOKEN@git.farh.net/groombook/infra.git /tmp/infra
- name: Install yq
run: |
@@ -335,23 +345,22 @@ jobs:
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"
DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
MIGRATE_JOB="apps/base/migrate-job.yaml"
MIGRATE_JOB="apps/groombook/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"
SEED_JOB="apps/groombook/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"
@@ -363,42 +372,40 @@ jobs:
- name: Create PR on groombook/infra
env:
TAG: ${{ needs.docker.outputs.tag }}
GITEA_TOKEN: ${{ gitea.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 "groombook-engineer@farh.net"
git config user.email "groombook-engineer[bot]@git.farh.net"
git checkout -b "chore/update-image-tags-${TAG}"
git add apps/overlays/dev/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/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=$(curl -s "https://git.farh.net/api/v1/repos/groombook/infra/pulls?state=open&head=groombook:chore/update-image-tags-${TAG}" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" | jq -r '.[0].number')
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
curl -s -X PUT "https://git.farh.net/api/v1/repos/groombook/infra/pulls/${EXISTING_PR}/merge" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
EXISTING_PR=$(curl -s \
-H "Authorization: token $GITEA_TOKEN" \
"https://git.farh.net/api/v1/repos/groombook/infra/pulls?state=open&limit=50" \
| jq -r ".[] | select(.head.label == \"chore/update-image-tags-${TAG}\") | .number" | head -1)
if [ -n "$EXISTING_PR" ]; then
echo "PR #$EXISTING_PR already exists, merging"
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"do": "merge"}'
"https://git.farh.net/api/v1/repos/groombook/infra/pulls/$EXISTING_PR/merge" \
-d '{"Do":"merge"}'
else
PR_RESPONSE=$(curl -s -X POST "https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
PR_NUM=$(curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"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\"
}")
PR_NUM=$(echo "$PR_RESPONSE" | jq -r '.number')
echo "Created PR #$PR_NUM"
curl -s -X PUT "https://git.farh.net/api/v1/repos/groombook/infra/pulls/${PR_NUM}/merge" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
"https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
-d "{\"head\":\"chore/update-image-tags-${TAG}\",\"base\":\"main\",\"title\":\"chore: deploy ${TAG} to dev\",\"body\":\"[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge\"}" \
| jq '.number')
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"do": "merge"}'
"https://git.farh.net/api/v1/repos/groombook/infra/pulls/$PR_NUM/merge" \
-d '{"Do":"merge"}'
fi
+54
View File
@@ -0,0 +1,54 @@
name: Release Helm Chart
on:
push:
branches: [main]
paths:
- 'charts/**'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout groombook
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Checkout groombook.dev (Helm chart host)
uses: actions/checkout@v4
with:
repository: groombook/groombook.dev
path: gitea-pages
token: ${{ gitea.token }}
- name: Install Helm
uses: azure/setup-helm@v4
- name: Update Helm dependencies
run: helm dependency update charts/groombook
- name: Package chart
run: |
mkdir -p gitea-pages/charts
helm package charts/groombook -d gitea-pages/charts
- name: Update repo index
run: |
# TODO: update URL once Gitea Pages hosting is confirmed
CHART_URL="${HELM_CHART_URL:-https://groombook.farh.net/charts}"
if [ -f gitea-pages/charts/index.yaml ]; then
helm repo index gitea-pages/charts --merge gitea-pages/charts/index.yaml --url "$CHART_URL"
else
helm repo index gitea-pages/charts --url "$CHART_URL"
fi
- name: Push to groombook.dev
run: |
cd gitea-pages
git config user.name "groombook-engineer[bot]"
git config user.email "groombook-engineer[bot]@git.farh.net"
git add charts/
git diff --staged --quiet && echo 'No chart changes' && exit 0
git commit -m "Update Helm chart repository"
git push
+108
View File
@@ -0,0 +1,108 @@
name: Promote to Production
on:
workflow_dispatch:
inputs:
tag:
description: "Image tag to promote (e.g. 2026.03.28-f1b85bf)"
required: true
type: string
jobs:
promote:
name: Promote to Production
runs-on: ubuntu-latest
steps:
- name: Validate tag format
run: |
TAG="${{ inputs.tag }}"
if ! echo "$TAG" | grep -qE '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[a-f0-9]{7}$'; then
echo "::error::Invalid tag format: '$TAG'. Expected format: YYYY.MM.DD-sha7 (e.g. 2026.03.28-f1b85bf)"
exit 1
fi
echo "Tag format valid: $TAG"
- name: Verify image exists in Gitea Container Registry
env:
GITEA_TOKEN: ${{ gitea.token }}
run: |
TAG="${{ inputs.tag }}"
if ! curl -sf \
-H "Authorization: token $GITEA_TOKEN" \
"https://git.farh.net/api/v1/packages/groombook?type=container&limit=50" \
| jq -e --arg t "$TAG" '[.[] | select(.name == "api" and .version == $t)] | length > 0' > /dev/null 2>&1; then
echo "::warning::Could not verify git.farh.net/groombook/api:$TAG via package API — verify manually if needed."
else
echo "Image verified: git.farh.net/groombook/api:$TAG exists"
fi
- name: Clone groombook/infra
env:
GITEA_TOKEN: ${{ gitea.token }}
run: |
git clone https://oauth2:$GITEA_TOKEN@git.farh.net/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 prod overlay image tags and base Job names
env:
TAG: ${{ inputs.tag }}
run: |
cd /tmp/infra
PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml"
SHORT_SHA="${TAG##*-}"
export SHORT_SHA
export TAG
yq -i '(.images[] | select(.name == "git.farh.net/groombook/api")).newTag = env(TAG)' "$PROD_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/web")).newTag = env(TAG)' "$PROD_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/migrate")).newTag = env(TAG)' "$PROD_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
MIGRATE_JOB="apps/groombook/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"
fi
SEED_JOB="apps/groombook/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"
fi
git -C /tmp/infra diff --stat
- name: Create PR on groombook/infra
env:
TAG: ${{ inputs.tag }}
GITEA_TOKEN: ${{ gitea.token }}
run: |
cd /tmp/infra
git config user.name "groombook-engineer[bot]"
git config user.email "groombook-engineer[bot]@git.farh.net"
git checkout -b "release/promote-prod-${TAG}"
git add apps/groombook/overlays/prod/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git commit -m "release: promote ${TAG} to production"
git push -u origin "release/promote-prod-${TAG}"
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
-d "{\"head\":\"release/promote-prod-${TAG}\",\"base\":\"main\",\"title\":\"release: promote ${TAG} to production\",\"body\":\"Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood\"}"
- name: Notify on failure
if: failure()
env:
GITEA_TOKEN: ${{ gitea.token }}
RUN_ID: ${{ github.run_id }}
run: |
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"https://git.farh.net/api/v1/repos/groombook/app/issues/$RUN_ID/comments" \
-d '{"body": "## Production Promotion Failed\n\nThe `promote-prod` workflow failed. Check the workflow run logs for details."}'
+98
View File
@@ -0,0 +1,98 @@
name: Promote to UAT
on:
workflow_dispatch:
inputs:
image_tag:
description: "Image tag to deploy to UAT (e.g. 2026.04.03-abc1234)"
required: true
type: string
jobs:
promote-to-uat:
name: Promote to groombook-uat
runs-on: ubuntu-latest
steps:
- name: Clone groombook/infra
env:
GITEA_TOKEN: ${{ gitea.token }}
run: |
git clone https://oauth2:$GITEA_TOKEN@git.farh.net/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 UAT overlay image tags and base Job names
env:
TAG: ${{ inputs.image_tag }}
run: |
echo "Updating UAT overlay image tags to: $TAG"
cd /tmp/infra
UAT_KUST="apps/groombook/overlays/uat/kustomization.yaml"
if [ ! -f "$UAT_KUST" ]; then
echo "ERROR: UAT overlay not found at $UAT_KUST. Ensure GRO-427 has been completed."
exit 1
fi
SHORT_SHA="${TAG##*-}"
export SHORT_SHA
export TAG
yq -i '(.images[] | select(.name == "git.farh.net/groombook/api")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/web")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/migrate")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
MIGRATE_JOB="apps/groombook/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"
fi
SEED_JOB="apps/groombook/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"
fi
git -C /tmp/infra diff --stat
- name: Create PR on groombook/infra
env:
TAG: ${{ inputs.image_tag }}
GITEA_TOKEN: ${{ gitea.token }}
run: |
cd /tmp/infra
git config user.name "groombook-engineer[bot]"
git config user.email "groombook-engineer[bot]@git.farh.net"
git checkout -b "chore/update-uat-image-tags-${TAG}"
git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git commit -m "chore: promote ${TAG} to UAT"
git push -u origin "chore/update-uat-image-tags-${TAG}"
PR_NUM=$(curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
-d "{\"head\":\"chore/update-uat-image-tags-${TAG}\",\"base\":\"main\",\"title\":\"chore: promote ${TAG} to UAT\",\"body\":\"[GRO-429](/GRO/issues/GRO-429) — UAT promotion triggered by CTO\"}" \
| jq '.number')
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"https://git.farh.net/api/v1/repos/groombook/infra/pulls/$PR_NUM/merge" \
-d '{"Do":"merge"}'
- name: Notify on failure
if: failure()
env:
GITEA_TOKEN: ${{ gitea.token }}
RUN_ID: ${{ github.run_id }}
run: |
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"https://git.farh.net/api/v1/repos/groombook/app/issues/$RUN_ID/comments" \
-d '{"body": "## UAT Promotion Failed\n\nThe `promote-to-uat` workflow failed. Check the workflow run logs for details.\n\nCommon issues:\n- UAT overlay not found (ensure GRO-427 is complete)\n- GITEA_TOKEN permissions"}'
-54
View File
@@ -1,54 +0,0 @@
name: Release Helm Chart
on:
push:
branches: [main]
paths:
- 'charts/**'
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout groombook
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Checkout groombook.github.io
uses: actions/checkout@v4
with:
repository: groombook/groombook.github.io
path: gh-pages
token: ${{ secrets.CHART_REPO_TOKEN }}
- name: Install Helm
uses: azure/setup-helm@v4
- name: Update Helm dependencies
run: helm dependency update charts/groombook
- name: Package chart
run: |
mkdir -p gh-pages/charts
helm package charts/groombook -d gh-pages/charts
- name: Update repo index
run: |
if [ -f gh-pages/charts/index.yaml ]; then
helm repo index gh-pages/charts --merge gh-pages/charts/index.yaml --url https://groombook.github.io/charts
else
helm repo index gh-pages/charts --url https://groombook.github.io/charts
fi
- name: Push to groombook.github.io
run: |
cd gh-pages
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add charts/
git diff --staged --quiet && echo 'No chart changes' && exit 0
git commit -m "Update Helm chart repository"
git push
-117
View File
@@ -1,117 +0,0 @@
name: Promote to Production
on:
workflow_dispatch:
inputs:
tag:
description: "Image tag to promote (e.g. 2026.03.28-f1b85bf)"
required: true
type: string
jobs:
promote:
name: Promote to Production
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
steps:
- name: Validate tag format
run: |
TAG="${{ inputs.tag }}"
if ! echo "$TAG" | grep -qE '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[a-f0-9]{7}$'; then
echo "::error::Invalid tag format: '$TAG'. Expected format: YYYY.MM.DD-sha7 (e.g. 2026.03.28-f1b85bf)"
exit 1
fi
echo "Tag format valid: $TAG"
- name: Verify image exists in GHCR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ inputs.tag }}"
# Check that the API image exists — if API was pushed, web/migrate were too
if ! gh api "/orgs/groombook/packages/container/api/versions" --jq ".[].metadata.container.tags[]" 2>/dev/null | grep -qF "$TAG"; then
echo "::error::Image ghcr.io/groombook/api:$TAG not found in GHCR. Verify the tag was built and pushed."
exit 1
fi
echo "Image verified: ghcr.io/groombook/api:$TAG exists"
- 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 prod overlay image tags and base Job names
env:
TAG: ${{ inputs.tag }}
run: |
cd /tmp/infra
PROD_KUST="apps/overlays/prod/kustomization.yaml"
SHORT_SHA="${TAG##*-}"
export SHORT_SHA
export TAG
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$PROD_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$PROD_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$PROD_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
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"
fi
# Update seed Job name to include short SHA (immutable template fix)
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"
fi
git -C /tmp/infra diff --stat
- name: Create PR on groombook/infra
env:
TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
run: |
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 "release/promote-prod-${TAG}"
git add apps/overlays/prod/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
git commit -m "release: promote ${TAG} to production"
git push -u origin "release/promote-prod-${TAG}"
gh pr create \
--repo groombook/infra \
--base main \
--head "release/promote-prod-${TAG}" \
--title "release: promote ${TAG} to production" \
--body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood"
- name: Notify on failure
if: failure()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: '## Production Promotion Failed\n\nThe `promote-prod` workflow failed. Check the workflow run logs for details.'
});
-108
View File
@@ -1,108 +0,0 @@
name: Promote to UAT
on:
workflow_dispatch:
inputs:
image_tag:
description: "Image tag to deploy to UAT (e.g. 2026.04.03-abc1234)"
required: true
type: string
jobs:
promote-to-uat:
name: Promote to groombook-uat
runs-on: ubuntu-latest
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 UAT overlay image tags and base Job names
env:
TAG: ${{ inputs.image_tag }}
run: |
echo "Updating UAT overlay image tags to: $TAG"
cd /tmp/infra
UAT_KUST="apps/overlays/uat/kustomization.yaml"
if [ ! -f "$UAT_KUST" ]; then
echo "ERROR: UAT overlay not found at $UAT_KUST. Ensure GRO-427 has been completed."
exit 1
fi
SHORT_SHA="${TAG##*-}"
export SHORT_SHA
export TAG
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
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"
fi
# Update seed Job name to include short SHA (immutable template fix)
# NOTE: Do NOT update the image tag here — let the Kustomize images transformer
# in the UAT overlay handle it via newTag. This avoids the immutable template issue.
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"
fi
git -C /tmp/infra diff --stat
- name: Create PR on groombook/infra
env:
TAG: ${{ inputs.image_tag }}
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
run: |
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-uat-image-tags-${TAG}"
git add apps/overlays/uat/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
git commit -m "chore: promote ${TAG} to UAT"
git push -u origin "chore/update-uat-image-tags-${TAG}"
# Create PR and merge immediately (no required checks on groombook/infra)
PR_URL=$(gh pr create \
--repo groombook/infra \
--base main \
--head "chore/update-uat-image-tags-${TAG}" \
--title "chore: promote ${TAG} to UAT" \
--body "[GRO-429](/GRO/issues/GRO-429) — UAT promotion triggered by CTO")
gh pr merge "$PR_URL" --merge
- name: Notify on failure
if: failure()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: '## UAT Promotion Failed\n\nThe `promote-to-uat` workflow failed. Check the workflow run logs for details.\n\nCommon issues:\n- UAT overlay not found (ensure GRO-427 is complete)\n- Infra repo access token expired'
});
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"gitea": {
"type": "http",
"url": "https://git-mcp.farh.net/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_TOKEN}"
}
}
}
}
+25 -200
View File
@@ -1,218 +1,43 @@
# GroomBook
# GroomBook Monorepo — Archived
> **The open-source scheduling and client management platform built specifically for independent pet groomers** — giving you the tools of enterprise software without the enterprise price tag or vendor lock-in.
> **This repository has been archived and replaced by standalone repositories.**
**Built for groomers, not corporations.**
## Successor Repositories
---
## Key Features
**Stop chasing confirmations**
- **Customer portal** — Clients confirm or cancel appointments on their own. Reduce no-shows with an automated waitlist.
**Your calendar, your way**
- **iCal calendar feed** — Push GroomBook appointments directly into Google Calendar or Apple Calendar. No app switching.
**Know every pet at a glance**
- **Client & pet records** — Detailed profiles with grooming history, preferences, and breed-specific notes. Full appointment notes for context on every regular.
- **Quick-find search** — Find clients and pets instantly without digging through spreadsheets.
**Staff access without stress**
- **Role-based access control (RBAC)** — Front desk sees bookings; only you see financials. Right access for every role.
**Everything else**
- **Appointment scheduling** — Calendar management for single or multiple groomers
- **Service management** — Pricing, duration, and service catalog
- **POS & invoicing** — Payments, tips, and receipt generation
- **Automated reminders** — SMS and email notifications
- **Reporting dashboard** — Revenue, utilization, and trend analytics
- **Staff impersonation** — Managers can view the customer portal as any client, with full audit logging and session controls
- **PWA** — Installable on mobile devices, works offline
---
## 🚀 Try the Demo
[**Live Demo**](https://demo.groombook.app) — explore GroomBook without installing anything.
---
## Quick Start
### Docker Compose (recommended for indie groomers)
Run GroomBook on your own hardware in minutes. Everything you need is in the box — no subscription, no vendor lock-in.
```bash
git clone https://github.com/groombook/groombook.git
cd groombook
# Start everything (Postgres + database migrations + API + web UI)
docker compose up --build
```
- **Web UI**: http://localhost:8080
- **API**: http://localhost:3000
The default `docker-compose.yml` sets `AUTH_DISABLED=true` so you can explore the app without configuring an OIDC provider. **Important:** Disable this in any internet-facing deployment.
---
## Tech Stack
| Layer | Technology |
| Repository | Description |
|---|---|
| Backend | [Hono](https://hono.dev/) (TypeScript, Node.js) |
| Frontend | React 19 + Vite + [vite-plugin-pwa](https://vite-pwa-org.netlify.app/) |
| Database | PostgreSQL via [CNPG](https://cloudnative-pg.io/) + [Drizzle ORM](https://orm.drizzle.team/) |
| Auth | OIDC via [Authentik](https://goauthentik.io/) |
| Infra | Kubernetes (namespace: `groombook`), Flux GitOps |
| CI | GitHub Actions (self-hosted `groombook-runners`) |
| [groombook/api](https://github.com/groombook/api) | Hono REST API (TypeScript, Node.js) |
| [groombook/web](https://github.com/groombook/web) | React PWA frontend |
| [groombook/charts](https://github.com/groombook/charts) | Helm charts for Kubernetes deployment |
## Repository Structure
## What Changed
```
groombook/
├── apps/
│ ├── api/ # Hono REST API
│ └── web/ # React PWA
├── packages/
│ ├── db/ # Drizzle schema + migrations
│ └── types/ # Shared TypeScript types
├── .github/
│ └── workflows/ # CI/CD pipelines
└── docker-compose.yml
```
- **Monorepo split complete** — The former `apps/api`, `apps/web`, and `packages/*` are now standalone repos
- **`@groombook/types`** — Inlined directly into `groombook/api` and `groombook/web`
- **E2E testing** — Now via Playwright MCP, no standalone repo needed
- **CI/CD** — Each repo has its own pipeline; see individual repos for status
## Getting Started
## Migration Notes
### Prerequisites
- Node.js >= 20
- pnpm >= 9 (`npm install -g pnpm`)
- Docker & Docker Compose (for local Postgres)
### Local Development
If you were cloning `groombook/groombook` for local development:
```bash
# Clone the repo
git clone https://github.com/groombook/groombook.git
cd groombook
# API
git clone https://github.com/groombook/api.git
cd api && pnpm install && pnpm dev
# Install dependencies
pnpm install
# Start local Postgres
docker compose up postgres -d
# Run database migrations
DATABASE_URL=postgres://groombook:groombook@localhost:5432/groombook pnpm db:migrate
# Start API and Web in parallel
pnpm dev
# Web (in a new terminal)
git clone https://github.com/groombook/web.git
cd web && pnpm install && pnpm dev
```
API will be available at http://localhost:3000
Web will be available at http://localhost:5173
For full Docker Compose setup, see each repo's README.
### Environment Variables
## Archive Info
#### API (`apps/api/.env`)
```env
DATABASE_URL=postgres://groombook:groombook@localhost:5432/groombook
OIDC_ISSUER=https://authentik.example.com
OIDC_AUDIENCE=groombook
CORS_ORIGIN=http://localhost:5173
PORT=3000
```
### Running Tests
```bash
# Unit tests (vitest)
pnpm test
# E2E tests (Playwright) — requires the full Docker Compose stack to be running
docker compose up -d --wait
pnpm --filter @groombook/e2e test
# Open the Playwright UI (interactive test runner)
pnpm --filter @groombook/e2e test:ui
# View the last E2E test report
pnpm --filter @groombook/e2e test:report
```
E2E tests target the Docker Compose stack (`http://localhost:8080`). They use API route mocking where needed so happy-path tests are deterministic without requiring seed data.
### Building
```bash
pnpm build
```
## Self-Hosting
### Production Configuration
Copy `.env.example` to `.env` and configure:
```bash
cp .env.example .env
```
Key variables to update for production:
| Variable | Description |
|---|---|
| `DATABASE_URL` | PostgreSQL connection string |
| `AUTH_DISABLED` | Set to `false` in production |
| `OIDC_ISSUER` | Authentik issuer URL |
| `OIDC_AUDIENCE` | OAuth2 audience (default: `groombook`) |
| `CORS_ORIGIN` | Public URL of the web frontend |
To use your `.env` file with Docker Compose:
```bash
docker compose --env-file .env up --build
```
### Kubernetes (production-grade deployments)
See the [groombook/infra](https://github.com/groombook/infra) repository for Kubernetes manifests and Flux configuration.
Groom Book is deployed in the `groombook` Kubernetes namespace using:
- **CNPG** for PostgreSQL
- **Authentik** for OIDC authentication
- **Flux** for GitOps-managed deployments
This repository was archived on 2026-05-14 as part of the monorepo decommission ([GRO-1081]).
The history is preserved but the repo is read-only.
---
## Contributing
GroomBook thrives on contributions from the grooming community. Whether you're a groomer with a feature request, a developer fixing a bug, or someone improving docs — we'd love your help.
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/my-feature`)
3. Commit your changes
4. Open a pull request
All PRs require CI to pass before merge. See [CONTRIBUTING.md](./CONTRIBUTING.md) for details.
---
## Why GroomBook?
- **Open source** — You own your data. No vendor lock-in.
- **Purpose-built** — Features designed for grooming workflows, not generic scheduling.
- **Self-hosted or managed** — Run it yourself for free, or pay for hosted support (coming soon).
- **Community-driven** — Used and built by actual groomers.
---
## License
AGPL-3.0
*For Kubernetes deployments, see [groombook/infra](https://github.com/groombook/infra) (private).*
-278
View File
@@ -1,278 +0,0 @@
# UAT Playbook
## 1. Overview
GroomBook is an open-source, self-hostable pet grooming business management & CRM platform. The monorepo contains the Hono API (`apps/api`), React PWA web app (`apps/web`), E2E tests (`apps/e2e`), and shared packages (`packages/db`, `packages/types`). Tech stack: Hono + React 19 + Vite + PostgreSQL + Drizzle ORM + Authentik OIDC.
## 2. Environments
| Environment | URL | Notes |
|-------------|-----|-------|
| Dev | `https://dev.groombook.dev` | Development environment for active development |
| UAT | `https://uat.groombook.dev` | User Acceptance Testing environment |
| Production | `https://demo.groombook.dev` | Production/demo environment |
**Local Development:** Run `docker compose up --build` at repository root. Web app available at `localhost:8080`, API at `localhost:3000`.
## 3. Pre-conditions
- UAT environment is accessible at `https://uat.groombook.dev`
- Test accounts are seeded with the following personas:
- **Manager:** Full administrative access
- **Staff:** Limited access to assigned appointments and clients
- **Client:** Portal access to view and manage their own appointments
- OIDC is configured with Authentik at `https://auth.farh.net`
- Seed data is populated:
- Sample clients and pets
- Grooming services with pricing and duration
- Existing appointments
- Stripe test keys are configured for payment flow testing
- Email/SMS providers (Telnyx, etc.) are configured for notification testing
## 4. Test Cases
### 4.1 Authentication
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.1.1 | OIDC login | 1. Navigate to UAT environment<br>2. Click "Login with Authentik"<br>3. Enter test credentials<br>4. Authorize the application | User is redirected to app dashboard, session is established |
| TC-APP-4.1.2 | Session persistence | 1. Log in as any user<br>2. Close browser tab<br>3. Reopen browser and navigate to UAT | User remains logged in, no re-authentication required |
| TC-APP-4.1.3 | Logout | 1. Log in as any user<br>2. Click logout button<br>3. Attempt to access protected route | User is logged out and redirected to login page |
| TC-APP-4.1.4 | RBAC - Manager access | 1. Log in as Manager<br>2. Navigate to Settings, Staff Management, Reports | All administrative features are accessible |
| TC-APP-4.1.5 | RBAC - Staff access | 1. Log in as Staff<br>2. Attempt to access Settings, Staff Management | Access denied or limited view, staff can only see assigned appointments |
| TC-APP-4.1.6 | RBAC - Client access | 1. Log in as Client<br>2. Navigate to portal<br>3. Attempt to access admin areas | Client can only view their own appointments, pets, and profile |
### 4.2 Setup Wizard / OOBE
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.2.1 | First-run setup | 1. Access fresh UAT environment with no configuration<br>2. Complete setup wizard: business name, hours, services | Configuration is saved, dashboard loads with setup complete |
| TC-APP-4.2.2 | Setup validation | 1. Start setup wizard<br>2. Leave required fields blank<br>3. Attempt to proceed | Validation errors displayed, cannot proceed without required fields |
| TC-APP-4.2.3 | Skip setup (if already configured) | 1. Access configured environment<br>2. Attempt to access setup wizard | Redirected to dashboard or setup is marked as complete |
### 4.3 Client Management
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.3.1 | Create new client | 1. Navigate to Clients page<br>2. Click "Add Client"<br>3. Fill in client details (name, email, phone, address)<br>4. Save | Client is created and appears in client list |
| TC-APP-4.3.2 | Edit client | 1. Select existing client<br>2. Click "Edit"<br>3. Modify client details<br>4. Save | Changes are saved and reflected in client profile |
| TC-APP-4.3.3 | Search clients | 1. Navigate to Clients page<br>2. Enter client name or email in search<br>3. Press Enter/submit | Search results display matching clients |
| TC-APP-4.3.4 | Archive client | 1. Select active client<br>2. Click "Archive"<br>3. Confirm action | Client is marked as archived, no longer appears in active client list |
### 4.4 Pet Management
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.4.1 | Add pet to client | 1. Select client<br>2. Click "Add Pet"<br>3. Fill in pet details (name, breed, weight, notes)<br>4. Save | Pet is added to client's pet list |
| TC-APP-4.4.2 | Edit pet information | 1. Select pet from client profile<br>2. Click "Edit"<br>3. Modify pet details<br>4. Save | Changes are saved and reflected |
| TC-APP-4.4.3 | View grooming history | 1. Select pet with past appointments<br>2. Navigate to "History" tab | All past grooming appointments and notes are displayed |
| TC-APP-4.4.4 | Add breed notes | 1. Edit pet<br>2. Add breed-specific notes (temperament, special handling)<br>3. Save | Notes are saved and visible to staff when scheduling |
### 4.5 Appointment Scheduling
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.5.1 | Create new appointment | 1. Navigate to Calendar or Appointments page<br>2. Click "New Appointment"<br>3. Select client, pet, service, staff, date/time<br>4. Save | Appointment is created and appears in calendar |
| TC-APP-4.5.2 | Modify appointment | 1. Select existing appointment<br>2. Click "Edit"<br>3. Change date/time, staff, or service<br>4. Save | Changes are saved and calendar updates |
| TC-APP-4.5.3 | Cancel appointment | 1. Select upcoming appointment<br>2. Click "Cancel"<br>3. Confirm and optionally select reason | Appointment is marked as cancelled, slot becomes available |
| TC-APP-4.5.4 | Calendar view (day/week/month) | 1. Navigate to Calendar<br>2. Switch between day, week, and month views | Calendar displays appointments in selected time range correctly |
| TC-APP-4.5.5 | Appointment groups | 1. Create multiple appointments for same time slot<br>2. View in calendar | Appointments are grouped/linked appropriately |
| TC-APP-4.5.6 | Appointment availability check | 1. Attempt to book appointment during unavailable slot | System shows conflict or prevents double-booking |
| TC-APP-4.5.7 | Booking wizard — size/coat selection | 1. Start new appointment booking wizard<br>2. Select a pet with sizeCategory and coatType set<br>3. Observe the service/slot selection step | Size and coat type dropdowns are displayed and persist the pet's existing values |
| TC-APP-4.5.8 | Large/X-Large pet slot duration reflects buffer | 1. Add a pet with sizeCategory = "large" or "x-large" to an appointment<br>2. Note the service duration<br>3. Complete booking and inspect the appointment | Appointment slot includes the service duration plus the configured buffer for the pet's size category |
| TC-APP-4.5.9 | Appointment overrun cascades downstream | 1. Book three consecutive same-groomer appointments (A → B → C)<br>2. Manually extend appointment A's endTime so it overlaps B's startTime by ≥15 min<br>3. Observe appointment B | Appointment B (and C if still overlapping) is automatically shifted forward by the overrun delta + buffer; no error thrown |
| TC-APP-4.5.10 | Cascaded appointments appear at new times | 1. Complete TC-APP-4.5.9<br>2. Check the calendar/list view | Appointments B and C are now shown at their shifted start/end times |
| TC-APP-4.5.11 | Client receives reschedule notification email | 1. Complete TC-APP-4.5.9<br>2. Check the client's email (or notification log) | Client receives an email with subject/lines indicating their appointment was rescheduled from original time to new time |
| TC-APP-4.5.12 | Appointment flagged when shift crosses day boundary | 1. Book appointment D for late afternoon (e.g. 17:30)<br>2. Extend a prior appointment so D would shift to the next day<br>3. Observe D | Appointment D is flagged for manual review and is NOT auto-shifted to the next day |
| TC-APP-4.5.13 | Only scheduled/confirmed appointments are cascaded | 1. Start a cascade scenario (TC-APP-4.5.9) where a downstream appointment is already `in_progress`<br>2. Complete the cascade | The `in_progress` appointment is not shifted; cascade continues to next eligible appointment |
### 4.6 Services
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.6.1 | List all services | 1. Navigate to Services page | All configured grooming services are listed |
| TC-APP-4.6.2 | Create new service | 1. Click "Add Service"<br>2. Enter service name, description, duration, price<br>3. Save | Service is created and appears in list |
| TC-APP-4.6.3 | Edit service | 1. Select existing service<br>2. Modify pricing or duration<br>3. Save | Changes are saved and reflected |
| TC-APP-4.6.4 | Deactivate service | 1. Select service<br>2. Click "Deactivate"<br>3. Confirm | Service is marked as inactive, not available for new appointments |
### 4.7 Staff Management
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.7.1 | List all staff | 1. Navigate to Staff page | All staff members are listed with roles and status |
| TC-APP-4.7.2 | Add new staff member | 1. Click "Add Staff"<br>2. Enter staff details and assign role<br>3. Save | Staff member is created and can be assigned to appointments |
| TC-APP-4.7.3 | Assign RBAC role | 1. Select staff member<br>2. Change role (e.g., from Staff to Manager)<br>3. Save | Role change takes effect immediately |
| TC-APP-4.7.4 | Impersonate client | 1. As Manager, select client<br>2. Click "Impersonate"<br>3. Verify audit log | Manager views client's perspective, action is logged |
| TC-APP-4.7.5 | End impersonation | 1. While impersonating, click "End Impersonation" | Session returns to Manager's view |
### 4.8 Invoicing & Payments
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.8.1 | Generate invoice | 1. Select completed appointment<br>2. Click "Generate Invoice"<br>3. Review invoice details | Invoice is created with correct services, pricing, and taxes |
| TC-APP-4.8.2 | Process Stripe payment | 1. Open invoice<br>2. Click "Pay Now"<br>3. Enter Stripe test card details<br>4. Submit | Payment is processed, invoice marked as paid |
| TC-APP-4.8.3 | Add tip | 1. Before or after payment, add tip amount<br>2. Save | Tip is added to invoice total |
| TC-APP-4.8.4 | Generate receipt | 1. After payment, click "Generate Receipt"<br>2. Download or view receipt | Receipt is generated with payment details |
| TC-APP-4.8.5 | Process refund | 1. Select paid invoice<br>2. Click "Refund"<br>3. Enter refund amount and reason<br>4. Confirm | Refund is processed via Stripe, invoice status updated |
| TC-APP-4.8.6 | Failed payment handling | 1. Attempt payment with declined card<br>2. Verify error handling | Appropriate error message displayed, invoice remains unpaid |
### 4.9 Customer Portal
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.9.1 | Client login | 1. Access portal URL<br>2. Log in with client credentials | Client lands on portal dashboard |
| TC-APP-4.9.2 | View appointments | 1. Navigate to "My Appointments"<br>2. Review upcoming and past appointments | All client's appointments are listed |
| TC-APP-4.9.3 | Confirm appointment | 1. Select upcoming appointment<br>2. Click "Confirm" | Appointment is marked as confirmed by client |
| TC-APP-4.9.4 | Cancel appointment | 1. Select upcoming appointment<br>2. Click "Cancel"<br>3. Provide reason | Appointment is cancelled, notification sent to business |
| TC-APP-4.9.5 | View appointment history | 1. Navigate to "History" tab | All past appointments with details are shown |
### 4.9.1 Communication Tab
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.9.6 | View message history (conversation exists) | 1. Log in as client with existing conversation<br>2. Navigate to Communication tab | Real message history is displayed (not mock data) |
| TC-APP-4.9.7 | Empty state (no conversation yet) | 1. Log in as client with no conversation<br>2. Navigate to Communication tab | Empty state is shown; app does not crash or show mock messages |
| TC-APP-4.9.8 | Composer disabled | 1. Log in as client<br>2. Navigate to Communication tab | Composer/Reply field is hidden or disabled with tooltip "Reply from your phone" |
| TC-APP-4.9.9 | Cross-tenant isolation | 1. As client A, retrieve session token<br>2. Attempt to fetch client B conversation via API | Request returns 403 or empty; client A cannot access client B messages |
### 4.10 Waitlist
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.10.1 | Add client to waitlist | 1. Navigate to Waitlist page<br>2. Click "Add to Waitlist"<br>3. Select client, pet, preferred dates<br>4. Save | Client is added to waitlist |
| TC-APP-4.10.2 | View waitlist | 1. Navigate to Waitlist page | All waitlisted requests are displayed with priority |
| TC-APP-4.10.3 | Promote to appointment | 1. Select waitlist entry<br>2. Click "Promote to Appointment"<br>3. Select available slot | Appointment is created from waitlist, entry removed |
| TC-APP-4.10.4 | Remove from waitlist | 1. Select waitlist entry<br>2. Click "Remove"<br>3. Confirm | Entry is removed from waitlist |
### 4.11 Search
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.11.1 | Global search for clients | 1. Use global search bar<br>2. Enter client name or email<br>3. Select "Clients" | Search returns matching clients |
| TC-APP-4.11.2 | Global search for pets | 1. Use global search bar<br>2. Enter pet name or breed<br>3. Select "Pets" | Search returns matching pets with owner info |
| TC-APP-4.11.3 | Search filters | 1. Perform search<br>2. Apply filters (date range, status, etc.) | Results are filtered according to criteria |
| TC-APP-4.11.4 | No results handling | 1. Search for non-existent term<br>2. Verify UI | "No results found" message displayed |
### 4.12 Reports
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.12.1 | Revenue dashboard | 1. Navigate to Reports > Revenue<br>2. Select date range | Revenue metrics displayed (total, by service, by staff) |
| TC-APP-4.12.2 | Staff utilization | 1. Navigate to Reports > Utilization<br>2. Select date range | Staff hours booked vs. available shown |
| TC-APP-4.12.3 | Trend analytics | 1. Navigate to Reports > Trends<br>2. Select metric and time period | Trend chart displays with data points |
| TC-APP-4.12.4 | Export report | 1. View any report<br>2. Click "Export"<br>3. Select format (CSV, PDF) | Report file is downloaded |
### 4.13 Calendar
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.13.1 | Generate iCal feed | 1. Navigate to Calendar<br>2. Click "iCal Feed"<br>3. Copy URL | iCal feed URL is generated for external calendar apps |
| TC-APP-4.13.2 | Calendar sync (external) | 1. Import iCal feed into external calendar (Google, Outlook)<br>2. Verify sync | Appointments appear in external calendar |
| TC-APP-4.13.3 | Calendar availability display | 1. View calendar in any view mode | Available and booked slots are visually distinct |
### 4.14 Email Reminders
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.14.1 | Configure email reminders | 1. Navigate to Settings > Notifications<br>2. Set reminder timing (24h, 1h before)<br>3. Save | Configuration is saved |
| TC-APP-4.14.2 | Verify reminder delivery | 1. Create appointment for tomorrow<br>2. Wait for reminder trigger<br>3. Check test email account | Reminder email is received with correct details |
| TC-APP-4.14.3 | SMS notification | 1. Configure SMS provider (Telnyx)<br>2. Enable SMS reminders<br>3. Create appointment | SMS is sent to client's phone number |
| TC-APP-4.14.4 | Notification preferences | 1. As client, access portal settings<br>2. Toggle email/SMS preferences | Preferences are respected for future notifications |
### 4.15 Grooming Logs
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.15.1 | Log grooming entry | 1. Select pet<br>2. Click "Add Grooming Log"<br>3. Enter details (date, services, notes, photos)<br>4. Save | Log entry is created and linked to pet |
| TC-APP-4.15.2 | View grooming history | 1. Select pet<br>2. Navigate to "Grooming History" | All log entries are displayed chronologically |
| TC-APP-4.15.3 | Add photos to log | 1. Create or edit grooming log<br>2. Upload before/after photos<br>3. Save | Photos are attached to log entry |
| TC-APP-4.15.4 | Edit grooming log | 1. Select existing log entry<br>2. Modify notes or services<br>3. Save | Changes are saved |
### 4.16 Settings
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.16.1 | Business settings | 1. Navigate to Settings > Business<br>2. Update business name, hours, contact info<br>3. Save | Settings are saved and reflected app-wide |
| TC-APP-4.16.2 | App configuration | 1. Navigate to Settings > App<br>2. Configure theme, time zone, date format<br>3. Save | Configuration takes effect immediately |
| TC-APP-4.16.3 | Payment settings | 1. Navigate to Settings > Payments<br>2. Configure Stripe keys, tax rates<br>3. Save | Payment settings are updated |
| TC-APP-4.16.4 | Notification settings | 1. Navigate to Settings > Notifications<br>2. Configure email/SMS providers and defaults<br>3. Save | Notification configuration is saved |
### 4.17 Mobile / PWA
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.17.1 | Install prompt | 1. Access app on mobile device (or DevTools mobile view)<br>2. Verify install prompt appears | "Add to Home Screen" prompt is shown |
| TC-APP-4.17.2 | Responsive design (mobile) | 1. Resize viewport to 390x844 (iPhone dimensions)<br>2. Navigate through app | All pages are usable and properly formatted |
| TC-APP-4.17.3 | Offline basics | 1. Load app<br>2. Enable offline mode in DevTools<br>3. Navigate to previously loaded pages | Cached content is displayed, offline indicator shown |
| TC-APP-4.17.4 | Touch interactions | 1. On mobile viewport, tap buttons, forms, and navigation<br>2. Verify responsiveness | All touch targets are accessible and responsive |
### 4.18 Navigation
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.18.1 | All major sections accessible | 1. Click each main navigation item<br>2. Verify page loads | All sections (Dashboard, Calendar, Clients, Pets, Appointments, Reports, Settings) load successfully |
| TC-APP-4.18.2 | No broken links | 1. Navigate through app<br>2. Click various links and buttons | No 404 errors or dead ends encountered |
| TC-APP-4.18.3 | No blank pages | 1. Navigate to each section and sub-section<br>2. Verify content is displayed | All pages render with appropriate content |
| TC-APP-4.18.4 | Back/forward navigation | 1. Navigate through multiple pages<br>2. Use browser back and forward buttons | Navigation history works correctly |
### 4.19 Error States
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.19.1 | Form with bad data | 1. On any form, enter invalid email, phone, or dates<br>2. Submit | Validation errors display specific issues |
| TC-APP-4.19.2 | Missing required fields | 1. On any form, leave required fields blank<br>2. Submit | Clear error messages indicate which fields are required |
| TC-APP-4.19.3 | Empty states | 1. Navigate to pages with no data (empty calendar, no clients)<br>2. Verify UI | Helpful empty state message with call-to-action displayed |
| TC-APP-4.19.4 | Network error handling | 1. Disable network in DevTools<br>2. Attempt actions that require API calls<br>3. Re-enable network | Appropriate error message shown, app recovers when network restored |
### 4.20 Staff Messages
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.20.1 | Staff messages inbox loads | 1. Log in as Staff<br>2. Navigate to Messages | Conversation list renders with client phone and last message preview |
| TC-APP-4.20.2 | Open conversation | 1. Select a conversation from the list | Full message thread loads chronologically |
| TC-APP-4.20.3 | Send message | 1. Type a reply and submit | Message appears in thread; POST /api/conversations/:id/messages succeeds |
| TC-APP-4.20.4 | Empty state | 1. Log in as Staff with no conversations | Empty state shown; no crash |
| TC-APP-4.20.5 | Unread indicator | 1. Client sends a new message | Thread marked unread until staff views it |
| TC-APP-4.20.6 | Cross-tenant isolation | 1. Staff from Business A attempts to read Business B conversations | 403 or empty response returned |
### 4.21 SMS Consent (STOP/HELP Keyword Handler)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.21.1 | STOP → unsubscribe + auto-reply | 1. Send `STOP` (case-insensitive, with whitespace) from a subscribed client's phone number | Client is opted out (`smsOptIn=false`, `smsOptOutDate` set), event is logged, user receives auto-reply: "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe." |
| TC-APP-4.21.2 | START → resubscribe + auto-reply | 1. Send `START` (case-insensitive) from an opted-out client's phone number | Client is opted back in (`smsOptIn=true`, `smsConsentDate` updated, `smsOptOutDate` cleared), event is logged, user receives auto-reply: "You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply." |
| TC-APP-4.21.3 | HELP → no opt-in change + default reply | 1. Send `HELP` (case-insensitive) from any client's phone number | No change to opt-in state, no database update, event is logged, user receives auto-reply: "Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly." |
| TC-APP-4.21.4 | STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT → opt-out | 1. Send each alias from a subscribed client's phone | Same behaviour as STOP: opt-out applied, correct reply sent |
| TC-APP-4.21.5 | UNSTOP / YES / SUBSCRIBE → opt-in | 1. Send each alias from an opted-out client's phone | Same behaviour as START: opt-in applied, correct reply sent |
| TC-APP-4.21.6 | INFO → help reply | 1. Send `INFO` from any client's phone | Same behaviour as HELP: no state change, help reply returned |
| TC-APP-4.21.7 | Double STOP (idempotency) | 1. Send `STOP` from an already-opted-out client | Event is logged, no update call made, idempotent — no duplicate update |
| TC-APP-4.21.8 | Double START (idempotency) | 1. Send `START` from an already-subscribed client | Event is logged, no update call made, idempotent — no duplicate update |
| TC-APP-4.21.9 | Case insensitivity | 1. Send `stop`, `Stop`, `sToP`, ` stop ` from subscribed client | All variants are detected and handled as opt-out |
| TC-APP-4.21.10 | Whitespace trimming | 1. Send ` START ` or `\tSTOP\n` | Keywords are trimmed before matching |
| TC-APP-4.21.11 | Non-keyword messages ignored | 1. Send `STOP IT`, `help me`, `hello` | Returns null from `detectKeyword`, no consent event inserted, no reply sent |
| TC-APP-4.21.12 | Consent event audit log | 1. After any keyword, query `messageConsentEvents` table | Record exists with correct `clientId`, `businessId`, `kind`, and `source: "sms_keyword"` |
## 5. Pass/Fail Criteria
**Pass:** All test cases execute without errors. Expected results match actual results. No regressions are observed. All functionality works as documented.
**Fail:** Any unexpected result is encountered. For failures, document:
- Severity (Critical, High, Medium, Low)
- Steps to reproduce
- Actual vs. expected behavior
- Screenshot(s) if applicable
- Browser and device information
**Regressions:** If a previously working feature fails during this UAT run, it is considered a regression and must be addressed before the release can proceed.
## 6. Update Policy
**Any PR that changes user-facing behaviour MUST update this file.**
When modifying features that affect:
- User workflows (authentication, scheduling, payments, etc.)
- UI/UX (navigation, forms, responsive design)
- Configuration (settings, integrations)
- Data visibility (reports, search, filtering)
The corresponding test case(s) in Section 4 must be updated to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.5 — new appointment group scheduling feature").
+1 -2
View File
@@ -24,14 +24,13 @@
"nodemailer": "^6.9.16",
"stripe": "^22.0.0",
"telnyx": "^1.23.0",
"uuid": "^11.1.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.18.0",
"tsx": "^4.19.2",
-210
View File
@@ -1,210 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { resolveBufferMinutes } from "../lib/buffer.js";
// ─── Mock types matching schema ─────────────────────────────────────────────
interface MockBufferTimeRule {
id: string;
serviceId: string;
sizeCategory: string | null;
coatType: string | null;
bufferMinutes: number;
}
interface MockService {
id: string;
name: string;
defaultBufferMinutes: number;
}
// ─── Mock db factory ─────────────────────────────────────────────────────────
// Simulates Drizzle query builder: db.select().from(t).where(eq(...)) → await → array
// For services we use db.select().from(t).where(eq(...)).limit(1) → await → first item
function createMockDb(rules: MockBufferTimeRule[], services: MockService[]) {
let callCount = 0;
return {
select: vi.fn(() => {
callCount++;
const rulesQuery = {
from: () => ({
where: () => rules, // await resolves directly to rules array
}),
};
const serviceQuery = {
from: () => ({
where: () => ({
limit: () => services, // await resolves to services array
}),
}),
};
// First select call → rules, second → services
return callCount === 1 ? rulesQuery : serviceQuery;
}),
} as any;
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("resolveBufferMinutes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns exact match when serviceId + sizeCategory + coatType all match", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: "medium", coatType: "short", bufferMinutes: 15 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: "medium", coatType: null, bufferMinutes: 10 },
{ id: "rule-3", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 5 },
],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "medium",
coatType: "short",
db,
});
expect(result).toBe(15);
});
it("returns service + size match when no exact match", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: "medium", coatType: null, bufferMinutes: 10 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 5 },
],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "medium",
coatType: "long",
db,
});
expect(result).toBe(10);
});
it("returns service + coat match when no exact or size match", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: null, coatType: "wire", bufferMinutes: 12 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 5 },
],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "large",
coatType: "wire",
db,
});
expect(result).toBe(12);
});
it("returns service-only match when no partial matches", async () => {
const db = createMockDb(
[{ id: "rule-1", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 7 }],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "large",
coatType: "long",
db,
});
expect(result).toBe(7);
});
it("falls back to service.defaultBufferMinutes when no rules exist", async () => {
const db = createMockDb([], [{ id: "svc-1", name: "Bath", defaultBufferMinutes: 8 }]);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "small",
coatType: "curly",
db,
});
expect(result).toBe(8);
});
it("falls back to 0 when no rules and no service default", async () => {
const db = createMockDb([], []);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "small",
coatType: null,
db,
});
expect(result).toBe(0);
});
it("exact match beats partial matches (priority verification)", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: "medium", coatType: "short", bufferMinutes: 20 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: "medium", coatType: null, bufferMinutes: 15 },
{ id: "rule-3", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 10 },
],
[{ id: "svc-1", name: "Groom", defaultBufferMinutes: 5 }]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "medium",
coatType: "short",
db,
});
// Exact match (20) should win over service+size (15) and service default (5)
expect(result).toBe(20);
});
it("handles null sizeCategory and null coatType at rule level", async () => {
const db = createMockDb(
[{ id: "rule-1", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 6 }],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: null,
coatType: null,
db,
});
expect(result).toBe(6);
});
it("prefers service+size over service-only when both exist", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: "large", coatType: null, bufferMinutes: 14 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 3 },
],
[{ id: "svc-1", name: "Groom", defaultBufferMinutes: 1 }]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "large",
coatType: "smooth",
db,
});
expect(result).toBe(14);
});
});
@@ -1,318 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mock data ────────────────────────────────────────────────────────────────
const STAFF_ROW = {
id: "staff-uuid-1",
email: "groomer@groombook.com",
name: "Groomer",
role: "groomer" as const,
businessId: "business-uuid-1",
active: true,
userId: null,
oidcSub: null,
isSuperUser: false,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const BUSINESS_SETTINGS = {
id: "business-uuid-1",
businessName: "Test Salon",
};
const CONV_1 = {
id: "conv-uuid-1",
businessId: "business-uuid-1",
clientId: "client-uuid-1",
channel: "sms",
externalNumber: "+15551111111",
businessNumber: "+15552222222",
lastMessageAt: new Date("2025-01-10T10:00:00Z"),
status: "active",
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-10T10:00:00Z"),
staffReadAt: null,
};
const MSG_INBOUND_1 = {
id: "msg-uuid-1",
conversationId: "conv-uuid-1",
direction: "inbound",
body: "Hello",
status: "delivered",
sentByStaffId: null,
createdAt: new Date("2025-01-10T09:00:00Z"),
deliveredAt: new Date("2025-01-10T09:01:00Z"),
};
const MSG_OUTBOUND_1 = {
id: "msg-uuid-2",
conversationId: "conv-uuid-1",
direction: "outbound",
body: "Hi Alice!",
status: "delivered",
sentByStaffId: "staff-uuid-1",
createdAt: new Date("2025-01-10T10:00:00Z"),
deliveredAt: new Date("2025-01-10T10:01:00Z"),
};
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
let selectRows: Record<string, unknown>[] = [];
let selectRows2: Record<string, unknown>[] = [];
let selectRows3: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
let selectCallCount = 0;
function resetMock() {
selectRows = [];
selectRows2 = [];
selectRows3 = [];
updatedValues = [];
selectCallCount = 0;
}
function resetAll() {
resetMock();
vi.clearAllMocks();
}
const mockSendMessage = vi.hoisted(() => vi.fn());
vi.mock("@groombook/db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "innerJoin") {
return () => chain;
}
if (prop === "from") {
return (table: unknown) => {
const tableName = (table as { _name?: string })._name;
const rows = tableName === "businessSettings" ? [BUSINESS_SETTINGS] : selectRows;
return makeChainable(rows);
};
}
// @ts-expect-error proxy
return target[prop];
},
});
return chain;
}
const conversations = new Proxy(
{ _name: "conversations" },
{ get: (t, p) => (p === "_name" ? "conversations" : { table: "conversations", column: p }) }
);
const messages = new Proxy(
{ _name: "messages" },
{ get: (t, p) => (p === "_name" ? "messages" : { table: "messages", column: p }) }
);
const clients = new Proxy(
{ _name: "clients" },
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
);
const businessSettings = new Proxy(
{ _name: "businessSettings" },
{ get: (t, p) => (p === "_name" ? "businessSettings" : { table: "businessSettings", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: (table: unknown) => {
const tableName = (table as { _name?: string })._name;
if (tableName === "businessSettings") return makeChainable([BUSINESS_SETTINGS]);
if (tableName === "messages") {
// Return selectRows3 if it has data (POST re-query), else cycle through selectRows/selectRows2
if (selectRows3.length > 0) {
return makeChainable(selectRows3);
}
if (selectCallCount === 0 || selectCallCount === 1) {
const rows = selectCallCount === 0 ? selectRows : selectRows2;
selectCallCount++;
return makeChainable(rows);
}
return makeChainable(selectRows);
}
return makeChainable(selectRows);
},
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => {
updatedValues.push(vals);
return { returning: () => [vals] };
},
}),
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
return { returning: () => [{ ...vals, id: "msg-uuid-new" }] };
},
}),
}),
conversations,
messages,
clients,
businessSettings,
eq: vi.fn((a, b) => ({ type: "eq", a, b })),
and: vi.fn((...args) => ({ type: "and", args })),
desc: vi.fn((col) => ({ type: "desc", col })),
lt: vi.fn((a, b) => ({ type: "lt", a, b })),
sql: vi.fn(() => ({ __type: "sql" })),
isNull: vi.fn((col) => ({ type: "isNull", col })),
count: vi.fn((col) => ({ type: "count", col })),
};
});
vi.mock("../services/messaging/outbound.js", () => ({
sendMessage: mockSendMessage,
}));
// ─── App setup ────────────────────────────────────────────────────────────────
const { conversationsRouter } = await import("../routes/conversations.js");
const app = new Hono();
app.use("*", async (c, next) => {
// @ts-expect-error — test-only context injection
c.set("staff", STAFF_ROW);
await next();
});
app.route("/conversations", conversationsRouter);
function jsonRequest(method: string, path: string, body?: unknown) {
return app.request(path, {
method,
headers: { "Content-Type": "application/json" },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
beforeEach(() => resetAll());
// ─── GET /conversations ───────────────────────────────────────────────────────
describe("GET /api/conversations", () => {
it("returns conversations sorted by recency with unread count", async () => {
selectRows = [
{ ...CONV_1, clientName: "Alice", clientPhone: "+15551111111", channel: "sms" },
];
selectRows2 = [{ count: "1" }];
const res = await app.request("/conversations");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.items).toHaveLength(1);
expect(body.items[0]!.id).toBe("conv-uuid-1");
expect(body.items[0]!.clientName).toBe("Alice");
});
it("supports cursor-based pagination", async () => {
selectRows = [];
const res = await app.request("/conversations?cursor=conv-uuid-1&limit=1");
expect(res.status).toBe(200);
});
it("enforces max limit of 50", async () => {
selectRows = [];
const res = await app.request("/conversations?limit=200");
expect(res.status).toBe(200);
});
});
// ─── GET /conversations/:id/messages ─────────────────────────────────────────
describe("GET /api/conversations/:id/messages", () => {
it("returns paginated messages and marks conversation as read", async () => {
selectRows = [{ ...MSG_INBOUND_1 }, { ...MSG_OUTBOUND_1 }];
const res = await app.request("/conversations/conv-uuid-1/messages");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.items).toHaveLength(2);
expect(body.items[0]!.id).toBe("msg-uuid-1");
expect(updatedValues.some((u) => u.staffReadAt !== undefined)).toBe(true);
});
it("returns 404 when conversation belongs to different business", async () => {
selectRows = [];
const res = await app.request("/conversations/conv-uuid-other/messages");
expect(res.status).toBe(404);
});
it("returns 401 when not authenticated", async () => {
const appNoAuth = new Hono();
appNoAuth.route("/conversations", conversationsRouter);
const res = await appNoAuth.request("/conversations/conv-uuid-1/messages");
expect(res.status).toBe(401);
});
});
// ─── POST /conversations/:id/messages ─────────────────────────────────────────
describe("POST /api/conversations/:id/messages", () => {
beforeEach(() => {
resetMock();
vi.clearAllMocks();
selectRows = [{ ...CONV_1, clientName: "Alice", clientPhone: "+15551111111", channel: "sms" }];
selectRows2 = [];
selectRows3 = [{ id: "msg-uuid-new", conversationId: "conv-uuid-1", direction: "outbound" as const, body: "Hello Alice!", status: "queued" as const, sentByStaffId: "staff-uuid-1", createdAt: new Date(), deliveredAt: null }];
updatedValues = [];
});
it("sends via outbound service and returns 201", async () => {
mockSendMessage.mockResolvedValueOnce({
messageId: "msg-uuid-new",
providerMessageId: "provider-msg-1",
status: "queued",
suppressed: false,
});
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "Hello Alice!",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.id).toBe("msg-uuid-new");
});
it("returns 409 when client opted out", async () => {
mockSendMessage.mockResolvedValueOnce({ suppressed: true });
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "Hello",
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.error).toMatch(/opted out/i);
});
it("returns 404 for cross-tenant conversation", async () => {
selectRows = [];
const res = await jsonRequest("POST", "/conversations/conv-uuid-other/messages", {
body: "Hello",
});
expect(res.status).toBe(404);
});
it("rejects empty body", async () => {
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "",
});
expect(res.status).toBe(400);
});
it("rejects body over 1600 chars", async () => {
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "a".repeat(1601),
});
expect(res.status).toBe(400);
});
});
-158
View File
@@ -40,17 +40,11 @@ const APPOINTMENT = {
let selectSessionRow: Record<string, unknown> | null = null;
let selectAppointmentRow: Record<string, unknown> | null = null;
let updatedValues: Record<string, unknown>[] = [];
let selectBusinessSettingsRow: Record<string, unknown> | null = null;
let selectConversationRow: Record<string, unknown> | null = null;
let selectMessageRows: Record<string, unknown>[] = [];
function resetMock() {
selectSessionRow = null;
selectAppointmentRow = null;
updatedValues = [];
selectBusinessSettingsRow = null;
selectConversationRow = null;
selectMessageRows = [];
}
vi.mock("@groombook/db", () => {
@@ -78,26 +72,6 @@ vi.mock("@groombook/db", () => {
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
const businessSettings = new Proxy(
{ _name: "businessSettings" },
{ get: (t, p) => (p === "_name" ? "businessSettings" : { table: "businessSettings", column: p }) }
);
const conversations = new Proxy(
{ _name: "conversations" },
{ get: (t, p) => (p === "_name" ? "conversations" : { table: "conversations", column: p }) }
);
const messages = new Proxy(
{ _name: "messages" },
{ get: (t, p) => (p === "_name" ? "messages" : { table: "messages", column: p }) }
);
const impersonationAuditLogs = new Proxy(
{ _name: "impersonationAuditLogs" },
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
);
return {
getDb: () => ({
select: () => ({
@@ -108,15 +82,6 @@ const businessSettings = new Proxy(
if (table._name === "appointments") {
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
}
if (table._name === "businessSettings") {
return makeChainable(selectBusinessSettingsRow ? [selectBusinessSettingsRow] : []);
}
if (table._name === "conversations") {
return makeChainable(selectConversationRow ? [selectConversationRow] : []);
}
if (table._name === "messages") {
return makeChainable(selectMessageRows);
}
return makeChainable([]);
},
}),
@@ -134,21 +99,11 @@ const businessSettings = new Proxy(
}),
}),
}),
insert: () => ({
values: () => ({
returning: () => [],
}),
}),
}),
impersonationSessions,
appointments,
impersonationAuditLogs,
businessSettings,
conversations,
messages,
eq: vi.fn(),
and: vi.fn(),
desc: vi.fn((col: unknown) => ({ _name: "desc", col })),
};
});
@@ -465,117 +420,4 @@ describe("POST /portal/appointments/:id/cancel", () => {
);
expect(res.status).toBe(404);
});
});
// ─── Conversation routes ───────────────────────────────────────────────────────
const BUSINESS_ID = "880e8400-e29b-41d4-a716-446655440008";
const CONVERSATION_ID = "990e8400-e29b-41d4-a716-446655440009";
const CONVERSATION = {
id: CONVERSATION_ID,
clientId: CLIENT_ID,
businessId: BUSINESS_ID,
channel: "sms",
status: "active",
lastMessageAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
};
const MESSAGE_1 = {
id: "m1",
conversationId: CONVERSATION_ID,
direction: "inbound",
body: "Hello",
status: "delivered",
createdAt: new Date().toISOString(),
deliveredAt: new Date().toISOString(),
};
const MESSAGE_2 = {
id: "m2",
conversationId: CONVERSATION_ID,
direction: "outbound",
body: "Hi there!",
status: "delivered",
createdAt: new Date(Date.now() + 1000).toISOString(),
deliveredAt: new Date().toISOString(),
};
function jsonGet(path: string, headers?: Record<string, string>) {
return app.request(path, { method: "GET", headers });
}
describe("GET /portal/conversation", () => {
it("returns 204 when no conversation exists", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = null;
const res = await jsonGet("/portal/conversation", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(204);
});
it("returns conversation for the authenticated client", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = { ...CONVERSATION };
const res = await jsonGet("/portal/conversation", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe(CONVERSATION_ID);
expect(body.channel).toBe("sms");
expect(body.status).toBe("active");
});
it("returns 204 when client A's session has no conversation (cross-tenant isolation)", async () => {
// Cross-tenant isolation is enforced at the query level via portalClientId scoping.
// The mock cannot replicate eq() filtering — this test verifies the query is issued
// and no conversation is returned when the mock has no row for the session's clientId.
// Real DB: eq() on clientId ensures client A never sees client B's conversation.
selectSessionRow = { ...ACTIVE_SESSION, clientId: "client-a" };
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = null; // client-a has no conversation
const res = await jsonGet("/portal/conversation", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(204);
});
});
describe("GET /portal/conversation/messages", () => {
it("returns 204 when no conversation exists", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = null;
const res = await jsonGet("/portal/conversation/messages", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(204);
});
it("returns paginated messages", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = { ...CONVERSATION };
selectMessageRows = [MESSAGE_2, MESSAGE_1];
const res = await jsonGet("/portal/conversation/messages", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.messages).toHaveLength(2);
expect(body.messages[0].id).toBe("m2");
expect(body.messages[1].id).toBe("m1");
expect(body.nextCursor).toBeNull();
});
it("returns messages and nextCursor reflects if more exist", async () => {
// Note: the mock does not enforce limit(), so it returns all messages.
// nextCursor is null when all messages fit (mock behavior).
// Real DB enforces limit and sets nextCursor when messages.length === limit.
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = { ...CONVERSATION };
selectMessageRows = [MESSAGE_1, MESSAGE_2];
const res = await jsonGet("/portal/conversation/messages?limit=1", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.messages.length).toBeGreaterThan(0);
// mock has no limit enforcement, so nextCursor may be null
expect(body).toHaveProperty("nextCursor");
});
});
-7
View File
@@ -8,7 +8,6 @@ import { petsRouter } from "./routes/pets.js";
import { servicesRouter } from "./routes/services.js";
import { appointmentsRouter } from "./routes/appointments.js";
import { waitlistRouter } from "./routes/waitlist.js";
import { conversationsRouter } from "./routes/conversations.js";
import { portalRouter } from "./routes/portal.js";
import { staffRouter } from "./routes/staff.js";
import { invoicesRouter } from "./routes/invoices.js";
@@ -30,7 +29,6 @@ import { devRouter } from "./routes/dev.js";
import { adminSeedRouter } from "./routes/admin/seed.js";
import { startReminderScheduler } from "./services/reminders.js";
import { webhooksRouter } from "./routes/stripe-webhooks.js";
import { telnyxWebhooksRouter } from "./routes/webhooks/telnyx.js";
const app = new Hono();
@@ -71,9 +69,6 @@ app.route("/api/portal", portalRouter);
// Public Stripe webhook endpoint — signature-verified, no auth required
app.route("/api/webhooks/stripe", webhooksRouter);
// Public Telnyx messaging webhook — signature-verified, no auth required
app.route("/api/webhooks/telnyx", telnyxWebhooksRouter);
// Dev/demo routes — config is always public, users endpoint is guarded internally
app.route("/api/dev", devRouter);
@@ -264,7 +259,6 @@ api.route("/pets", petsRouter);
api.route("/services", servicesRouter);
api.route("/appointments", appointmentsRouter);
api.route("/waitlist", waitlistRouter);
api.route("/conversations", conversationsRouter);
api.route("/staff", staffRouter);
api.route("/invoices", invoicesRouter);
api.route("/reports", reportsRouter);
@@ -275,7 +269,6 @@ api.route("/admin/settings", settingsRouter);
api.route("/admin/auth-provider", authProviderRouter);
api.route("/admin/seed", adminSeedRouter);
api.route("/search", searchRouter);
api.route("/conversations", conversationsRouter);
const port = Number(process.env.PORT ?? 3000);
await initAuth();
-6
View File
@@ -97,9 +97,6 @@ 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,
},
},
@@ -250,9 +247,6 @@ 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,
},
},
-66
View File
@@ -1,66 +0,0 @@
import { eq } from "@groombook/db";
import { bufferTimeRules, services, type Db } from "@groombook/db";
export async function resolveBufferMinutes({
serviceId,
sizeCategory,
coatType,
db,
}: {
serviceId: string;
sizeCategory: string | null;
coatType: string | null;
db: Db;
}): Promise<number> {
// Query all rules for this service in one DB call
const allRules = await db
.select()
.from(bufferTimeRules)
.where(eq(bufferTimeRules.serviceId, serviceId));
// Priority 1: exact match (serviceId + sizeCategory + coatType all match)
const exact = allRules.find(
(r) =>
r.sizeCategory === sizeCategory &&
r.coatType === coatType
);
if (exact) return exact.bufferMinutes;
// Priority 2: service + size, null coatType
const serviceSize = allRules.find(
(r) =>
r.sizeCategory === sizeCategory &&
r.coatType === null
);
if (serviceSize) return serviceSize.bufferMinutes;
// Priority 3: service + coat, null sizeCategory
const serviceCoat = allRules.find(
(r) =>
r.sizeCategory === null &&
r.coatType === coatType
);
if (serviceCoat) return serviceCoat.bufferMinutes;
// Priority 4: service only (null sizeCategory, null coatType)
const serviceOnly = allRules.find(
(r) =>
r.sizeCategory === null &&
r.coatType === null
);
if (serviceOnly) return serviceOnly.bufferMinutes;
// Priority 5: fallback to service.defaultBufferMinutes
const [service] = await db
.select({ defaultBufferMinutes: services.defaultBufferMinutes })
.from(services)
.where(eq(services.id, serviceId))
.limit(1);
if (service?.defaultBufferMinutes != null) {
return service.defaultBufferMinutes;
}
// Priority 6: final fallback to 0
return 0;
}
-281
View File
@@ -1,281 +0,0 @@
import { eq, and, gt, gte, lt, ne, or, asc } from "@groombook/db";
import { appointments, clients, pets, services, staff, type Db } from "@groombook/db";
import { resolveBufferMinutes } from "./buffer.js";
import { sendEmail, buildRescheduleNotificationEmail } from "../services/email.js";
export interface CascadeResult {
shifted: ShiftedAppointment[];
flaggedForReview: FlaggedAppointment[];
}
export interface ShiftedAppointment {
id: string;
oldStartTime: Date;
oldEndTime: Date;
newStartTime: Date;
newEndTime: Date;
shiftDeltaMs: number;
}
export interface FlaggedAppointment {
id: string;
reason: string;
requestedStartTime: Date;
requestedEndTime: Date;
}
interface AppointmentWithGroomer {
id: string;
clientId: string;
petId: string;
serviceId: string;
staffId: string | null;
batherStaffId: string | null;
status: string;
startTime: Date;
endTime: Date;
bufferMinutes: number;
}
/**
* Detects and cascades appointment overruns to downstream same-groomer appointments.
*
* Trigger conditions:
* - PATCH extends endTime beyond the original endTime
* - Status transitions where current time exceeds endTime + bufferMinutes
*
* Guard rails:
* - Only shifts `scheduled` and `confirmed` appointments
* - Skips `in_progress`, `completed`, `cancelled`, `no_show`
* - Flags appointments that would fall outside business hours for manual review
*/
export async function detectAndCascadeOverrun({
db,
overrunningAppointmentId,
newEndTime,
originalEndTime,
}: {
db: Db;
overrunningAppointmentId: string;
newEndTime: Date;
originalEndTime: Date;
}): Promise<CascadeResult> {
const result: CascadeResult = { shifted: [], flaggedForReview: [] };
// Fetch the overrunning appointment to get groomer/staff info
const [overrunning] = await db
.select()
.from(appointments)
.where(eq(appointments.id, overrunningAppointmentId))
.limit(1);
if (!overrunning) return result;
const groomerId = overrunning.staffId;
if (!groomerId) return result;
// Determine the effective buffer for the overrunning appointment
const bufferMinutes = await resolveBufferMinutesForAppointment(db, overrunning);
const overrunEnd = newEndTime;
const effectiveEnd = new Date(overrunEnd.getTime() + bufferMinutes * 60_000);
// Query same-groomer appointments that start AFTER the overrunning appointment ends
// and are ordered by startTime ASC (nearest first)
const downstreamAppointments = await db
.select()
.from(appointments)
.where(
and(
eq(appointments.staffId, groomerId),
gt(appointments.startTime, overrunning.endTime),
or(
eq(appointments.status, "scheduled"),
eq(appointments.status, "confirmed")
)
)
)
.orderBy(asc(appointments.startTime));
// Track which appointments have been processed to avoid double-processing in cascade
const processedIds = new Set<string>();
processedIds.add(overrunningAppointmentId);
let currentOverrunEnd = effectiveEnd;
for (const downstream of downstreamAppointments) {
if (processedIds.has(downstream.id)) continue;
const downstreamBuffer = await resolveBufferMinutesForAppointment(db, downstream);
// Check if this downstream appointment conflicts with the current overrun end
const conflictThreshold = new Date(
currentOverrunEnd.getTime() + downstreamBuffer * 60_000
);
if (conflictThreshold <= downstream.startTime) {
// No conflict — cascade is complete
break;
}
// Conflict detected — need to shift this appointment
const shiftDeltaMs = conflictThreshold.getTime() - downstream.startTime.getTime();
const newStartTime = new Date(downstream.startTime.getTime() + shiftDeltaMs);
const newEndTime = new Date(downstream.endTime.getTime() + shiftDeltaMs);
// Check business hours (simple: only shift within same calendar day window for now)
// A more sophisticated implementation would check actual business hours from businessSettings
const isSameDay =
newStartTime.toDateString() === downstream.startTime.toDateString();
if (!isSameDay) {
result.flaggedForReview.push({
id: downstream.id,
reason: `Shifted appointment would fall on a different day (${newStartTime.toDateString()})`,
requestedStartTime: newStartTime,
requestedEndTime: newEndTime,
});
// Continue cascade check — we still process downstream appointments
currentOverrunEnd = newEndTime;
processedIds.add(downstream.id);
continue;
}
// Apply the shift
await db
.update(appointments)
.set({
startTime: newStartTime,
endTime: newEndTime,
updatedAt: new Date(),
})
.where(eq(appointments.id, downstream.id));
result.shifted.push({
id: downstream.id,
oldStartTime: downstream.startTime,
oldEndTime: downstream.endTime,
newStartTime,
newEndTime,
shiftDeltaMs,
});
// Update current overrun end for next iteration
currentOverrunEnd = newEndTime;
processedIds.add(downstream.id);
}
// Send notifications for all shifted appointments
for (const shifted of result.shifted) {
await notifyShiftedAppointment(db, shifted);
}
return result;
}
/**
* Determines if an appointment update represents an overrun that triggers cascade logic.
*/
export function isOverrun({
originalEndTime,
newEndTime,
originalStartTime,
newStartTime,
status,
currentTime,
bufferMinutes,
}: {
originalEndTime: Date;
newEndTime: Date;
originalStartTime: Date;
newStartTime?: Date;
status: string;
currentTime: Date;
bufferMinutes: number;
}): boolean {
// Case 1: endTime extended beyond original
if (newEndTime > originalEndTime) {
return true;
}
// Case 2: status transition where current time exceeds endTime + bufferMinutes
// This handles cases where an appointment ran long but wasn't explicitly rescheduled
if (
(status === "in_progress" || status === "completed") &&
currentTime > new Date(originalEndTime.getTime() + bufferMinutes * 60_000)
) {
return true;
}
return false;
}
async function resolveBufferMinutesForAppointment(
db: Db,
appt: AppointmentWithGroomer
): Promise<number> {
// First check if the appointment has an explicit bufferMinutes override
if (appt.bufferMinutes > 0) {
return appt.bufferMinutes;
}
// Fall back to buffer time rules based on service + pet characteristics
const [pet] = await db
.select({ sizeCategory: pets.sizeCategory, coatType: pets.coatType })
.from(pets)
.where(eq(pets.id, appt.petId))
.limit(1);
if (!pet) return 0;
return resolveBufferMinutes({
serviceId: appt.serviceId,
sizeCategory: pet.sizeCategory,
coatType: pet.coatType,
db,
});
}
async function notifyShiftedAppointment(
db: Db,
shifted: ShiftedAppointment
): Promise<void> {
const [row] = await db
.select({
clientName: clients.name,
clientEmail: clients.email,
clientEmailOptOut: clients.emailOptOut,
petName: pets.name,
serviceName: services.name,
groomerName: staff.name,
appointmentStartTime: appointments.startTime,
})
.from(appointments)
.innerJoin(clients, eq(clients.id, appointments.clientId))
.innerJoin(pets, eq(pets.id, appointments.petId))
.innerJoin(services, eq(services.id, appointments.serviceId))
.leftJoin(staff, eq(staff.id, appointments.staffId))
.where(eq(appointments.id, shifted.id))
.limit(1);
if (!row) return;
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
if (!clientEmail || clientEmailOptOut) return;
if (!petName || !serviceName) return;
console.log(
`[cascade] Notifying shift for appointment ${shifted.id}: ` +
`${shifted.oldStartTime.toISOString()}${shifted.newStartTime.toISOString()}`
);
await sendEmail(
buildRescheduleNotificationEmail(clientEmail, {
clientName,
petName,
serviceName,
groomerName: groomerName ?? null,
oldStartTime: shifted.oldStartTime,
newStartTime: shifted.newStartTime,
})
);
}
+1 -2
View File
@@ -23,8 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
}
export const authMiddleware: MiddlewareHandler = async (c, next) => {
const path = c.req.path;
if (path.startsWith("/api/auth/") || path.startsWith("/api/webhooks/")) {
if (c.req.path.startsWith("/api/auth/")) {
await next();
return;
}
-31
View File
@@ -21,10 +21,6 @@ import {
} from "@groombook/db";
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
import {
detectAndCascadeOverrun,
isOverrun,
} from "../lib/cascade.js";
import type { AppEnv } from "../middleware/rbac.js";
async function withRetry<T>(
@@ -588,7 +584,6 @@ appointmentsRouter.patch(
// (fixes #18). Also falls back to the existing staffId when staffId is
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
let row: typeof appointments.$inferSelect | undefined;
let originalEndTime: Date | undefined;
try {
row = await db.transaction(async (tx) => {
const [current] = await tx
@@ -600,9 +595,6 @@ appointmentsRouter.patch(
throw Object.assign(new Error("not found"), { statusCode: 404 });
}
// Preserve original endTime for cascade detection after update
originalEndTime = current.endTime;
const start = updateFields.startTime
? new Date(updateFields.startTime)
: current.startTime;
@@ -692,29 +684,6 @@ appointmentsRouter.patch(
}
if (!row) return c.json({ error: "Not found" }, 404);
// Cascade delay prevention: detect overrun and shift downstream appointments
if (
originalEndTime &&
updateFields.endTime &&
isOverrun({
originalEndTime,
newEndTime: new Date(updateFields.endTime),
originalStartTime: row.startTime,
status: row.status,
currentTime: new Date(),
bufferMinutes: row.bufferMinutes ?? 0,
})
) {
const cascadeResult = await detectAndCascadeOverrun({
db,
overrunningAppointmentId: id,
newEndTime: new Date(updateFields.endTime),
originalEndTime,
});
return c.json({ ...row, cascade: cascadeResult });
}
return c.json(row);
}
+3 -24
View File
@@ -38,13 +38,11 @@ bookRouter.get("/services", async (c) => {
// ─── GET /api/book/availability ─────────────────────────────────────────────
// Public: return ISO startTime strings for slots where ≥1 groomer is free
// Query params: serviceId (uuid), date (YYYY-MM-DD), petSizeCategory, petCoatType
// Query params: serviceId (uuid), date (YYYY-MM-DD)
bookRouter.get("/availability", async (c) => {
const serviceId = c.req.query("serviceId");
const dateStr = c.req.query("date");
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
const petCoatType = c.req.query("petCoatType") ?? undefined;
if (!serviceId || !dateStr) {
return c.json({ error: "serviceId and date are required" }, 400);
@@ -60,12 +58,6 @@ bookRouter.get("/availability", async (c) => {
.where(and(eq(services.id, serviceId), eq(services.active, true)));
if (!service) return c.json({ error: "Service not found" }, 404);
// Buffer-aware duration: extra time for large/x-large or complex coats
const extraBuffer = (petSizeCategory === "large" || petSizeCategory === "xlarge")
? (service.defaultBufferMinutes ?? 0)
: 0;
const durationMinutes = service.durationMinutes + extraBuffer;
const groomers = await db
.select({ id: staff.id })
.from(staff)
@@ -97,7 +89,7 @@ bookRouter.get("/availability", async (c) => {
const slots = generateAvailableSlots({
dateStr,
durationMinutes,
durationMinutes: service.durationMinutes,
groomerIds: groomers.map((g) => g.id),
booked,
});
@@ -120,12 +112,6 @@ const bookingSchema = z.object({
petName: z.string().min(1).max(200),
petSpecies: z.string().min(1).max(100),
petBreed: z.string().max(100).optional(),
petSizeCategory: z
.enum(["small", "medium", "large", "xlarge"])
.optional(),
petCoatType: z
.enum(["smooth", "double", "curly", "wire", "long", "hairless"])
.optional(),
notes: z.string().max(2000).optional(),
});
@@ -143,7 +129,7 @@ bookRouter.post(
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
if (!service) return c.json({ error: "Service not found" }, 404);
let end = new Date(start.getTime() + service.durationMinutes * 60_000);
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
// Find all active groomers
const groomers = await db
@@ -205,18 +191,11 @@ bookRouter.post(
name: body.petName,
species: body.petSpecies,
breed: body.petBreed ?? null,
sizeCategory: body.petSizeCategory ?? null,
coatType: body.petCoatType ?? null,
})
.returning();
const pet = petInserted[0];
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
// Buffer-aware end time: large/x-large pets add service bufferMinutes
if (body.petSizeCategory === "large" || body.petSizeCategory === "xlarge") {
end = new Date(start.getTime() + (service.durationMinutes + (service.defaultBufferMinutes ?? 0)) * 60_000);
}
// Insert appointment in a transaction to guard against race conditions
let appointment;
try {
-274
View File
@@ -1,274 +0,0 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import {
and,
eq,
desc,
lt,
sql,
getDb,
conversations,
messages,
clients,
businessSettings,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import { sendMessage } from "../services/messaging/outbound.js";
export const conversationsRouter = new Hono<AppEnv>();
const sendMessageSchema = z.object({
body: z.string().min(1).max(1600),
});
// GET /api/conversations — List conversations
conversationsRouter.get("/", async (c) => {
const db = getDb();
const staffRow = c.get("staff");
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
if (!settings) return c.json({ error: "Business not found" }, 404);
const cursor = c.req.query("cursor") || undefined;
const limit = Math.min(Number(c.req.query("limit") || "20"), 50);
let baseQuery = db
.select({
id: conversations.id,
clientId: conversations.clientId,
lastMessageAt: conversations.lastMessageAt,
status: conversations.status,
staffReadAt: conversations.staffReadAt,
clientName: clients.name,
clientPhone: clients.phone,
channel: conversations.channel,
})
.from(conversations)
.innerJoin(clients, eq(conversations.clientId, clients.id))
.where(eq(conversations.businessId, settings.id))
.orderBy(desc(conversations.lastMessageAt))
.limit(limit + 1);
if (cursor) {
const [cursorRow] = await db
.select({ lastMessageAt: conversations.lastMessageAt })
.from(conversations)
.where(eq(conversations.id, cursor))
.limit(1);
if (cursorRow?.lastMessageAt) {
baseQuery = db
.select({
id: conversations.id,
clientId: conversations.clientId,
lastMessageAt: conversations.lastMessageAt,
status: conversations.status,
staffReadAt: conversations.staffReadAt,
clientName: clients.name,
clientPhone: clients.phone,
channel: conversations.channel,
})
.from(conversations)
.innerJoin(clients, eq(conversations.clientId, clients.id))
.where(
and(
eq(conversations.businessId, settings.id),
lt(conversations.lastMessageAt, cursorRow.lastMessageAt)
)
)
.orderBy(desc(conversations.lastMessageAt))
.limit(limit + 1);
}
}
const rows = await baseQuery;
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
const items = await Promise.all(
rows.map(async (row) => {
const [unreadRow] = await db
.select({ count: sql<number>`count(*)` })
.from(messages)
.where(
and(
eq(messages.conversationId, row.id),
eq(messages.direction, "inbound"),
sql`${messages.createdAt} > COALESCE(${row.staffReadAt}, '1970-01-01'::timestamp)`
)
)
.limit(1);
const [lastMsg] = await db
.select({
body: messages.body,
direction: messages.direction,
createdAt: messages.createdAt,
})
.from(messages)
.where(eq(messages.conversationId, row.id))
.orderBy(desc(messages.createdAt))
.limit(1);
return {
id: row.id,
clientId: row.clientId,
clientName: row.clientName,
clientPhone: row.clientPhone,
channel: row.channel,
lastMessageAt: row.lastMessageAt,
status: row.status,
unreadCount: Number(unreadRow?.count ?? 0),
lastMessage: lastMsg ?? null,
};
})
);
const lastRow = rows[rows.length - 1];
const nextCursor = hasMore && lastRow ? lastRow.id : null;
return c.json({ items, nextCursor });
});
// GET /api/conversations/:id/messages — List messages for a conversation
conversationsRouter.get("/:id/messages", async (c) => {
const db = getDb();
const staffRow = c.get("staff");
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
const conversationId = c.req.param("id");
const cursor = c.req.query("cursor") || undefined;
const limit = Math.min(Number(c.req.query("limit") || "50"), 100);
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
if (!settings) return c.json({ error: "Business not found" }, 404);
const [conv] = await db
.select({ id: conversations.id })
.from(conversations)
.where(
and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id))
)
.limit(1);
if (!conv) return c.json({ error: "Not found" }, 404);
await db
.update(conversations)
.set({ staffReadAt: new Date() })
.where(eq(conversations.id, conversationId));
let query = db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
sentByStaffId: messages.sentByStaffId,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(desc(messages.createdAt))
.limit(limit + 1);
if (cursor) {
const [cursorRow] = await db
.select({ createdAt: messages.createdAt })
.from(messages)
.where(eq(messages.id, cursor))
.limit(1);
if (cursorRow?.createdAt) {
query = db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
sentByStaffId: messages.sentByStaffId,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(
and(
eq(messages.conversationId, conversationId),
lt(messages.createdAt, cursorRow.createdAt)
)
)
.orderBy(desc(messages.createdAt))
.limit(limit + 1);
}
}
const rows = await query;
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
const lastRow = rows[rows.length - 1];
const nextCursor = hasMore && lastRow ? lastRow.id : null;
return c.json({ items: rows, nextCursor });
});
// POST /api/conversations/:id/messages — Send a message
conversationsRouter.post(
"/:id/messages",
zValidator("json", sendMessageSchema),
async (c) => {
const db = getDb();
const staffRow = c.get("staff");
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
const conversationId = c.req.param("id");
const { body } = c.req.valid("json");
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
if (!settings) return c.json({ error: "Business not found" }, 404);
const [conv] = await db
.select({ id: conversations.id, clientId: conversations.clientId })
.from(conversations)
.where(
and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id))
)
.limit(1);
if (!conv) return c.json({ error: "Not found" }, 404);
const result = await sendMessage({
businessId: settings.id,
clientId: conv.clientId,
body,
sentByStaffId: staffRow.id,
});
if (result.suppressed) {
return c.json({ error: "Client has opted out of SMS" }, 409);
}
const [msg] = await db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
sentByStaffId: messages.sentByStaffId,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(eq(messages.id, result.messageId))
.limit(1);
return c.json(msg, 201);
}
);
+12 -9
View File
@@ -14,8 +14,7 @@ import {
clients,
sql,
} from "@groombook/db";
import type { AppEnv, StaffRole } from "../middleware/rbac.js";
import { requireRole } from "../middleware/rbac.js";
import type { AppEnv } from "../middleware/rbac.js";
export const invoicesRouter = new Hono<AppEnv>();
@@ -461,9 +460,6 @@ invoicesRouter.post(
if (invoice.status !== "paid") {
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
}
if (!invoice.stripePaymentIntentId) {
return c.json({ error: "Invoice has no Stripe payment intent" }, 422);
}
return await db.transaction(async (tx) => {
if (body.idempotencyKey) {
@@ -476,9 +472,16 @@ invoicesRouter.post(
}
}
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
const refundId = result.refundId;
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()}`;
}
await tx.insert(refunds).values({
invoiceId: id,
@@ -493,7 +496,7 @@ invoicesRouter.post(
);
// Payment stats for admin dashboard
invoicesRouter.get("/stats/summary", requireRole("manager" as StaffRole), async (c) => {
invoicesRouter.get("/stats/summary", async (c) => {
try {
const db = getDb();
const now = new Date();
+2 -95
View File
@@ -1,8 +1,8 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, inArray, desc, lt } from "@groombook/db";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems, businessSettings, conversations, messages } from "@groombook/db";
import { eq, inArray } from "@groombook/db";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
import { validatePortalSession } from "../middleware/portalSession.js";
import { portalAudit } from "../middleware/portalAudit.js";
import type { PortalEnv } from "../middleware/portalSession.js";
@@ -175,99 +175,6 @@ portalRouter.get("/invoices", async (c) => {
})));
});
// ─── Conversation routes ──────────────────────────────────────────────────────
portalRouter.get("/conversation", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const [settings] = await db.select({ id: businessSettings.id }).from(businessSettings).limit(1);
if (!settings) return c.json({ error: "Business not configured" }, 500);
const businessId = settings.id;
const [conversation] = await db
.select({
id: conversations.id,
channel: conversations.channel,
lastMessageAt: conversations.lastMessageAt,
status: conversations.status,
createdAt: conversations.createdAt,
})
.from(conversations)
.where(and(eq(conversations.clientId, clientId), eq(conversations.businessId, businessId)))
.limit(1);
if (!conversation) {
return c.body(null, 204);
}
return c.json(conversation);
});
portalRouter.get("/conversation/messages", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const cursor = c.req.query("cursor") || undefined;
const limit = Math.min(Number(c.req.query("limit") || "50"), 100);
const [settings] = await db.select({ id: businessSettings.id }).from(businessSettings).limit(1);
if (!settings) return c.json({ error: "Business not configured" }, 500);
const businessId = settings.id;
const [conversation] = await db
.select({ id: conversations.id })
.from(conversations)
.where(and(eq(conversations.clientId, clientId), eq(conversations.businessId, businessId)))
.limit(1);
if (!conversation) {
return c.body(null, 204);
}
let query = db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(eq(messages.conversationId, conversation.id))
.orderBy(desc(messages.createdAt))
.limit(limit);
if (cursor) {
const [cursorMsg] = await db
.select({ createdAt: messages.createdAt })
.from(messages)
.where(eq(messages.id, cursor))
.limit(1);
if (cursorMsg) {
query = db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(and(eq(messages.conversationId, conversation.id), lt(messages.createdAt, cursorMsg.createdAt)))
.orderBy(desc(messages.createdAt))
.limit(limit);
}
}
const messagesResult = await query;
const nextCursor = messagesResult.length === limit ? messagesResult[messagesResult.length - 1]!.id : null;
return c.json({ messages: messagesResult, nextCursor });
});
// ─── Appointment action routes ────────────────────────────────────────────────
const customerNotesSchema = z.object({
-59
View File
@@ -1,59 +0,0 @@
import { Hono } from "hono";
import { validateTelnyxSignature } from "../../services/sms.js";
import {
handleMessageReceived,
handleMessageFinalized,
TelnyxMessageReceivedPayload,
} from "../../services/messaging/inbound.js";
export const telnyxWebhooksRouter = new Hono();
telnyxWebhooksRouter.post("/messaging", async (c) => {
const signature = c.req.header("telnyx-signature");
let rawBody: string;
try {
rawBody = await c.req.text();
} catch {
return c.json({ error: "Could not read body" }, 400);
}
if (!validateTelnyxSignature(rawBody, signature)) {
return c.json({ error: "Invalid signature" }, 401);
}
let payload: TelnyxMessageReceivedPayload;
try {
payload = JSON.parse(rawBody) as TelnyxMessageReceivedPayload;
} catch {
return c.json({ error: "Invalid JSON" }, 400);
}
const eventType = payload.data?.event_type;
if (!eventType) {
return c.json({ error: "Missing event_type" }, 400);
}
if (eventType === "message.received") {
try {
await handleMessageReceived(payload);
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
if (msg.startsWith("No business owns")) {
return c.json({ error: "Unknown messaging number" }, 404);
}
return c.json({ error: msg }, 500);
}
return c.json({ received: true });
}
if (eventType === "message.finalized") {
const result = await handleMessageFinalized(payload);
if (result) {
return c.json({ received: true, messageId: result.messageId, status: result.newStatus });
}
return c.json({ received: true, messageId: null });
}
return c.json({ received: true });
});
-49
View File
@@ -201,52 +201,3 @@ export function buildWaitlistNotificationEmail(
<p>— Groom Book</p>`,
};
}
// ─── Reschedule notification email ────────────────────────────────────────────
interface RescheduleEmailData {
clientName: string;
petName: string;
serviceName: string;
groomerName: string | null;
oldStartTime: Date;
newStartTime: Date;
}
export function buildRescheduleNotificationEmail(
to: string,
data: RescheduleEmailData
): Mail.Options {
const oldTime = formatDateTime(data.oldStartTime);
const newTime = formatDateTime(data.newStartTime);
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
return {
to,
subject: `Appointment Rescheduled — ${data.petName}'s appointment has been moved`,
text: [
`Hi ${data.clientName},`,
``,
`Your appointment has been rescheduled.`,
``,
` Pet: ${data.petName}`,
` Service: ${data.serviceName}`,
` Was: ${oldTime}${groomer}`,
` Now: ${newTime}${groomer}`,
``,
`If you have any questions or need to make changes, please contact us.`,
``,
`— Groom Book`,
].join("\n"),
html: `
<p>Hi ${data.clientName},</p>
<p>Your appointment has been <strong>rescheduled</strong>.</p>
<table style="border-collapse:collapse;margin:1em 0">
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#ef4444">Was</td><td style="text-decoration:line-through;color:#ef4444">${oldTime}${groomer}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#10b981">Now</td><td style="color:#10b981">${newTime}${groomer}</td></tr>
</table>
<p>If you have any questions or need to make changes, please contact us.</p>
<p>— Groom Book</p>`,
};
}
@@ -1,214 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { detectKeyword } from "../consent.js";
const mockDb = {
insert: vi.fn(),
update: vi.fn(),
select: vi.fn(),
};
vi.mock("@groombook/db", () => ({
getDb: () => mockDb,
clients: {},
messageConsentEvents: {},
businessSettings: {},
eq: vi.fn(),
}));
const { handleConsentKeyword } = await import("../consent.js");
describe("detectKeyword", () => {
it.each([
["STOP", "opt_out"],
["STOPALL", "opt_out"],
["UNSUBSCRIBE", "opt_out"],
["CANCEL", "opt_out"],
["END", "opt_out"],
["QUIT", "opt_out"],
])("opt-out keyword %s → opt_out", (keyword, expected) => {
expect(detectKeyword(keyword)).toEqual({ kind: expected });
});
it.each([
["START", "opt_in"],
["UNSTOP", "opt_in"],
["YES", "opt_in"],
["SUBSCRIBE", "opt_in"],
])("opt-in keyword %s → opt_in", (keyword, expected) => {
expect(detectKeyword(keyword)).toEqual({ kind: expected });
});
it.each([
["HELP", "help"],
["INFO", "help"],
])("help keyword %s → help", (keyword, expected) => {
expect(detectKeyword(keyword)).toEqual({ kind: expected });
});
it("is case insensitive", () => {
expect(detectKeyword("stop")).toEqual({ kind: "opt_out" });
expect(detectKeyword("Stop")).toEqual({ kind: "opt_out" });
expect(detectKeyword("sToP")).toEqual({ kind: "opt_out" });
});
it("trims whitespace", () => {
expect(detectKeyword(" STOP ")).toEqual({ kind: "opt_out" });
expect(detectKeyword("\tSTART\n")).toEqual({ kind: "opt_in" });
});
it("returns null for non-keyword messages", () => {
expect(detectKeyword("hello")).toBeNull();
expect(detectKeyword("STOP IT")).toBeNull();
expect(detectKeyword("help me")).toBeNull();
});
});
describe("handleConsentKeyword", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.insert.mockReturnValue({
values: vi.fn().mockResolvedValue([{ id: "event-1" }]),
} as any);
mockDb.update.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
} as any);
});
const baseOpts = {
clientId: "client-1",
businessId: "biz-1",
db: mockDb as unknown as ReturnType<typeof import("@groombook/db").getDb>,
};
describe("opt_out", () => {
it("inserts consent event with sms_keyword source", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(mockDb.insert).toHaveBeenCalledOnce();
});
it("sets smsOptIn=false and smsOptOutDate when currently opted in", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(mockDb.update).toHaveBeenCalled();
});
it("is idempotent — second opt-out logs event but skips client update", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(mockDb.update).not.toHaveBeenCalled();
});
it("returns unsubscribe reply text", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(result.replyText).toBe(
"You have been unsubscribed and will no longer receive messages. Reply START to resubscribe."
);
});
});
describe("opt_in", () => {
it("sets smsOptIn=true and smsConsentDate when currently opted out", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false, smsConsentDate: null }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(mockDb.update).toHaveBeenCalled();
});
it("clears smsOptOutDate on opt-in after opt-out", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(mockDb.update).toHaveBeenCalled();
});
it("is idempotent — second opt-in skips client update", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(mockDb.update).not.toHaveBeenCalled();
});
it("returns resubscribe reply text", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
}),
}),
} as any);
const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(result.replyText).toBe(
"You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply."
);
});
});
describe("help", () => {
it("returns default help reply without querying businessSettings", async () => {
const result = await handleConsentKeyword({ ...baseOpts, kind: "help" });
expect(mockDb.update).not.toHaveBeenCalled();
expect(mockDb.select).not.toHaveBeenCalled();
expect(result.replyText).toBe(
"Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly."
);
});
});
});
@@ -1,313 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
findOrCreateConversation,
upsertMessage,
handleMessageReceived,
handleMessageFinalized,
TelnyxMessageReceivedPayload,
} from "../inbound.js";
import * as schema from "@groombook/db";
vi.mock("@groombook/db", () => ({
getDb: vi.fn(),
conversations: { id: "", businessId: "", clientId: "", externalNumber: "", businessNumber: "", channel: "", lastMessageAt: null, status: "", createdAt: null, updatedAt: null },
messages: { id: "", conversationId: "", direction: "", body: "", status: "", providerMessageId: "", sentByStaffId: null, createdAt: null, deliveredAt: null, readByClientAt: null },
businessSettings: { id: "", messagingPhoneNumber: "" },
clients: { id: "", name: "", email: "", phone: "", status: "" },
eq: vi.fn(),
and: vi.fn(),
sql: vi.fn(),
}));
const mockDb = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnThis(),
};
vi.mocked(schema.getDb).mockReturnValue(mockDb as unknown as ReturnType<typeof schema.getDb>);
const makePayload = (
eventType: "message.received" | "message.sent" | "message.finalized",
messageId: string,
fromPhone: string,
toPhone: string,
body = "Hello"
): TelnyxMessageReceivedPayload => ({
data: {
id: "evt-1",
event_type: eventType,
payload: {
message: {
id: messageId,
from: { phone: fromPhone, carrier: "carrier" },
to: [{ phone: toPhone }],
body,
},
},
},
});
describe("signature validation via route", () => {
beforeEach(() => {
vi.resetModules();
});
it("returns 401 when telnyx-signature header is missing", async () => {
const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js");
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
const req = new Request("http://localhost/messaging", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
});
const res = await telnyxWebhooksRouter.fetch(req);
expect(res.status).toBe(401);
});
it("returns 401 when signature does not match", async () => {
process.env.TELNYX_WEBHOOK_SECRET = "test-secret";
const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js");
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
const req = new Request("http://localhost/messaging", {
method: "POST",
headers: {
"Content-Type": "application/json",
"telnyx-signature": "sha256=bad",
},
body: payload,
});
const res = await telnyxWebhooksRouter.fetch(req);
expect(res.status).toBe(401);
});
});
describe("findOrCreateConversation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.select.mockReset();
mockDb.from.mockReset();
mockDb.where.mockReset();
mockDb.limit.mockReset();
mockDb.insert.mockReset();
mockDb.update.mockReset();
mockDb.returning.mockReset();
});
it("returns existing conversation when found", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ id: "conv-1", clientId: "client-1" }]),
}),
}),
});
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
expect(result.id).toBe("conv-1");
});
it("creates new conversation when none exists", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "conv-2", clientId: "client-2" }]),
}),
});
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
expect(result.id).toBe("conv-2");
});
it("creates placeholder client for unknown phone then creates conversation", async () => {
mockDb.select
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "conv-3", clientId: "client-3" }]),
}),
});
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
expect(result.id).toBe("conv-3");
expect(result.clientId).toBe("client-3");
});
});
describe("upsertMessage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns isNew=false when message with providerMessageId already exists", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ id: "msg-existing" }]),
}),
}),
});
const result = await upsertMessage("msg-123", "conv-1", "inbound", "Hello", "received");
expect(result.isNew).toBe(false);
expect(result.id).toBe("msg-existing");
});
it("inserts new message and returns isNew=true", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "msg-new" }]),
}),
});
const result = await upsertMessage("msg-new-123", "conv-1", "inbound", "New message", "queued");
expect(result.isNew).toBe(true);
expect(result.id).toBe("msg-new");
});
});
describe("handleMessageReceived", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.select.mockReset();
mockDb.from.mockReset();
mockDb.where.mockReset();
mockDb.limit.mockReset();
mockDb.insert.mockReset();
mockDb.update.mockReset();
mockDb.returning.mockReset();
mockDb.select.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
}));
});
it("returns 404 when no business owns the to number", async () => {
const payload = makePayload("message.received", "msg-123", "+1555111", "+1555000");
await expect(handleMessageReceived(payload)).rejects.toThrow("No business owns messaging number");
});
it("creates conversation and message for valid inbound", async () => {
mockDb.select
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ id: "biz-1" }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
mockDb.insert
.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "client-new" }]),
}),
})
.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "conv-new", clientId: "client-new" }]),
}),
});
mockDb.update.mockReturnValueOnce({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({}),
}),
});
mockDb.insert.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "msg-new" }]),
}),
});
const payload = makePayload("message.received", "msg-abc", "+1555111", "+1555222", "Test message");
const result = await handleMessageReceived(payload);
expect(result.messageId).toBe("msg-new");
});
});
describe("handleMessageFinalized", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.select.mockReset();
mockDb.from.mockReset();
mockDb.where.mockReset();
mockDb.limit.mockReset();
mockDb.insert.mockReset();
mockDb.update.mockReset();
mockDb.returning.mockReset();
});
it("returns null when message not found", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
const payload = makePayload("message.finalized", "msg-unknown", "+1555111", "+1555222");
const result = await handleMessageFinalized(payload);
expect(result).toBeNull();
});
it("updates status to delivered for finalized inbound", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ id: "msg-1", status: "sent" }]),
}),
}),
});
mockDb.update.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "msg-1" }]),
}),
}),
});
const payload = makePayload("message.finalized", "msg-1", "+1555111", "+1555222");
const result = await handleMessageFinalized(payload);
expect(result?.newStatus).toBe("delivered");
});
});
@@ -1,200 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockSendSms = vi.fn();
const mockGetDb = vi.fn();
const mockUuidv4 = vi.fn();
vi.mock("../../sms.js", () => ({
sendSms: mockSendSms,
}));
vi.mock("@groombook/db", () => ({
getDb: () => mockGetDb(),
conversations: {},
messages: {},
clients: {},
businessSettings: {},
eq: vi.fn((a, b) => [a, b]),
and: vi.fn((...args) => args),
}));
vi.mock("uuid", () => ({
v4: () => mockUuidv4(),
}));
const { sendMessage, MissingTenantPhoneNumberError } = await import("../outbound.js");
describe("sendMessage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUuidv4.mockReturnValue("test-uuid");
});
function buildSelectMock(results: unknown[]) {
return vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(results),
}),
}),
});
}
it("returns suppressed=true when client has no phone", async () => {
mockGetDb.mockReturnValue({
select: buildSelectMock([{ phone: null, smsOptIn: true }]),
});
const result = await sendMessage({
businessId: "biz-1",
clientId: "client-1",
body: "Hello",
});
expect(result).toEqual({ suppressed: true });
expect(mockSendSms).not.toHaveBeenCalled();
});
it("returns suppressed=true when client has opted out of SMS", async () => {
mockGetDb.mockReturnValue({
select: buildSelectMock([{ phone: "+1234567890", smsOptIn: false }]),
});
const result = await sendMessage({
businessId: "biz-1",
clientId: "client-1",
body: "Hello",
});
expect(result).toEqual({ suppressed: true });
expect(mockSendSms).not.toHaveBeenCalled();
});
it("throws MissingTenantPhoneNumberError when tenant has no messaging phone", async () => {
mockGetDb.mockReturnValue({
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: null }]),
}),
}),
}),
});
await expect(
sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" })
).rejects.toThrow(MissingTenantPhoneNumberError);
});
it("persists provider message id on success", async () => {
const messageId = "msg-1";
const conversationId = "conv-1";
mockGetDb.mockReturnValue({
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: conversationId }]),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([{ id: messageId }]),
}),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}),
});
mockSendSms.mockResolvedValue({ messageId: "provider-msg-1", status: "sent" });
const result = await sendMessage({
businessId: "biz-1",
clientId: "client-1",
body: "Hello",
});
expect(result).toEqual({
messageId,
providerMessageId: "provider-msg-1",
status: "sent",
suppressed: false,
});
expect(mockSendSms).toHaveBeenCalledWith("+1234567890", "Hello", undefined);
});
it("persists error on Telnyx failure", async () => {
const messageId = "msg-1";
mockGetDb.mockReturnValue({
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([{ id: messageId }]),
}),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}),
});
mockSendSms.mockRejectedValue(new Error("Telnyx API error"));
await expect(
sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" })
).rejects.toThrow("Telnyx API error");
});
});
@@ -1,77 +0,0 @@
import { clients, messageConsentEvents, eq } from "@groombook/db";
import type { Db } from "@groombook/db";
export type KeywordKind = "opt_in" | "opt_out" | "help";
const OPT_OUT_KEYWORDS = new Set(["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]);
const OPT_IN_KEYWORDS = new Set(["START", "UNSTOP", "YES", "SUBSCRIBE"]);
const HELP_KEYWORDS = new Set(["HELP", "INFO"]);
export function detectKeyword(body: string): { kind: KeywordKind } | null {
const normalized = body.trim().toUpperCase();
if (OPT_OUT_KEYWORDS.has(normalized)) return { kind: "opt_out" };
if (OPT_IN_KEYWORDS.has(normalized)) return { kind: "opt_in" };
if (HELP_KEYWORDS.has(normalized)) return { kind: "help" };
return null;
}
export async function handleConsentKeyword(opts: {
clientId: string;
businessId: string;
kind: KeywordKind;
db: Db;
}): Promise<{ replyText: string }> {
const { clientId, businessId, kind, db: database } = opts;
await database.insert(messageConsentEvents).values({
clientId,
businessId,
kind,
source: "sms_keyword",
});
if (kind === "opt_out") {
const [existing] = await database
.select({ smsOptIn: clients.smsOptIn })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (existing?.smsOptIn !== false) {
await database
.update(clients)
.set({ smsOptIn: false, smsOptOutDate: new Date() })
.where(eq(clients.id, clientId));
}
return {
replyText: "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe.",
};
}
if (kind === "opt_in") {
const [existing] = await database
.select({ smsOptIn: clients.smsOptIn, smsConsentDate: clients.smsConsentDate })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (existing?.smsOptIn !== true) {
await database
.update(clients)
.set({ smsOptIn: true, smsConsentDate: new Date(), smsOptOutDate: null })
.where(eq(clients.id, clientId));
}
return {
replyText:
"You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply.",
};
}
// kind === "help"
const replyText =
"Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly.";
return { replyText };
}
-218
View File
@@ -1,218 +0,0 @@
import { getDb, conversations, messages, businessSettings, clients, eq, and } from "@groombook/db";
import { v4 as uuidv4 } from "uuid";
import { detectKeyword, handleConsentKeyword } from "./consent.js";
import { sendMessage } from "./outbound.js";
export interface TelnyxMessageReceivedPayload {
data: {
id: string;
event_type: "message.received" | "message.sent" | "message.finalized";
payload: {
message: {
id: string;
from: { phone: string; carrier?: string };
to: { phone: string }[];
body: string;
media?: Array<{ type: string; url: string }>;
};
recording?: unknown;
leg_count?: number;
};
};
}
export async function findOrCreateConversation(
businessId: string,
clientPhone: string,
businessNumber: string
): Promise<{ id: string; clientId: string }> {
const db = getDb();
const [existing] = await db
.select({ id: conversations.id, clientId: conversations.clientId })
.from(conversations)
.where(
and(
eq(conversations.businessId, businessId),
eq(conversations.externalNumber, clientPhone),
eq(conversations.businessNumber, businessNumber)
)
)
.limit(1);
if (existing) {
return { id: existing.id, clientId: existing.clientId };
}
const [existingClient] = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.phone, clientPhone))
.limit(1);
const clientId = existingClient?.id ?? uuidv4();
if (!existingClient) {
await db.insert(clients).values({
id: clientId,
name: clientPhone,
email: `sms-${uuidv4()}@placeholder.local`,
phone: clientPhone,
status: "active",
});
}
const [created] = await db
.insert(conversations)
.values({
id: crypto.randomUUID(),
businessId,
clientId,
channel: "sms",
externalNumber: clientPhone,
businessNumber,
lastMessageAt: new Date(),
status: "active",
})
.returning({ id: conversations.id, clientId: conversations.clientId });
if (!created) throw new Error("Failed to create conversation");
return { id: created.id, clientId: created.clientId };
}
export async function upsertMessage(
providerMessageId: string,
conversationId: string,
direction: "inbound" | "outbound",
body: string,
status: "queued" | "sent" | "delivered" | "failed" | "received",
sentByStaffId?: string
): Promise<{ id: string; isNew: boolean }> {
const db = getDb();
const [existing] = await db
.select({ id: messages.id })
.from(messages)
.where(eq(messages.providerMessageId, providerMessageId))
.limit(1);
if (existing) {
return { id: existing.id, isNew: false };
}
try {
const [inserted] = await db
.insert(messages)
.values({
id: crypto.randomUUID(),
conversationId,
direction,
body,
status,
providerMessageId,
sentByStaffId: sentByStaffId ?? null,
})
.returning({ id: messages.id });
if (!inserted) throw new Error("Failed to insert message");
return { id: inserted.id, isNew: true };
} catch (err) {
if (err instanceof Error && err.message.includes("unique")) {
const [existing] = await db
.select({ id: messages.id })
.from(messages)
.where(eq(messages.providerMessageId, providerMessageId))
.limit(1);
if (existing) return { id: existing.id, isNew: false };
}
throw err;
}
}
export async function resolveBusinessIdByMessagingNumber(toNumber: string): Promise<string | null> {
const db = getDb();
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.where(eq(businessSettings.messagingPhoneNumber, toNumber))
.limit(1);
return settings?.id ?? null;
}
export async function handleMessageReceived(payload: TelnyxMessageReceivedPayload): Promise<{ conversationId: string; messageId: string }> {
const { message } = payload.data.payload;
const fromPhone = message.from.phone;
const toPhone = message.to[0]?.phone;
if (!toPhone) {
throw new Error("No recipient phone in payload");
}
const businessId = await resolveBusinessIdByMessagingNumber(toPhone);
if (!businessId) {
throw new Error(`No business owns messaging number: ${toPhone}`);
}
const { id: conversationId, clientId } = await findOrCreateConversation(businessId, fromPhone, toPhone);
await getDb()
.update(conversations)
.set({ lastMessageAt: new Date(), updatedAt: new Date() })
.where(eq(conversations.id, conversationId));
const { id: messageId } = await upsertMessage(
message.id,
conversationId,
"inbound",
message.body,
"received"
);
const keyword = detectKeyword(message.body ?? "");
if (keyword) {
const { replyText } = await handleConsentKeyword({
clientId,
businessId,
kind: keyword.kind,
db: getDb(),
});
await sendMessage({
businessId,
clientId,
body: replyText,
sentByStaffId: undefined,
});
}
return { conversationId, messageId };
}
export async function handleMessageFinalized(payload: TelnyxMessageReceivedPayload): Promise<{ messageId: string; newStatus: string } | null> {
const { message } = payload.data.payload;
if (!message.id) return null;
const db = getDb();
const [existing] = await db
.select({ id: messages.id, status: messages.status })
.from(messages)
.where(eq(messages.providerMessageId, message.id))
.limit(1);
if (!existing) return null;
let newStatus = existing.status;
if (payload.data.event_type === "message.finalized") {
newStatus = "delivered";
}
if (newStatus !== existing.status) {
await db
.update(messages)
.set({ status: newStatus, deliveredAt: new Date() })
.where(eq(messages.id, existing.id));
}
return { messageId: existing.id, newStatus };
}
-159
View File
@@ -1,159 +0,0 @@
import { getDb, conversations, messages, clients, businessSettings, eq, and } from "@groombook/db";
import { v4 as uuidv4 } from "uuid";
import { sendSms } from "../sms.js";
export interface SendMessageOptions {
businessId: string;
clientId: string;
body: string;
sentByStaffId?: string;
mediaUrls?: string[];
}
export interface SendMessageResult {
messageId: string;
providerMessageId: string;
status: string;
suppressed: false;
}
export interface SendMessageSuppressed {
suppressed: true;
}
export type SendMessageResponse = SendMessageResult | SendMessageSuppressed;
export class MissingTenantPhoneNumberError extends Error {
constructor() {
super("Tenant messagingPhoneNumber is not configured");
this.name = "MissingTenantPhoneNumberError";
}
}
async function findOrCreateConversation(
businessId: string,
clientId: string,
externalNumber: string,
businessNumber: string
): Promise<{ id: string }> {
const db = getDb();
const [existing] = await db
.select({ id: conversations.id })
.from(conversations)
.where(
and(
eq(conversations.businessId, businessId),
eq(conversations.externalNumber, externalNumber),
eq(conversations.businessNumber, businessNumber)
)
)
.limit(1);
if (existing) return { id: existing.id };
const [created] = await db
.insert(conversations)
.values({
id: uuidv4(),
businessId,
clientId,
channel: "sms",
externalNumber,
businessNumber,
lastMessageAt: new Date(),
status: "active",
})
.returning({ id: conversations.id });
if (!created) throw new Error("Failed to create conversation");
return { id: created.id };
}
async function resolveFromNumber(businessId: string): Promise<string | null> {
const db = getDb();
const [settings] = await db
.select({ messagingPhoneNumber: businessSettings.messagingPhoneNumber })
.from(businessSettings)
.where(eq(businessSettings.id, businessId))
.limit(1);
return settings?.messagingPhoneNumber ?? null;
}
export async function sendMessage(opts: SendMessageOptions): Promise<SendMessageResponse> {
const db = getDb();
const { businessId, clientId, body, sentByStaffId, mediaUrls } = opts;
const [client] = await db
.select({ phone: clients.phone, smsOptIn: clients.smsOptIn })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (!client?.phone) {
return { suppressed: true };
}
if (!client.smsOptIn) {
return { suppressed: true };
}
const from = await resolveFromNumber(businessId);
if (!from) throw new MissingTenantPhoneNumberError();
const to = client.phone;
const conversationId = (await findOrCreateConversation(businessId, clientId, to, from)).id;
const [queuedMessage] = await db
.insert(messages)
.values({
id: uuidv4(),
conversationId,
direction: "outbound",
body,
status: "queued",
sentByStaffId: sentByStaffId ?? null,
})
.returning({ id: messages.id });
if (!queuedMessage) throw new Error("Failed to insert queued message");
try {
const result = await sendSms(to, body, mediaUrls);
await db
.update(messages)
.set({
status: "sent",
providerMessageId: result.messageId,
})
.where(eq(messages.id, queuedMessage.id));
await db
.update(conversations)
.set({ lastMessageAt: new Date() })
.where(eq(conversations.id, conversationId));
return {
messageId: queuedMessage.id,
providerMessageId: result.messageId,
status: result.status,
suppressed: false,
};
} catch (err) {
const errorCode = err instanceof Error ? err.name : "UNKNOWN";
const errorMessage = err instanceof Error ? err.message : String(err);
await db
.update(messages)
.set({
status: "failed",
errorCode,
errorMessage,
})
.where(eq(messages.id, queuedMessage.id));
throw err;
}
}
+27 -30
View File
@@ -32,35 +32,6 @@ function isE164(phone: string): boolean {
return /^\+[1-9]\d{7,14}$/.test(phone);
}
export function validateTelnyxSignature(
rawBody: string,
signature: string | undefined | null
): boolean {
if (!signature) return false;
const secret = process.env.TELNYX_WEBHOOK_SECRET;
if (!secret) return false;
try {
const hmac = createHmac("sha256", secret);
const expected = `sha256=${hmac.update(rawBody).digest("hex")}`;
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length) return false;
let diff = 0;
for (let i = 0; i < sigBuf.length; i++) {
const sigByte = sigBuf[i] ?? 0;
const expByte = expBuf[i] ?? 0;
diff |= sigByte ^ expByte;
}
return diff === 0;
} catch {
return false;
}
}
export async function sendSms(
to: string,
body: string,
@@ -103,7 +74,33 @@ export class TelnyxProvider implements SmsProvider {
}
validateWebhookSignature(req: Request): boolean {
return validateTelnyxSignature(JSON.stringify(req.body), req.headers.get("telnyx-signature"));
const secret = process.env.TELNYX_WEBHOOK_SECRET;
if (!secret) return false;
const signature = req.headers.get("telnyx-signature");
if (!signature) return false;
const payload = JSON.stringify(req.body);
try {
const hmac = createHmac("sha256", secret);
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length) return false;
let diff = 0;
for (let i = 0; i < sigBuf.length; i++) {
const sigByte = sigBuf[i] ?? 0;
const expByte = expBuf[i] ?? 0;
diff |= sigByte ^ expByte;
}
return diff === 0;
} catch {
return false;
}
}
}
+3 -9
View File
@@ -72,15 +72,9 @@ test.describe("Portal Data Integrity", () => {
});
test("billing section renders without JS errors", async ({ page }) => {
// Mock portal billing endpoints
await page.route("**/api/portal/config**", (route) =>
route.fulfill({ json: { stripePublishableKey: "" } })
);
await page.route("**/api/portal/invoices**", (route) =>
route.fulfill({ json: [] })
);
await page.route("**/api/portal/payment-methods**", (route) =>
route.fulfill({ json: [] })
// Mock billing endpoint
await page.route("**/api/billing**", (route) =>
route.fulfill({ json: { invoices: [], balanceCents: 0 } })
);
const consoleErrors: string[] = [];
+1 -1
View File
@@ -1 +1 @@
VITE_API_URL=https://uat.groombook.dev
VITE_API_URL=
-2
View File
@@ -11,8 +11,6 @@ RUN pnpm install --frozen-lockfile
# Build
FROM deps AS builder
ARG VITE_API_URL=
ENV VITE_API_URL=
COPY packages/types/ packages/types/
COPY apps/web/ apps/web/
RUN pnpm --filter @groombook/web build
+1 -7
View File
@@ -4,7 +4,6 @@ import { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.js";
import { ClientDetailPage } from "./pages/ClientDetailPage.js";
import { ServicesPage } from "./pages/Services.js";
import { MessagesPage } from "./pages/Messages.js";
import { StaffPage } from "./pages/Staff.js";
import { InvoicesPage } from "./pages/Invoices.js";
import { BookPage } from "./pages/Book.js";
@@ -40,10 +39,7 @@ function LoginPage() {
const handleSocialLogin = async (provider: string) => {
setIsLoading(true);
setError(null);
// Use /admin as callback URL so Better-Auth redirects to the app's dashboard
// after the OAuth callback completes, rather than back to /login
const callbackURL = `${window.location.origin}/admin`;
const result = await signIn.social({ provider, callbackURL });
const result = await signIn.social({ provider, callbackURL: window.location.origin });
if (result?.error) {
setError(result.error.message ?? "Sign-in failed");
setIsLoading(false);
@@ -174,7 +170,6 @@ function LoginPage() {
const NAV_LINKS = [
{ to: "/admin", label: "Appointments" },
{ to: "/admin/messages", label: "Messages" },
{ to: "/admin/clients", label: "Clients" },
{ to: "/admin/services", label: "Services" },
{ to: "/admin/staff", label: "Staff" },
@@ -301,7 +296,6 @@ function AdminLayout() {
<main style={{ padding: "1.25rem 1.5rem" }}>
<Routes>
<Route path="/" element={<AppointmentsPage />} />
<Route path="/messages" element={<MessagesPage />} />
<Route path="/clients" element={<ClientsPage />} />
<Route path="/clients/:clientId" element={<ClientDetailPage />} />
<Route path="/services" element={<ServicesPage />} />
-151
View File
@@ -1,151 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MessagesPage } from "../pages/Messages.js";
const mockConversations = [
{
id: "conv-1",
clientId: "client-1",
clientName: "Alice Smith",
channel: "sms",
clientPhone: "+1234567890",
lastMessageAt: "2026-05-14T10:00:00Z",
lastMessage: { body: "Hello, is my dog ready?", direction: "inbound", createdAt: "2026-05-14T10:00:00Z" },
unreadCount: 2,
status: "active",
},
{
id: "conv-2",
clientId: "client-2",
clientName: "Bob Jones",
channel: "sms",
clientPhone: "+1987654321",
lastMessageAt: "2026-05-13T08:00:00Z",
lastMessage: { body: "Thanks for the update", direction: "outbound", createdAt: "2026-05-13T08:05:00Z" },
unreadCount: 0,
status: "active",
},
];
const mockMessages = [
{
id: "msg-1",
direction: "inbound" as const,
body: "Hello, is my dog ready?",
status: "delivered",
createdAt: "2026-05-14T10:00:00Z",
sentByStaffId: null,
},
{
id: "msg-2",
direction: "outbound" as const,
body: "Yes, she is all done!",
status: "delivered",
createdAt: "2026-05-14T10:05:00Z",
sentByStaffId: "staff-1",
},
];
const makeResponse = (data: unknown): Response => {
return {
ok: true,
json: () => Promise.resolve(data),
} as Response;
};
const makeResponseWithStatus = (data: unknown, status: number): Response => {
return {
ok: true,
status,
json: () => Promise.resolve(data),
} as Response;
};
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("MessagesPage", () => {
it("renders empty state when no conversations", async () => {
vi.mocked(global.fetch).mockResolvedValue(makeResponse({ items: [], nextCursor: null }));
render(<MessagesPage />);
await waitFor(() => {
expect(screen.getByText("No conversations yet")).toBeInTheDocument();
});
});
it("renders conversation list", async () => {
vi.mocked(global.fetch).mockResolvedValue(makeResponse({ items: mockConversations, nextCursor: null }));
render(<MessagesPage />);
await waitFor(() => {
expect(screen.getByText("Alice Smith")).toBeInTheDocument();
expect(screen.getByText("Bob Jones")).toBeInTheDocument();
});
const unreadBadges = screen.getAllByText("2");
expect(unreadBadges).toHaveLength(1);
});
it("loads and displays messages when thread is selected", async () => {
vi.mocked(global.fetch).mockImplementation((input) => {
const url = String(input);
if (url === "/api/conversations?limit=20") {
return Promise.resolve(makeResponse({ items: mockConversations, nextCursor: null }));
}
if (url === "/api/conversations/conv-1/messages?limit=50") {
return Promise.resolve(makeResponse({ items: mockMessages, nextCursor: null }));
}
return Promise.resolve(makeResponseWithStatus(null, 404));
});
render(<MessagesPage />);
await waitFor(() => screen.getByText("Alice Smith"));
fireEvent.click(screen.getByText("Alice Smith"));
await waitFor(() => {
expect(screen.getAllByText("Hello, is my dog ready?").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Yes, she is all done!")).toBeInTheDocument();
});
});
it("sends a message on form submit", async () => {
let capturedBody: unknown = null;
vi.mocked(global.fetch).mockImplementation((input, init) => {
const url = String(input);
if (url.includes("/messages") && init?.method === "POST") {
capturedBody = init?.body;
return Promise.resolve(makeResponseWithStatus({
id: "msg-new",
direction: "outbound",
body: "Test message",
status: "queued",
createdAt: new Date().toISOString(),
sentByStaffId: "staff-1",
}, 201));
}
return Promise.resolve(makeResponse({ items: mockConversations, nextCursor: null }));
});
render(<MessagesPage />);
await waitFor(() => screen.getByText("Alice Smith"));
fireEvent.click(screen.getByText("Alice Smith"));
await waitFor(() => screen.getByPlaceholderText("Type a message…"));
fireEvent.change(screen.getByPlaceholderText("Type a message…"), {
target: { value: "Test message" },
});
fireEvent.click(screen.getByText("Send"));
await waitFor(() => {
expect(capturedBody).toBe('{"body":"Test message"}');
});
});
});
-10
View File
@@ -82,13 +82,3 @@ input:focus, select:focus, textarea:focus {
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* ─── Scrollbar hide utility ─── */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL || window.location.origin,
baseURL: import.meta.env.VITE_API_URL ?? "",
});
export const { signIn, signOut, useSession, changePassword } = authClient;
+6 -47
View File
@@ -13,8 +13,6 @@ interface BookingBody {
petName: string;
petSpecies: string;
petBreed: string;
petSizeCategory: string;
petCoatType: string;
notes: string;
}
@@ -125,8 +123,6 @@ export function BookPage() {
petName: "",
petSpecies: "",
petBreed: "",
petSizeCategory: "",
petCoatType: "",
notes: "",
});
const [formError, setFormError] = useState<string | null>(null);
@@ -172,18 +168,14 @@ export function BookPage() {
if (!selectedService || !date) return;
setSlotsLoading(true);
setSelectedSlot(null);
const params = new URLSearchParams({
serviceId: selectedService.id,
date,
});
if (form.petSizeCategory) params.set("petSizeCategory", form.petSizeCategory);
if (form.petCoatType) params.set("petCoatType", form.petCoatType);
fetch(`/api/book/availability?${params}`)
fetch(
`/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}`
)
.then((r) => r.json() as Promise<string[]>)
.then(setSlots)
.catch(() => setSlots([]))
.finally(() => setSlotsLoading(false));
}, [selectedService, date, form.petSizeCategory, form.petCoatType]);
}, [selectedService, date]);
function goToStep2(svc: Service) {
setSelectedService(svc);
@@ -222,8 +214,6 @@ export function BookPage() {
petName: form.petName,
petSpecies: form.petSpecies,
petBreed: form.petBreed || undefined,
petSizeCategory: form.petSizeCategory || undefined,
petCoatType: form.petCoatType || undefined,
notes: form.notes || undefined,
}),
});
@@ -504,36 +494,6 @@ export function BookPage() {
placeholder="Golden Retriever"
/>
</div>
<div>
<label style={label}>Pet size (optional, but encouraged)</label>
<select
style={input}
value={form.petSizeCategory}
onChange={(e) => setForm((f) => ({ ...f, petSizeCategory: e.target.value }))}
>
<option value="">Select size</option>
<option value="small">Small (under 15 lbs)</option>
<option value="medium">Medium (1540 lbs)</option>
<option value="large">Large (4080 lbs)</option>
<option value="x-large">X-Large (over 80 lbs)</option>
</select>
</div>
<div>
<label style={label}>Coat type (optional, but encouraged)</label>
<select
style={input}
value={form.petCoatType}
onChange={(e) => setForm((f) => ({ ...f, petCoatType: e.target.value }))}
>
<option value="">Select coat type</option>
<option value="smooth">Smooth</option>
<option value="double">Double</option>
<option value="curly">Curly</option>
<option value="wire">Wire</option>
<option value="long">Long</option>
<option value="hairless">Hairless</option>
</select>
</div>
<div>
<label style={label}>Notes for groomer</label>
<textarea
@@ -568,7 +528,7 @@ export function BookPage() {
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes + ((form.petSizeCategory === "large" || form.petSizeCategory === "x-large") ? (selectedService.defaultBufferMinutes ?? 0) : 0))}</div>
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes)}</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
@@ -639,8 +599,7 @@ export function BookPage() {
setResult(null);
setForm({
serviceId: "", startTime: "", clientName: "", clientEmail: "",
clientPhone: "", petName: "", petSpecies: "", petBreed: "",
petSizeCategory: "", petCoatType: "", notes: "",
clientPhone: "", petName: "", petSpecies: "", petBreed: "", notes: "",
});
}}
>
-275
View File
@@ -1,275 +0,0 @@
import { useEffect, useState, useRef } from "react";
interface Conversation {
id: string;
clientId: string;
clientName: string;
channel: string;
clientPhone: string;
lastMessageAt: string | null;
unreadCount: number;
status: string;
lastMessage: { body: string | null; direction: string; createdAt: string } | null;
}
interface Message {
id: string;
direction: "inbound" | "outbound";
body: string | null;
status: string;
createdAt: string;
sentByStaffId: string | null;
}
function relativeTime(dateStr: string | null): string {
if (!dateStr) return "";
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function truncate(text: string | null, max: number): string {
if (!text) return "";
return text.length > max ? text.slice(0, max) + "…" : text;
}
export function MessagesPage() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [messages, setMessages] = useState<Message[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [messagesLoading, setMessagesLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [messageError, setMessageError] = useState<string | null>(null);
const [body, setBody] = useState("");
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
async function loadConversations() {
try {
const res = await fetch("/api/conversations?limit=20");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const data = json.items as Conversation[];
setConversations(data);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load conversations");
}
}
async function loadMessages(conversationId: string) {
setMessagesLoading(true);
setMessageError(null);
try {
const res = await fetch(`/api/conversations/${conversationId}/messages?limit=50`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setMessages((json.items as Message[]).reverse());
} catch (e: unknown) {
setMessageError(e instanceof Error ? e.message : "Failed to load messages");
} finally {
setMessagesLoading(false);
}
}
useEffect(() => {
loadConversations().finally(() => setLoading(false));
const interval = setInterval(loadConversations, 10000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (selectedId) {
loadMessages(selectedId);
} else {
setMessages([]);
}
}, [selectedId]);
useEffect(() => {
if (messages.length > 0) {
messagesEndRef.current?.scrollIntoView?.({ behavior: "smooth" });
}
}, [messages]);
async function handleSend(e: React.FormEvent) {
e.preventDefault();
if (!selectedId || !body.trim() || sending) return;
setSending(true);
setMessageError(null);
const optimistic: Message = {
id: `temp-${Date.now()}`,
direction: "outbound",
body: body.trim(),
status: "queued",
createdAt: new Date().toISOString(),
sentByStaffId: null,
};
setMessages((prev) => [...prev, optimistic]);
const currentBody = body;
setBody("");
try {
const res = await fetch(`/api/conversations/${selectedId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: currentBody }),
});
if (res.status === 409) {
const data = (await res.json()) as { error?: string };
setMessageError(data.error ?? "Client has opted out of SMS");
setMessages((prev) => prev.filter((m) => m.id !== optimistic.id));
return;
}
if (!res.ok) {
const data = (await res.json()) as { error?: string };
throw new Error(data.error ?? `HTTP ${res.status}`);
}
const sent = (await res.json()) as Message;
setMessages((prev) => prev.map((m) => (m.id === optimistic.id ? sent : m)));
loadConversations();
} catch (e: unknown) {
setMessageError(e instanceof Error ? e.message : "Failed to send message");
setMessages((prev) => prev.filter((m) => m.id !== optimistic.id));
} finally {
setSending(false);
}
}
return (
<div style={{ display: "flex", height: "calc(100vh - 90px)", fontFamily: "system-ui, sans-serif" }}>
{/* Thread list */}
<div style={{ width: 320, borderRight: "1px solid #e5e7eb", overflowY: "auto", background: "#fff" }}>
<div style={{ padding: "0.75rem 1rem", borderBottom: "1px solid #f3f4f6", fontWeight: 600, fontSize: 14, color: "#374151" }}>
Conversations
</div>
{loading ? (
<p style={{ padding: "1rem", color: "#6b7280", fontSize: 13 }}>Loading</p>
) : error ? (
<p style={{ padding: "1rem", color: "#ef4444", fontSize: 13 }}>{error}</p>
) : conversations.length === 0 ? (
<p style={{ padding: "1rem", color: "#6b7280", fontSize: 13 }}>No conversations yet</p>
) : (
conversations.map((conv) => (
<div
key={conv.id}
onClick={() => setSelectedId(conv.id)}
style={{
padding: "0.75rem 1rem",
borderBottom: "1px solid #f3f4f6",
cursor: "pointer",
background: selectedId === conv.id ? "#ecfdf5" : "transparent",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<span style={{ fontWeight: 500, fontSize: 14, color: "#1a202c" }}>{conv.clientName}</span>
{conv.unreadCount > 0 && (
<span style={{ background: "#10b981", color: "#fff", borderRadius: 10, padding: "1px 6px", fontSize: 11, fontWeight: 600 }}>
{conv.unreadCount}
</span>
)}
</div>
<div style={{ marginTop: 2, color: "#6b7280", fontSize: 12 }}>
{truncate(conv.lastMessage?.body ?? null, 60)}
</div>
<div style={{ marginTop: 2, color: "#9ca3af", fontSize: 11 }}>
{relativeTime(conv.lastMessageAt)}
</div>
</div>
))
)}
</div>
{/* Conversation view */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: "#f9fafb" }}>
{!selectedId ? (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#9ca3af" }}>
Select a conversation
</div>
) : messagesLoading ? (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#6b7280" }}>
Loading messages
</div>
) : (
<>
<div style={{ flex: 1, overflowY: "auto", padding: "1rem" }}>
{messages.map((msg) => (
<div
key={msg.id}
style={{
display: "flex",
flexDirection: "column",
alignItems: msg.direction === "outbound" ? "flex-end" : "flex-start",
marginBottom: "0.75rem",
}}
>
<div
style={{
maxWidth: "70%",
padding: "0.5rem 0.75rem",
borderRadius: 12,
background: msg.direction === "outbound" ? "var(--color-primary, #4f8a6f)" : "#fff",
color: msg.direction === "outbound" ? "#fff" : "#1a202c",
border: msg.direction === "inbound" ? "1px solid #e5e7eb" : "none",
fontSize: 14,
lineHeight: 1.5,
}}
>
{msg.body}
</div>
<span style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
{new Date(msg.createdAt).toLocaleString()}
</span>
</div>
))}
<div ref={messagesEndRef} />
</div>
{messageError && (
<div style={{ margin: "0 1rem 0.5rem", padding: "0.5rem 0.75rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, color: "#991b1b", fontSize: 13 }}>
{messageError}
</div>
)}
<form onSubmit={handleSend} style={{ display: "flex", gap: "0.5rem", padding: "0.75rem 1rem", borderTop: "1px solid #e5e7eb", background: "#fff" }}>
<input
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Type a message…"
disabled={sending}
style={{ flex: 1, padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
/>
<button
type="submit"
disabled={sending || !body.trim()}
style={{
padding: "0.5rem 1rem",
background: "var(--color-primary, #4f8a6f)",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 14,
fontWeight: 500,
cursor: sending ? "wait" : "pointer",
opacity: sending ? 0.7 : 1,
}}
>
{sending ? "Sending…" : "Send"}
</button>
</form>
</>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -170,7 +170,7 @@ export function CustomerPortal() {
case "billing":
return <BillingPayments readOnly={!!isReadOnly} sessionId={sessionId} />;
case "messages":
return <Communication readOnly={!!isReadOnly} sessionId={sessionId} />;
return <Communication readOnly={!!isReadOnly} />;
case "settings":
return <AccountSettings readOnly={!!isReadOnly} sessionId={sessionId} />;
}
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
<div className="flex gap-2 flex-wrap overflow-x-auto">
{([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
@@ -1,159 +0,0 @@
export interface Conversation {
id: string;
channel: string;
lastMessageAt: string | null;
status: string;
createdAt: string;
}
export interface Message {
id: string;
direction: "inbound" | "outbound";
body: string | null;
status: string;
createdAt: string;
deliveredAt: string | null;
}
export interface MessagesResponse {
messages: Message[];
nextCursor: string | null;
}
export async function fetchConversation(sessionId: string): Promise<Conversation | null> {
const res = await fetch("/api/portal/conversation", {
headers: { "X-Impersonation-Session-Id": sessionId },
});
if (res.status === 204) return null;
if (!res.ok) throw new Error("Failed to fetch conversation");
return res.json();
}
export async function fetchMessages(
sessionId: string,
cursor?: string,
limit?: number
): Promise<MessagesResponse> {
const params = new URLSearchParams();
if (cursor) params.set("cursor", cursor);
if (limit) params.set("limit", String(limit));
const query = params.toString();
const res = await fetch(`/api/portal/conversation/messages${query ? `?${query}` : ""}`, {
headers: { "X-Impersonation-Session-Id": sessionId },
});
if (res.status === 204) return { messages: [], nextCursor: null };
if (!res.ok) throw new Error("Failed to fetch messages");
return res.json();
}
import { useState, useEffect } from "react";
export function useConversation(sessionId: string | null): {
conversation: Conversation | null;
loading: boolean;
error: string | null;
} {
const [conversation, setConversation] = useState<Conversation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!sessionId) {
setLoading(false);
setConversation(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
fetchConversation(sessionId)
.then((conv) => {
if (!cancelled) {
setConversation(conv);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "An error occurred");
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [sessionId]);
return { conversation, loading, error };
}
export function useMessages(sessionId: string | null): {
messages: Message[];
loading: boolean;
error: string | null;
loadMore: () => void;
hasMore: boolean;
} {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
useEffect(() => {
if (!sessionId) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
setMessages([]);
setCursor(undefined);
setHasMore(false);
fetchMessages(sessionId)
.then((res) => {
if (!cancelled) {
setMessages(res.messages);
setCursor(res.nextCursor ?? undefined);
setHasMore(res.nextCursor !== null);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "An error occurred");
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [sessionId]);
const loadMore = () => {
if (loadingMore || !hasMore || !sessionId) return;
setLoadingMore(true);
fetchMessages(sessionId, cursor)
.then((res) => {
setMessages((prev) => [...prev, ...res.messages]);
setCursor(res.nextCursor ?? undefined);
setHasMore(res.nextCursor !== null);
setLoadingMore(false);
})
.catch(() => {
setLoadingMore(false);
});
};
return { messages, loading, error, loadMore, hasMore };
}
+70 -105
View File
@@ -1,7 +1,14 @@
import { useState, useEffect } from "react";
import { Bell, Mail, Smartphone } from "lucide-react";
import { useConversation, useMessages } from "./Communication.api.js";
import type { Message as ApiMessage } from "./Communication.api.js";
import { Send, Check, CheckCheck, Bell, Mail, Smartphone, Megaphone, FileText, CreditCard } from "lucide-react";
interface Message {
id: string;
sender: "customer" | "business";
senderName: string;
text: string;
timestamp: string;
read: boolean;
}
interface NotificationCategory {
email: boolean;
@@ -18,11 +25,10 @@ interface NotificationPreferences {
}
interface Props {
sessionId: string | null;
readOnly: boolean;
}
export function Communication({ sessionId, readOnly }: Props) {
export function Communication({ readOnly }: Props) {
const [tab, setTab] = useState<"messages" | "notifications">("messages");
return (
@@ -47,23 +53,17 @@ export function Communication({ sessionId, readOnly }: Props) {
</button>
</div>
{tab === "messages" && <MessageThread sessionId={sessionId} readOnly={readOnly} />}
{tab === "messages" && <MessageThread readOnly={readOnly} />}
{tab === "notifications" && <NotificationPreferences readOnly={readOnly} />}
</div>
);
}
interface MessageThreadProps {
sessionId: string | null;
readOnly: boolean;
}
function MessageThread({ sessionId, readOnly: _readOnly }: MessageThreadProps) {
function MessageThread({ readOnly }: { readOnly: boolean }) {
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState("");
const [businessName, setBusinessName] = useState<string>("Business");
const { conversation, loading: convLoading, error: convError } = useConversation(sessionId);
const { messages, loading: msgLoading, error: msgError, loadMore, hasMore } = useMessages(sessionId);
useEffect(() => {
async function fetchBranding() {
try {
@@ -79,57 +79,19 @@ function MessageThread({ sessionId, readOnly: _readOnly }: MessageThreadProps) {
fetchBranding();
}, []);
const loading = convLoading || msgLoading;
const error = convError || msgError;
if (loading) {
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50 flex items-center justify-center">
<div className="animate-pulse text-stone-400 text-sm">Loading messages...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
<p className="text-sm font-medium text-stone-800">{businessName}</p>
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-red-500 text-sm">{error}</p>
</div>
</div>
);
}
if (!conversation) {
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
<p className="text-sm font-medium text-stone-800">{businessName}</p>
<p className="text-xs text-stone-400">Usually replies within a few hours</p>
</div>
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-8">
<div className="w-12 h-12 rounded-full bg-stone-100 flex items-center justify-center">
<Mail size={20} className="text-stone-400" />
</div>
<p className="text-stone-500 text-sm text-center">No conversation yet</p>
<p className="text-stone-400 text-xs text-center">Messages with {businessName} will appear here once you start texting.</p>
</div>
<div className="border-t border-stone-200 p-3 flex gap-2">
<div
className="flex-1 border border-stone-200 rounded-lg px-3 py-2 text-sm text-stone-400 bg-stone-50 flex items-center justify-center gap-2"
title="Reply from your phone"
>
Reply from your phone
</div>
</div>
</div>
);
}
const handleSend = () => {
if (!newMessage.trim() || readOnly) return;
const msg: Message = {
id: `m-${Date.now()}`,
sender: "customer",
senderName: "You",
text: newMessage.trim(),
timestamp: new Date().toISOString(),
read: false,
};
setMessages([...messages, msg]);
setNewMessage("");
};
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
@@ -142,46 +104,49 @@ function MessageThread({ sessionId, readOnly: _readOnly }: MessageThreadProps) {
{messages.length === 0 ? (
<p className="text-stone-400 text-center text-sm italic">No messages yet</p>
) : (
messages.map((msg: ApiMessage) => {
const sender = msg.direction === "inbound" ? "customer" : "business";
return (
<div key={msg.id} className={`flex ${sender === "customer" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
sender === "customer"
? "bg-(--color-accent) text-white rounded-br-md"
: "bg-stone-100 text-stone-800 rounded-bl-md"
}`}>
{msg.body && <p className="text-sm">{msg.body}</p>}
<div className={`flex items-center gap-1 mt-1 ${sender === "customer" ? "justify-end" : ""}`}>
<span className={`text-xs ${sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
{new Date(msg.createdAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
</span>
</div>
messages.map(msg => (
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
msg.sender === "customer"
? "bg-(--color-accent) text-white rounded-br-md"
: "bg-stone-100 text-stone-800 rounded-bl-md"
}`}>
<p className="text-sm">{msg.text}</p>
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
</span>
{msg.sender === "customer" && (
msg.read
? <CheckCheck size={12} className="text-white/60" />
: <Check size={12} className="text-white/60" />
)}
</div>
</div>
);
})
)}
{hasMore && (
<div className="flex justify-center">
<button
onClick={loadMore}
className="text-sm text-(--color-accent) hover:underline"
>
Load more
</button>
</div>
</div>
))
)}
</div>
<div className="border-t border-stone-200 p-3 flex gap-2">
<div
className="flex-1 border border-stone-200 rounded-lg px-3 py-2 text-sm text-stone-400 bg-stone-50 flex items-center justify-center gap-2"
title="Reply from your phone"
>
Reply from your phone
{!readOnly && (
<div className="border-t border-stone-200 p-3 flex gap-2">
<input
type="text"
value={newMessage}
onChange={e => setNewMessage(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleSend()}
placeholder="Type a message..."
className="flex-1 border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)/30 focus:border-(--color-accent)"
/>
<button
onClick={handleSend}
disabled={!newMessage.trim()}
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg hover:bg-(--color-accent-hover) disabled:opacity-50"
>
<Send size={16} />
</button>
</div>
</div>
)}
</div>
);
}
@@ -211,10 +176,10 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
const categories: { key: PrefKey; label: string; desc: string; icon: typeof Bell }[] = [
{ key: "appointmentReminders", label: "Appointment Reminders", desc: "Upcoming appointment notifications", icon: Bell },
{ key: "vaccinationAlerts", label: "Vaccination Alerts", desc: "Expiration and renewal reminders", icon: Mail },
{ key: "promotional", label: "Promotions & Offers", desc: "Deals and seasonal specials", icon: Smartphone },
{ key: "reportCards", label: "Report Cards", desc: "Grooming report card delivery", icon: Mail },
{ key: "invoiceReceipts", label: "Invoice & Receipts", desc: "Payment confirmations", icon: Bell },
{ key: "vaccinationAlerts", label: "Vaccination Alerts", desc: "Expiration and renewal reminders", icon: FileText },
{ key: "promotional", label: "Promotions & Offers", desc: "Deals and seasonal specials", icon: Megaphone },
{ key: "reportCards", label: "Report Cards", desc: "Grooming report card delivery", icon: FileText },
{ key: "invoiceReceipts", label: "Invoice & Receipts", desc: "Payment confirmations", icon: CreditCard },
];
const channels: { key: ChannelKey; label: string; icon: typeof Mail }[] = [
@@ -271,4 +236,4 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
);
}
export default Communication;
export default Communication;
+1 -1
View File
@@ -182,7 +182,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
)}
{/* Tabs */}
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto scrollbar-hide">
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
{([
{ id: "info", label: "Basic Info", icon: PawPrint },
{ id: "medical", label: "Medical", icon: Heart },
-309
View File
@@ -1,309 +0,0 @@
# 10DLC Pilot Tenant Registration Runbook
Authored for GRO-106 Phase 1.
---
## Pre-Flight Checklist
Before starting Telnyx registration, collect the following:
| Item | Details |
|------|---------|
| Legal business name | Exact name on EIN / business registration |
| EIN (Employer Identification Number) | 9-digit IRS format: XX-XXXXXXX |
| Business type | Sole Proprietor / LLC / Corporation |
| Primary contact email | General contact address (postmaster@, info@, etc.) |
| Primary contact phone | Direct line for carrier verification |
| Website URL | Must be live and contain privacy policy |
| Sample message templates | See [Sample Templates](#sample-message-templates) below |
| Messaging use case | Customer Care / Account Notification |
---
## Step 1 — Telnyx Account Requirements
- Active Telnyx account with billing configured.
- Role required: **Admin** or **Super User** to register brands and campaigns.
---
## Step 2 — Brand Registration
### Via Telnyx Console
1. Log in to [Telnyx Portal](https://portal.telnyx.com).
2. Navigate to **Messaging → A2P 10DLC → Brands**.
3. Click **Register Brand**.
4. Fill in:
- **Brand Name**: Legal business name
- **Legal Company Name**: Exact EIN name
- **Company Type**: Select from dropdown
- **EIN**: XX-XXXXXXX
- **Primary Contact**: Name, email, phone
- **Website**: Must be accessible
- **BusinessVertical**: Select appropriate vertical
5. Acknowledge the **Terms of Service**.
6. Submit.
### Via API
```bash
curl -X POST https://api.telnyx.com/v2/10dlc/brands \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Your Legal Business Name",
"legal_company_name": "Your Legal Business Name",
"company_type": "llc",
"ein": "XX-XXXXXXX",
"primary_contact": {
"name": "Jane Doe",
"email": "compliance@example.com",
"phone": "+1XXXXXXXXXX"
},
"website": "https://www.example.com",
"business_vertical": "PROFESSIONAL_SERVICES"
}'
```
**Response fields to record:**
- `brand_id` — required for campaign registration
- `brand_score` — affects campaign vetting speed
### Expected Fees
| Fee Type | Amount |
|----------|--------|
| Brand registration fee | ~$0 (no direct fee from Telnyx) |
| Campaign registration fee | ~$15$25 per campaign (Telnyx fee, subject to change) |
| Carrier fees | Passed through from T-Mobile/AT&T/Verizon |
### Expected Approval Window
- **Vetting by Telnyx**: 13 business days after submission.
- **Carrier (T-Mobile/AT&T/Verizon) review**: 25 business days after Telnyx approval.
- Total end-to-end: **38 business days**.
---
## Step 3 — Campaign Registration
### Use Case Selection
- **Primary**: Customer Care
- **Secondary**: Account Notification
### Via Telnyx Console
1. Navigate to **Messaging → A2P 10DLC → Campaigns**.
2. Click **Register Campaign**.
3. Select **Brand** (use the brand registered in Step 2).
4. Fill in:
- **Campaign Name**: e.g., `groombook-pilot-customer-care`
- **Use Case**: Customer Care / Account Notification
- **Sample Messages**: Paste exactly the templates from [Sample Templates](#sample-message-templates) below.
- **Description**: Brief description of messaging program
- **Estimated Volume**: Enter monthly estimate (e.g., 500)
5. Submit.
### Via API
```bash
curl -X POST https://api.telnyx.com/v2/10dlc/campaigns \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"brand_id": "YOUR_BRAND_ID",
"name": "groombook-pilot-customer-care",
"use_case": "CUSTOMER_CARE",
"sample_messages": [
"Hi {{first_name}}, this is a reminder from {{business_name}} that your appointment is scheduled for {{date}} at {{time}}. Reply STOP to opt out.",
"Your appointment with {{business_name}} is confirmed for {{date}}. Need to reschedule? Reply HELP or call us at {{phone}}."
],
"description": "Appointment reminders and account notifications for grooming clients",
"estimated_monthly_volume": 500
}'
```
**Response fields to record:**
- `campaign_id` — required for messaging profile
- `status` — initially `PENDING`, transitions to `ACTIVE` after carrier approval
### Campaign Vetting — STOP/HELP Language Requirements
Every campaign **must** include compliant STOP/HELP messaging. The following must appear in your sample messages or be included in your terms of service:
- **STOP**: Users can text `STOP` to opt out of all messages.
- **HELP**: Users can text `HELP` to receive contact information.
Example STOP/HELP block:
```
Text STOP to opt out. Text HELP for help. Msg & data rates may apply.
```
---
## Step 4 — Messaging Profile + Phone Number Provisioning
### Create Messaging Profile
1. In Telnyx Portal, navigate to **Messaging → Messaging Profiles**.
2. Click **Create Messaging Profile**.
3. Name it (e.g., `groombook-pilot-prod`).
4. Copy the **Messaging Profile ID** (`messaging_profile_id`) — record this in the DB.
### Provision a 10DLC Phone Number
1. Navigate to **Messaging → Phone Numbers**.
2. Search for a number in your desired area code.
3. Confirm the number is 10DLC-capable.
4. Purchase the number.
### Associate Number with Messaging Profile
```bash
# Assign number to messaging profile
curl -X PATCH https://api.telnyx.com/v2/phone_numbers/YOUR_PHONE_NUMBER_ID \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"messaging_profile_id": "YOUR_MESSAGING_PROFILE_ID"
}'
```
---
## Step 5 — Record in Database
Once GRO-981 lands, record the following against the business record:
### SQL Path (when GRO-981 is complete)
```sql
UPDATE businesses
SET
messaging_phone_number = '+1XXXXXXXXXX',
telnyx_messaging_profile_id = 'YOUR_MESSAGING_PROFILE_ID',
telnyx_brand_id = 'YOUR_BRAND_ID',
telnyx_campaign_id = 'YOUR_CAMPAIGN_ID',
telnyx_brand_status = 'APPROVED',
telnyx_campaign_status = 'ACTIVE',
updated_at = NOW()
WHERE id = 'pilot_business_id';
```
### Manual Admin Path (before GRO-981)
Until GRO-981 is complete, use the Telnyx Portal to verify and record values manually in your internal ops sheet:
| Field | Value |
|-------|-------|
| `messagingPhoneNumber` | +1XXXXXXXXXX |
| `telnyxMessagingProfileId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `telnyxBrandId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `telnyxCampaignId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `brandStatus` | APPROVED / PENDING |
| `campaignStatus` | ACTIVE / PENDING |
---
## Sample Message Templates
These must match exactly what your system will send. Vetting reviewers compare templates against actual traffic.
### Transactional Appointment Reminder
```
Hi {{first_name}}, this is a reminder from {{business_name}} that your appointment is scheduled for {{date}} at {{time}}. Reply STOP to opt out. Msg & data rates may apply.
```
### Manual Staff Message
```
Your appointment with {{business_name}} is confirmed for {{date}}. Need to reschedule? Reply HELP for assistance or call us at {{phone}}. Msg & data rates may apply.
```
---
## Failure Modes + Retry Guidance
### Vetting Rejection — Brand
| Rejection Reason | Common Fix |
|-----------------|------------|
| Legal name mismatch with EIN | Ensure exact EIN name matches legal company name exactly |
| Website not accessible / missing privacy policy | Add privacy policy page to website before resubmitting |
| Incomplete primary contact | Provide direct phone and real email (no noreply) |
| High-risk business vertical | Contact Telnyx support for pre-screening before resubmitting |
### Campaign Rejection
| Rejection Reason | Common Fix |
|-----------------|------------|
| Sample messages do not match actual traffic | Update sample messages to match exactly what the system sends |
| Missing STOP/HELP language | Add compliant STOP/HELP block to sample messages |
| Volume estimate too low/high | Revise estimate to be realistic |
| Use case mismatch | Re-select use case that matches actual messaging |
### Re-submission
After fixing the rejection reason, re-submit via the same API endpoint. Telnyx will re-run vetting (typically 2448 hours).
---
## Cost Summary
### Telnyx Fees (as of 2026)
| Fee Type | Amount | Notes |
|----------|--------|-------|
| 10DLC number (monthly) | ~$1.00$2.50/number | Varies by type and area code |
| Outbound message | $0.005$0.015/message | Depends on destination carrier |
| Inbound message | Included | No charge for received messages |
| Campaign registration | ~$15$25 one-time | Per campaign, subject to change |
### Carrier Fees (T-Mobile / AT&T / Verizon)
| Carrier | Outbound Fee | Notes |
|---------|-------------|-------|
| T-Mobile | ~$0.005$0.01/message | Varies by message size (segment) |
| AT&T | ~$0.005$0.015/message | Varies by message size (segment) |
| Verizon | ~$0.005$0.01/message | Varies by message size (segment) |
**Note**: Carrier fees are subject to change. Check [Telnyx pricing page](https://telnyx.com/pricing) and carrier fee schedules for current rates.
### Example Monthly Cost (Pilot — 500 messages/month)
| Line Item | Cost |
|-----------|------|
| 1x 10DLC number | ~$2.00 |
| 500 outbound messages | ~$5.00$7.50 |
| Carrier pass-through | ~$2.50$7.50 |
| **Estimated Monthly Total** | **~$9.50$17.00** |
---
## Rollback / De-provisioning
If the pilot tenant must be de-provisioned:
1. Release the phone number: Telnyx Portal → Phone Numbers → Release.
2. Archive the campaign: set status to `INACTIVE` via API or console.
3. Remove DB record: clear `messagingPhoneNumber`, `telnyxMessagingProfileId`, `telnyxCampaignId` fields in the business record.
4. Brand can remain registered (no harm) but will not be used.
---
## Contacts
| Resource | Contact |
|----------|---------|
| Telnyx Support | support@telnyx.com |
| Telnyx Dashboard | portal.telnyx.com |
| Internal Engineering | Raise issue in GRO-106 |
---
_Owner: Engineering · Last updated: 2026-05-04_
-11
View File
@@ -1,11 +0,0 @@
# GroomBook Runbooks
Operational runbooks for GroomBook staff and operators.
| Runbook | Description | Status |
|---------|-------------|--------|
| [10DLC Pilot Registration](./10dlc-pilot-registration.md) | Register a pilot grooming business as an A2P 10DLC brand + campaign on Telnyx | Active |
---
_To add a runbook, create a markdown file in this directory and update this table._
-7
View File
@@ -1,7 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"permission": "allow",
"experimental": {
"snapshots": false
}
}
-72
View File
@@ -1,72 +0,0 @@
-- Migration: 0030_messaging.sql
-- Messaging schema: conversations, messages, attachments, consent events + business messaging settings
-- ─── Enums ───────────────────────────────────────────────────────────────────
CREATE TYPE "messaging_channel" AS ENUM ('sms', 'mms');
CREATE TYPE "message_direction" AS ENUM ('inbound', 'outbound');
CREATE TYPE "message_status" AS ENUM ('queued', 'sent', 'delivered', 'failed', 'received');
CREATE TYPE "message_consent_kind" AS ENUM ('opt_in', 'opt_out', 'help');
-- ─── Tables ───────────────────────────────────────────────────────────────────
CREATE TABLE "conversations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"business_id" uuid NOT NULL,
"client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE,
"channel" "messaging_channel" NOT NULL,
"external_number" text NOT NULL,
"business_number" text NOT NULL,
"last_message_at" timestamp,
"status" text NOT NULL DEFAULT 'active',
"created_at" timestamp NOT NULL DEFAULT now(),
"updated_at" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX "idx_conversations_business_id_last_message_at" ON "conversations"("business_id", "last_message_at" DESC);
CREATE UNIQUE INDEX "uq_conversations_business_client_number" ON "conversations"("business_id", "client_id", "business_number");
CREATE TABLE "messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"conversation_id" uuid NOT NULL REFERENCES "conversations"("id") ON DELETE CASCADE,
"direction" "message_direction" NOT NULL,
"body" text,
"status" "message_status" NOT NULL DEFAULT 'queued',
"provider_message_id" text,
"error_code" text,
"error_message" text,
"sent_by_staff_id" uuid REFERENCES "staff"("id") ON DELETE SET NULL,
"created_at" timestamp NOT NULL DEFAULT now(),
"delivered_at" timestamp,
"read_by_client_at" timestamp
);
CREATE INDEX "idx_messages_conversation_id_created_at" ON "messages"("conversation_id", "created_at" DESC);
CREATE UNIQUE INDEX "uq_messages_provider_message_id" ON "messages"("provider_message_id");
CREATE TABLE "message_attachments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE,
"content_type" text NOT NULL,
"url" text NOT NULL,
"size" integer NOT NULL,
"provider_media_id" text
);
CREATE INDEX "idx_message_attachments_message_id" ON "message_attachments"("message_id");
CREATE TABLE "message_consent_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE,
"business_id" uuid NOT NULL,
"kind" "message_consent_kind" NOT NULL,
"source" text,
"created_at" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX "idx_message_consent_events_client_id" ON "message_consent_events"("client_id");
-- ─── Business Settings extensions ────────────────────────────────────────────
ALTER TABLE "business_settings" ADD COLUMN "messaging_phone_number" text;
ALTER TABLE "business_settings" ADD COLUMN "telnyx_messaging_profile_id" text;
-356
View File
@@ -1,356 +0,0 @@
CREATE TYPE "public"."client_status" AS ENUM('active', 'disabled');--> statement-breakpoint
CREATE TYPE "public"."coat_type" AS ENUM('smooth', 'double', 'curly', 'wire', 'long', 'hairless');--> statement-breakpoint
CREATE TYPE "public"."impersonation_session_status" AS ENUM('active', 'ended', 'expired');--> statement-breakpoint
CREATE TYPE "public"."invoice_status" AS ENUM('draft', 'pending', 'paid', 'void');--> statement-breakpoint
CREATE TYPE "public"."message_consent_kind" AS ENUM('opt_in', 'opt_out', 'help');--> statement-breakpoint
CREATE TYPE "public"."message_direction" AS ENUM('inbound', 'outbound');--> statement-breakpoint
CREATE TYPE "public"."message_status" AS ENUM('queued', 'sent', 'delivered', 'failed', 'received');--> statement-breakpoint
CREATE TYPE "public"."messaging_channel" AS ENUM('sms', 'mms');--> statement-breakpoint
CREATE TYPE "public"."payment_method" AS ENUM('cash', 'card', 'check', 'other');--> statement-breakpoint
CREATE TYPE "public"."pet_size_category" AS ENUM('small', 'medium', 'large', 'xlarge');--> statement-breakpoint
CREATE TYPE "public"."waitlist_status" AS ENUM('active', 'notified', 'expired', 'cancelled');--> statement-breakpoint
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "appointment_groups" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_id" uuid NOT NULL,
"notes" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth_provider_config" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"provider_id" text NOT NULL,
"display_name" text NOT NULL,
"issuer_url" text NOT NULL,
"internal_base_url" text,
"client_id" text NOT NULL,
"client_secret" text NOT NULL,
"scopes" text DEFAULT 'openid profile email' NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "auth_provider_config_provider_id_unique" UNIQUE("provider_id")
);
--> statement-breakpoint
CREATE TABLE "buffer_time_rules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"service_id" uuid NOT NULL,
"size_category" "pet_size_category",
"coat_type" "coat_type",
"buffer_minutes" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "buffer_time_rules_service_id_size_category_coat_type_unique" UNIQUE("service_id","size_category","coat_type")
);
--> statement-breakpoint
CREATE TABLE "business_settings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"business_name" text DEFAULT 'GroomBook' NOT NULL,
"logo_base64" text,
"logo_mime_type" text,
"logo_key" text,
"primary_color" text DEFAULT '#4f8a6f' NOT NULL,
"accent_color" text DEFAULT '#8b7355' NOT NULL,
"messaging_phone_number" text,
"telnyx_messaging_profile_id" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "conversations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"business_id" uuid NOT NULL,
"client_id" uuid NOT NULL,
"channel" "messaging_channel" NOT NULL,
"external_number" text NOT NULL,
"business_number" text NOT NULL,
"last_message_at" timestamp,
"status" text DEFAULT 'active' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "uq_conversations_business_client_number" UNIQUE("business_id","client_id","business_number")
);
--> statement-breakpoint
CREATE TABLE "grooming_visit_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pet_id" uuid NOT NULL,
"appointment_id" uuid,
"staff_id" uuid,
"cut_style" text,
"products_used" text,
"notes" text,
"groomed_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "impersonation_audit_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"session_id" uuid NOT NULL,
"action" text NOT NULL,
"page_visited" text,
"metadata" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "impersonation_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"staff_id" uuid NOT NULL,
"client_id" uuid NOT NULL,
"reason" text,
"status" "impersonation_session_status" DEFAULT 'active' NOT NULL,
"started_at" timestamp DEFAULT now() NOT NULL,
"ended_at" timestamp,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invoice_line_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invoice_id" uuid NOT NULL,
"description" text NOT NULL,
"quantity" integer DEFAULT 1 NOT NULL,
"unit_price_cents" integer NOT NULL,
"total_cents" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invoice_tip_splits" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invoice_id" uuid NOT NULL,
"staff_id" uuid,
"staff_name" text NOT NULL,
"share_pct" numeric(5, 2) NOT NULL,
"share_cents" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invoices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"appointment_id" uuid,
"client_id" uuid NOT NULL,
"subtotal_cents" integer NOT NULL,
"tax_cents" integer DEFAULT 0 NOT NULL,
"tip_cents" integer DEFAULT 0 NOT NULL,
"total_cents" integer NOT NULL,
"status" "invoice_status" DEFAULT 'draft' NOT NULL,
"payment_method" "payment_method",
"paid_at" timestamp,
"stripe_payment_intent_id" text,
"stripe_refund_id" text,
"payment_failure_reason" text,
"notes" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "message_attachments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"message_id" uuid NOT NULL,
"content_type" text NOT NULL,
"url" text NOT NULL,
"size" integer NOT NULL,
"provider_media_id" text
);
--> statement-breakpoint
CREATE TABLE "message_consent_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_id" uuid NOT NULL,
"business_id" uuid NOT NULL,
"kind" "message_consent_kind" NOT NULL,
"source" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"conversation_id" uuid NOT NULL,
"direction" "message_direction" NOT NULL,
"body" text,
"status" "message_status" DEFAULT 'queued' NOT NULL,
"provider_message_id" text,
"error_code" text,
"error_message" text,
"sent_by_staff_id" uuid,
"created_at" timestamp DEFAULT now() NOT NULL,
"delivered_at" timestamp,
"read_by_client_at" timestamp,
CONSTRAINT "uq_messages_provider_message_id" UNIQUE("provider_message_id")
);
--> statement-breakpoint
CREATE TABLE "recurring_series" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"frequency_weeks" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "refunds" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invoice_id" uuid NOT NULL,
"stripe_refund_id" text NOT NULL,
"idempotency_key" text,
"amount_cents" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "refunds_idempotency_key_unique" UNIQUE("idempotency_key")
);
--> statement-breakpoint
CREATE TABLE "reminder_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"appointment_id" uuid NOT NULL,
"reminder_type" text NOT NULL,
"channel" text DEFAULT 'email' NOT NULL,
"sent_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE("appointment_id","reminder_type","channel")
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "waitlist_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_id" uuid NOT NULL,
"pet_id" uuid NOT NULL,
"service_id" uuid NOT NULL,
"preferred_date" text NOT NULL,
"preferred_time" text NOT NULL,
"status" "waitlist_status" DEFAULT 'active' NOT NULL,
"notified_at" timestamp,
"expires_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "clients" ALTER COLUMN "email" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "bather_staff_id" uuid;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "series_id" uuid;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "series_index" integer;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "group_id" uuid;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "confirmation_status" text DEFAULT 'pending' NOT NULL;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "confirmed_at" timestamp;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "cancelled_at" timestamp;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "buffer_minutes" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "confirmation_token" text;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "customer_notes" text;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "email_opt_out" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_opt_in" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_consent_date" timestamp;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_opt_out_date" timestamp;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_consent_text" text;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "status" "client_status" DEFAULT 'active' NOT NULL;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "disabled_at" timestamp;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "health_alerts" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "cut_style" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "shampoo_preference" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "special_care_notes" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "custom_fields" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "photo_key" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "photo_uploaded_at" timestamp;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "image" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "size_category" "pet_size_category";--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "coat_type" "coat_type";--> statement-breakpoint
ALTER TABLE "services" ADD COLUMN "default_buffer_minutes" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "staff" ADD COLUMN "user_id" text;--> statement-breakpoint
ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "staff" ADD COLUMN "ical_token" text;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "appointment_groups" ADD CONSTRAINT "appointment_groups_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "buffer_time_rules" ADD CONSTRAINT "buffer_time_rules_service_id_services_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."services"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "grooming_visit_logs" ADD CONSTRAINT "grooming_visit_logs_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "grooming_visit_logs" ADD CONSTRAINT "grooming_visit_logs_appointment_id_appointments_id_fk" FOREIGN KEY ("appointment_id") REFERENCES "public"."appointments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "grooming_visit_logs" ADD CONSTRAINT "grooming_visit_logs_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "impersonation_audit_logs" ADD CONSTRAINT "impersonation_audit_logs_session_id_impersonation_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."impersonation_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "impersonation_sessions" ADD CONSTRAINT "impersonation_sessions_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "impersonation_sessions" ADD CONSTRAINT "impersonation_sessions_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_line_items" ADD CONSTRAINT "invoice_line_items_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_tip_splits" ADD CONSTRAINT "invoice_tip_splits_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_tip_splits" ADD CONSTRAINT "invoice_tip_splits_staff_id_staff_id_fk" FOREIGN KEY ("staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_appointment_id_appointments_id_fk" FOREIGN KEY ("appointment_id") REFERENCES "public"."appointments"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "message_attachments" ADD CONSTRAINT "message_attachments_message_id_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "message_consent_events" ADD CONSTRAINT "message_consent_events_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_sent_by_staff_id_staff_id_fk" FOREIGN KEY ("sent_by_staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "refunds" ADD CONSTRAINT "refunds_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_appointments_id_fk" FOREIGN KEY ("appointment_id") REFERENCES "public"."appointments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_service_id_services_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."services"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_buffer_rules_service_id" ON "buffer_time_rules" USING btree ("service_id");--> statement-breakpoint
CREATE INDEX "idx_conversations_business_id_last_message_at" ON "conversations" USING btree ("business_id","last_message_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "impersonation_audit_logs_session_id_idx" ON "impersonation_audit_logs" USING btree ("session_id");--> statement-breakpoint
CREATE INDEX "impersonation_sessions_staff_id_status_idx" ON "impersonation_sessions" USING btree ("staff_id","status");--> statement-breakpoint
CREATE INDEX "impersonation_sessions_client_id_idx" ON "impersonation_sessions" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_invoice_line_items_invoice_id" ON "invoice_line_items" USING btree ("invoice_id");--> statement-breakpoint
CREATE INDEX "idx_invoice_tip_splits_invoice_id" ON "invoice_tip_splits" USING btree ("invoice_id");--> statement-breakpoint
CREATE INDEX "idx_invoices_client_id" ON "invoices" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_invoices_status" ON "invoices" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_invoices_created_at" ON "invoices" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_invoices_stripe_payment_intent_id" ON "invoices" USING btree ("stripe_payment_intent_id");--> statement-breakpoint
CREATE INDEX "idx_message_attachments_message_id" ON "message_attachments" USING btree ("message_id");--> statement-breakpoint
CREATE INDEX "idx_message_consent_events_client_id" ON "message_consent_events" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_messages_conversation_id_created_at" ON "messages" USING btree ("conversation_id","created_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "idx_refunds_invoice_id" ON "refunds" USING btree ("invoice_id");--> statement-breakpoint
CREATE INDEX "idx_refunds_idempotency_key" ON "refunds" USING btree ("idempotency_key");--> statement-breakpoint
CREATE INDEX "idx_waitlist_client_id" ON "waitlist_entries" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_waitlist_preferred_date" ON "waitlist_entries" USING btree ("preferred_date");--> statement-breakpoint
CREATE INDEX "idx_waitlist_status" ON "waitlist_entries" USING btree ("status");--> statement-breakpoint
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_bather_staff_id_staff_id_fk" FOREIGN KEY ("bather_staff_id") REFERENCES "public"."staff"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_series_id_recurring_series_id_fk" FOREIGN KEY ("series_id") REFERENCES "public"."recurring_series"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_group_id_appointment_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."appointment_groups"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "staff" ADD CONSTRAINT "staff_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_appointments_client_id" ON "appointments" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_appointments_staff_id" ON "appointments" USING btree ("staff_id");--> statement-breakpoint
CREATE INDEX "idx_appointments_start_time" ON "appointments" USING btree ("start_time");--> statement-breakpoint
CREATE INDEX "idx_appointments_status" ON "appointments" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_clients_email" ON "clients" USING btree ("email");--> statement-breakpoint
CREATE INDEX "idx_pets_client_id" ON "pets" USING btree ("client_id");--> statement-breakpoint
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_confirmation_token_unique" UNIQUE("confirmation_token");--> statement-breakpoint
ALTER TABLE "services" ADD CONSTRAINT "services_name_unique" UNIQUE("name");--> statement-breakpoint
ALTER TABLE "staff" ADD CONSTRAINT "staff_ical_token_unique" UNIQUE("ical_token");
@@ -1,4 +0,0 @@
-- Add staffReadAt column to conversations for unread tracking
ALTER TABLE "conversations" ADD COLUMN "staff_read_at" timestamp;
CREATE INDEX "idx_conversations_business_id_staff_read_at" ON "conversations"("business_id", "staff_read_at" DESC);
@@ -1 +0,0 @@
ALTER TABLE "conversations" ADD COLUMN "staff_read_at" timestamp;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,504 @@
{
"id": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97",
"prevId": "5983a2e9-f185-4f8a-a73f-5a7c0a0eea9c",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"account_id": { "name": "account_id", "type": "text", "primaryKey": false, "notNull": true },
"provider_id": { "name": "provider_id", "type": "text", "primaryKey": false, "notNull": true },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
"access_token": { "name": "access_token", "type": "text", "primaryKey": false, "notNull": false },
"refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, "notNull": false },
"id_token": { "name": "id_token", "type": "text", "primaryKey": false, "notNull": false },
"access_token_expires_at": { "name": "access_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"refresh_token_expires_at": { "name": "refresh_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"scope": { "name": "scope", "type": "text", "primaryKey": false, "notNull": false },
"password": { "name": "password", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "account_user_id_user_id_fk": { "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment_groups": {
"name": "appointment_groups",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "appointment_groups_client_id_clients_id_fk": { "name": "appointment_groups_client_id_clients_id_fk", "tableFrom": "appointment_groups", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointments": {
"name": "appointments",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
"service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"bather_staff_id": { "name": "bather_staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"status": { "name": "status", "type": "appointment_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'scheduled'" },
"start_time": { "name": "start_time", "type": "timestamp", "primaryKey": false, "notNull": true },
"end_time": { "name": "end_time", "type": "timestamp", "primaryKey": false, "notNull": true },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"price_cents": { "name": "price_cents", "type": "integer", "primaryKey": false, "notNull": false },
"series_id": { "name": "series_id", "type": "uuid", "primaryKey": false, "notNull": false },
"series_index": { "name": "series_index", "type": "integer", "primaryKey": false, "notNull": false },
"group_id": { "name": "group_id", "type": "uuid", "primaryKey": false, "notNull": false },
"confirmation_status": { "name": "confirmation_status", "type": "text", "primaryKey": false, "notNull": true, "default": "'pending'" },
"confirmed_at": { "name": "confirmed_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"cancelled_at": { "name": "cancelled_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"confirmation_token": { "name": "confirmation_token", "type": "text", "primaryKey": false, "notNull": false },
"customer_notes": { "name": "customer_notes", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"appointments_client_id_clients_id_fk": { "name": "appointments_client_id_clients_id_fk", "tableFrom": "appointments", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"appointments_pet_id_pets_id_fk": { "name": "appointments_pet_id_pets_id_fk", "tableFrom": "appointments", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"appointments_service_id_services_id_fk": { "name": "appointments_service_id_services_id_fk", "tableFrom": "appointments", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"appointments_staff_id_staff_id_fk": { "name": "appointments_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"appointments_bather_staff_id_staff_id_fk": { "name": "appointments_bather_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["bather_staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"appointments_series_id_recurring_series_id_fk": { "name": "appointments_series_id_recurring_series_id_fk", "tableFrom": "appointments", "tableTo": "recurring_series", "columnsFrom": ["series_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"appointments_group_id_appointment_groups_id_fk": { "name": "appointments_group_id_appointment_groups_id_fk", "tableFrom": "appointments", "tableTo": "appointment_groups", "columnsFrom": ["group_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": { "appointments_confirmation_token_unique": { "name": "appointments_confirmation_token_unique", "nullsNotDistinct": false, "columns": ["confirmation_token"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.business_settings": {
"name": "business_settings",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"business_name": { "name": "business_name", "type": "text", "primaryKey": false, "notNull": true, "default": "'GroomBook'" },
"logo_base64": { "name": "logo_base64", "type": "text", "primaryKey": false, "notNull": false },
"logo_mime_type": { "name": "logo_mime_type", "type": "text", "primaryKey": false, "notNull": false },
"primary_color": { "name": "primary_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#4f8a6f'" },
"accent_color": { "name": "accent_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#8b7355'" },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.clients": {
"name": "clients",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": false },
"phone": { "name": "phone", "type": "text", "primaryKey": false, "notNull": false },
"address": { "name": "address", "type": "text", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"email_opt_out": { "name": "email_opt_out", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"status": { "name": "status", "type": "client_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"disabled_at": { "name": "disabled_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.grooming_visit_logs": {
"name": "grooming_visit_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false },
"products_used": { "name": "products_used", "type": "text", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"groomed_at": { "name": "groomed_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"grooming_visit_logs_pet_id_pets_id_fk": { "name": "grooming_visit_logs_pet_id_pets_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"grooming_visit_logs_appointment_id_appointments_id_fk": { "name": "grooming_visit_logs_appointment_id_appointments_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"grooming_visit_logs_staff_id_staff_id_fk": { "name": "grooming_visit_logs_staff_id_staff_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.impersonation_audit_logs": {
"name": "impersonation_audit_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"session_id": { "name": "session_id", "type": "uuid", "primaryKey": false, "notNull": true },
"action": { "name": "action", "type": "text", "primaryKey": false, "notNull": true },
"page_visited": { "name": "page_visited", "type": "text", "primaryKey": false, "notNull": false },
"metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": { "impersonation_audit_logs_session_id_idx": { "name": "impersonation_audit_logs_session_id_idx", "columns": [{ "expression": "session_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } },
"foreignKeys": { "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", "tableFrom": "impersonation_audit_logs", "tableTo": "impersonation_sessions", "columnsFrom": ["session_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.impersonation_sessions": {
"name": "impersonation_sessions",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": true },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"reason": { "name": "reason", "type": "text", "primaryKey": false, "notNull": false },
"status": { "name": "status", "type": "impersonation_session_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"started_at": { "name": "started_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"ended_at": { "name": "ended_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {
"impersonation_sessions_staff_id_status_idx": { "name": "impersonation_sessions_staff_id_status_idx", "columns": [{ "expression": "staff_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"impersonation_sessions_client_id_idx": { "name": "impersonation_sessions_client_id_idx", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
},
"foreignKeys": {
"impersonation_sessions_staff_id_staff_id_fk": { "name": "impersonation_sessions_staff_id_staff_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"impersonation_sessions_client_id_clients_id_fk": { "name": "impersonation_sessions_client_id_clients_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoice_line_items": {
"name": "invoice_line_items",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": true },
"quantity": { "name": "quantity", "type": "integer", "primaryKey": false, "notNull": true, "default": 1 },
"unit_price_cents": { "name": "unit_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "invoice_line_items_invoice_id_invoices_id_fk": { "name": "invoice_line_items_invoice_id_invoices_id_fk", "tableFrom": "invoice_line_items", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoice_tip_splits": {
"name": "invoice_tip_splits",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"staff_name": { "name": "staff_name", "type": "text", "primaryKey": false, "notNull": true },
"share_pct": { "name": "share_pct", "type": "numeric(5, 2)", "primaryKey": false, "notNull": true },
"share_cents": { "name": "share_cents", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"invoice_tip_splits_invoice_id_invoices_id_fk": { "name": "invoice_tip_splits_invoice_id_invoices_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"invoice_tip_splits_staff_id_staff_id_fk": { "name": "invoice_tip_splits_staff_id_staff_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoices": {
"name": "invoices",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"subtotal_cents": { "name": "subtotal_cents", "type": "integer", "primaryKey": false, "notNull": true },
"tax_cents": { "name": "tax_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
"tip_cents": { "name": "tip_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
"status": { "name": "status", "type": "invoice_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'draft'" },
"payment_method": { "name": "payment_method", "type": "payment_method", "typeSchema": "public", "primaryKey": false, "notNull": false },
"paid_at": { "name": "paid_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"invoices_appointment_id_appointments_id_fk": { "name": "invoices_appointment_id_appointments_id_fk", "tableFrom": "invoices", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"invoices_client_id_clients_id_fk": { "name": "invoices_client_id_clients_id_fk", "tableFrom": "invoices", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pets": {
"name": "pets",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"species": { "name": "species", "type": "text", "primaryKey": false, "notNull": true },
"breed": { "name": "breed", "type": "text", "primaryKey": false, "notNull": false },
"weight_kg": { "name": "weight_kg", "type": "numeric(5, 2)", "primaryKey": false, "notNull": false },
"date_of_birth": { "name": "date_of_birth", "type": "timestamp", "primaryKey": false, "notNull": false },
"health_alerts": { "name": "health_alerts", "type": "text", "primaryKey": false, "notNull": false },
"grooming_notes": { "name": "grooming_notes", "type": "text", "primaryKey": false, "notNull": false },
"cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false },
"shampoo_preference": { "name": "shampoo_preference", "type": "text", "primaryKey": false, "notNull": false },
"special_care_notes": { "name": "special_care_notes", "type": "text", "primaryKey": false, "notNull": false },
"custom_fields": { "name": "custom_fields", "type": "jsonb", "primaryKey": false, "notNull": true, "default": "'{}'::jsonb" },
"photo_key": { "name": "photo_key", "type": "text", "primaryKey": false, "notNull": false },
"photo_uploaded_at": { "name": "photo_uploaded_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"image": { "name": "image", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "pets_client_id_clients_id_fk": { "name": "pets_client_id_clients_id_fk", "tableFrom": "pets", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.recurring_series": {
"name": "recurring_series",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"frequency_weeks": { "name": "frequency_weeks", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reminder_logs": {
"name": "reminder_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": true },
"reminder_type": { "name": "reminder_type", "type": "text", "primaryKey": false, "notNull": true },
"sent_at": { "name": "sent_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "reminder_logs_appointment_id_appointments_id_fk": { "name": "reminder_logs_appointment_id_appointments_id_fk", "tableFrom": "reminder_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "reminder_logs_appointment_id_reminder_type_unique": { "name": "reminder_logs_appointment_id_reminder_type_unique", "nullsNotDistinct": false, "columns": ["appointment_id", "reminder_type"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.services": {
"name": "services",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": false },
"base_price_cents": { "name": "base_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
"duration_minutes": { "name": "duration_minutes", "type": "integer", "primaryKey": false, "notNull": true },
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "services_name_unique": { "name": "services_name_unique", "nullsNotDistinct": false, "columns": ["name"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"token": { "name": "token", "type": "text", "primaryKey": false, "notNull": true },
"ip_address": { "name": "ip_address", "type": "text", "primaryKey": false, "notNull": false },
"user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, "columns": ["token"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.staff": {
"name": "staff",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
"oidc_sub": { "name": "oidc_sub", "type": "text", "primaryKey": false, "notNull": false },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": false },
"role": { "name": "role", "type": "staff_role", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'groomer'" },
"is_super_user": { "name": "is_super_user", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
"ical_token": { "name": "ical_token", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "staff_user_id_user_id_fk": { "name": "staff_user_id_user_id_fk", "tableFrom": "staff", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {
"staff_email_unique": { "name": "staff_email_unique", "nullsNotDistinct": false, "columns": ["email"] },
"staff_oidc_sub_unique": { "name": "staff_oidc_sub_unique", "nullsNotDistinct": false, "columns": ["oidc_sub"] },
"staff_ical_token_unique": { "name": "staff_ical_token_unique", "nullsNotDistinct": false, "columns": ["ical_token"] }
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
"email_verified": { "name": "email_verified", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"image": { "name": "image", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, "columns": ["email"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"identifier": { "name": "identifier", "type": "text", "primaryKey": false, "notNull": true },
"value": { "name": "value", "type": "text", "primaryKey": false, "notNull": true },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.waitlist_entries": {
"name": "waitlist_entries",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
"service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true },
"preferred_date": { "name": "preferred_date", "type": "text", "primaryKey": false, "notNull": true },
"preferred_time": { "name": "preferred_time", "type": "text", "primaryKey": false, "notNull": true },
"status": { "name": "status", "type": "waitlist_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"notified_at": { "name": "notified_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {
"idx_waitlist_client_id": { "name": "idx_waitlist_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"idx_waitlist_preferred_date": { "name": "idx_waitlist_preferred_date", "columns": [{ "expression": "preferred_date", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"idx_waitlist_status": { "name": "idx_waitlist_status", "columns": [{ "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
},
"foreignKeys": {
"waitlist_entries_client_id_clients_id_fk": { "name": "waitlist_entries_client_id_clients_id_fk", "tableFrom": "waitlist_entries", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"waitlist_entries_pet_id_pets_id_fk": { "name": "waitlist_entries_pet_id_pets_id_fk", "tableFrom": "waitlist_entries", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"waitlist_entries_service_id_services_id_fk": { "name": "waitlist_entries_service_id_services_id_fk", "tableFrom": "waitlist_entries", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.appointment_status": { "name": "appointment_status", "schema": "public", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
"public.client_status": { "name": "client_status", "schema": "public", "values": ["active", "disabled"] },
"public.impersonation_session_status": { "name": "impersonation_session_status", "schema": "public", "values": ["active", "ended", "expired"] },
"public.invoice_status": { "name": "invoice_status", "schema": "public", "values": ["draft", "pending", "paid", "void"] },
"public.payment_method": { "name": "payment_method", "schema": "public", "values": ["cash", "card", "check", "other"] },
"public.staff_role": { "name": "staff_role", "schema": "public", "values": ["groomer", "receptionist", "manager"] },
"public.waitlist_status": { "name": "waitlist_status", "schema": "public", "values": ["active", "notified", "expired", "cancelled"] }
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
}
@@ -0,0 +1,505 @@
{
"id": "9e8d3f2a-1c7b-4a6d-8f0e-5c2b9a3d7e1f",
"prevId": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"account_id": { "name": "account_id", "type": "text", "primaryKey": false, "notNull": true },
"provider_id": { "name": "provider_id", "type": "text", "primaryKey": false, "notNull": true },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
"access_token": { "name": "access_token", "type": "text", "primaryKey": false, "notNull": false },
"refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, "notNull": false },
"id_token": { "name": "id_token", "type": "text", "primaryKey": false, "notNull": false },
"access_token_expires_at": { "name": "access_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"refresh_token_expires_at": { "name": "refresh_token_expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"scope": { "name": "scope", "type": "text", "primaryKey": false, "notNull": false },
"password": { "name": "password", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "account_user_id_user_id_fk": { "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment_groups": {
"name": "appointment_groups",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "appointment_groups_client_id_clients_id_fk": { "name": "appointment_groups_client_id_clients_id_fk", "tableFrom": "appointment_groups", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointments": {
"name": "appointments",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
"service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"bather_staff_id": { "name": "bather_staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"status": { "name": "status", "type": "appointment_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'scheduled'" },
"start_time": { "name": "start_time", "type": "timestamp", "primaryKey": false, "notNull": true },
"end_time": { "name": "end_time", "type": "timestamp", "primaryKey": false, "notNull": true },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"price_cents": { "name": "price_cents", "type": "integer", "primaryKey": false, "notNull": false },
"series_id": { "name": "series_id", "type": "uuid", "primaryKey": false, "notNull": false },
"series_index": { "name": "series_index", "type": "integer", "primaryKey": false, "notNull": false },
"group_id": { "name": "group_id", "type": "uuid", "primaryKey": false, "notNull": false },
"confirmation_status": { "name": "confirmation_status", "type": "text", "primaryKey": false, "notNull": true, "default": "'pending'" },
"confirmed_at": { "name": "confirmed_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"cancelled_at": { "name": "cancelled_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"confirmation_token": { "name": "confirmation_token", "type": "text", "primaryKey": false, "notNull": false },
"customer_notes": { "name": "customer_notes", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"appointments_client_id_clients_id_fk": { "name": "appointments_client_id_clients_id_fk", "tableFrom": "appointments", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"appointments_pet_id_pets_id_fk": { "name": "appointments_pet_id_pets_id_fk", "tableFrom": "appointments", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"appointments_service_id_services_id_fk": { "name": "appointments_service_id_services_id_fk", "tableFrom": "appointments", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"appointments_staff_id_staff_id_fk": { "name": "appointments_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"appointments_bather_staff_id_staff_id_fk": { "name": "appointments_bather_staff_id_staff_id_fk", "tableFrom": "appointments", "tableTo": "staff", "columnsFrom": ["bather_staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"appointments_series_id_recurring_series_id_fk": { "name": "appointments_series_id_recurring_series_id_fk", "tableFrom": "appointments", "tableTo": "recurring_series", "columnsFrom": ["series_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"appointments_group_id_appointment_groups_id_fk": { "name": "appointments_group_id_appointment_groups_id_fk", "tableFrom": "appointments", "tableTo": "appointment_groups", "columnsFrom": ["group_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": { "appointments_confirmation_token_unique": { "name": "appointments_confirmation_token_unique", "nullsNotDistinct": false, "columns": ["confirmation_token"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.business_settings": {
"name": "business_settings",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"business_name": { "name": "business_name", "type": "text", "primaryKey": false, "notNull": true, "default": "'GroomBook'" },
"logo_base64": { "name": "logo_base64", "type": "text", "primaryKey": false, "notNull": false },
"logo_mime_type": { "name": "logo_mime_type", "type": "text", "primaryKey": false, "notNull": false },
"logo_key": { "name": "logo_key", "type": "text", "primaryKey": false, "notNull": false },
"primary_color": { "name": "primary_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#4f8a6f'" },
"accent_color": { "name": "accent_color", "type": "text", "primaryKey": false, "notNull": true, "default": "'#8b7355'" },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.clients": {
"name": "clients",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": false },
"phone": { "name": "phone", "type": "text", "primaryKey": false, "notNull": false },
"address": { "name": "address", "type": "text", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"email_opt_out": { "name": "email_opt_out", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"status": { "name": "status", "type": "client_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"disabled_at": { "name": "disabled_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.grooming_visit_logs": {
"name": "grooming_visit_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false },
"products_used": { "name": "products_used", "type": "text", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"groomed_at": { "name": "groomed_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"grooming_visit_logs_pet_id_pets_id_fk": { "name": "grooming_visit_logs_pet_id_pets_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"grooming_visit_logs_appointment_id_appointments_id_fk": { "name": "grooming_visit_logs_appointment_id_appointments_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" },
"grooming_visit_logs_staff_id_staff_id_fk": { "name": "grooming_visit_logs_staff_id_staff_id_fk", "tableFrom": "grooming_visit_logs", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.impersonation_audit_logs": {
"name": "impersonation_audit_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"session_id": { "name": "session_id", "type": "uuid", "primaryKey": false, "notNull": true },
"action": { "name": "action", "type": "text", "primaryKey": false, "notNull": true },
"page_visited": { "name": "page_visited", "type": "text", "primaryKey": false, "notNull": false },
"metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": { "impersonation_audit_logs_session_id_idx": { "name": "impersonation_audit_logs_session_id_idx", "columns": [{ "expression": "session_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } },
"foreignKeys": { "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", "tableFrom": "impersonation_audit_logs", "tableTo": "impersonation_sessions", "columnsFrom": ["session_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.impersonation_sessions": {
"name": "impersonation_sessions",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": true },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"reason": { "name": "reason", "type": "text", "primaryKey": false, "notNull": false },
"status": { "name": "status", "type": "impersonation_session_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"started_at": { "name": "started_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"ended_at": { "name": "ended_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {
"impersonation_sessions_staff_id_status_idx": { "name": "impersonation_sessions_staff_id_status_idx", "columns": [{ "expression": "staff_id", "isExpression": false, "asc": true, "nulls": "last" }, { "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"impersonation_sessions_client_id_idx": { "name": "impersonation_sessions_client_id_idx", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
},
"foreignKeys": {
"impersonation_sessions_staff_id_staff_id_fk": { "name": "impersonation_sessions_staff_id_staff_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"impersonation_sessions_client_id_clients_id_fk": { "name": "impersonation_sessions_client_id_clients_id_fk", "tableFrom": "impersonation_sessions", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoice_line_items": {
"name": "invoice_line_items",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": true },
"quantity": { "name": "quantity", "type": "integer", "primaryKey": false, "notNull": true, "default": 1 },
"unit_price_cents": { "name": "unit_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "invoice_line_items_invoice_id_invoices_id_fk": { "name": "invoice_line_items_invoice_id_invoices_id_fk", "tableFrom": "invoice_line_items", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoice_tip_splits": {
"name": "invoice_tip_splits",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"invoice_id": { "name": "invoice_id", "type": "uuid", "primaryKey": false, "notNull": true },
"staff_id": { "name": "staff_id", "type": "uuid", "primaryKey": false, "notNull": false },
"staff_name": { "name": "staff_name", "type": "text", "primaryKey": false, "notNull": true },
"share_pct": { "name": "share_pct", "type": "numeric(5, 2)", "primaryKey": false, "notNull": true },
"share_cents": { "name": "share_cents", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"invoice_tip_splits_invoice_id_invoices_id_fk": { "name": "invoice_tip_splits_invoice_id_invoices_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "invoices", "columnsFrom": ["invoice_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"invoice_tip_splits_staff_id_staff_id_fk": { "name": "invoice_tip_splits_staff_id_staff_id_fk", "tableFrom": "invoice_tip_splits", "tableTo": "staff", "columnsFrom": ["staff_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invoices": {
"name": "invoices",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": false },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"subtotal_cents": { "name": "subtotal_cents", "type": "integer", "primaryKey": false, "notNull": true },
"tax_cents": { "name": "tax_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
"tip_cents": { "name": "tip_cents", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 },
"total_cents": { "name": "total_cents", "type": "integer", "primaryKey": false, "notNull": true },
"status": { "name": "status", "type": "invoice_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'draft'" },
"payment_method": { "name": "payment_method", "type": "payment_method", "typeSchema": "public", "primaryKey": false, "notNull": false },
"paid_at": { "name": "paid_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"notes": { "name": "notes", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {
"invoices_appointment_id_appointments_id_fk": { "name": "invoices_appointment_id_appointments_id_fk", "tableFrom": "invoices", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" },
"invoices_client_id_clients_id_fk": { "name": "invoices_client_id_clients_id_fk", "tableFrom": "invoices", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pets": {
"name": "pets",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"species": { "name": "species", "type": "text", "primaryKey": false, "notNull": true },
"breed": { "name": "breed", "type": "text", "primaryKey": false, "notNull": false },
"weight_kg": { "name": "weight_kg", "type": "numeric(5, 2)", "primaryKey": false, "notNull": false },
"date_of_birth": { "name": "date_of_birth", "type": "timestamp", "primaryKey": false, "notNull": false },
"health_alerts": { "name": "health_alerts", "type": "text", "primaryKey": false, "notNull": false },
"grooming_notes": { "name": "grooming_notes", "type": "text", "primaryKey": false, "notNull": false },
"cut_style": { "name": "cut_style", "type": "text", "primaryKey": false, "notNull": false },
"shampoo_preference": { "name": "shampoo_preference", "type": "text", "primaryKey": false, "notNull": false },
"special_care_notes": { "name": "special_care_notes", "type": "text", "primaryKey": false, "notNull": false },
"custom_fields": { "name": "custom_fields", "type": "jsonb", "primaryKey": false, "notNull": true, "default": "'{}'::jsonb" },
"photo_key": { "name": "photo_key", "type": "text", "primaryKey": false, "notNull": false },
"photo_uploaded_at": { "name": "photo_uploaded_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"image": { "name": "image", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "pets_client_id_clients_id_fk": { "name": "pets_client_id_clients_id_fk", "tableFrom": "pets", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.recurring_series": {
"name": "recurring_series",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"frequency_weeks": { "name": "frequency_weeks", "type": "integer", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reminder_logs": {
"name": "reminder_logs",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"appointment_id": { "name": "appointment_id", "type": "uuid", "primaryKey": false, "notNull": true },
"reminder_type": { "name": "reminder_type", "type": "text", "primaryKey": false, "notNull": true },
"sent_at": { "name": "sent_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "reminder_logs_appointment_id_appointments_id_fk": { "name": "reminder_logs_appointment_id_appointments_id_fk", "tableFrom": "reminder_logs", "tableTo": "appointments", "columnsFrom": ["appointment_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "reminder_logs_appointment_id_reminder_type_unique": { "name": "reminder_logs_appointment_id_reminder_type_unique", "nullsNotDistinct": false, "columns": ["appointment_id", "reminder_type"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.services": {
"name": "services",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"description": { "name": "description", "type": "text", "primaryKey": false, "notNull": false },
"base_price_cents": { "name": "base_price_cents", "type": "integer", "primaryKey": false, "notNull": true },
"duration_minutes": { "name": "duration_minutes", "type": "integer", "primaryKey": false, "notNull": true },
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "services_name_unique": { "name": "services_name_unique", "nullsNotDistinct": false, "columns": ["name"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"token": { "name": "token", "type": "text", "primaryKey": false, "notNull": true },
"ip_address": { "name": "ip_address", "type": "text", "primaryKey": false, "notNull": false },
"user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, "columns": ["token"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.staff": {
"name": "staff",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
"oidc_sub": { "name": "oidc_sub", "type": "text", "primaryKey": false, "notNull": false },
"user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": false },
"role": { "name": "role", "type": "staff_role", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'groomer'" },
"is_super_user": { "name": "is_super_user", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"active": { "name": "active", "type": "boolean", "primaryKey": false, "notNull": true, "default": true },
"ical_token": { "name": "ical_token", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": { "staff_user_id_user_id_fk": { "name": "staff_user_id_user_id_fk", "tableFrom": "staff", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } },
"compositePrimaryKeys": {},
"uniqueConstraints": {
"staff_email_unique": { "name": "staff_email_unique", "nullsNotDistinct": false, "columns": ["email"] },
"staff_oidc_sub_unique": { "name": "staff_oidc_sub_unique", "nullsNotDistinct": false, "columns": ["oidc_sub"] },
"staff_ical_token_unique": { "name": "staff_ical_token_unique", "nullsNotDistinct": false, "columns": ["ical_token"] }
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true },
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true },
"email_verified": { "name": "email_verified", "type": "boolean", "primaryKey": false, "notNull": true, "default": false },
"image": { "name": "image", "type": "text", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, "columns": ["email"] } },
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true },
"identifier": { "name": "identifier", "type": "text", "primaryKey": false, "notNull": true },
"value": { "name": "value", "type": "text", "primaryKey": false, "notNull": true },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": true },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.waitlist_entries": {
"name": "waitlist_entries",
"schema": "",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "notNull": true, "default": "gen_random_uuid()" },
"client_id": { "name": "client_id", "type": "uuid", "primaryKey": false, "notNull": true },
"pet_id": { "name": "pet_id", "type": "uuid", "primaryKey": false, "notNull": true },
"service_id": { "name": "service_id", "type": "uuid", "primaryKey": false, "notNull": true },
"preferred_date": { "name": "preferred_date", "type": "text", "primaryKey": false, "notNull": true },
"preferred_time": { "name": "preferred_time", "type": "text", "primaryKey": false, "notNull": true },
"status": { "name": "status", "type": "waitlist_status", "typeSchema": "public", "primaryKey": false, "notNull": true, "default": "'active'" },
"notified_at": { "name": "notified_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"expires_at": { "name": "expires_at", "type": "timestamp", "primaryKey": false, "notNull": false },
"created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" },
"updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }
},
"indexes": {
"idx_waitlist_client_id": { "name": "idx_waitlist_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"idx_waitlist_preferred_date": { "name": "idx_waitlist_preferred_date", "columns": [{ "expression": "preferred_date", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} },
"idx_waitlist_status": { "name": "idx_waitlist_status", "columns": [{ "expression": "status", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }
},
"foreignKeys": {
"waitlist_entries_client_id_clients_id_fk": { "name": "waitlist_entries_client_id_clients_id_fk", "tableFrom": "waitlist_entries", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"waitlist_entries_pet_id_pets_id_fk": { "name": "waitlist_entries_pet_id_pets_id_fk", "tableFrom": "waitlist_entries", "tableTo": "pets", "columnsFrom": ["pet_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" },
"waitlist_entries_service_id_services_id_fk": { "name": "waitlist_entries_service_id_services_id_fk", "tableFrom": "waitlist_entries", "tableTo": "services", "columnsFrom": ["service_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.appointment_status": { "name": "appointment_status", "schema": "public", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
"public.client_status": { "name": "client_status", "schema": "public", "values": ["active", "disabled"] },
"public.impersonation_session_status": { "name": "impersonation_session_status", "schema": "public", "values": ["active", "ended", "expired"] },
"public.invoice_status": { "name": "invoice_status", "schema": "public", "values": ["draft", "pending", "paid", "void"] },
"public.payment_method": { "name": "payment_method", "schema": "public", "values": ["cash", "card", "check", "other"] },
"public.staff_role": { "name": "staff_role", "schema": "public", "values": ["groomer", "receptionist", "manager"] },
"public.waitlist_status": { "name": "waitlist_status", "schema": "public", "values": ["active", "notified", "expired", "cancelled"] }
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
}
@@ -0,0 +1,103 @@
{
"id": "0026_stripe_payment",
"version": "7",
"dialect": "postgresql",
"tables": {
"authProviderConfig": {
"name": "auth_provider_config",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"businessSettings": {
"name": "business_settings",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"clients": {
"name": "clients",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"name": { "name": "name", "type": "text", "isNullable": false },
"email": { "name": "email", "type": "text", "isNullable": true },
"phone": { "name": "phone", "type": "text", "isNullable": true },
"address": { "name": "address", "type": "text", "isNullable": true },
"notes": { "name": "notes", "type": "text", "isNullable": true },
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
},
"invoices": {
"name": "invoices",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
"notes": { "name": "notes", "type": "text", "isNullable": true },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
}
},
"enums": {
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
},
"nativeEnums": {}
}
-21
View File
@@ -204,27 +204,6 @@
"when": 1775741667192,
"tag": "0028_sms_reminders",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1775784467192,
"tag": "0029_db_indexes_constraints",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1775828067192,
"tag": "0030_messaging",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1778818472097,
"tag": "0032_staff_read_at",
"breakpoints": true
}
]
}
-4
View File
@@ -103,8 +103,6 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
photoKey: null,
photoUploadedAt: null,
image: null,
sizeCategory: null,
coatType: null,
createdAt: 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",
basePriceCents: 6500,
durationMinutes: 60,
defaultBufferMinutes: 0,
active: true,
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
@@ -151,7 +148,6 @@ export function buildAppointment(
confirmationStatus: "pending",
confirmedAt: null,
cancelledAt: null,
bufferMinutes: 0,
confirmationToken: null,
customerNotes: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
+1 -1
View File
@@ -4,7 +4,7 @@ import * as schema from "./schema.js";
export * from "./schema.js";
export { encryptSecret, decryptSecret } from "./crypto.js";
export { and, asc, count, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
let _db: ReturnType<typeof drizzle> | null = null;
-154
View File
@@ -48,22 +48,6 @@ export const clientStatusEnum = pgEnum("client_status", [
"disabled",
]);
export const petSizeCategoryEnum = pgEnum("pet_size_category", [
"small",
"medium",
"large",
"xlarge",
]);
export const coatTypeEnum = pgEnum("coat_type", [
"smooth",
"double",
"curly",
"wire",
"long",
"hairless",
]);
// ─── Better-Auth Tables ──────────────────────────────────────────────────────
export const user = pgTable("user", {
@@ -151,8 +135,6 @@ export const pets = pgTable(
name: text("name").notNull(),
species: text("species").notNull(),
breed: text("breed"),
sizeCategory: petSizeCategoryEnum("size_category"),
coatType: coatTypeEnum("coat_type"),
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
dateOfBirth: timestamp("date_of_birth"),
healthAlerts: text("health_alerts"),
@@ -176,7 +158,6 @@ export const services = pgTable("services", {
description: text("description"),
basePriceCents: integer("base_price_cents").notNull(),
durationMinutes: integer("duration_minutes").notNull(),
defaultBufferMinutes: integer("default_buffer_minutes").notNull().default(0),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
@@ -259,8 +240,6 @@ export const appointments = pgTable(
confirmationStatus: text("confirmation_status").notNull().default("pending"),
confirmedAt: timestamp("confirmed_at"),
cancelledAt: timestamp("cancelled_at"),
// Per-appointment buffer time (may be overridden from service default or bufferTimeRules)
bufferMinutes: integer("buffer_minutes").notNull().default(0),
// Token for tokenized email confirm/cancel links (no auth required)
confirmationToken: text("confirmation_token").unique(),
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
@@ -427,118 +406,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"),
staffReadAt: timestamp("staff_read_at"),
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"),
@@ -547,8 +414,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(),
});
@@ -621,22 +486,3 @@ export const authProviderConfig = pgTable("auth_provider_config", {
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const bufferTimeRules = pgTable(
"buffer_time_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().on(t.serviceId, t.sizeCategory, t.coatType),
index("idx_buffer_rules_service_id").on(t.serviceId),
]
);
-4
View File
@@ -32,8 +32,6 @@ export interface Pet {
name: string;
species: string;
breed: string | null;
sizeCategory: string | null;
coatType: string | null;
weightKg: number | null;
dateOfBirth: string | null;
healthAlerts: string | null;
@@ -66,7 +64,6 @@ export interface Service {
description: string | null;
basePriceCents: number;
durationMinutes: number;
defaultBufferMinutes: number;
active: boolean;
createdAt: string;
updatedAt: string;
@@ -117,7 +114,6 @@ export interface Appointment {
cancelledAt: string | null;
confirmationToken: string | null;
customerNotes: string | null;
bufferMinutes: number;
createdAt: string;
updatedAt: string;
}
-19
View File
@@ -46,9 +46,6 @@ importers:
telnyx:
specifier: ^1.23.0
version: 1.27.0
uuid:
specifier: ^11.1.1
version: 11.1.1
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -62,9 +59,6 @@ importers:
'@types/nodemailer':
specifier: ^6.4.17
version: 6.4.23
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
@@ -2340,9 +2334,6 @@ packages:
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@typescript-eslint/eslint-plugin@8.57.1':
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4353,18 +4344,12 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
uuid@11.1.1:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
victory-vendor@37.3.6:
@@ -6925,8 +6910,6 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
'@types/uuid@10.0.0': {}
'@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -9031,8 +9014,6 @@ snapshots:
dependencies:
react: 19.2.4
uuid@11.1.1: {}
uuid@8.3.2: {}
uuid@9.0.1: {}