Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d45582609 |
@@ -11,10 +11,6 @@ AUTH_DISABLED=false
|
|||||||
OIDC_ISSUER=https://authentik.example.com
|
OIDC_ISSUER=https://authentik.example.com
|
||||||
OIDC_AUDIENCE=groombook
|
OIDC_AUDIENCE=groombook
|
||||||
|
|
||||||
# ── Webhooks ─────────────────────────────────────────────────────────────────
|
|
||||||
# Telnyx webhook secret for validating inbound message webhooks.
|
|
||||||
TELNYX_WEBHOOK_SECRET=your-telnyx-webhook-secret-here
|
|
||||||
|
|
||||||
# ── Setup Wizard ─────────────────────────────────────────────────────────────
|
# ── Setup Wizard ─────────────────────────────────────────────────────────────
|
||||||
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
|
# 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
|
# super user exists in the database. Useful in dev/test environments where the
|
||||||
|
|||||||
+147
-82
@@ -6,6 +6,11 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Branch or ref to run CI against"
|
||||||
|
required: false
|
||||||
|
default: "main"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-typecheck:
|
lint-typecheck:
|
||||||
@@ -53,6 +58,47 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: E2E Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint-typecheck, test]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.4'
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: pnpm --filter @groombook/e2e exec playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Start Docker Compose stack
|
||||||
|
run: docker compose up -d --wait
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
run: pnpm --filter @groombook/e2e test
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -73,16 +119,17 @@ jobs:
|
|||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build all packages
|
- name: Build all packages
|
||||||
env:
|
|
||||||
VITE_API_URL: ""
|
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
name: Build & Push Docker Images
|
name: Build & Push Docker Images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build]
|
needs: [build, e2e]
|
||||||
outputs:
|
outputs:
|
||||||
tag: ${{ steps.version.outputs.tag }}
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -103,12 +150,12 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.farh.net
|
registry: ghcr.io
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push API image
|
- name: Build and push API image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -118,10 +165,10 @@ jobs:
|
|||||||
target: runner
|
target: runner
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
ghcr.io/groombook/api:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/api:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
cache-from: type=gha
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Build and push Migrate image
|
- name: Build and push Migrate image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -131,10 +178,10 @@ jobs:
|
|||||||
target: migrate
|
target: migrate
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
ghcr.io/groombook/migrate:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/migrate:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
cache-from: type=gha
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Build and push Seed image
|
- name: Build and push Seed image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -144,10 +191,10 @@ jobs:
|
|||||||
target: seed
|
target: seed
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
ghcr.io/groombook/seed:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/seed:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:seed
|
cache-from: type=gha
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Build and push Reset image
|
- name: Build and push Reset image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -157,10 +204,10 @@ jobs:
|
|||||||
target: reset
|
target: reset
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
ghcr.io/groombook/reset:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/reset:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
|
cache-from: type=gha
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Build and push Web image
|
- name: Build and push Web image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -169,16 +216,19 @@ jobs:
|
|||||||
file: apps/web/Dockerfile
|
file: apps/web/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/web:${{ steps.version.outputs.tag }}
|
ghcr.io/groombook/web:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/web:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:web
|
cache-from: type=gha
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:web,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
name: Deploy PR to groombook-dev
|
name: Deploy PR to groombook-dev
|
||||||
runs-on: runners-groombook
|
runs-on: runners-groombook
|
||||||
needs: [docker]
|
needs: [docker]
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Install kubectl
|
- name: Install kubectl
|
||||||
run: |
|
run: |
|
||||||
@@ -195,6 +245,7 @@ jobs:
|
|||||||
TAG="pr-$PR_NUM-${SHA::7}"
|
TAG="pr-$PR_NUM-${SHA::7}"
|
||||||
echo "Deploying images tagged $TAG to groombook-dev..."
|
echo "Deploying images tagged $TAG to groombook-dev..."
|
||||||
|
|
||||||
|
# Run migration with PR image
|
||||||
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
|
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
|
||||||
cat <<EOF | kubectl apply -n groombook-dev -f -
|
cat <<EOF | kubectl apply -n groombook-dev -f -
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
@@ -209,7 +260,7 @@ jobs:
|
|||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
containers:
|
containers:
|
||||||
- name: migrate
|
- name: migrate
|
||||||
image: git.farh.net/groombook/migrate:$TAG
|
image: ghcr.io/groombook/migrate:$TAG
|
||||||
env:
|
env:
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
@@ -220,33 +271,35 @@ jobs:
|
|||||||
kubectl wait --for=condition=complete "job/migrate-pr-$PR_NUM" \
|
kubectl wait --for=condition=complete "job/migrate-pr-$PR_NUM" \
|
||||||
-n groombook-dev --timeout=120s
|
-n groombook-dev --timeout=120s
|
||||||
|
|
||||||
kubectl set image deployment/api api=git.farh.net/groombook/api:$TAG -n groombook-dev
|
# Update deployments
|
||||||
kubectl set image deployment/web web=git.farh.net/groombook/web:$TAG -n groombook-dev
|
kubectl set image deployment/api api=ghcr.io/groombook/api:$TAG -n groombook-dev
|
||||||
|
kubectl set image deployment/web web=ghcr.io/groombook/web:$TAG -n groombook-dev
|
||||||
|
|
||||||
|
# Wait for rollout
|
||||||
kubectl rollout status deployment/api -n groombook-dev --timeout=300s
|
kubectl rollout status deployment/api -n groombook-dev --timeout=300s
|
||||||
kubectl rollout status deployment/web -n groombook-dev --timeout=300s
|
kubectl rollout status deployment/web -n groombook-dev --timeout=300s
|
||||||
|
|
||||||
echo "Deployment complete."
|
echo "Deployment complete."
|
||||||
|
|
||||||
- name: Comment on PR
|
- name: Comment on PR
|
||||||
env:
|
uses: actions/github-script@v7
|
||||||
PR_NUM: ${{ github.event.pull_request.number }}
|
with:
|
||||||
run: |
|
script: |
|
||||||
PR_NUM="$PR_NUM"
|
const pr = context.issue.number;
|
||||||
BODY=$(cat <<'EOFBODY'
|
const tag = `pr-${pr}`;
|
||||||
## Deployed to groombook-dev
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
**Images:** `pr-'"$PR_NUM"'`
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr,
|
||||||
**URL:** https://dev.groombook.farh.net
|
body: [
|
||||||
|
'## Deployed to groombook-dev',
|
||||||
Ready for UAT validation.
|
'',
|
||||||
EOFBODY
|
`**Images:** \`${tag}\``,
|
||||||
)
|
'**URL:** https://dev.groombook.farh.net',
|
||||||
curl -s -X POST "https://git.farh.net/api/v1/repos/groombook/app/issues/${PR_NUM}/comments" \
|
'',
|
||||||
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
|
'Ready for UAT validation.'
|
||||||
-H "Content-Type: application/json" \
|
].join('\n')
|
||||||
-d "{\"body\": $(echo "$BODY" | jq -Rs .)}"
|
});
|
||||||
|
|
||||||
web-e2e:
|
web-e2e:
|
||||||
name: Web E2E (Dev)
|
name: Web E2E (Dev)
|
||||||
@@ -275,15 +328,33 @@ jobs:
|
|||||||
run: pnpm --filter @groombook/web test:e2e
|
run: pnpm --filter @groombook/web test:e2e
|
||||||
timeout-minutes: 10
|
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:
|
cd:
|
||||||
name: Update Infra Image Tags
|
name: Update Infra Image Tags
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [docker]
|
needs: [docker]
|
||||||
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
steps:
|
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
|
- name: Clone groombook/infra
|
||||||
run: |
|
run: |
|
||||||
git clone https://oauth2:${{ secrets.REGISTRY_TOKEN }}@git.farh.net/groombook/infra.git /tmp/infra
|
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
||||||
|
|
||||||
- name: Install yq
|
- name: Install yq
|
||||||
run: |
|
run: |
|
||||||
@@ -302,24 +373,28 @@ jobs:
|
|||||||
echo "Updating dev overlay image tags to: $TAG"
|
echo "Updating dev overlay image tags to: $TAG"
|
||||||
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
|
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
|
||||||
cd /tmp/infra
|
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 == "ghcr.io/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 == "ghcr.io/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 == "ghcr.io/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 == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
|
||||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
|
||||||
|
|
||||||
MIGRATE_JOB="apps/base/migrate-job.yaml"
|
# Update migrate Job name to include short SHA (immutable template fix)
|
||||||
|
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||||
if [ -f "$MIGRATE_JOB" ]; then
|
if [ -f "$MIGRATE_JOB" ]; then
|
||||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
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 '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||||
|
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
|
||||||
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
|
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SEED_JOB="apps/base/seed-job.yaml"
|
# Update seed Job name to include short SHA (immutable template fix)
|
||||||
|
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||||
if [ -f "$SEED_JOB" ]; then
|
if [ -f "$SEED_JOB" ]; then
|
||||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
||||||
|
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
|
||||||
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
|
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -328,6 +403,7 @@ jobs:
|
|||||||
- name: Create PR on groombook/infra
|
- name: Create PR on groombook/infra
|
||||||
env:
|
env:
|
||||||
TAG: ${{ needs.docker.outputs.tag }}
|
TAG: ${{ needs.docker.outputs.tag }}
|
||||||
|
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$TAG" ]; then
|
if [ -z "$TAG" ]; then
|
||||||
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||||
@@ -335,35 +411,24 @@ jobs:
|
|||||||
|
|
||||||
cd /tmp/infra
|
cd /tmp/infra
|
||||||
git config user.name "groombook-engineer[bot]"
|
git config user.name "groombook-engineer[bot]"
|
||||||
git config user.email "groombook-engineer@farh.net"
|
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||||
git checkout -b "chore/update-image-tags-${TAG}"
|
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 commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
|
||||||
|
|
||||||
git push -u origin "chore/update-image-tags-${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}" \
|
# Check if PR already exists for this branch
|
||||||
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" | jq -r '.[0].number')
|
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
|
||||||
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then
|
if [ -n "$EXISTING_PR" ]; then
|
||||||
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
|
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" \
|
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
|
||||||
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"do": "merge"}'
|
|
||||||
else
|
else
|
||||||
PR_RESPONSE=$(curl -s -X POST "https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
|
PR_URL=$(gh pr create \
|
||||||
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
|
--repo groombook/infra \
|
||||||
-H "Content-Type: application/json" \
|
--base main \
|
||||||
-d "{
|
--head "chore/update-image-tags-${TAG}" \
|
||||||
\"base\": \"main\",
|
--title "chore: deploy ${TAG} to dev" \
|
||||||
\"head\": \"chore/update-image-tags-${TAG}\",
|
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
||||||
\"title\": \"chore: deploy ${TAG} to dev\",
|
gh pr merge "$PR_URL" --merge
|
||||||
\"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 }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"do": "merge"}'
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
TAG: ${{ inputs.tag }}
|
TAG: ${{ inputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
cd /tmp/infra
|
cd /tmp/infra
|
||||||
PROD_KUST="apps/overlays/prod/kustomization.yaml"
|
PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml"
|
||||||
|
|
||||||
SHORT_SHA="${TAG##*-}"
|
SHORT_SHA="${TAG##*-}"
|
||||||
export SHORT_SHA
|
export SHORT_SHA
|
||||||
@@ -70,14 +70,14 @@ jobs:
|
|||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).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)
|
# Update migrate Job name to include short SHA (immutable template fix)
|
||||||
MIGRATE_JOB="apps/base/migrate-job.yaml"
|
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||||
if [ -f "$MIGRATE_JOB" ]; then
|
if [ -f "$MIGRATE_JOB" ]; then
|
||||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
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 '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Update seed Job name to include short SHA (immutable template fix)
|
# Update seed Job name to include short SHA (immutable template fix)
|
||||||
SEED_JOB="apps/base/seed-job.yaml"
|
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||||
if [ -f "$SEED_JOB" ]; then
|
if [ -f "$SEED_JOB" ]; then
|
||||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
git config user.name "groombook-engineer[bot]"
|
git config user.name "groombook-engineer[bot]"
|
||||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||||
git checkout -b "release/promote-prod-${TAG}"
|
git checkout -b "release/promote-prod-${TAG}"
|
||||||
git add apps/overlays/prod/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
|
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 commit -m "release: promote ${TAG} to production"
|
||||||
git push -u origin "release/promote-prod-${TAG}"
|
git push -u origin "release/promote-prod-${TAG}"
|
||||||
gh pr create \
|
gh pr create \
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "Updating UAT overlay image tags to: $TAG"
|
echo "Updating UAT overlay image tags to: $TAG"
|
||||||
cd /tmp/infra
|
cd /tmp/infra
|
||||||
UAT_KUST="apps/overlays/uat/kustomization.yaml"
|
UAT_KUST="apps/groombook/overlays/uat/kustomization.yaml"
|
||||||
|
|
||||||
if [ ! -f "$UAT_KUST" ]; then
|
if [ ! -f "$UAT_KUST" ]; then
|
||||||
echo "ERROR: UAT overlay not found at $UAT_KUST. Ensure GRO-427 has been completed."
|
echo "ERROR: UAT overlay not found at $UAT_KUST. Ensure GRO-427 has been completed."
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).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)
|
# Update migrate Job name to include short SHA (immutable template fix)
|
||||||
MIGRATE_JOB="apps/base/migrate-job.yaml"
|
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||||
if [ -f "$MIGRATE_JOB" ]; then
|
if [ -f "$MIGRATE_JOB" ]; then
|
||||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
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 '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
# Update seed Job name to include short SHA (immutable template fix)
|
# Update seed Job name to include short SHA (immutable template fix)
|
||||||
# NOTE: Do NOT update the image tag here — let the Kustomize images transformer
|
# 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.
|
# in the UAT overlay handle it via newTag. This avoids the immutable template issue.
|
||||||
SEED_JOB="apps/base/seed-job.yaml"
|
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||||
if [ -f "$SEED_JOB" ]; then
|
if [ -f "$SEED_JOB" ]; then
|
||||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
||||||
@@ -81,7 +81,7 @@ jobs:
|
|||||||
git config user.name "groombook-engineer[bot]"
|
git config user.name "groombook-engineer[bot]"
|
||||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||||
git checkout -b "chore/update-uat-image-tags-${TAG}"
|
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 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 commit -m "chore: promote ${TAG} to UAT"
|
||||||
|
|
||||||
git push -u origin "chore/update-uat-image-tags-${TAG}"
|
git push -u origin "chore/update-uat-image-tags-${TAG}"
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# Shedward Scissorhands — UAT Agent Instructions
|
|
||||||
|
|
||||||
You are the GroomBook User Acceptance Tester. Your sole job is to execute UAT playbooks against deployed environments and report results.
|
|
||||||
|
|
||||||
## Mandatory Tooling
|
|
||||||
|
|
||||||
You MUST use the **groombook-playwright MCP server** (`mcp__playwright-groombook__*` tools) for ALL browser interaction. Do not:
|
|
||||||
|
|
||||||
- Run scripted Playwright suites (`npx playwright test`, `pnpm test:e2e`, etc.)
|
|
||||||
- Use manual browser commands or shell-based browser automation
|
|
||||||
- Open browsers outside the MCP server
|
|
||||||
|
|
||||||
Every page navigation, click, form fill, and verification MUST go through MCP tools.
|
|
||||||
|
|
||||||
## Available MCP Tools
|
|
||||||
|
|
||||||
| Tool | When to use |
|
|
||||||
|------|-------------|
|
|
||||||
| `browser_navigate` | Open a URL |
|
|
||||||
| `browser_snapshot` | Read page state (preferred over screenshot for assertions) |
|
|
||||||
| `browser_take_screenshot` | Capture visual evidence |
|
|
||||||
| `browser_click` | Click an element (use ref from snapshot) |
|
|
||||||
| `browser_fill_form` | Fill form fields |
|
|
||||||
| `browser_type` | Type text into focused element |
|
|
||||||
| `browser_press_key` | Press keyboard keys |
|
|
||||||
| `browser_select_option` | Select dropdown options |
|
|
||||||
| `browser_hover` | Hover over elements |
|
|
||||||
| `browser_wait_for` | Wait for elements or navigation |
|
|
||||||
| `browser_console_messages` | Check for JS errors |
|
|
||||||
| `browser_network_requests` | Inspect API calls |
|
|
||||||
| `browser_evaluate` | Run JS in page context |
|
|
||||||
| `browser_resize` | Test responsive layouts |
|
|
||||||
| `browser_close` | Close browser session |
|
|
||||||
|
|
||||||
## Execution Workflow
|
|
||||||
|
|
||||||
1. Read the `UAT_PLAYBOOK.md` in the repo being tested.
|
|
||||||
2. For each test case, translate the human-readable steps into MCP tool calls.
|
|
||||||
3. Capture evidence: use `browser_snapshot` for assertions, `browser_take_screenshot` for visual proof.
|
|
||||||
4. Report pass/fail per test case with evidence.
|
|
||||||
5. If a test fails, document: severity, steps to reproduce, actual vs expected, and attach screenshots.
|
|
||||||
|
|
||||||
## Environments
|
|
||||||
|
|
||||||
| Environment | URL | Auth |
|
|
||||||
|-------------|-----|------|
|
|
||||||
| Dev | `https://dev.groombook.dev` | Dev login selector (no OIDC) |
|
|
||||||
| UAT | `https://uat.groombook.dev` | Authentik OIDC at `https://auth.farh.net` |
|
|
||||||
| Production | `https://demo.groombook.dev` | Authentik OIDC |
|
|
||||||
| Site | `https://groombook.farh.net` | No auth required |
|
|
||||||
-325
@@ -1,325 +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. Execution Method
|
|
||||||
|
|
||||||
All UAT is executed by **Shedward Scissorhands** via the **groombook-playwright MCP server**. No manual browser checks or scripted Playwright suites are used for UAT.
|
|
||||||
|
|
||||||
### MCP Tools
|
|
||||||
|
|
||||||
Shedward uses the `mcp__playwright-groombook__*` tool family:
|
|
||||||
|
|
||||||
| Tool | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `browser_navigate` | Navigate to a URL |
|
|
||||||
| `browser_snapshot` | Capture accessibility snapshot (preferred over screenshot) |
|
|
||||||
| `browser_take_screenshot` | Capture visual screenshot when needed |
|
|
||||||
| `browser_click` | Click an element by ref or selector |
|
|
||||||
| `browser_fill_form` | Fill form fields |
|
|
||||||
| `browser_type` | Type text into focused element |
|
|
||||||
| `browser_press_key` | Press keyboard keys (Enter, Tab, etc.) |
|
|
||||||
| `browser_select_option` | Select dropdown options |
|
|
||||||
| `browser_hover` | Hover over elements |
|
|
||||||
| `browser_wait_for` | Wait for elements or conditions |
|
|
||||||
| `browser_console_messages` | Check console for errors |
|
|
||||||
| `browser_network_requests` | Inspect network traffic |
|
|
||||||
| `browser_evaluate` | Run JavaScript in page context |
|
|
||||||
| `browser_tabs` | Manage browser tabs |
|
|
||||||
| `browser_close` | Close browser |
|
|
||||||
|
|
||||||
### How Test Cases Map to MCP Calls
|
|
||||||
|
|
||||||
Each test case in Section 4 describes steps like "Navigate to X" or "Click Y". Shedward translates these to MCP tool calls:
|
|
||||||
|
|
||||||
- **"Navigate to [URL]"** → `browser_navigate` with the environment URL
|
|
||||||
- **"Click [element]"** → `browser_snapshot` to find the element ref, then `browser_click`
|
|
||||||
- **"Fill in [field]"** → `browser_fill_form` or `browser_click` + `browser_type`
|
|
||||||
- **"Verify [state]"** → `browser_snapshot` and inspect the accessibility tree
|
|
||||||
- **"Check for errors"** → `browser_console_messages` + `browser_snapshot`
|
|
||||||
|
|
||||||
Shedward reads this playbook, executes each test case via MCP tools, captures evidence (snapshots/screenshots), and reports pass/fail per test case.
|
|
||||||
|
|
||||||
### Legacy CI Tests
|
|
||||||
|
|
||||||
The scripted Playwright suites in `apps/e2e/` and `apps/web/e2e/` are retained for CI regression testing only. They are **not** the primary UAT mechanism. UAT is exclusively MCP-driven by Shedward.
|
|
||||||
|
|
||||||
## 3. 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`.
|
|
||||||
|
|
||||||
## 4. 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
|
|
||||||
|
|
||||||
## 5. Test Cases
|
|
||||||
|
|
||||||
### 4.1 Authentication
|
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
|
||||||
|---|----------|-------|----------|
|
|
||||||
| TC-APP-4.1.1 | OIDC login (Authentik) | 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 | Email + password login (UAT Super) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-super@groombook.dev` and UAT super password<br>4. Submit | User is logged in and redirected to dashboard with manager access |
|
|
||||||
| TC-APP-4.1.3 | Email + password login (UAT Groomer) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-groomer@groombook.dev` and UAT groomer password<br>4. Submit | User is logged in and redirected to dashboard with staff/groomer access |
|
|
||||||
| TC-APP-4.1.4 | Email + password login (UAT Customer) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-customer@groombook.dev` and UAT customer password<br>4. Submit | User is logged in with client portal access |
|
|
||||||
| TC-APP-4.1.5 | Email + password login (UAT Tester) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-tester@groombook.dev` and UAT tester password<br>4. Submit | User is logged in with staff/tester access |
|
|
||||||
| TC-APP-4.1.6 | 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.7 | 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.8 | RBAC - Manager access | 1. Log in as Manager (OIDC or email+password)<br>2. Navigate to Settings, Staff Management, Reports | All administrative features are accessible |
|
|
||||||
| TC-APP-4.1.9 | RBAC - Staff access | 1. Log in as Staff (OIDC or email+password)<br>2. Attempt to access Settings, Staff Management | Access denied or limited view, staff can only see assigned appointments |
|
|
||||||
| TC-APP-4.1.10 | RBAC - Client access | 1. Log in as Client (email+password)<br>2. Navigate to portal<br>3. Attempt to access admin areas | Client can only view their own appointments, pets, and profile |
|
|
||||||
| TC-APP-4.1.11 | Login after hourly reset | 1. Wait for or trigger `reset-demo-data` CronJob to run<br>2. Attempt email+password login as any UAT persona | Login succeeds — Better Auth credential accounts survive the reset cycle |
|
|
||||||
|
|
||||||
### 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/Xlarge pet slot duration reflects buffer | 1. Add a pet with sizeCategory = "large" or "xlarge" 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"` |
|
|
||||||
## 6. 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.
|
|
||||||
|
|
||||||
## 7. 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 5 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").
|
|
||||||
@@ -24,14 +24,13 @@
|
|||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"stripe": "^22.0.0",
|
"stripe": "^22.0.0",
|
||||||
"telnyx": "^1.23.0",
|
"telnyx": "^1.23.0",
|
||||||
"uuid": "^11.1.1",
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -40,17 +40,11 @@ const APPOINTMENT = {
|
|||||||
let selectSessionRow: Record<string, unknown> | null = null;
|
let selectSessionRow: Record<string, unknown> | null = null;
|
||||||
let selectAppointmentRow: Record<string, unknown> | null = null;
|
let selectAppointmentRow: Record<string, unknown> | null = null;
|
||||||
let updatedValues: Record<string, unknown>[] = [];
|
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() {
|
function resetMock() {
|
||||||
selectSessionRow = null;
|
selectSessionRow = null;
|
||||||
selectAppointmentRow = null;
|
selectAppointmentRow = null;
|
||||||
updatedValues = [];
|
updatedValues = [];
|
||||||
selectBusinessSettingsRow = null;
|
|
||||||
selectConversationRow = null;
|
|
||||||
selectMessageRows = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock("@groombook/db", () => {
|
vi.mock("@groombook/db", () => {
|
||||||
@@ -78,26 +72,6 @@ vi.mock("@groombook/db", () => {
|
|||||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
{ 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 {
|
return {
|
||||||
getDb: () => ({
|
getDb: () => ({
|
||||||
select: () => ({
|
select: () => ({
|
||||||
@@ -108,15 +82,6 @@ const businessSettings = new Proxy(
|
|||||||
if (table._name === "appointments") {
|
if (table._name === "appointments") {
|
||||||
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
|
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([]);
|
return makeChainable([]);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -134,21 +99,11 @@ const businessSettings = new Proxy(
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
insert: () => ({
|
|
||||||
values: () => ({
|
|
||||||
returning: () => [],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
impersonationSessions,
|
impersonationSessions,
|
||||||
appointments,
|
appointments,
|
||||||
impersonationAuditLogs,
|
|
||||||
businessSettings,
|
|
||||||
conversations,
|
|
||||||
messages,
|
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
and: 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);
|
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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -8,7 +8,6 @@ import { petsRouter } from "./routes/pets.js";
|
|||||||
import { servicesRouter } from "./routes/services.js";
|
import { servicesRouter } from "./routes/services.js";
|
||||||
import { appointmentsRouter } from "./routes/appointments.js";
|
import { appointmentsRouter } from "./routes/appointments.js";
|
||||||
import { waitlistRouter } from "./routes/waitlist.js";
|
import { waitlistRouter } from "./routes/waitlist.js";
|
||||||
import { conversationsRouter } from "./routes/conversations.js";
|
|
||||||
import { portalRouter } from "./routes/portal.js";
|
import { portalRouter } from "./routes/portal.js";
|
||||||
import { staffRouter } from "./routes/staff.js";
|
import { staffRouter } from "./routes/staff.js";
|
||||||
import { invoicesRouter } from "./routes/invoices.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 { adminSeedRouter } from "./routes/admin/seed.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||||
import { telnyxWebhooksRouter } from "./routes/webhooks/telnyx.js";
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -71,9 +69,6 @@ app.route("/api/portal", portalRouter);
|
|||||||
// Public Stripe webhook endpoint — signature-verified, no auth required
|
// Public Stripe webhook endpoint — signature-verified, no auth required
|
||||||
app.route("/api/webhooks/stripe", webhooksRouter);
|
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
|
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||||
app.route("/api/dev", devRouter);
|
app.route("/api/dev", devRouter);
|
||||||
|
|
||||||
@@ -264,7 +259,6 @@ api.route("/pets", petsRouter);
|
|||||||
api.route("/services", servicesRouter);
|
api.route("/services", servicesRouter);
|
||||||
api.route("/appointments", appointmentsRouter);
|
api.route("/appointments", appointmentsRouter);
|
||||||
api.route("/waitlist", waitlistRouter);
|
api.route("/waitlist", waitlistRouter);
|
||||||
api.route("/conversations", conversationsRouter);
|
|
||||||
api.route("/staff", staffRouter);
|
api.route("/staff", staffRouter);
|
||||||
api.route("/invoices", invoicesRouter);
|
api.route("/invoices", invoicesRouter);
|
||||||
api.route("/reports", reportsRouter);
|
api.route("/reports", reportsRouter);
|
||||||
@@ -275,7 +269,6 @@ api.route("/admin/settings", settingsRouter);
|
|||||||
api.route("/admin/auth-provider", authProviderRouter);
|
api.route("/admin/auth-provider", authProviderRouter);
|
||||||
api.route("/admin/seed", adminSeedRouter);
|
api.route("/admin/seed", adminSeedRouter);
|
||||||
api.route("/search", searchRouter);
|
api.route("/search", searchRouter);
|
||||||
api.route("/conversations", conversationsRouter);
|
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
await initAuth();
|
await initAuth();
|
||||||
|
|||||||
@@ -97,9 +97,6 @@ export async function initAuth(): Promise<void> {
|
|||||||
window: 10,
|
window: 10,
|
||||||
storage: "memory",
|
storage: "memory",
|
||||||
customRules: {
|
customRules: {
|
||||||
"/sign-in/social": { max: 10, window: 60 },
|
|
||||||
"/sign-in/email": { max: 10, window: 60 },
|
|
||||||
"/sign-up/email": { max: 5, window: 60 },
|
|
||||||
"/get-session": false,
|
"/get-session": false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -250,9 +247,6 @@ export async function initAuth(): Promise<void> {
|
|||||||
window: 10,
|
window: 10,
|
||||||
storage: "memory",
|
storage: "memory",
|
||||||
customRules: {
|
customRules: {
|
||||||
"/sign-in/social": { max: 10, window: 60 },
|
|
||||||
"/sign-in/email": { max: 10, window: 60 },
|
|
||||||
"/sign-up/email": { max: 5, window: 60 },
|
|
||||||
"/get-session": false,
|
"/get-session": false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,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;
|
|
||||||
}
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
import { eq, and, gt, 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,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -23,8 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
const path = c.req.path;
|
if (c.req.path.startsWith("/api/auth/")) {
|
||||||
if (path.startsWith("/api/auth/") || path.startsWith("/api/webhooks/")) {
|
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ import {
|
|||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||||
import {
|
|
||||||
detectAndCascadeOverrun,
|
|
||||||
isOverrun,
|
|
||||||
} from "../lib/cascade.js";
|
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
async function withRetry<T>(
|
async function withRetry<T>(
|
||||||
@@ -588,7 +584,6 @@ appointmentsRouter.patch(
|
|||||||
// (fixes #18). Also falls back to the existing staffId when staffId is
|
// (fixes #18). Also falls back to the existing staffId when staffId is
|
||||||
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
||||||
let row: typeof appointments.$inferSelect | undefined;
|
let row: typeof appointments.$inferSelect | undefined;
|
||||||
let originalEndTime: Date | undefined;
|
|
||||||
try {
|
try {
|
||||||
row = await db.transaction(async (tx) => {
|
row = await db.transaction(async (tx) => {
|
||||||
const [current] = await tx
|
const [current] = await tx
|
||||||
@@ -600,9 +595,6 @@ appointmentsRouter.patch(
|
|||||||
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve original endTime for cascade detection after update
|
|
||||||
originalEndTime = current.endTime;
|
|
||||||
|
|
||||||
const start = updateFields.startTime
|
const start = updateFields.startTime
|
||||||
? new Date(updateFields.startTime)
|
? new Date(updateFields.startTime)
|
||||||
: current.startTime;
|
: current.startTime;
|
||||||
@@ -692,29 +684,6 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
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: originalEndTime,
|
|
||||||
});
|
|
||||||
return c.json({ ...row, cascade: cascadeResult });
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(row);
|
return c.json(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,12 +38,11 @@ bookRouter.get("/services", async (c) => {
|
|||||||
|
|
||||||
// ─── GET /api/book/availability ─────────────────────────────────────────────
|
// ─── GET /api/book/availability ─────────────────────────────────────────────
|
||||||
// Public: return ISO startTime strings for slots where ≥1 groomer is free
|
// 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) => {
|
bookRouter.get("/availability", async (c) => {
|
||||||
const serviceId = c.req.query("serviceId");
|
const serviceId = c.req.query("serviceId");
|
||||||
const dateStr = c.req.query("date");
|
const dateStr = c.req.query("date");
|
||||||
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
|
|
||||||
|
|
||||||
if (!serviceId || !dateStr) {
|
if (!serviceId || !dateStr) {
|
||||||
return c.json({ error: "serviceId and date are required" }, 400);
|
return c.json({ error: "serviceId and date are required" }, 400);
|
||||||
@@ -59,12 +58,6 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
.where(and(eq(services.id, serviceId), eq(services.active, true)));
|
.where(and(eq(services.id, serviceId), eq(services.active, true)));
|
||||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
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
|
const groomers = await db
|
||||||
.select({ id: staff.id })
|
.select({ id: staff.id })
|
||||||
.from(staff)
|
.from(staff)
|
||||||
@@ -96,7 +89,7 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
|
|
||||||
const slots = generateAvailableSlots({
|
const slots = generateAvailableSlots({
|
||||||
dateStr,
|
dateStr,
|
||||||
durationMinutes,
|
durationMinutes: service.durationMinutes,
|
||||||
groomerIds: groomers.map((g) => g.id),
|
groomerIds: groomers.map((g) => g.id),
|
||||||
booked,
|
booked,
|
||||||
});
|
});
|
||||||
@@ -119,12 +112,6 @@ const bookingSchema = z.object({
|
|||||||
petName: z.string().min(1).max(200),
|
petName: z.string().min(1).max(200),
|
||||||
petSpecies: z.string().min(1).max(100),
|
petSpecies: z.string().min(1).max(100),
|
||||||
petBreed: z.string().max(100).optional(),
|
petBreed: z.string().max(100).optional(),
|
||||||
petSizeCategory: z
|
|
||||||
.enum(["small", "medium", "large", "xlarge"])
|
|
||||||
.optional(),
|
|
||||||
petCoatType: z
|
|
||||||
.enum(["smooth", "double", "curly", "wire", "long", "hairless"])
|
|
||||||
.optional(),
|
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,7 +129,7 @@ bookRouter.post(
|
|||||||
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
|
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
|
||||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
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
|
// Find all active groomers
|
||||||
const groomers = await db
|
const groomers = await db
|
||||||
@@ -204,18 +191,11 @@ bookRouter.post(
|
|||||||
name: body.petName,
|
name: body.petName,
|
||||||
species: body.petSpecies,
|
species: body.petSpecies,
|
||||||
breed: body.petBreed ?? null,
|
breed: body.petBreed ?? null,
|
||||||
sizeCategory: body.petSizeCategory ?? null,
|
|
||||||
coatType: body.petCoatType ?? null,
|
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
const pet = petInserted[0];
|
const pet = petInserted[0];
|
||||||
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
|
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
|
// Insert appointment in a transaction to guard against race conditions
|
||||||
let appointment;
|
let appointment;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -14,8 +14,7 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
sql,
|
sql,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import type { AppEnv, StaffRole } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import { requireRole } from "../middleware/rbac.js";
|
|
||||||
|
|
||||||
export const invoicesRouter = new Hono<AppEnv>();
|
export const invoicesRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
@@ -102,8 +101,6 @@ invoicesRouter.get(
|
|||||||
paymentMethod: invoices.paymentMethod,
|
paymentMethod: invoices.paymentMethod,
|
||||||
paidAt: invoices.paidAt,
|
paidAt: invoices.paidAt,
|
||||||
notes: invoices.notes,
|
notes: invoices.notes,
|
||||||
stripePaymentIntentId: invoices.stripePaymentIntentId,
|
|
||||||
stripeRefundId: invoices.stripeRefundId,
|
|
||||||
createdAt: invoices.createdAt,
|
createdAt: invoices.createdAt,
|
||||||
updatedAt: invoices.updatedAt,
|
updatedAt: invoices.updatedAt,
|
||||||
})
|
})
|
||||||
@@ -131,17 +128,7 @@ invoicesRouter.get("/:id", async (c) => {
|
|||||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let cardLast4: string | null = null;
|
return c.json({ ...invoice, lineItems, tipSplits });
|
||||||
let paymentStatus: string | null = null;
|
|
||||||
if (invoice.stripePaymentIntentId) {
|
|
||||||
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
|
||||||
if (details) {
|
|
||||||
cardLast4 = details.cardLast4;
|
|
||||||
paymentStatus = details.paymentStatus;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save tip splits for an invoice (replaces existing splits)
|
// Save tip splits for an invoice (replaces existing splits)
|
||||||
@@ -462,7 +449,7 @@ invoicesRouter.post(
|
|||||||
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
||||||
}
|
}
|
||||||
if (!invoice.stripePaymentIntentId) {
|
if (!invoice.stripePaymentIntentId) {
|
||||||
return c.json({ error: "Invoice has no Stripe payment intent" }, 422);
|
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await db.transaction(async (tx) => {
|
return await db.transaction(async (tx) => {
|
||||||
@@ -478,66 +465,55 @@ invoicesRouter.post(
|
|||||||
|
|
||||||
const result = await processRefund(id, body.amountCents);
|
const result = await processRefund(id, body.amountCents);
|
||||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||||
const refundId = result.refundId;
|
|
||||||
|
|
||||||
await tx.insert(refunds).values({
|
await tx.insert(refunds).values({
|
||||||
invoiceId: id,
|
invoiceId: id,
|
||||||
stripeRefundId: refundId,
|
stripeRefundId: result.refundId,
|
||||||
idempotencyKey: body.idempotencyKey ?? null,
|
idempotencyKey: body.idempotencyKey ?? null,
|
||||||
amountCents: body.amountCents ?? null,
|
amountCents: body.amountCents ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({ refundId });
|
return c.json({ refundId: result.refundId });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Payment stats for admin dashboard
|
// 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 db = getDb();
|
const now = new Date();
|
||||||
const now = new Date();
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
||||||
|
|
||||||
const [revenueResult] = await db
|
const [revenueResult] = await db
|
||||||
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
|
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
|
||||||
|
|
||||||
const [outstandingResult] = await db
|
const [outstandingResult] = await db
|
||||||
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq(invoices.status, "pending"));
|
.where(eq(invoices.status, "pending"));
|
||||||
|
|
||||||
const [refundsResult] = await db
|
const [refundsResult] = await db
|
||||||
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
|
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
|
||||||
.from(refunds)
|
.from(refunds)
|
||||||
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
|
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
|
||||||
|
|
||||||
const methodBreakdown = await db
|
const methodBreakdown = await db
|
||||||
.select({
|
.select({
|
||||||
method: invoices.paymentMethod,
|
method: invoices.paymentMethod,
|
||||||
total: sql<number>`count(*)`,
|
total: sql<number>`count(*)`,
|
||||||
})
|
})
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
|
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
|
||||||
.groupBy(invoices.paymentMethod);
|
.groupBy(invoices.paymentMethod);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
revenueThisMonth: revenueResult?.total ?? 0,
|
revenueThisMonth: revenueResult?.total ?? 0,
|
||||||
outstanding: outstandingResult?.total ?? 0,
|
outstanding: outstandingResult?.total ?? 0,
|
||||||
refundsThisMonth: refundsResult?.total ?? 0,
|
refundsThisMonth: refundsResult?.total ?? 0,
|
||||||
methodBreakdown,
|
methodBreakdown,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
|
||||||
console.error("stats/summary error:", err);
|
|
||||||
return c.json({
|
|
||||||
revenueThisMonth: 0,
|
|
||||||
outstanding: 0,
|
|
||||||
refundsThisMonth: 0,
|
|
||||||
methodBreakdown: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
|
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { and, eq, inArray, desc, lt } from "@groombook/db";
|
import { eq, inArray } from "@groombook/db";
|
||||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems, businessSettings, conversations, messages } from "@groombook/db";
|
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
import { validatePortalSession } from "../middleware/portalSession.js";
|
||||||
import { portalAudit } from "../middleware/portalAudit.js";
|
import { portalAudit } from "../middleware/portalAudit.js";
|
||||||
import type { PortalEnv } from "../middleware/portalSession.js";
|
import type { PortalEnv } from "../middleware/portalSession.js";
|
||||||
@@ -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 ────────────────────────────────────────────────
|
// ─── Appointment action routes ────────────────────────────────────────────────
|
||||||
|
|
||||||
const customerNotesSchema = z.object({
|
const customerNotesSchema = z.object({
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ settingsRouter.post(
|
|||||||
* Proxies the logo from S3 so the browser never sees an S3 URL.
|
* Proxies the logo from S3 so the browser never sees an S3 URL.
|
||||||
* Returns the image bytes with proper Content-Type.
|
* Returns the image bytes with proper Content-Type.
|
||||||
*/
|
*/
|
||||||
settingsRouter.get("/logo", async (c) => {
|
settingsRouter.get("/logo", requireSuperUser(), async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const [row] = await db.select().from(businessSettings).limit(1);
|
const [row] = await db.select().from(businessSettings).limit(1);
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
});
|
|
||||||
@@ -201,52 +201,3 @@ export function buildWaitlistNotificationEmail(
|
|||||||
<p>— Groom Book</p>`,
|
<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 };
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,35 +32,6 @@ function isE164(phone: string): boolean {
|
|||||||
return /^\+[1-9]\d{7,14}$/.test(phone);
|
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(
|
export async function sendSms(
|
||||||
to: string,
|
to: string,
|
||||||
body: string,
|
body: string,
|
||||||
@@ -103,7 +74,33 @@ export class TelnyxProvider implements SmsProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validateWebhookSignature(req: Request): boolean {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
|||||||
reporter: process.env.CI ? "github" : "list",
|
reporter: process.env.CI ? "github" : "list",
|
||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:8080",
|
baseURL: "http://localhost:8080",
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
serviceWorkers: "block",
|
serviceWorkers: "block",
|
||||||
|
|||||||
@@ -72,15 +72,9 @@ test.describe("Portal Data Integrity", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("billing section renders without JS errors", async ({ page }) => {
|
test("billing section renders without JS errors", async ({ page }) => {
|
||||||
// Mock portal billing endpoints
|
// Mock billing endpoint
|
||||||
await page.route("**/api/portal/config**", (route) =>
|
await page.route("**/api/billing**", (route) =>
|
||||||
route.fulfill({ json: { stripePublishableKey: "" } })
|
route.fulfill({ json: { invoices: [], balanceCents: 0 } })
|
||||||
);
|
|
||||||
await page.route("**/api/portal/invoices**", (route) =>
|
|
||||||
route.fulfill({ json: [] })
|
|
||||||
);
|
|
||||||
await page.route("**/api/portal/payment-methods**", (route) =>
|
|
||||||
route.fulfill({ json: [] })
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const consoleErrors: string[] = [];
|
const consoleErrors: string[] = [];
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
VITE_API_URL=https://uat.groombook.dev
|
VITE_API_URL=
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ RUN pnpm install --frozen-lockfile
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
FROM deps AS builder
|
FROM deps AS builder
|
||||||
ARG VITE_API_URL=
|
|
||||||
ENV VITE_API_URL=
|
|
||||||
COPY packages/types/ packages/types/
|
COPY packages/types/ packages/types/
|
||||||
COPY apps/web/ apps/web/
|
COPY apps/web/ apps/web/
|
||||||
RUN pnpm --filter @groombook/web build
|
RUN pnpm --filter @groombook/web build
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { AppointmentsPage } from "./pages/Appointments.js";
|
|||||||
import { ClientsPage } from "./pages/Clients.js";
|
import { ClientsPage } from "./pages/Clients.js";
|
||||||
import { ClientDetailPage } from "./pages/ClientDetailPage.js";
|
import { ClientDetailPage } from "./pages/ClientDetailPage.js";
|
||||||
import { ServicesPage } from "./pages/Services.js";
|
import { ServicesPage } from "./pages/Services.js";
|
||||||
import { MessagesPage } from "./pages/Messages.js";
|
|
||||||
import { StaffPage } from "./pages/Staff.js";
|
import { StaffPage } from "./pages/Staff.js";
|
||||||
import { InvoicesPage } from "./pages/Invoices.js";
|
import { InvoicesPage } from "./pages/Invoices.js";
|
||||||
import { BookPage } from "./pages/Book.js";
|
import { BookPage } from "./pages/Book.js";
|
||||||
@@ -40,10 +39,7 @@ function LoginPage() {
|
|||||||
const handleSocialLogin = async (provider: string) => {
|
const handleSocialLogin = async (provider: string) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
// Use /admin as callback URL so Better-Auth redirects to the app's dashboard
|
const result = await signIn.social({ provider, callbackURL: window.location.origin });
|
||||||
// after the OAuth callback completes, rather than back to /login
|
|
||||||
const callbackURL = `${window.location.origin}/admin`;
|
|
||||||
const result = await signIn.social({ provider, callbackURL });
|
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
setError(result.error.message ?? "Sign-in failed");
|
setError(result.error.message ?? "Sign-in failed");
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -174,7 +170,6 @@ function LoginPage() {
|
|||||||
|
|
||||||
const NAV_LINKS = [
|
const NAV_LINKS = [
|
||||||
{ to: "/admin", label: "Appointments" },
|
{ to: "/admin", label: "Appointments" },
|
||||||
{ to: "/admin/messages", label: "Messages" },
|
|
||||||
{ to: "/admin/clients", label: "Clients" },
|
{ to: "/admin/clients", label: "Clients" },
|
||||||
{ to: "/admin/services", label: "Services" },
|
{ to: "/admin/services", label: "Services" },
|
||||||
{ to: "/admin/staff", label: "Staff" },
|
{ to: "/admin/staff", label: "Staff" },
|
||||||
@@ -301,7 +296,6 @@ function AdminLayout() {
|
|||||||
<main style={{ padding: "1.25rem 1.5rem" }}>
|
<main style={{ padding: "1.25rem 1.5rem" }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<AppointmentsPage />} />
|
<Route path="/" element={<AppointmentsPage />} />
|
||||||
<Route path="/messages" element={<MessagesPage />} />
|
|
||||||
<Route path="/clients" element={<ClientsPage />} />
|
<Route path="/clients" element={<ClientsPage />} />
|
||||||
<Route path="/clients/:clientId" element={<ClientDetailPage />} />
|
<Route path="/clients/:clientId" element={<ClientDetailPage />} />
|
||||||
<Route path="/services" element={<ServicesPage />} />
|
<Route path="/services" element={<ServicesPage />} />
|
||||||
|
|||||||
@@ -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"}');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -82,13 +82,3 @@ input:focus, select:focus, textarea:focus {
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #94a3b8;
|
background: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Scrollbar hide utility ─── */
|
|
||||||
.scrollbar-hide {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
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;
|
export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||||
@@ -112,17 +112,9 @@ export function AppointmentsPage() {
|
|||||||
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
||||||
// null key = unassigned; staffId string = that groomer; undefined set = all visible
|
// null key = unassigned; staffId string = that groomer; undefined set = all visible
|
||||||
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
|
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
|
||||||
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
|
|
||||||
|
|
||||||
const weekEnd = addDays(weekStart, 6);
|
const weekEnd = addDays(weekStart, 6);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/invoices/stats/summary")
|
|
||||||
.then((r) => r.ok ? r.json() : null)
|
|
||||||
.then((data) => { if (data) setPaymentStats(data); })
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadAppointments = useCallback(() => {
|
const loadAppointments = useCallback(() => {
|
||||||
const from = weekStart.toISOString();
|
const from = weekStart.toISOString();
|
||||||
const to = addDays(weekStart, 7).toISOString();
|
const to = addDays(weekStart, 7).toISOString();
|
||||||
@@ -322,24 +314,6 @@ export function AppointmentsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment Stats Summary */}
|
|
||||||
{paymentStats && (
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
|
|
||||||
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
|
||||||
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
|
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
|
||||||
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
|
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>${(paymentStats.outstanding / 100).toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
|
||||||
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
|
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── View Mode + Groomer Filters ── */}
|
{/* ── View Mode + Groomer Filters ── */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ interface BookingBody {
|
|||||||
petName: string;
|
petName: string;
|
||||||
petSpecies: string;
|
petSpecies: string;
|
||||||
petBreed: string;
|
petBreed: string;
|
||||||
petSizeCategory: string;
|
|
||||||
petCoatType: string;
|
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +123,6 @@ export function BookPage() {
|
|||||||
petName: "",
|
petName: "",
|
||||||
petSpecies: "",
|
petSpecies: "",
|
||||||
petBreed: "",
|
petBreed: "",
|
||||||
petSizeCategory: "",
|
|
||||||
petCoatType: "",
|
|
||||||
notes: "",
|
notes: "",
|
||||||
});
|
});
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
@@ -172,18 +168,14 @@ export function BookPage() {
|
|||||||
if (!selectedService || !date) return;
|
if (!selectedService || !date) return;
|
||||||
setSlotsLoading(true);
|
setSlotsLoading(true);
|
||||||
setSelectedSlot(null);
|
setSelectedSlot(null);
|
||||||
const params = new URLSearchParams({
|
fetch(
|
||||||
serviceId: selectedService.id,
|
`/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}`
|
||||||
date,
|
)
|
||||||
});
|
|
||||||
if (form.petSizeCategory) params.set("petSizeCategory", form.petSizeCategory);
|
|
||||||
if (form.petCoatType) params.set("petCoatType", form.petCoatType);
|
|
||||||
fetch(`/api/book/availability?${params}`)
|
|
||||||
.then((r) => r.json() as Promise<string[]>)
|
.then((r) => r.json() as Promise<string[]>)
|
||||||
.then(setSlots)
|
.then(setSlots)
|
||||||
.catch(() => setSlots([]))
|
.catch(() => setSlots([]))
|
||||||
.finally(() => setSlotsLoading(false));
|
.finally(() => setSlotsLoading(false));
|
||||||
}, [selectedService, date, form.petSizeCategory, form.petCoatType]);
|
}, [selectedService, date]);
|
||||||
|
|
||||||
function goToStep2(svc: Service) {
|
function goToStep2(svc: Service) {
|
||||||
setSelectedService(svc);
|
setSelectedService(svc);
|
||||||
@@ -222,8 +214,6 @@ export function BookPage() {
|
|||||||
petName: form.petName,
|
petName: form.petName,
|
||||||
petSpecies: form.petSpecies,
|
petSpecies: form.petSpecies,
|
||||||
petBreed: form.petBreed || undefined,
|
petBreed: form.petBreed || undefined,
|
||||||
petSizeCategory: form.petSizeCategory || undefined,
|
|
||||||
petCoatType: form.petCoatType || undefined,
|
|
||||||
notes: form.notes || undefined,
|
notes: form.notes || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -504,36 +494,6 @@ export function BookPage() {
|
|||||||
placeholder="Golden Retriever"
|
placeholder="Golden Retriever"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 (15–40 lbs)</option>
|
|
||||||
<option value="large">Large (40–80 lbs)</option>
|
|
||||||
<option value="xlarge">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>
|
<div>
|
||||||
<label style={label}>Notes for groomer</label>
|
<label style={label}>Notes for groomer</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -568,7 +528,7 @@ export function BookPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
|
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
|
||||||
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
|
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
|
||||||
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes + ((form.petSizeCategory === "large" || form.petSizeCategory === "xlarge") ? (selectedService.defaultBufferMinutes ?? 0) : 0))}</div>
|
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
|
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
|
||||||
@@ -639,8 +599,7 @@ export function BookPage() {
|
|||||||
setResult(null);
|
setResult(null);
|
||||||
setForm({
|
setForm({
|
||||||
serviceId: "", startTime: "", clientName: "", clientEmail: "",
|
serviceId: "", startTime: "", clientName: "", clientEmail: "",
|
||||||
clientPhone: "", petName: "", petSpecies: "", petBreed: "",
|
clientPhone: "", petName: "", petSpecies: "", petBreed: "", notes: "",
|
||||||
petSizeCategory: "", petCoatType: "", notes: "",
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -173,21 +173,22 @@ function InvoiceDetailModal({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
||||||
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
||||||
const [showRefundDialog, setShowRefundDialog] = useState(false);
|
const [showRefundDialog, setShowRefundDialog] = useState(false);
|
||||||
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
||||||
const [refundAmount, setRefundAmount] = useState("");
|
const [partialAmount, setPartialAmount] = useState("");
|
||||||
const [refundError, setRefundError] = useState<string | null>(null);
|
const [stripeDetails, setStripeDetails] = useState<{ cardLast4: string | null; paymentStatus: string | null; stripeRefundId: string | null } | null>(null);
|
||||||
const [refunding, setRefunding] = useState(false);
|
|
||||||
|
|
||||||
// Fetch current staff role to determine manager access
|
// Fetch Stripe details when modal opens for paid invoices with a payment intent
|
||||||
const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/staff/me")
|
if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
|
||||||
.then((r) => r.json())
|
fetch(`/api/invoices/${invoice.id}/stripe-details`)
|
||||||
.then((d) => setStaffMe(d))
|
.then((r) => r.ok ? r.json() : null)
|
||||||
.catch(() => setStaffMe(null));
|
.then((data) => { if (data) setStripeDetails(data); })
|
||||||
}, []);
|
.catch(() => {});
|
||||||
const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser);
|
} else {
|
||||||
|
setStripeDetails(null);
|
||||||
|
}
|
||||||
|
}, [invoice.id, invoice.status, invoice.stripePaymentIntentId]);
|
||||||
|
|
||||||
// Tip split state: array of {staffId, staffName, pct}
|
// Tip split state: array of {staffId, staffName, pct}
|
||||||
const linkedAppt = invoice.appointmentId
|
const linkedAppt = invoice.appointmentId
|
||||||
@@ -291,6 +292,35 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function issueRefund() {
|
||||||
|
const amountCents = refundType === "partial"
|
||||||
|
? Math.round(parseFloat(partialAmount) * 100)
|
||||||
|
: undefined;
|
||||||
|
if (refundType === "partial" && (!amountCents || amountCents <= 0)) {
|
||||||
|
setError("Enter a valid refund amount");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(amountCents ? { amountCents } : {}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json()) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setShowRefundDialog(false);
|
||||||
|
onUpdated();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to issue refund");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading…</p></Modal>;
|
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading…</p></Modal>;
|
||||||
|
|
||||||
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
|
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
|
||||||
@@ -350,15 +380,15 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
|
|||||||
/>
|
/>
|
||||||
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
||||||
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
||||||
{invoice.stripePaymentIntentId && (
|
{stripeDetails && (
|
||||||
<>
|
<>
|
||||||
{invoice.cardLast4 && (
|
{stripeDetails.cardLast4 && (
|
||||||
<SummaryRow label="Card" value={`•••• ${invoice.cardLast4}`} />
|
<SummaryRow label="Card" value={`•••• ${stripeDetails.cardLast4}`} />
|
||||||
)}
|
)}
|
||||||
{invoice.paymentStatus && (
|
{stripeDetails.paymentStatus && (
|
||||||
<SummaryRow label="Stripe status" value={invoice.paymentStatus} />
|
<SummaryRow label="Stripe status" value={stripeDetails.paymentStatus} />
|
||||||
)}
|
)}
|
||||||
{invoice.stripeRefundId && (
|
{stripeDetails.stripeRefundId && (
|
||||||
<SummaryRow label="Refund" value="Refunded" />
|
<SummaryRow label="Refund" value="Refunded" />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -480,92 +510,77 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(invoice.status === "paid" || invoice.status === "void") && (
|
{(invoice.status === "paid" || invoice.status === "void") && (
|
||||||
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
|
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
||||||
{invoice.stripeRefundId && (
|
{invoice.status === "paid" && invoice.stripePaymentIntentId && (
|
||||||
<div style={{ marginBottom: "0.75rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
<button
|
||||||
<span style={{ background: "#fef3c7", color: "#92400e", padding: "0.2rem 0.6rem", borderRadius: 4, fontSize: 13, fontWeight: 600 }}>Refunded</span>
|
onClick={() => setShowRefundDialog(true)}
|
||||||
</div>
|
style={{ ...btnStyle, color: "#b45309", borderColor: "#b45309" }}
|
||||||
|
>
|
||||||
|
Refund
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
|
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||||
{invoice.status === "paid" && !invoice.stripeRefundId && isManager && (
|
|
||||||
<button onClick={() => setShowRefundDialog(true)} style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}>
|
|
||||||
Refund
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Refund Dialog */}
|
||||||
{showRefundDialog && (
|
{showRefundDialog && (
|
||||||
<div style={{ marginTop: "1rem", border: "1px solid #e2e8f0", borderRadius: 8, padding: "1rem", background: "#f9fafb" }}>
|
<Modal onClose={() => setShowRefundDialog(false)}>
|
||||||
<p style={{ fontWeight: 600, margin: "0 0 0.75rem" }}>Process Refund</p>
|
<h2 style={{ marginTop: 0 }}>Issue Refund</h2>
|
||||||
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
<p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
|
Invoice total: <strong>{fmtMoney(invoice.totalCents)}</strong>
|
||||||
<input type="radio" checked={refundType === "full"} onChange={() => setRefundType("full")} />
|
</p>
|
||||||
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600, marginBottom: "0.5rem" }}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="refundType"
|
||||||
|
value="full"
|
||||||
|
checked={refundType === "full"}
|
||||||
|
onChange={() => setRefundType("full")}
|
||||||
|
/>
|
||||||
Full refund
|
Full refund
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
|
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600 }}>
|
||||||
<input type="radio" checked={refundType === "partial"} onChange={() => setRefundType("partial")} />
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="refundType"
|
||||||
|
value="partial"
|
||||||
|
checked={refundType === "partial"}
|
||||||
|
onChange={() => setRefundType("partial")}
|
||||||
|
/>
|
||||||
Partial refund
|
Partial refund
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{refundType === "partial" && (
|
{refundType === "partial" && (
|
||||||
<div style={{ marginBottom: "0.75rem" }}>
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0.01"
|
min="0.01"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
placeholder="Amount ($)"
|
placeholder="0.00"
|
||||||
value={refundAmount}
|
value={partialAmount}
|
||||||
onChange={(e) => setRefundAmount(e.target.value)}
|
onChange={(e) => setPartialAmount(e.target.value)}
|
||||||
style={{ ...inputStyle, width: 100 }}
|
style={{ ...inputStyle, width: 120 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>}
|
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={issueRefund}
|
||||||
setRefunding(true);
|
disabled={saving}
|
||||||
setRefundError(null);
|
style={{ ...btnStyle, backgroundColor: "#b45309", color: "#fff", borderColor: "#b45309" }}
|
||||||
try {
|
|
||||||
if (refundType === "partial") {
|
|
||||||
const parsed = parseFloat(refundAmount);
|
|
||||||
if (isNaN(parsed) || parsed <= 0) {
|
|
||||||
setRefundError("Please enter a valid amount greater than zero.");
|
|
||||||
setRefunding(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const body = refundType === "partial" ? { amountCents: Math.round(parseFloat(refundAmount) * 100) } : {};
|
|
||||||
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = (await res.json()) as { error?: string };
|
|
||||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
setShowRefundDialog(false);
|
|
||||||
onUpdated();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setRefundError(e instanceof Error ? e.message : "Refund failed");
|
|
||||||
} finally {
|
|
||||||
setRefunding(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={refunding}
|
|
||||||
style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}
|
|
||||||
>
|
>
|
||||||
{refunding ? "Processing…" : "Process Refund"}
|
{saving ? "Processing…" : "Issue Refund"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -170,7 +170,7 @@ export function CustomerPortal() {
|
|||||||
case "billing":
|
case "billing":
|
||||||
return <BillingPayments readOnly={!!isReadOnly} sessionId={sessionId} />;
|
return <BillingPayments readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||||
case "messages":
|
case "messages":
|
||||||
return <Communication readOnly={!!isReadOnly} sessionId={sessionId} />;
|
return <Communication readOnly={!!isReadOnly} />;
|
||||||
case "settings":
|
case "settings":
|
||||||
return <AccountSettings readOnly={!!isReadOnly} sessionId={sessionId} />;
|
return <AccountSettings readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||||
}
|
}
|
||||||
@@ -326,7 +326,7 @@ export function CustomerPortal() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 min-h-screen overflow-hidden">
|
<main className="flex-1 min-h-screen overflow-x-hidden">
|
||||||
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-stone-800">
|
<h1 className="text-lg font-semibold text-stone-800">
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{([
|
{([
|
||||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||||
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
{ 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 };
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Bell, Mail, Smartphone } from "lucide-react";
|
import { Send, Check, CheckCheck, Bell, Mail, Smartphone, Megaphone, FileText, CreditCard } from "lucide-react";
|
||||||
import { useConversation, useMessages } from "./Communication.api.js";
|
|
||||||
import type { Message as ApiMessage } from "./Communication.api.js";
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
sender: "customer" | "business";
|
||||||
|
senderName: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: string;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface NotificationCategory {
|
interface NotificationCategory {
|
||||||
email: boolean;
|
email: boolean;
|
||||||
@@ -18,11 +25,10 @@ interface NotificationPreferences {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string | null;
|
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Communication({ sessionId, readOnly }: Props) {
|
export function Communication({ readOnly }: Props) {
|
||||||
const [tab, setTab] = useState<"messages" | "notifications">("messages");
|
const [tab, setTab] = useState<"messages" | "notifications">("messages");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,23 +53,17 @@ export function Communication({ sessionId, readOnly }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "messages" && <MessageThread sessionId={sessionId} readOnly={readOnly} />}
|
{tab === "messages" && <MessageThread readOnly={readOnly} />}
|
||||||
{tab === "notifications" && <NotificationPreferences readOnly={readOnly} />}
|
{tab === "notifications" && <NotificationPreferences readOnly={readOnly} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageThreadProps {
|
function MessageThread({ readOnly }: { readOnly: boolean }) {
|
||||||
sessionId: string | null;
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
readOnly: boolean;
|
const [newMessage, setNewMessage] = useState("");
|
||||||
}
|
|
||||||
|
|
||||||
function MessageThread({ sessionId, readOnly: _readOnly }: MessageThreadProps) {
|
|
||||||
const [businessName, setBusinessName] = useState<string>("Business");
|
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(() => {
|
useEffect(() => {
|
||||||
async function fetchBranding() {
|
async function fetchBranding() {
|
||||||
try {
|
try {
|
||||||
@@ -79,57 +79,19 @@ function MessageThread({ sessionId, readOnly: _readOnly }: MessageThreadProps) {
|
|||||||
fetchBranding();
|
fetchBranding();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loading = convLoading || msgLoading;
|
const handleSend = () => {
|
||||||
const error = convError || msgError;
|
if (!newMessage.trim() || readOnly) return;
|
||||||
|
const msg: Message = {
|
||||||
if (loading) {
|
id: `m-${Date.now()}`,
|
||||||
return (
|
sender: "customer",
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
|
senderName: "You",
|
||||||
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50 flex items-center justify-center">
|
text: newMessage.trim(),
|
||||||
<div className="animate-pulse text-stone-400 text-sm">Loading messages...</div>
|
timestamp: new Date().toISOString(),
|
||||||
</div>
|
read: false,
|
||||||
</div>
|
};
|
||||||
);
|
setMessages([...messages, msg]);
|
||||||
}
|
setNewMessage("");
|
||||||
|
};
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
|
<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 ? (
|
{messages.length === 0 ? (
|
||||||
<p className="text-stone-400 text-center text-sm italic">No messages yet</p>
|
<p className="text-stone-400 text-center text-sm italic">No messages yet</p>
|
||||||
) : (
|
) : (
|
||||||
messages.map((msg: ApiMessage) => {
|
messages.map(msg => (
|
||||||
const sender = msg.direction === "inbound" ? "customer" : "business";
|
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
|
||||||
return (
|
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
|
||||||
<div key={msg.id} className={`flex ${sender === "customer" ? "justify-end" : "justify-start"}`}>
|
msg.sender === "customer"
|
||||||
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
|
? "bg-(--color-accent) text-white rounded-br-md"
|
||||||
sender === "customer"
|
: "bg-stone-100 text-stone-800 rounded-bl-md"
|
||||||
? "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" : ""}`}>
|
||||||
{msg.body && <p className="text-sm">{msg.body}</p>}
|
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
|
||||||
<div className={`flex items-center gap-1 mt-1 ${sender === "customer" ? "justify-end" : ""}`}>
|
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
||||||
<span className={`text-xs ${sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
|
</span>
|
||||||
{new Date(msg.createdAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
{msg.sender === "customer" && (
|
||||||
</span>
|
msg.read
|
||||||
</div>
|
? <CheckCheck size={12} className="text-white/60" />
|
||||||
|
: <Check size={12} className="text-white/60" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
{!readOnly && (
|
||||||
<div
|
<div className="border-t border-stone-200 p-3 flex gap-2">
|
||||||
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"
|
<input
|
||||||
title="Reply from your phone"
|
type="text"
|
||||||
>
|
value={newMessage}
|
||||||
Reply from your phone
|
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>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -211,10 +176,10 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
|||||||
|
|
||||||
const categories: { key: PrefKey; label: string; desc: string; icon: typeof Bell }[] = [
|
const categories: { key: PrefKey; label: string; desc: string; icon: typeof Bell }[] = [
|
||||||
{ key: "appointmentReminders", label: "Appointment Reminders", desc: "Upcoming appointment notifications", icon: 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: "vaccinationAlerts", label: "Vaccination Alerts", desc: "Expiration and renewal reminders", icon: FileText },
|
||||||
{ key: "promotional", label: "Promotions & Offers", desc: "Deals and seasonal specials", icon: Smartphone },
|
{ key: "promotional", label: "Promotions & Offers", desc: "Deals and seasonal specials", icon: Megaphone },
|
||||||
{ key: "reportCards", label: "Report Cards", desc: "Grooming report card delivery", icon: Mail },
|
{ key: "reportCards", label: "Report Cards", desc: "Grooming report card delivery", icon: FileText },
|
||||||
{ key: "invoiceReceipts", label: "Invoice & Receipts", desc: "Payment confirmations", icon: Bell },
|
{ key: "invoiceReceipts", label: "Invoice & Receipts", desc: "Payment confirmations", icon: CreditCard },
|
||||||
];
|
];
|
||||||
|
|
||||||
const channels: { key: ChannelKey; label: string; icon: typeof Mail }[] = [
|
const channels: { key: ChannelKey; label: string; icon: typeof Mail }[] = [
|
||||||
@@ -271,4 +236,4 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Communication;
|
export default Communication;
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* 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: "info", label: "Basic Info", icon: PawPrint },
|
||||||
{ id: "medical", label: "Medical", icon: Heart },
|
{ id: "medical", label: "Medical", icon: Heart },
|
||||||
|
|||||||
@@ -119,10 +119,3 @@ uri
|
|||||||
database-url
|
database-url
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
|
||||||
Auth secret name — always use groombook-auth (sealed secret name)
|
|
||||||
*/}}
|
|
||||||
{{- define "groombook.authSecretName" -}}
|
|
||||||
{{- printf "%s" "groombook-auth" }}
|
|
||||||
{{- end }}
|
|
||||||
|
|||||||
@@ -50,27 +50,6 @@ spec:
|
|||||||
- name: OIDC_AUDIENCE
|
- name: OIDC_AUDIENCE
|
||||||
value: {{ .Values.api.env.oidcAudience | quote }}
|
value: {{ .Values.api.env.oidcAudience | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Values.api.env.internalBaseUrl }}
|
|
||||||
- name: OIDC_INTERNAL_BASE
|
|
||||||
value: {{ .Values.api.env.internalBaseUrl | quote }}
|
|
||||||
{{- end }}
|
|
||||||
- name: BETTER_AUTH_URL
|
|
||||||
value: {{ .Values.api.env.betterAuthUrl | quote }}
|
|
||||||
- name: OIDC_CLIENT_ID
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: {{ include "groombook.authSecretName" . }}
|
|
||||||
key: OIDC_CLIENT_ID
|
|
||||||
- name: OIDC_CLIENT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: {{ include "groombook.authSecretName" . }}
|
|
||||||
key: OIDC_CLIENT_SECRET
|
|
||||||
- name: BETTER_AUTH_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: {{ include "groombook.authSecretName" . }}
|
|
||||||
key: BETTER_AUTH_SECRET
|
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ api:
|
|||||||
corsOrigin: ""
|
corsOrigin: ""
|
||||||
oidcIssuer: ""
|
oidcIssuer: ""
|
||||||
oidcAudience: groombook
|
oidcAudience: groombook
|
||||||
betterAuthUrl: ""
|
|
||||||
internalBaseUrl: ""
|
|
||||||
port: "3000"
|
port: "3000"
|
||||||
service:
|
service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
|
|||||||
+1
-16
@@ -43,12 +43,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 20
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
@@ -56,17 +50,8 @@ services:
|
|||||||
dockerfile: apps/web/Dockerfile
|
dockerfile: apps/web/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
- api
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:80 || exit 1"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 20
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -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**: 1–3 business days after submission.
|
|
||||||
- **Carrier (T-Mobile/AT&T/Verizon) review**: 2–5 business days after Telnyx approval.
|
|
||||||
- Total end-to-end: **3–8 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 24–48 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_
|
|
||||||
@@ -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._
|
|
||||||
@@ -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;
|
|
||||||
@@ -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": {} }
|
||||||
|
}
|
||||||
+13
-1029
File diff suppressed because it is too large
Load Diff
+10
-922
File diff suppressed because it is too large
Load Diff
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -204,27 +204,6 @@
|
|||||||
"when": 1775741667192,
|
"when": 1775741667192,
|
||||||
"tag": "0028_sms_reminders",
|
"tag": "0028_sms_reminders",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 29,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775784467192,
|
|
||||||
"tag": "0029_db_indexes_constraints",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 30,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775828067192,
|
|
||||||
"tag": "0030_messaging",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 32,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1778818472097,
|
|
||||||
"tag": "0032_staff_read_at",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -103,8 +103,6 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
|
|||||||
photoKey: null,
|
photoKey: null,
|
||||||
photoUploadedAt: null,
|
photoUploadedAt: null,
|
||||||
image: null,
|
image: null,
|
||||||
sizeCategory: null,
|
|
||||||
coatType: null,
|
|
||||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
};
|
};
|
||||||
@@ -119,7 +117,6 @@ export function buildService(overrides: Partial<ServiceRow> = {}): ServiceRow {
|
|||||||
description: "A grooming service",
|
description: "A grooming service",
|
||||||
basePriceCents: 6500,
|
basePriceCents: 6500,
|
||||||
durationMinutes: 60,
|
durationMinutes: 60,
|
||||||
defaultBufferMinutes: 0,
|
|
||||||
active: true,
|
active: true,
|
||||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
@@ -151,7 +148,6 @@ export function buildAppointment(
|
|||||||
confirmationStatus: "pending",
|
confirmationStatus: "pending",
|
||||||
confirmedAt: null,
|
confirmedAt: null,
|
||||||
cancelledAt: null,
|
cancelledAt: null,
|
||||||
bufferMinutes: 0,
|
|
||||||
confirmationToken: null,
|
confirmationToken: null,
|
||||||
customerNotes: null,
|
customerNotes: null,
|
||||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as schema from "./schema.js";
|
|||||||
|
|
||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
export { encryptSecret, decryptSecret } from "./crypto.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;
|
let _db: ReturnType<typeof drizzle> | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -48,22 +48,6 @@ export const clientStatusEnum = pgEnum("client_status", [
|
|||||||
"disabled",
|
"disabled",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const petSizeCategoryEnum = pgEnum("pet_size_category", [
|
|
||||||
"small",
|
|
||||||
"medium",
|
|
||||||
"large",
|
|
||||||
"xlarge",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const coatTypeEnum = pgEnum("coat_type", [
|
|
||||||
"smooth",
|
|
||||||
"double",
|
|
||||||
"curly",
|
|
||||||
"wire",
|
|
||||||
"long",
|
|
||||||
"hairless",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ─── Better-Auth Tables ──────────────────────────────────────────────────────
|
// ─── Better-Auth Tables ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const user = pgTable("user", {
|
export const user = pgTable("user", {
|
||||||
@@ -151,8 +135,6 @@ export const pets = pgTable(
|
|||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
species: text("species").notNull(),
|
species: text("species").notNull(),
|
||||||
breed: text("breed"),
|
breed: text("breed"),
|
||||||
sizeCategory: petSizeCategoryEnum("size_category"),
|
|
||||||
coatType: coatTypeEnum("coat_type"),
|
|
||||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||||
dateOfBirth: timestamp("date_of_birth"),
|
dateOfBirth: timestamp("date_of_birth"),
|
||||||
healthAlerts: text("health_alerts"),
|
healthAlerts: text("health_alerts"),
|
||||||
@@ -176,7 +158,6 @@ export const services = pgTable("services", {
|
|||||||
description: text("description"),
|
description: text("description"),
|
||||||
basePriceCents: integer("base_price_cents").notNull(),
|
basePriceCents: integer("base_price_cents").notNull(),
|
||||||
durationMinutes: integer("duration_minutes").notNull(),
|
durationMinutes: integer("duration_minutes").notNull(),
|
||||||
defaultBufferMinutes: integer("default_buffer_minutes").notNull().default(0),
|
|
||||||
active: boolean("active").notNull().default(true),
|
active: boolean("active").notNull().default(true),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
@@ -259,8 +240,6 @@ export const appointments = pgTable(
|
|||||||
confirmationStatus: text("confirmation_status").notNull().default("pending"),
|
confirmationStatus: text("confirmation_status").notNull().default("pending"),
|
||||||
confirmedAt: timestamp("confirmed_at"),
|
confirmedAt: timestamp("confirmed_at"),
|
||||||
cancelledAt: timestamp("cancelled_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)
|
// Token for tokenized email confirm/cancel links (no auth required)
|
||||||
confirmationToken: text("confirmation_token").unique(),
|
confirmationToken: text("confirmation_token").unique(),
|
||||||
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
|
// 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)]
|
(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", {
|
export const businessSettings = pgTable("business_settings", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
businessName: text("business_name").notNull().default("GroomBook"),
|
businessName: text("business_name").notNull().default("GroomBook"),
|
||||||
@@ -547,8 +414,6 @@ export const businessSettings = pgTable("business_settings", {
|
|||||||
logoKey: text("logo_key"),
|
logoKey: text("logo_key"),
|
||||||
primaryColor: text("primary_color").notNull().default("#4f8a6f"),
|
primaryColor: text("primary_color").notNull().default("#4f8a6f"),
|
||||||
accentColor: text("accent_color").notNull().default("#8b7355"),
|
accentColor: text("accent_color").notNull().default("#8b7355"),
|
||||||
messagingPhoneNumber: text("messaging_phone_number"),
|
|
||||||
telnyxMessagingProfileId: text("telnyx_messaging_profile_id"),
|
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
@@ -621,22 +486,3 @@ export const authProviderConfig = pgTable("auth_provider_config", {
|
|||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const 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),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|||||||
+1
-86
@@ -20,7 +20,6 @@ import postgres from "postgres";
|
|||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
import { randomBytes, scrypt } from "node:crypto";
|
|
||||||
|
|
||||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -510,81 +509,6 @@ async function seedKnownUsers() {
|
|||||||
}
|
}
|
||||||
console.log(`✓ Seeded ${demoSvcs.length} services`);
|
console.log(`✓ Seeded ${demoSvcs.length} services`);
|
||||||
|
|
||||||
// ── Better Auth credential accounts for UAT personas ─────────────────────
|
|
||||||
// Creates user + account rows so UAT personas can email+password login.
|
|
||||||
// Uses the same scrypt config as better-auth (keylen=64, N=16384, r=8, p=1).
|
|
||||||
const uatCredAccounts: Array<{ email: string; passwordEnvKey: string; staffId: string }> = [
|
|
||||||
{ email: "uat-super@groombook.dev", passwordEnvKey: "SEED_UAT_SUPER_PASSWORD", staffId: "00000000-0000-0000-0000-000000000003" },
|
|
||||||
{ email: "uat-groomer@groombook.dev", passwordEnvKey: "SEED_UAT_GROOMER_PASSWORD", staffId: "00000000-0000-0000-0000-000000000004" },
|
|
||||||
{ email: "uat-customer@groombook.dev", passwordEnvKey: "SEED_UAT_CUSTOMER_PASSWORD", staffId: "" },
|
|
||||||
{ email: "uat-tester@groombook.dev", passwordEnvKey: "SEED_UAT_TESTER_PASSWORD", staffId: "" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const acct of uatCredAccounts) {
|
|
||||||
const password = process.env[acct.passwordEnvKey];
|
|
||||||
if (!password) {
|
|
||||||
console.log(`⊘ No ${acct.passwordEnvKey} set — skipping Better Auth account for ${acct.email}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user already exists
|
|
||||||
const [existingUser] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.user)
|
|
||||||
.where(eq(schema.user.email, acct.email))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let userId: string;
|
|
||||||
if (existingUser) {
|
|
||||||
userId = existingUser.id;
|
|
||||||
console.log(`✓ Better Auth user '${acct.email}' already exists — skipping`);
|
|
||||||
} else {
|
|
||||||
// Hash with same scrypt params as better-auth: keylen=64, N=16384, r=8, p=1
|
|
||||||
// Use Promise-based scrypt API (callback pattern, wrapped in Promise)
|
|
||||||
const salt = randomBytes(16);
|
|
||||||
const key = await new Promise<Buffer>((resolve, reject) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
scrypt(password.normalize("NFKC"), salt, 64, { N: 16384, r: 8, p: 1 } as any, (err: Error | null, derivedKey: Buffer) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(derivedKey);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const passwordHash = `${salt.toString("hex")}:${key.toString("hex")}`;
|
|
||||||
|
|
||||||
const [newUser] = await db.insert(schema.user).values({
|
|
||||||
id: uuid(),
|
|
||||||
name: acct.email.split("@")[0]!,
|
|
||||||
email: acct.email,
|
|
||||||
emailVerified: true,
|
|
||||||
}).returning();
|
|
||||||
userId = newUser!.id;
|
|
||||||
|
|
||||||
await db.insert(schema.account).values({
|
|
||||||
id: uuid(),
|
|
||||||
accountId: userId,
|
|
||||||
providerId: "credential",
|
|
||||||
userId,
|
|
||||||
password: passwordHash,
|
|
||||||
});
|
|
||||||
console.log(`✓ Created Better Auth credential account for '${acct.email}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link staff record to Better Auth user if staff exists and has no userId yet
|
|
||||||
if (acct.staffId) {
|
|
||||||
const [existingStaff] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.staff)
|
|
||||||
.where(eq(schema.staff.id, acct.staffId))
|
|
||||||
.limit(1);
|
|
||||||
if (existingStaff && !existingStaff.userId) {
|
|
||||||
await db.update(schema.staff)
|
|
||||||
.set({ userId })
|
|
||||||
.where(eq(schema.staff.id, acct.staffId));
|
|
||||||
console.log(` ↳ Linked staff '${acct.email}' to Better Auth user`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Client: Demo Client ──
|
// ── Client: Demo Client ──
|
||||||
const [existingClient] = await db
|
const [existingClient] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -959,7 +883,6 @@ async function seed() {
|
|||||||
let appointmentCount = 0;
|
let appointmentCount = 0;
|
||||||
let invoiceCount = 0;
|
let invoiceCount = 0;
|
||||||
let visitLogCount = 0;
|
let visitLogCount = 0;
|
||||||
let paidInvoiceCounter = 0;
|
|
||||||
|
|
||||||
// Process in batches per client to keep memory manageable
|
// Process in batches per client to keep memory manageable
|
||||||
const apptBatchSize = 100;
|
const apptBatchSize = 100;
|
||||||
@@ -1054,10 +977,6 @@ async function seed() {
|
|||||||
|
|
||||||
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
||||||
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
||||||
paidInvoiceCounter++;
|
|
||||||
const stripePaymentIntentId = invoiceStatus === "paid"
|
|
||||||
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
invoiceBatch.push({
|
invoiceBatch.push({
|
||||||
id: invoiceId,
|
id: invoiceId,
|
||||||
@@ -1070,7 +989,6 @@ async function seed() {
|
|||||||
status: invoiceStatus,
|
status: invoiceStatus,
|
||||||
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
||||||
paidAt,
|
paidAt,
|
||||||
stripePaymentIntentId,
|
|
||||||
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1174,16 +1092,13 @@ async function seed() {
|
|||||||
const taxCents = Math.round(effectivePrice * 0.08);
|
const taxCents = Math.round(effectivePrice * 0.08);
|
||||||
const totalCents = effectivePrice + taxCents + tipCents;
|
const totalCents = effectivePrice + taxCents + tipCents;
|
||||||
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
||||||
paidInvoiceCounter++;
|
|
||||||
|
|
||||||
invoiceBatch.push({
|
invoiceBatch.push({
|
||||||
id: invoiceId, appointmentId: apptId, clientId,
|
id: invoiceId, appointmentId: apptId, clientId,
|
||||||
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
||||||
status: "paid" as const,
|
status: "paid" as const,
|
||||||
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
||||||
paidAt,
|
paidAt, notes: null,
|
||||||
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
|
|
||||||
notes: null,
|
|
||||||
});
|
});
|
||||||
lineItemBatch.push({
|
lineItemBatch.push({
|
||||||
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ export interface Pet {
|
|||||||
name: string;
|
name: string;
|
||||||
species: string;
|
species: string;
|
||||||
breed: string | null;
|
breed: string | null;
|
||||||
sizeCategory: string | null;
|
|
||||||
coatType: string | null;
|
|
||||||
weightKg: number | null;
|
weightKg: number | null;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
healthAlerts: string | null;
|
healthAlerts: string | null;
|
||||||
@@ -66,7 +64,6 @@ export interface Service {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
basePriceCents: number;
|
basePriceCents: number;
|
||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
defaultBufferMinutes: number;
|
|
||||||
active: boolean;
|
active: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -117,7 +114,6 @@ export interface Appointment {
|
|||||||
cancelledAt: string | null;
|
cancelledAt: string | null;
|
||||||
confirmationToken: string | null;
|
confirmationToken: string | null;
|
||||||
customerNotes: string | null;
|
customerNotes: string | null;
|
||||||
bufferMinutes: number;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-19
@@ -46,9 +46,6 @@ importers:
|
|||||||
telnyx:
|
telnyx:
|
||||||
specifier: ^1.23.0
|
specifier: ^1.23.0
|
||||||
version: 1.27.0
|
version: 1.27.0
|
||||||
uuid:
|
|
||||||
specifier: ^11.1.1
|
|
||||||
version: 11.1.1
|
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -62,9 +59,6 @@ importers:
|
|||||||
'@types/nodemailer':
|
'@types/nodemailer':
|
||||||
specifier: ^6.4.17
|
specifier: ^6.4.17
|
||||||
version: 6.4.23
|
version: 6.4.23
|
||||||
'@types/uuid':
|
|
||||||
specifier: ^10.0.0
|
|
||||||
version: 10.0.0
|
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^3.2.4
|
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))
|
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':
|
'@types/use-sync-external-store@0.0.6':
|
||||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
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':
|
'@typescript-eslint/eslint-plugin@8.57.1':
|
||||||
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
|
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -4353,18 +4344,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
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:
|
uuid@8.3.2:
|
||||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
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
|
hasBin: true
|
||||||
|
|
||||||
uuid@9.0.1:
|
uuid@9.0.1:
|
||||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
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
|
hasBin: true
|
||||||
|
|
||||||
victory-vendor@37.3.6:
|
victory-vendor@37.3.6:
|
||||||
@@ -6925,8 +6910,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/use-sync-external-store@0.0.6': {}
|
'@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)':
|
'@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:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -9031,8 +9014,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
uuid@11.1.1: {}
|
|
||||||
|
|
||||||
uuid@8.3.2: {}
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
uuid@9.0.1: {}
|
uuid@9.0.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user