Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45477bce4f | |||
| 964c63bbdf | |||
| 4ec2885b09 | |||
| fdd35a4cde | |||
| 559274becd | |||
| f3c56b43f0 | |||
| 89b3d81a82 | |||
| 4a628ef3b7 | |||
| 15af4f0962 | |||
| 990bc4400c | |||
| c12935de9c | |||
| 9b49b6388d | |||
| fe5de5fec8 | |||
| 82f1e3856f | |||
| 0d191743e2 | |||
| 526251b63a | |||
| 3aa7631519 | |||
| 511bdf0d7d | |||
| de3877b28d | |||
| 7d3adeae98 | |||
| 1fbe670751 | |||
| f265d61475 | |||
| 7d8d7535a5 | |||
| da14866abe | |||
| cc45692564 | |||
| cc0259975b | |||
| 8e7a0b22e0 | |||
| c4268a923e | |||
| bddbf008b5 | |||
| 12ee1f054b | |||
| 3063fde870 | |||
| a7b3dc2f02 | |||
| 90af76f222 | |||
| 0c7cd96130 | |||
| 4bcc78f1e6 | |||
| f27110eb07 | |||
| d069eff7d6 | |||
| 3ed1e10ecb | |||
| 904cd9c1b9 | |||
| 573869e517 | |||
| b31cbce82e | |||
| 2398dabe3a | |||
| c2dd1dbf84 | |||
| 7339d51acf | |||
| 8eec29ad90 | |||
| 050d478621 | |||
| 795081cf10 | |||
| 8d5b71dc0f | |||
| c2d38bd3ee | |||
| 6a7229f330 | |||
| 9d9d7da13d | |||
| 2c29c5e4a9 | |||
| ba5f8a916d | |||
| acb65fa5bb | |||
| e873f11e4f | |||
| aae11c0c4d | |||
| 537b5cb0b3 | |||
| d60200f8a7 | |||
| f150663047 | |||
| e605e1be74 | |||
| c4978be280 | |||
| f43e566dbd | |||
| 9c9568b80c | |||
| d0ba537b31 | |||
| a9b9a0a733 | |||
| e818bdef4e | |||
| dce9c96442 | |||
| f50d240e56 | |||
| 22135859c2 | |||
| a5115f5291 | |||
| e64538822d | |||
| 53ab415713 | |||
| a330e342e1 | |||
| 0f841e27fc | |||
| cd25d98384 | |||
| e9fceb78b3 | |||
| 0cae8adef8 | |||
| 674626ba1e | |||
| 903fbf55d5 | |||
| 7bf9cf9734 | |||
| bf159f8b1f | |||
| 2f3d4d8d01 | |||
| db9bb31702 | |||
| b38db65dde | |||
| 3178f81b99 | |||
| 544d65959d | |||
| f38bb244a4 | |||
| abee344ca4 |
+82
-147
@@ -6,11 +6,6 @@ on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Branch or ref to run CI against"
|
||||
required: false
|
||||
default: "main"
|
||||
|
||||
jobs:
|
||||
lint-typecheck:
|
||||
@@ -58,47 +53,6 @@ jobs:
|
||||
- name: Run tests
|
||||
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:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
@@ -119,17 +73,16 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build all packages
|
||||
env:
|
||||
VITE_API_URL: ""
|
||||
run: pnpm build
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, e2e]
|
||||
needs: [build]
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -150,12 +103,12 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
registry: git.farh.net
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push API image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -165,10 +118,10 @@ jobs:
|
||||
target: runner
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/groombook/api:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/api:latest' || '' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
||||
|
||||
- name: Build and push Migrate image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -178,10 +131,10 @@ jobs:
|
||||
target: migrate
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/groombook/migrate:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/migrate:latest' || '' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
|
||||
|
||||
- name: Build and push Seed image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -191,10 +144,10 @@ jobs:
|
||||
target: seed
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/groombook/seed:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/seed:latest' || '' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:seed
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max
|
||||
|
||||
- name: Build and push Reset image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -204,10 +157,10 @@ jobs:
|
||||
target: reset
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/groombook/reset:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/reset:latest' || '' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
|
||||
|
||||
- name: Build and push Web image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -216,19 +169,16 @@ jobs:
|
||||
file: apps/web/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/groombook/web:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/web:latest' || '' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
git.farh.net/groombook/web:${{ steps.version.outputs.tag }}
|
||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }}
|
||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:web
|
||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:web,mode=max
|
||||
|
||||
deploy-dev:
|
||||
name: Deploy PR to groombook-dev
|
||||
runs-on: runners-groombook
|
||||
needs: [docker]
|
||||
if: github.event_name == 'pull_request'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Install kubectl
|
||||
run: |
|
||||
@@ -245,7 +195,6 @@ jobs:
|
||||
TAG="pr-$PR_NUM-${SHA::7}"
|
||||
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
|
||||
cat <<EOF | kubectl apply -n groombook-dev -f -
|
||||
apiVersion: batch/v1
|
||||
@@ -260,7 +209,7 @@ jobs:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: migrate
|
||||
image: ghcr.io/groombook/migrate:$TAG
|
||||
image: git.farh.net/groombook/migrate:$TAG
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
@@ -271,35 +220,33 @@ jobs:
|
||||
kubectl wait --for=condition=complete "job/migrate-pr-$PR_NUM" \
|
||||
-n groombook-dev --timeout=120s
|
||||
|
||||
# Update deployments
|
||||
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
|
||||
kubectl set image deployment/api api=git.farh.net/groombook/api:$TAG -n groombook-dev
|
||||
kubectl set image deployment/web web=git.farh.net/groombook/web:$TAG -n groombook-dev
|
||||
|
||||
# Wait for rollout
|
||||
kubectl rollout status deployment/api -n groombook-dev --timeout=300s
|
||||
kubectl rollout status deployment/web -n groombook-dev --timeout=300s
|
||||
|
||||
echo "Deployment complete."
|
||||
|
||||
- name: Comment on PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.issue.number;
|
||||
const tag = `pr-${pr}`;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr,
|
||||
body: [
|
||||
'## Deployed to groombook-dev',
|
||||
'',
|
||||
`**Images:** \`${tag}\``,
|
||||
'**URL:** https://dev.groombook.farh.net',
|
||||
'',
|
||||
'Ready for UAT validation.'
|
||||
].join('\n')
|
||||
});
|
||||
env:
|
||||
PR_NUM: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
PR_NUM="$PR_NUM"
|
||||
BODY=$(cat <<'EOFBODY'
|
||||
## Deployed to groombook-dev
|
||||
|
||||
**Images:** `pr-'"$PR_NUM"'`
|
||||
|
||||
**URL:** https://dev.groombook.farh.net
|
||||
|
||||
Ready for UAT validation.
|
||||
EOFBODY
|
||||
)
|
||||
curl -s -X POST "https://git.farh.net/api/v1/repos/groombook/app/issues/${PR_NUM}/comments" \
|
||||
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": $(echo "$BODY" | jq -Rs .)}"
|
||||
|
||||
web-e2e:
|
||||
name: Web E2E (Dev)
|
||||
@@ -328,33 +275,15 @@ jobs:
|
||||
run: pnpm --filter @groombook/web test:e2e
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-web-e2e-report
|
||||
path: apps/web/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
cd:
|
||||
name: Update Infra Image Tags
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Generate infra repo token
|
||||
id: infra-token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ vars.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clone groombook/infra
|
||||
run: |
|
||||
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
||||
git clone https://oauth2:${{ secrets.REGISTRY_TOKEN }}@git.farh.net/groombook/infra.git /tmp/infra
|
||||
|
||||
- name: Install yq
|
||||
run: |
|
||||
@@ -373,28 +302,24 @@ jobs:
|
||||
echo "Updating dev overlay image tags to: $TAG"
|
||||
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
|
||||
cd /tmp/infra
|
||||
DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
|
||||
DEV_KUST="apps/overlays/dev/kustomization.yaml"
|
||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
|
||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
|
||||
|
||||
# Update migrate Job name to include short SHA (immutable template fix)
|
||||
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||
MIGRATE_JOB="apps/base/migrate-job.yaml"
|
||||
if [ -f "$MIGRATE_JOB" ]; then
|
||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
|
||||
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
|
||||
fi
|
||||
|
||||
# Update seed Job name to include short SHA (immutable template fix)
|
||||
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||
SEED_JOB="apps/base/seed-job.yaml"
|
||||
if [ -f "$SEED_JOB" ]; then
|
||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
||||
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
|
||||
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
|
||||
fi
|
||||
|
||||
@@ -403,7 +328,6 @@ jobs:
|
||||
- name: Create PR on groombook/infra
|
||||
env:
|
||||
TAG: ${{ needs.docker.outputs.tag }}
|
||||
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
||||
run: |
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||
@@ -411,24 +335,35 @@ jobs:
|
||||
|
||||
cd /tmp/infra
|
||||
git config user.name "groombook-engineer[bot]"
|
||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||
git config user.email "groombook-engineer@farh.net"
|
||||
git checkout -b "chore/update-image-tags-${TAG}"
|
||||
git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
||||
git add apps/overlays/dev/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
|
||||
git commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
|
||||
|
||||
git push -u origin "chore/update-image-tags-${TAG}"
|
||||
|
||||
# Check if PR already exists for this branch
|
||||
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
EXISTING_PR=$(curl -s "https://git.farh.net/api/v1/repos/groombook/infra/pulls?state=open&head=groombook:chore/update-image-tags-${TAG}" \
|
||||
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" | jq -r '.[0].number')
|
||||
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then
|
||||
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
|
||||
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
|
||||
curl -s -X PUT "https://git.farh.net/api/v1/repos/groombook/infra/pulls/${EXISTING_PR}/merge" \
|
||||
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do": "merge"}'
|
||||
else
|
||||
PR_URL=$(gh pr create \
|
||||
--repo groombook/infra \
|
||||
--base main \
|
||||
--head "chore/update-image-tags-${TAG}" \
|
||||
--title "chore: deploy ${TAG} to dev" \
|
||||
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
||||
gh pr merge "$PR_URL" --merge
|
||||
PR_RESPONSE=$(curl -s -X POST "https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
|
||||
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"base\": \"main\",
|
||||
\"head\": \"chore/update-image-tags-${TAG}\",
|
||||
\"title\": \"chore: deploy ${TAG} to dev\",
|
||||
\"body\": \"[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge\"
|
||||
}")
|
||||
PR_NUM=$(echo "$PR_RESPONSE" | jq -r '.number')
|
||||
echo "Created PR #$PR_NUM"
|
||||
curl -s -X PUT "https://git.farh.net/api/v1/repos/groombook/infra/pulls/${PR_NUM}/merge" \
|
||||
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do": "merge"}'
|
||||
fi
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
cd /tmp/infra
|
||||
PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml"
|
||||
PROD_KUST="apps/overlays/prod/kustomization.yaml"
|
||||
|
||||
SHORT_SHA="${TAG##*-}"
|
||||
export SHORT_SHA
|
||||
@@ -70,14 +70,14 @@ jobs:
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
|
||||
|
||||
# Update migrate Job name to include short SHA (immutable template fix)
|
||||
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||
MIGRATE_JOB="apps/base/migrate-job.yaml"
|
||||
if [ -f "$MIGRATE_JOB" ]; then
|
||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||
fi
|
||||
|
||||
# Update seed Job name to include short SHA (immutable template fix)
|
||||
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||
SEED_JOB="apps/base/seed-job.yaml"
|
||||
if [ -f "$SEED_JOB" ]; then
|
||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
git config user.name "groombook-engineer[bot]"
|
||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||
git checkout -b "release/promote-prod-${TAG}"
|
||||
git add apps/groombook/overlays/prod/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
||||
git add apps/overlays/prod/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
|
||||
git commit -m "release: promote ${TAG} to production"
|
||||
git push -u origin "release/promote-prod-${TAG}"
|
||||
gh pr create \
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
run: |
|
||||
echo "Updating UAT overlay image tags to: $TAG"
|
||||
cd /tmp/infra
|
||||
UAT_KUST="apps/groombook/overlays/uat/kustomization.yaml"
|
||||
UAT_KUST="apps/overlays/uat/kustomization.yaml"
|
||||
|
||||
if [ ! -f "$UAT_KUST" ]; then
|
||||
echo "ERROR: UAT overlay not found at $UAT_KUST. Ensure GRO-427 has been completed."
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
|
||||
|
||||
# Update migrate Job name to include short SHA (immutable template fix)
|
||||
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||
MIGRATE_JOB="apps/base/migrate-job.yaml"
|
||||
if [ -f "$MIGRATE_JOB" ]; then
|
||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
# Update seed Job name to include short SHA (immutable template fix)
|
||||
# NOTE: Do NOT update the image tag here — let the Kustomize images transformer
|
||||
# in the UAT overlay handle it via newTag. This avoids the immutable template issue.
|
||||
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||
SEED_JOB="apps/base/seed-job.yaml"
|
||||
if [ -f "$SEED_JOB" ]; then
|
||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
git config user.name "groombook-engineer[bot]"
|
||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||
git checkout -b "chore/update-uat-image-tags-${TAG}"
|
||||
git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
||||
git add apps/overlays/uat/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
|
||||
git commit -m "chore: promote ${TAG} to UAT"
|
||||
|
||||
git push -u origin "chore/update-uat-image-tags-${TAG}"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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 |
|
||||
+94
-12
@@ -4,7 +4,49 @@
|
||||
|
||||
GroomBook is an open-source, self-hostable pet grooming business management & CRM platform. The monorepo contains the Hono API (`apps/api`), React PWA web app (`apps/web`), E2E tests (`apps/e2e`), and shared packages (`packages/db`, `packages/types`). Tech stack: Hono + React 19 + Vite + PostgreSQL + Drizzle ORM + Authentik OIDC.
|
||||
|
||||
## 2. Environments
|
||||
## 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 |
|
||||
|-------------|-----|-------|
|
||||
@@ -14,7 +56,7 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
|
||||
|
||||
**Local Development:** Run `docker compose up --build` at repository root. Web app available at `localhost:8080`, API at `localhost:3000`.
|
||||
|
||||
## 3. Pre-conditions
|
||||
## 4. Pre-conditions
|
||||
|
||||
- UAT environment is accessible at `https://uat.groombook.dev`
|
||||
- Test accounts are seeded with the following personas:
|
||||
@@ -29,18 +71,23 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
|
||||
- Stripe test keys are configured for payment flow testing
|
||||
- Email/SMS providers (Telnyx, etc.) are configured for notification testing
|
||||
|
||||
## 4. Test Cases
|
||||
## 5. Test Cases
|
||||
|
||||
### 4.1 Authentication
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-APP-4.1.1 | OIDC login | 1. Navigate to UAT environment<br>2. Click "Login with Authentik"<br>3. Enter test credentials<br>4. Authorize the application | User is redirected to app dashboard, session is established |
|
||||
| TC-APP-4.1.2 | Session persistence | 1. Log in as any user<br>2. Close browser tab<br>3. Reopen browser and navigate to UAT | User remains logged in, no re-authentication required |
|
||||
| TC-APP-4.1.3 | Logout | 1. Log in as any user<br>2. Click logout button<br>3. Attempt to access protected route | User is logged out and redirected to login page |
|
||||
| TC-APP-4.1.4 | RBAC - Manager access | 1. Log in as Manager<br>2. Navigate to Settings, Staff Management, Reports | All administrative features are accessible |
|
||||
| TC-APP-4.1.5 | RBAC - Staff access | 1. Log in as Staff<br>2. Attempt to access Settings, Staff Management | Access denied or limited view, staff can only see assigned appointments |
|
||||
| TC-APP-4.1.6 | RBAC - Client access | 1. Log in as Client<br>2. Navigate to portal<br>3. Attempt to access admin areas | Client can only view their own appointments, pets, and profile |
|
||||
| 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
|
||||
|
||||
@@ -78,6 +125,13 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
|
||||
| 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
|
||||
|
||||
@@ -217,7 +271,35 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
|
||||
| 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 |
|
||||
|
||||
## 5. Pass/Fail Criteria
|
||||
### 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.
|
||||
|
||||
@@ -230,7 +312,7 @@ GroomBook is an open-source, self-hostable pet grooming business management & CR
|
||||
|
||||
**Regressions:** If a previously working feature fails during this UAT run, it is considered a regression and must be addressed before the release can proceed.
|
||||
|
||||
## 6. Update Policy
|
||||
## 7. Update Policy
|
||||
|
||||
**Any PR that changes user-facing behaviour MUST update this file.**
|
||||
|
||||
@@ -240,4 +322,4 @@ When modifying features that affect:
|
||||
- Configuration (settings, integrations)
|
||||
- Data visibility (reports, search, filtering)
|
||||
|
||||
The corresponding test case(s) in Section 4 must be updated to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.5 — new appointment group scheduling feature").
|
||||
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").
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ vi.mock("@groombook/db", () => {
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
);
|
||||
|
||||
const businessSettings = new Proxy(
|
||||
const businessSettings = new Proxy(
|
||||
{ _name: "businessSettings" },
|
||||
{ get: (t, p) => (p === "_name" ? "businessSettings" : { table: "businessSettings", column: p }) }
|
||||
);
|
||||
@@ -134,6 +134,11 @@ vi.mock("@groombook/db", () => {
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: () => ({
|
||||
returning: () => [],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
impersonationSessions,
|
||||
appointments,
|
||||
@@ -143,7 +148,6 @@ vi.mock("@groombook/db", () => {
|
||||
messages,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
lt: vi.fn(),
|
||||
desc: vi.fn((col: unknown) => ({ _name: "desc", col })),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -275,6 +275,7 @@ api.route("/admin/settings", settingsRouter);
|
||||
api.route("/admin/auth-provider", authProviderRouter);
|
||||
api.route("/admin/seed", adminSeedRouter);
|
||||
api.route("/search", searchRouter);
|
||||
api.route("/conversations", conversationsRouter);
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
await initAuth();
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
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,7 +23,8 @@ if (process.env.AUTH_DISABLED === "true") {
|
||||
}
|
||||
|
||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
if (c.req.path.startsWith("/api/auth/")) {
|
||||
const path = c.req.path;
|
||||
if (path.startsWith("/api/auth/") || path.startsWith("/api/webhooks/")) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
} from "@groombook/db";
|
||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||
import {
|
||||
detectAndCascadeOverrun,
|
||||
isOverrun,
|
||||
} from "../lib/cascade.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
async function withRetry<T>(
|
||||
@@ -584,6 +588,7 @@ appointmentsRouter.patch(
|
||||
// (fixes #18). Also falls back to the existing staffId when staffId is
|
||||
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
||||
let row: typeof appointments.$inferSelect | undefined;
|
||||
let originalEndTime: Date | undefined;
|
||||
try {
|
||||
row = await db.transaction(async (tx) => {
|
||||
const [current] = await tx
|
||||
@@ -595,6 +600,9 @@ appointmentsRouter.patch(
|
||||
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
||||
}
|
||||
|
||||
// Preserve original endTime for cascade detection after update
|
||||
originalEndTime = current.endTime;
|
||||
|
||||
const start = updateFields.startTime
|
||||
? new Date(updateFields.startTime)
|
||||
: current.startTime;
|
||||
@@ -684,6 +692,29 @@ appointmentsRouter.patch(
|
||||
}
|
||||
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
// Cascade delay prevention: detect overrun and shift downstream appointments
|
||||
if (
|
||||
originalEndTime &&
|
||||
updateFields.endTime &&
|
||||
isOverrun({
|
||||
originalEndTime,
|
||||
newEndTime: new Date(updateFields.endTime),
|
||||
_originalStartTime: row.startTime,
|
||||
status: row.status,
|
||||
currentTime: new Date(),
|
||||
bufferMinutes: row.bufferMinutes ?? 0,
|
||||
})
|
||||
) {
|
||||
const cascadeResult = await detectAndCascadeOverrun({
|
||||
db,
|
||||
overrunningAppointmentId: id,
|
||||
newEndTime: new Date(updateFields.endTime),
|
||||
_originalEndTime: originalEndTime,
|
||||
});
|
||||
return c.json({ ...row, cascade: cascadeResult });
|
||||
}
|
||||
|
||||
return c.json(row);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,11 +38,12 @@ bookRouter.get("/services", async (c) => {
|
||||
|
||||
// ─── GET /api/book/availability ─────────────────────────────────────────────
|
||||
// Public: return ISO startTime strings for slots where ≥1 groomer is free
|
||||
// Query params: serviceId (uuid), date (YYYY-MM-DD)
|
||||
// Query params: serviceId (uuid), date (YYYY-MM-DD), petSizeCategory, petCoatType
|
||||
|
||||
bookRouter.get("/availability", async (c) => {
|
||||
const serviceId = c.req.query("serviceId");
|
||||
const dateStr = c.req.query("date");
|
||||
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
|
||||
|
||||
if (!serviceId || !dateStr) {
|
||||
return c.json({ error: "serviceId and date are required" }, 400);
|
||||
@@ -58,6 +59,12 @@ bookRouter.get("/availability", async (c) => {
|
||||
.where(and(eq(services.id, serviceId), eq(services.active, true)));
|
||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||
|
||||
// Buffer-aware duration: extra time for large/x-large or complex coats
|
||||
const extraBuffer = (petSizeCategory === "large" || petSizeCategory === "xlarge")
|
||||
? (service.defaultBufferMinutes ?? 0)
|
||||
: 0;
|
||||
const durationMinutes = service.durationMinutes + extraBuffer;
|
||||
|
||||
const groomers = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
@@ -89,7 +96,7 @@ bookRouter.get("/availability", async (c) => {
|
||||
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr,
|
||||
durationMinutes: service.durationMinutes,
|
||||
durationMinutes,
|
||||
groomerIds: groomers.map((g) => g.id),
|
||||
booked,
|
||||
});
|
||||
@@ -112,6 +119,12 @@ const bookingSchema = z.object({
|
||||
petName: z.string().min(1).max(200),
|
||||
petSpecies: z.string().min(1).max(100),
|
||||
petBreed: z.string().max(100).optional(),
|
||||
petSizeCategory: z
|
||||
.enum(["small", "medium", "large", "xlarge"])
|
||||
.optional(),
|
||||
petCoatType: z
|
||||
.enum(["smooth", "double", "curly", "wire", "long", "hairless"])
|
||||
.optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
@@ -129,7 +142,7 @@ bookRouter.post(
|
||||
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
|
||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||
|
||||
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
|
||||
let end = new Date(start.getTime() + service.durationMinutes * 60_000);
|
||||
|
||||
// Find all active groomers
|
||||
const groomers = await db
|
||||
@@ -191,11 +204,18 @@ bookRouter.post(
|
||||
name: body.petName,
|
||||
species: body.petSpecies,
|
||||
breed: body.petBreed ?? null,
|
||||
sizeCategory: body.petSizeCategory ?? null,
|
||||
coatType: body.petCoatType ?? null,
|
||||
})
|
||||
.returning();
|
||||
const pet = petInserted[0];
|
||||
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
|
||||
|
||||
// Buffer-aware end time: large/x-large pets add service bufferMinutes
|
||||
if (body.petSizeCategory === "large" || body.petSizeCategory === "xlarge") {
|
||||
end = new Date(start.getTime() + (service.durationMinutes + (service.defaultBufferMinutes ?? 0)) * 60_000);
|
||||
}
|
||||
|
||||
// Insert appointment in a transaction to guard against race conditions
|
||||
let appointment;
|
||||
try {
|
||||
|
||||
@@ -1,214 +1,273 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, desc, lt, isNull, sql, count } from "@groombook/db";
|
||||
import { getDb, conversations, messages, clients } from "@groombook/db";
|
||||
import { resolveStaffMiddleware } from "../middleware/rbac.js";
|
||||
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>();
|
||||
|
||||
conversationsRouter.use("/*", resolveStaffMiddleware);
|
||||
const sendMessageSchema = z.object({
|
||||
body: z.string().min(1).max(1600),
|
||||
});
|
||||
|
||||
// GET /api/conversations — list all conversations for staff's business
|
||||
// GET /api/conversations — List conversations
|
||||
conversationsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const businessId = c.get("staff").businessId;
|
||||
const staffRow = c.get("staff");
|
||||
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const rows = await db
|
||||
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,
|
||||
businessId: conversations.businessId,
|
||||
clientId: conversations.clientId,
|
||||
channel: conversations.channel,
|
||||
externalNumber: conversations.externalNumber,
|
||||
businessNumber: conversations.businessNumber,
|
||||
lastMessageAt: conversations.lastMessageAt,
|
||||
status: conversations.status,
|
||||
createdAt: conversations.createdAt,
|
||||
staffReadAt: conversations.staffReadAt,
|
||||
clientName: clients.name,
|
||||
clientPhone: clients.phone,
|
||||
channel: conversations.channel,
|
||||
})
|
||||
.from(conversations)
|
||||
.where(eq(conversations.businessId, businessId))
|
||||
.innerJoin(clients, eq(conversations.clientId, clients.id))
|
||||
.where(eq(conversations.businessId, settings.id))
|
||||
.orderBy(desc(conversations.lastMessageAt))
|
||||
.limit(20);
|
||||
.limit(limit + 1);
|
||||
|
||||
// For each conversation, fetch client name and count unread messages
|
||||
const enriched = await Promise.all(
|
||||
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 [client] = await db
|
||||
.select({ name: clients.name })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, row.clientId))
|
||||
.limit(1);
|
||||
|
||||
// Count messages where direction = 'inbound' AND readByClientAt IS NULL
|
||||
const [{ count: unreadCount }] = await db
|
||||
.select({ count: count() })
|
||||
const [unreadRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(messages)
|
||||
.where(
|
||||
and(
|
||||
eq(messages.conversationId, row.id),
|
||||
eq(messages.direction, "inbound"),
|
||||
isNull(messages.readByClientAt)
|
||||
sql`${messages.createdAt} > COALESCE(${row.staffReadAt}, '1970-01-01'::timestamp)`
|
||||
)
|
||||
);
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
// Fetch last message body for preview
|
||||
const [lastMsg] = await db
|
||||
.select({ body: messages.body, createdAt: messages.createdAt })
|
||||
.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 {
|
||||
...row,
|
||||
clientName: client?.name ?? "Unknown",
|
||||
lastMessageBody: lastMsg?.body ?? null,
|
||||
unreadCount: Number(unreadCount),
|
||||
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,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return c.json(enriched);
|
||||
const lastRow = rows[rows.length - 1];
|
||||
const nextCursor = hasMore && lastRow ? lastRow.id : null;
|
||||
return c.json({ items, nextCursor });
|
||||
});
|
||||
|
||||
// GET /api/conversations/:id — get a single conversation
|
||||
conversationsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const businessId = c.get("staff").businessId;
|
||||
const conversationId = c.req.param("id");
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(and(eq(conversations.id, conversationId), eq(conversations.businessId, businessId)))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
const [client] = await db
|
||||
.select({ name: clients.name })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, row.clientId))
|
||||
.limit(1);
|
||||
|
||||
return c.json({ ...row, clientName: client?.name ?? "Unknown" });
|
||||
});
|
||||
|
||||
// GET /api/conversations/:id/messages — get messages for a conversation
|
||||
// GET /api/conversations/:id/messages — List messages for a conversation
|
||||
conversationsRouter.get("/:id/messages", async (c) => {
|
||||
const db = getDb();
|
||||
const businessId = c.get("staff").businessId;
|
||||
const conversationId = c.req.param("id");
|
||||
const limit = parseInt(c.req.query("limit") ?? "50", 10);
|
||||
const cursor = c.req.query("cursor");
|
||||
const staffRow = c.get("staff");
|
||||
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
// Verify staff owns this conversation
|
||||
const [conversation] = await db
|
||||
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, businessId)))
|
||||
.where(
|
||||
and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id))
|
||||
)
|
||||
.limit(1);
|
||||
if (!conv) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (!conversation) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
// Mark conversation as read by staff
|
||||
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 [cursorMsg] = await db
|
||||
const [cursorRow] = await db
|
||||
.select({ createdAt: messages.createdAt })
|
||||
.from(messages)
|
||||
.where(eq(messages.id, cursor))
|
||||
.limit(1);
|
||||
|
||||
if (cursorMsg) {
|
||||
const rows = await db
|
||||
.select()
|
||||
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, cursorMsg.createdAt)))
|
||||
.where(
|
||||
and(
|
||||
eq(messages.conversationId, conversationId),
|
||||
lt(messages.createdAt, cursorRow.createdAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(messages.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
return c.json({ messages: rows.reverse(), nextCursor: rows.length === limit ? rows[0]?.id : null });
|
||||
.limit(limit + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.conversationId, conversationId))
|
||||
.orderBy(desc(messages.createdAt))
|
||||
.limit(limit);
|
||||
const rows = await query;
|
||||
const hasMore = rows.length > limit;
|
||||
if (hasMore) rows.pop();
|
||||
|
||||
return c.json({ messages: rows.reverse(), nextCursor: null });
|
||||
});
|
||||
|
||||
// POST /api/conversations/:id/messages — send a message
|
||||
const sendMessageSchema = z.object({
|
||||
body: z.string().min(1).max(1600),
|
||||
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 businessId = c.get("staff").businessId;
|
||||
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");
|
||||
|
||||
// Verify staff owns this conversation
|
||||
const [conversation] = await db
|
||||
.select()
|
||||
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, businessId)))
|
||||
.where(
|
||||
and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id))
|
||||
)
|
||||
.limit(1);
|
||||
if (!conv) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (!conversation) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
const result = await sendMessage({
|
||||
businessId: settings.id,
|
||||
clientId: conv.clientId,
|
||||
body,
|
||||
sentByStaffId: staffRow.id,
|
||||
});
|
||||
|
||||
// Check if client has opted out
|
||||
const [client] = await db
|
||||
.select({ optedOutAt: clients.optedOutAt })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, conversation.clientId))
|
||||
.limit(1);
|
||||
|
||||
if (client?.optedOutAt) {
|
||||
if (result.suppressed) {
|
||||
return c.json({ error: "Client has opted out of SMS" }, 409);
|
||||
}
|
||||
|
||||
// Create outbound message
|
||||
const [msg] = await db
|
||||
.insert(messages)
|
||||
.values({
|
||||
conversationId,
|
||||
direction: "outbound",
|
||||
body,
|
||||
status: "queued",
|
||||
sentByStaffId: staffRow.id,
|
||||
.select({
|
||||
id: messages.id,
|
||||
direction: messages.direction,
|
||||
body: messages.body,
|
||||
status: messages.status,
|
||||
sentByStaffId: messages.sentByStaffId,
|
||||
createdAt: messages.createdAt,
|
||||
deliveredAt: messages.deliveredAt,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Update conversation lastMessageAt
|
||||
await db
|
||||
.update(conversations)
|
||||
.set({ lastMessageAt: new Date() })
|
||||
.where(eq(conversations.id, conversationId));
|
||||
|
||||
// TODO: Enqueue Telnyx outbound job
|
||||
.from(messages)
|
||||
.where(eq(messages.id, result.messageId))
|
||||
.limit(1);
|
||||
|
||||
return c.json(msg, 201);
|
||||
}
|
||||
|
||||
@@ -201,3 +201,52 @@ export function buildWaitlistNotificationEmail(
|
||||
<p>— Groom Book</p>`,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Reschedule notification email ────────────────────────────────────────────
|
||||
|
||||
interface RescheduleEmailData {
|
||||
clientName: string;
|
||||
petName: string;
|
||||
serviceName: string;
|
||||
groomerName: string | null;
|
||||
oldStartTime: Date;
|
||||
newStartTime: Date;
|
||||
}
|
||||
|
||||
export function buildRescheduleNotificationEmail(
|
||||
to: string,
|
||||
data: RescheduleEmailData
|
||||
): Mail.Options {
|
||||
const oldTime = formatDateTime(data.oldStartTime);
|
||||
const newTime = formatDateTime(data.newStartTime);
|
||||
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
|
||||
return {
|
||||
to,
|
||||
subject: `Appointment Rescheduled — ${data.petName}'s appointment has been moved`,
|
||||
text: [
|
||||
`Hi ${data.clientName},`,
|
||||
``,
|
||||
`Your appointment has been rescheduled.`,
|
||||
``,
|
||||
` Pet: ${data.petName}`,
|
||||
` Service: ${data.serviceName}`,
|
||||
` Was: ${oldTime}${groomer}`,
|
||||
` Now: ${newTime}${groomer}`,
|
||||
``,
|
||||
`If you have any questions or need to make changes, please contact us.`,
|
||||
``,
|
||||
`— Groom Book`,
|
||||
].join("\n"),
|
||||
html: `
|
||||
<p>Hi ${data.clientName},</p>
|
||||
<p>Your appointment has been <strong>rescheduled</strong>.</p>
|
||||
<table style="border-collapse:collapse;margin:1em 0">
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#ef4444">Was</td><td style="text-decoration:line-through;color:#ef4444">${oldTime}${groomer}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#10b981">Now</td><td style="color:#10b981">${newTime}${groomer}</td></tr>
|
||||
</table>
|
||||
<p>If you have any questions or need to make changes, please contact us.</p>
|
||||
<p>— Groom Book</p>`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
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."
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
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,5 +1,7 @@
|
||||
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: {
|
||||
@@ -152,7 +154,7 @@ export async function handleMessageReceived(payload: TelnyxMessageReceivedPayloa
|
||||
throw new Error(`No business owns messaging number: ${toPhone}`);
|
||||
}
|
||||
|
||||
const { id: conversationId } = await findOrCreateConversation(businessId, fromPhone, toPhone);
|
||||
const { id: conversationId, clientId } = await findOrCreateConversation(businessId, fromPhone, toPhone);
|
||||
|
||||
await getDb()
|
||||
.update(conversations)
|
||||
@@ -167,6 +169,22 @@ export async function handleMessageReceived(payload: TelnyxMessageReceivedPayloa
|
||||
"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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
reporter: process.env.CI ? "github" : "list",
|
||||
|
||||
use: {
|
||||
baseURL: "http://localhost:8080",
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:8080",
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
serviceWorkers: "block",
|
||||
|
||||
@@ -1 +1 @@
|
||||
VITE_API_URL=
|
||||
VITE_API_URL=https://uat.groombook.dev
|
||||
|
||||
@@ -11,6 +11,8 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build
|
||||
FROM deps AS builder
|
||||
ARG VITE_API_URL=
|
||||
ENV VITE_API_URL=
|
||||
COPY packages/types/ packages/types/
|
||||
COPY apps/web/ apps/web/
|
||||
RUN pnpm --filter @groombook/web build
|
||||
|
||||
@@ -40,7 +40,10 @@ function LoginPage() {
|
||||
const handleSocialLogin = async (provider: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const result = await signIn.social({ provider, callbackURL: window.location.origin });
|
||||
// Use /admin as callback URL so Better-Auth redirects to the app's dashboard
|
||||
// after the OAuth callback completes, rather than back to /login
|
||||
const callbackURL = `${window.location.origin}/admin`;
|
||||
const result = await signIn.social({ provider, callbackURL });
|
||||
if (result?.error) {
|
||||
setError(result.error.message ?? "Sign-in failed");
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -8,10 +8,9 @@ const mockConversations = [
|
||||
clientId: "client-1",
|
||||
clientName: "Alice Smith",
|
||||
channel: "sms",
|
||||
externalNumber: "+1234567890",
|
||||
clientPhone: "+1234567890",
|
||||
lastMessageAt: "2026-05-14T10:00:00Z",
|
||||
staffReadAt: null,
|
||||
lastMessageBody: "Hello, is my dog ready?",
|
||||
lastMessage: { body: "Hello, is my dog ready?", direction: "inbound", createdAt: "2026-05-14T10:00:00Z" },
|
||||
unreadCount: 2,
|
||||
status: "active",
|
||||
},
|
||||
@@ -20,10 +19,9 @@ const mockConversations = [
|
||||
clientId: "client-2",
|
||||
clientName: "Bob Jones",
|
||||
channel: "sms",
|
||||
externalNumber: "+1987654321",
|
||||
clientPhone: "+1987654321",
|
||||
lastMessageAt: "2026-05-13T08:00:00Z",
|
||||
staffReadAt: "2026-05-13T09:00:00Z",
|
||||
lastMessageBody: "Thanks for the update",
|
||||
lastMessage: { body: "Thanks for the update", direction: "outbound", createdAt: "2026-05-13T08:05:00Z" },
|
||||
unreadCount: 0,
|
||||
status: "active",
|
||||
},
|
||||
@@ -73,7 +71,7 @@ afterEach(() => {
|
||||
|
||||
describe("MessagesPage", () => {
|
||||
it("renders empty state when no conversations", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue(makeResponse([]));
|
||||
vi.mocked(global.fetch).mockResolvedValue(makeResponse({ items: [], nextCursor: null }));
|
||||
|
||||
render(<MessagesPage />);
|
||||
await waitFor(() => {
|
||||
@@ -82,7 +80,7 @@ describe("MessagesPage", () => {
|
||||
});
|
||||
|
||||
it("renders conversation list", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue(makeResponse(mockConversations));
|
||||
vi.mocked(global.fetch).mockResolvedValue(makeResponse({ items: mockConversations, nextCursor: null }));
|
||||
|
||||
render(<MessagesPage />);
|
||||
await waitFor(() => {
|
||||
@@ -98,10 +96,10 @@ describe("MessagesPage", () => {
|
||||
vi.mocked(global.fetch).mockImplementation((input) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/conversations?limit=20") {
|
||||
return Promise.resolve(makeResponse(mockConversations));
|
||||
return Promise.resolve(makeResponse({ items: mockConversations, nextCursor: null }));
|
||||
}
|
||||
if (url === "/api/conversations/conv-1/messages?limit=50") {
|
||||
return Promise.resolve(makeResponse({ messages: mockMessages }));
|
||||
return Promise.resolve(makeResponse({ items: mockMessages, nextCursor: null }));
|
||||
}
|
||||
return Promise.resolve(makeResponseWithStatus(null, 404));
|
||||
});
|
||||
@@ -112,7 +110,7 @@ describe("MessagesPage", () => {
|
||||
fireEvent.click(screen.getByText("Alice Smith"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Hello, is my dog ready?")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Hello, is my dog ready?").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("Yes, she is all done!")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -132,7 +130,7 @@ describe("MessagesPage", () => {
|
||||
sentByStaffId: "staff-1",
|
||||
}, 201));
|
||||
}
|
||||
return Promise.resolve(makeResponse(mockConversations));
|
||||
return Promise.resolve(makeResponse({ items: mockConversations, nextCursor: null }));
|
||||
});
|
||||
|
||||
render(<MessagesPage />);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_API_URL ?? "",
|
||||
baseURL: import.meta.env.VITE_API_URL || window.location.origin,
|
||||
});
|
||||
|
||||
export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
@@ -13,6 +13,8 @@ interface BookingBody {
|
||||
petName: string;
|
||||
petSpecies: string;
|
||||
petBreed: string;
|
||||
petSizeCategory: string;
|
||||
petCoatType: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
@@ -123,6 +125,8 @@ export function BookPage() {
|
||||
petName: "",
|
||||
petSpecies: "",
|
||||
petBreed: "",
|
||||
petSizeCategory: "",
|
||||
petCoatType: "",
|
||||
notes: "",
|
||||
});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
@@ -168,14 +172,18 @@ export function BookPage() {
|
||||
if (!selectedService || !date) return;
|
||||
setSlotsLoading(true);
|
||||
setSelectedSlot(null);
|
||||
fetch(
|
||||
`/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}`
|
||||
)
|
||||
const params = new URLSearchParams({
|
||||
serviceId: selectedService.id,
|
||||
date,
|
||||
});
|
||||
if (form.petSizeCategory) params.set("petSizeCategory", form.petSizeCategory);
|
||||
if (form.petCoatType) params.set("petCoatType", form.petCoatType);
|
||||
fetch(`/api/book/availability?${params}`)
|
||||
.then((r) => r.json() as Promise<string[]>)
|
||||
.then(setSlots)
|
||||
.catch(() => setSlots([]))
|
||||
.finally(() => setSlotsLoading(false));
|
||||
}, [selectedService, date]);
|
||||
}, [selectedService, date, form.petSizeCategory, form.petCoatType]);
|
||||
|
||||
function goToStep2(svc: Service) {
|
||||
setSelectedService(svc);
|
||||
@@ -214,6 +222,8 @@ export function BookPage() {
|
||||
petName: form.petName,
|
||||
petSpecies: form.petSpecies,
|
||||
petBreed: form.petBreed || undefined,
|
||||
petSizeCategory: form.petSizeCategory || undefined,
|
||||
petCoatType: form.petCoatType || undefined,
|
||||
notes: form.notes || undefined,
|
||||
}),
|
||||
});
|
||||
@@ -494,6 +504,36 @@ export function BookPage() {
|
||||
placeholder="Golden Retriever"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>Pet size (optional, but encouraged)</label>
|
||||
<select
|
||||
style={input}
|
||||
value={form.petSizeCategory}
|
||||
onChange={(e) => setForm((f) => ({ ...f, petSizeCategory: e.target.value }))}
|
||||
>
|
||||
<option value="">Select size…</option>
|
||||
<option value="small">Small (under 15 lbs)</option>
|
||||
<option value="medium">Medium (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>
|
||||
<label style={label}>Notes for groomer</label>
|
||||
<textarea
|
||||
@@ -528,7 +568,7 @@ export function BookPage() {
|
||||
<div>
|
||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
|
||||
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
|
||||
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes)}</div>
|
||||
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes + ((form.petSizeCategory === "large" || form.petSizeCategory === "xlarge") ? (selectedService.defaultBufferMinutes ?? 0) : 0))}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
|
||||
@@ -599,7 +639,8 @@ export function BookPage() {
|
||||
setResult(null);
|
||||
setForm({
|
||||
serviceId: "", startTime: "", clientName: "", clientEmail: "",
|
||||
clientPhone: "", petName: "", petSpecies: "", petBreed: "", notes: "",
|
||||
clientPhone: "", petName: "", petSpecies: "", petBreed: "",
|
||||
petSizeCategory: "", petCoatType: "", notes: "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -5,12 +5,11 @@ interface Conversation {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
channel: string;
|
||||
externalNumber: string;
|
||||
clientPhone: string;
|
||||
lastMessageAt: string | null;
|
||||
staffReadAt: string | null;
|
||||
lastMessageBody: string | null;
|
||||
unreadCount: number;
|
||||
status: string;
|
||||
lastMessage: { body: string | null; direction: string; createdAt: string } | null;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
@@ -55,7 +54,8 @@ export function MessagesPage() {
|
||||
try {
|
||||
const res = await fetch("/api/conversations?limit=20");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as Conversation[];
|
||||
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");
|
||||
@@ -68,8 +68,8 @@ export function MessagesPage() {
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${conversationId}/messages?limit=50`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { messages: Message[] };
|
||||
setMessages(data.messages);
|
||||
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 {
|
||||
@@ -93,7 +93,7 @@ export function MessagesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
messagesEndRef.current?.scrollIntoView?.({ behavior: "smooth" });
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
@@ -180,7 +180,7 @@ export function MessagesPage() {
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 2, color: "#6b7280", fontSize: 12 }}>
|
||||
{truncate(conv.lastMessageBody, 60)}
|
||||
{truncate(conv.lastMessage?.body ?? null, 60)}
|
||||
</div>
|
||||
<div style={{ marginTop: 2, color: "#9ca3af", fontSize: 11 }}>
|
||||
{relativeTime(conv.lastMessageAt)}
|
||||
|
||||
@@ -58,7 +58,7 @@ interface MessageThreadProps {
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
function MessageThread({ sessionId, readOnly }: MessageThreadProps) {
|
||||
function MessageThread({ sessionId, readOnly: _readOnly }: MessageThreadProps) {
|
||||
const [businessName, setBusinessName] = useState<string>("Business");
|
||||
|
||||
const { conversation, loading: convLoading, error: convError } = useConversation(sessionId);
|
||||
@@ -144,7 +144,6 @@ function MessageThread({ sessionId, readOnly }: MessageThreadProps) {
|
||||
) : (
|
||||
messages.map((msg: ApiMessage) => {
|
||||
const sender = msg.direction === "inbound" ? "customer" : "business";
|
||||
const senderName = sender === "customer" ? "You" : businessName;
|
||||
return (
|
||||
<div key={msg.id} className={`flex ${sender === "customer" ? "justify-end" : "justify-start"}`}>
|
||||
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
|
||||
|
||||
+16
-1
@@ -43,6 +43,12 @@ services:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
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:
|
||||
build:
|
||||
@@ -50,8 +56,17 @@ services:
|
||||
dockerfile: apps/web/Dockerfile
|
||||
ports:
|
||||
- "8080:80"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
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:
|
||||
postgres_data:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "conversations" ADD COLUMN "staff_read_at" timestamp;
|
||||
@@ -222,8 +222,8 @@
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1778818472097,
|
||||
"tag": "0032_add_staff_read_at",
|
||||
"when": 1778818472097,
|
||||
"tag": "0032_staff_read_at",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as schema from "./schema.js";
|
||||
|
||||
export * from "./schema.js";
|
||||
export { encryptSecret, decryptSecret } from "./crypto.js";
|
||||
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
|
||||
export { and, asc, count, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
|
||||
@@ -151,6 +151,8 @@ export const pets = pgTable(
|
||||
name: text("name").notNull(),
|
||||
species: text("species").notNull(),
|
||||
breed: text("breed"),
|
||||
sizeCategory: petSizeCategoryEnum("size_category"),
|
||||
coatType: coatTypeEnum("coat_type"),
|
||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||
dateOfBirth: timestamp("date_of_birth"),
|
||||
healthAlerts: text("health_alerts"),
|
||||
@@ -162,8 +164,6 @@ export const pets = pgTable(
|
||||
photoKey: text("photo_key"),
|
||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||
image: text("image"),
|
||||
sizeCategory: petSizeCategoryEnum("size_category"),
|
||||
coatType: coatTypeEnum("coat_type"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
|
||||
+85
-3
@@ -20,6 +20,7 @@ import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import * as schema from "./schema.js";
|
||||
import { randomBytes, scrypt } from "node:crypto";
|
||||
|
||||
// ── Seed profile configuration ─────────────────────────────────────────────
|
||||
|
||||
@@ -509,6 +510,81 @@ async function seedKnownUsers() {
|
||||
}
|
||||
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 ──
|
||||
const [existingClient] = await db
|
||||
.select()
|
||||
@@ -883,6 +959,7 @@ async function seed() {
|
||||
let appointmentCount = 0;
|
||||
let invoiceCount = 0;
|
||||
let visitLogCount = 0;
|
||||
let paidInvoiceCounter = 0;
|
||||
|
||||
// Process in batches per client to keep memory manageable
|
||||
const apptBatchSize = 100;
|
||||
@@ -977,8 +1054,11 @@ async function seed() {
|
||||
|
||||
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
||||
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
||||
paidInvoiceCounter++;
|
||||
const stripePaymentIntentId = invoiceStatus === "paid"
|
||||
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
|
||||
: null;
|
||||
|
||||
const stripePaymentIntentId = invoiceStatus === "paid" && rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
|
||||
invoiceBatch.push({
|
||||
id: invoiceId,
|
||||
appointmentId: apptId,
|
||||
@@ -1094,14 +1174,16 @@ async function seed() {
|
||||
const taxCents = Math.round(effectivePrice * 0.08);
|
||||
const totalCents = effectivePrice + taxCents + tipCents;
|
||||
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
||||
const stripePaymentIntentId = rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
|
||||
paidInvoiceCounter++;
|
||||
|
||||
invoiceBatch.push({
|
||||
id: invoiceId, appointmentId: apptId, clientId,
|
||||
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
||||
status: "paid" as const,
|
||||
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
||||
paidAt, stripePaymentIntentId, notes: null,
|
||||
paidAt,
|
||||
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
|
||||
notes: null,
|
||||
});
|
||||
lineItemBatch.push({
|
||||
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface Pet {
|
||||
name: string;
|
||||
species: string;
|
||||
breed: string | null;
|
||||
sizeCategory: string | null;
|
||||
coatType: string | null;
|
||||
weightKg: number | null;
|
||||
dateOfBirth: string | null;
|
||||
healthAlerts: string | null;
|
||||
@@ -64,6 +66,7 @@ export interface Service {
|
||||
description: string | null;
|
||||
basePriceCents: number;
|
||||
durationMinutes: number;
|
||||
defaultBufferMinutes: number;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -114,6 +117,7 @@ export interface Appointment {
|
||||
cancelledAt: string | null;
|
||||
confirmationToken: string | null;
|
||||
customerNotes: string | null;
|
||||
bufferMinutes: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user