Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bddbf008b5 | |||
| acb65fa5bb |
+108
-76
@@ -6,6 +6,11 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Branch or ref to run CI against"
|
||||||
|
required: false
|
||||||
|
default: "main"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-typecheck:
|
lint-typecheck:
|
||||||
@@ -81,8 +86,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Run E2E tests
|
- name: Run E2E tests
|
||||||
run: pnpm --filter @groombook/e2e test
|
run: pnpm --filter @groombook/e2e test
|
||||||
env:
|
|
||||||
PLAYWRIGHT_BASE_URL: http://host.docker.internal:8080
|
- name: Upload Playwright report
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: apps/e2e/playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
- name: Stop Docker Compose stack
|
- name: Stop Docker Compose stack
|
||||||
if: always()
|
if: always()
|
||||||
@@ -118,6 +129,9 @@ jobs:
|
|||||||
needs: [build, e2e]
|
needs: [build, e2e]
|
||||||
outputs:
|
outputs:
|
||||||
tag: ${{ steps.version.outputs.tag }}
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -138,12 +152,12 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.farh.net
|
registry: ghcr.io
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push API image
|
- name: Build and push API image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -153,10 +167,10 @@ jobs:
|
|||||||
target: runner
|
target: runner
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
ghcr.io/groombook/api:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/api:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
cache-from: type=gha
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Build and push Migrate image
|
- name: Build and push Migrate image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -166,10 +180,10 @@ jobs:
|
|||||||
target: migrate
|
target: migrate
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
ghcr.io/groombook/migrate:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/migrate:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
cache-from: type=gha
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Build and push Seed image
|
- name: Build and push Seed image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -179,10 +193,10 @@ jobs:
|
|||||||
target: seed
|
target: seed
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
ghcr.io/groombook/seed:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/seed:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:seed
|
cache-from: type=gha
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Build and push Reset image
|
- name: Build and push Reset image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -192,10 +206,10 @@ jobs:
|
|||||||
target: reset
|
target: reset
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
ghcr.io/groombook/reset:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/reset:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
|
cache-from: type=gha
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Build and push Web image
|
- name: Build and push Web image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -204,16 +218,19 @@ jobs:
|
|||||||
file: apps/web/Dockerfile
|
file: apps/web/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.farh.net/groombook/web:${{ steps.version.outputs.tag }}
|
ghcr.io/groombook/web:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/web:latest' || '' }}
|
||||||
cache-from: type=registry,ref=git.farh.net/groombook/cache:web
|
cache-from: type=gha
|
||||||
cache-to: type=registry,ref=git.farh.net/groombook/cache:web,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
name: Deploy PR to groombook-dev
|
name: Deploy PR to groombook-dev
|
||||||
runs-on: runners-groombook
|
runs-on: runners-groombook
|
||||||
needs: [docker]
|
needs: [docker]
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Install kubectl
|
- name: Install kubectl
|
||||||
run: |
|
run: |
|
||||||
@@ -230,6 +247,7 @@ jobs:
|
|||||||
TAG="pr-$PR_NUM-${SHA::7}"
|
TAG="pr-$PR_NUM-${SHA::7}"
|
||||||
echo "Deploying images tagged $TAG to groombook-dev..."
|
echo "Deploying images tagged $TAG to groombook-dev..."
|
||||||
|
|
||||||
|
# Run migration with PR image
|
||||||
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
|
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
|
||||||
cat <<EOF | kubectl apply -n groombook-dev -f -
|
cat <<EOF | kubectl apply -n groombook-dev -f -
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
@@ -244,7 +262,7 @@ jobs:
|
|||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
containers:
|
containers:
|
||||||
- name: migrate
|
- name: migrate
|
||||||
image: git.farh.net/groombook/migrate:$TAG
|
image: ghcr.io/groombook/migrate:$TAG
|
||||||
env:
|
env:
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
@@ -255,33 +273,35 @@ jobs:
|
|||||||
kubectl wait --for=condition=complete "job/migrate-pr-$PR_NUM" \
|
kubectl wait --for=condition=complete "job/migrate-pr-$PR_NUM" \
|
||||||
-n groombook-dev --timeout=120s
|
-n groombook-dev --timeout=120s
|
||||||
|
|
||||||
kubectl set image deployment/api api=git.farh.net/groombook/api:$TAG -n groombook-dev
|
# Update deployments
|
||||||
kubectl set image deployment/web web=git.farh.net/groombook/web:$TAG -n groombook-dev
|
kubectl set image deployment/api api=ghcr.io/groombook/api:$TAG -n groombook-dev
|
||||||
|
kubectl set image deployment/web web=ghcr.io/groombook/web:$TAG -n groombook-dev
|
||||||
|
|
||||||
|
# Wait for rollout
|
||||||
kubectl rollout status deployment/api -n groombook-dev --timeout=300s
|
kubectl rollout status deployment/api -n groombook-dev --timeout=300s
|
||||||
kubectl rollout status deployment/web -n groombook-dev --timeout=300s
|
kubectl rollout status deployment/web -n groombook-dev --timeout=300s
|
||||||
|
|
||||||
echo "Deployment complete."
|
echo "Deployment complete."
|
||||||
|
|
||||||
- name: Comment on PR
|
- name: Comment on PR
|
||||||
env:
|
uses: actions/github-script@v7
|
||||||
PR_NUM: ${{ github.event.pull_request.number }}
|
with:
|
||||||
run: |
|
script: |
|
||||||
PR_NUM="$PR_NUM"
|
const pr = context.issue.number;
|
||||||
BODY=$(cat <<'EOFBODY'
|
const tag = `pr-${pr}`;
|
||||||
## Deployed to groombook-dev
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
**Images:** `pr-'"$PR_NUM"'`
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr,
|
||||||
**URL:** https://dev.groombook.farh.net
|
body: [
|
||||||
|
'## Deployed to groombook-dev',
|
||||||
Ready for UAT validation.
|
'',
|
||||||
EOFBODY
|
`**Images:** \`${tag}\``,
|
||||||
)
|
'**URL:** https://dev.groombook.farh.net',
|
||||||
curl -s -X POST "https://git.farh.net/api/v1/repos/groombook/app/issues/${PR_NUM}/comments" \
|
'',
|
||||||
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
|
'Ready for UAT validation.'
|
||||||
-H "Content-Type: application/json" \
|
].join('\n')
|
||||||
-d "{\"body\": $(echo "$BODY" | jq -Rs .)}"
|
});
|
||||||
|
|
||||||
web-e2e:
|
web-e2e:
|
||||||
name: Web E2E (Dev)
|
name: Web E2E (Dev)
|
||||||
@@ -310,15 +330,33 @@ jobs:
|
|||||||
run: pnpm --filter @groombook/web test:e2e
|
run: pnpm --filter @groombook/web test:e2e
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-web-e2e-report
|
||||||
|
path: apps/web/playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
cd:
|
cd:
|
||||||
name: Update Infra Image Tags
|
name: Update Infra Image Tags
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [docker]
|
needs: [docker]
|
||||||
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
|
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
|
- name: Generate infra repo token
|
||||||
|
id: infra-token
|
||||||
|
uses: tibdex/github-app-token@v2
|
||||||
|
with:
|
||||||
|
app_id: ${{ vars.GH_APP_ID }}
|
||||||
|
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Clone groombook/infra
|
- name: Clone groombook/infra
|
||||||
run: |
|
run: |
|
||||||
git clone https://oauth2:${{ secrets.REGISTRY_TOKEN }}@git.farh.net/groombook/infra.git /tmp/infra
|
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
||||||
|
|
||||||
- name: Install yq
|
- name: Install yq
|
||||||
run: |
|
run: |
|
||||||
@@ -338,23 +376,27 @@ jobs:
|
|||||||
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
|
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
|
||||||
cd /tmp/infra
|
cd /tmp/infra
|
||||||
DEV_KUST="apps/overlays/dev/kustomization.yaml"
|
DEV_KUST="apps/overlays/dev/kustomization.yaml"
|
||||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
|
||||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
|
||||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
|
||||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
|
||||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
|
||||||
|
|
||||||
|
# Update migrate Job name to include short SHA (immutable template fix)
|
||||||
MIGRATE_JOB="apps/base/migrate-job.yaml"
|
MIGRATE_JOB="apps/base/migrate-job.yaml"
|
||||||
if [ -f "$MIGRATE_JOB" ]; then
|
if [ -f "$MIGRATE_JOB" ]; then
|
||||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
||||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||||
|
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
|
||||||
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
|
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Update seed Job name to include short SHA (immutable template fix)
|
||||||
SEED_JOB="apps/base/seed-job.yaml"
|
SEED_JOB="apps/base/seed-job.yaml"
|
||||||
if [ -f "$SEED_JOB" ]; then
|
if [ -f "$SEED_JOB" ]; then
|
||||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
||||||
|
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
|
||||||
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
|
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -363,6 +405,7 @@ jobs:
|
|||||||
- name: Create PR on groombook/infra
|
- name: Create PR on groombook/infra
|
||||||
env:
|
env:
|
||||||
TAG: ${{ needs.docker.outputs.tag }}
|
TAG: ${{ needs.docker.outputs.tag }}
|
||||||
|
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$TAG" ]; then
|
if [ -z "$TAG" ]; then
|
||||||
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||||
@@ -370,35 +413,24 @@ jobs:
|
|||||||
|
|
||||||
cd /tmp/infra
|
cd /tmp/infra
|
||||||
git config user.name "groombook-engineer[bot]"
|
git config user.name "groombook-engineer[bot]"
|
||||||
git config user.email "groombook-engineer@farh.net"
|
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||||
git checkout -b "chore/update-image-tags-${TAG}"
|
git checkout -b "chore/update-image-tags-${TAG}"
|
||||||
git add apps/overlays/dev/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
|
git add apps/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 commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
|
||||||
|
|
||||||
git push -u origin "chore/update-image-tags-${TAG}"
|
git push -u origin "chore/update-image-tags-${TAG}"
|
||||||
|
|
||||||
EXISTING_PR=$(curl -s "https://git.farh.net/api/v1/repos/groombook/infra/pulls?state=open&head=groombook:chore/update-image-tags-${TAG}" \
|
# Check if PR already exists for this branch
|
||||||
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" | jq -r '.[0].number')
|
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
|
||||||
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then
|
if [ -n "$EXISTING_PR" ]; then
|
||||||
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
|
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
|
||||||
curl -s -X PUT "https://git.farh.net/api/v1/repos/groombook/infra/pulls/${EXISTING_PR}/merge" \
|
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
|
||||||
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"do": "merge"}'
|
|
||||||
else
|
else
|
||||||
PR_RESPONSE=$(curl -s -X POST "https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
|
PR_URL=$(gh pr create \
|
||||||
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
|
--repo groombook/infra \
|
||||||
-H "Content-Type: application/json" \
|
--base main \
|
||||||
-d "{
|
--head "chore/update-image-tags-${TAG}" \
|
||||||
\"base\": \"main\",
|
--title "chore: deploy ${TAG} to dev" \
|
||||||
\"head\": \"chore/update-image-tags-${TAG}\",
|
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
||||||
\"title\": \"chore: deploy ${TAG} to dev\",
|
gh pr merge "$PR_URL" --merge
|
||||||
\"body\": \"[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge\"
|
|
||||||
}")
|
|
||||||
PR_NUM=$(echo "$PR_RESPONSE" | jq -r '.number')
|
|
||||||
echo "Created PR #$PR_NUM"
|
|
||||||
curl -s -X PUT "https://git.farh.net/api/v1/repos/groombook/infra/pulls/${PR_NUM}/merge" \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"do": "merge"}'
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -78,13 +78,6 @@ 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.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.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.6 | Appointment availability check | 1. Attempt to book appointment during unavailable slot | System shows conflict or prevents double-booking |
|
||||||
| TC-APP-4.5.7 | Booking wizard — size/coat selection | 1. Start new appointment booking wizard<br>2. Select a pet with sizeCategory and coatType set<br>3. Observe the service/slot selection step | Size and coat type dropdowns are displayed and persist the pet's existing values |
|
|
||||||
| TC-APP-4.5.8 | Large/X-Large pet slot duration reflects buffer | 1. Add a pet with sizeCategory = "large" or "x-large" to an appointment<br>2. Note the service duration<br>3. Complete booking and inspect the appointment | Appointment slot includes the service duration plus the configured buffer for the pet's size category |
|
|
||||||
| TC-APP-4.5.9 | Appointment overrun cascades downstream | 1. Book three consecutive same-groomer appointments (A → B → C)<br>2. Manually extend appointment A's endTime so it overlaps B's startTime by ≥15 min<br>3. Observe appointment B | Appointment B (and C if still overlapping) is automatically shifted forward by the overrun delta + buffer; no error thrown |
|
|
||||||
| TC-APP-4.5.10 | Cascaded appointments appear at new times | 1. Complete TC-APP-4.5.9<br>2. Check the calendar/list view | Appointments B and C are now shown at their shifted start/end times |
|
|
||||||
| TC-APP-4.5.11 | Client receives reschedule notification email | 1. Complete TC-APP-4.5.9<br>2. Check the client's email (or notification log) | Client receives an email with subject/lines indicating their appointment was rescheduled from original time to new time |
|
|
||||||
| TC-APP-4.5.12 | Appointment flagged when shift crosses day boundary | 1. Book appointment D for late afternoon (e.g. 17:30)<br>2. Extend a prior appointment so D would shift to the next day<br>3. Observe D | Appointment D is flagged for manual review and is NOT auto-shifted to the next day |
|
|
||||||
| TC-APP-4.5.13 | Only scheduled/confirmed appointments are cascaded | 1. Start a cascade scenario (TC-APP-4.5.9) where a downstream appointment is already `in_progress`<br>2. Complete the cascade | The `in_progress` appointment is not shifted; cascade continues to next eligible appointment |
|
|
||||||
|
|
||||||
### 4.6 Services
|
### 4.6 Services
|
||||||
|
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
import { eq, and, gt, gte, lt, ne, or, asc } from "@groombook/db";
|
|
||||||
import { appointments, clients, pets, services, staff, type Db } from "@groombook/db";
|
|
||||||
import { resolveBufferMinutes } from "./buffer.js";
|
|
||||||
import { sendEmail, buildRescheduleNotificationEmail } from "../services/email.js";
|
|
||||||
|
|
||||||
export interface CascadeResult {
|
|
||||||
shifted: ShiftedAppointment[];
|
|
||||||
flaggedForReview: FlaggedAppointment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShiftedAppointment {
|
|
||||||
id: string;
|
|
||||||
oldStartTime: Date;
|
|
||||||
oldEndTime: Date;
|
|
||||||
newStartTime: Date;
|
|
||||||
newEndTime: Date;
|
|
||||||
shiftDeltaMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FlaggedAppointment {
|
|
||||||
id: string;
|
|
||||||
reason: string;
|
|
||||||
requestedStartTime: Date;
|
|
||||||
requestedEndTime: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppointmentWithGroomer {
|
|
||||||
id: string;
|
|
||||||
clientId: string;
|
|
||||||
petId: string;
|
|
||||||
serviceId: string;
|
|
||||||
staffId: string | null;
|
|
||||||
batherStaffId: string | null;
|
|
||||||
status: string;
|
|
||||||
startTime: Date;
|
|
||||||
endTime: Date;
|
|
||||||
bufferMinutes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects and cascades appointment overruns to downstream same-groomer appointments.
|
|
||||||
*
|
|
||||||
* Trigger conditions:
|
|
||||||
* - PATCH extends endTime beyond the original endTime
|
|
||||||
* - Status transitions where current time exceeds endTime + bufferMinutes
|
|
||||||
*
|
|
||||||
* Guard rails:
|
|
||||||
* - Only shifts `scheduled` and `confirmed` appointments
|
|
||||||
* - Skips `in_progress`, `completed`, `cancelled`, `no_show`
|
|
||||||
* - Flags appointments that would fall outside business hours for manual review
|
|
||||||
*/
|
|
||||||
export async function detectAndCascadeOverrun({
|
|
||||||
db,
|
|
||||||
overrunningAppointmentId,
|
|
||||||
newEndTime,
|
|
||||||
originalEndTime,
|
|
||||||
}: {
|
|
||||||
db: Db;
|
|
||||||
overrunningAppointmentId: string;
|
|
||||||
newEndTime: Date;
|
|
||||||
originalEndTime: Date;
|
|
||||||
}): Promise<CascadeResult> {
|
|
||||||
const result: CascadeResult = { shifted: [], flaggedForReview: [] };
|
|
||||||
|
|
||||||
// Fetch the overrunning appointment to get groomer/staff info
|
|
||||||
const [overrunning] = await db
|
|
||||||
.select()
|
|
||||||
.from(appointments)
|
|
||||||
.where(eq(appointments.id, overrunningAppointmentId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!overrunning) return result;
|
|
||||||
|
|
||||||
const groomerId = overrunning.staffId;
|
|
||||||
if (!groomerId) return result;
|
|
||||||
|
|
||||||
// Determine the effective buffer for the overrunning appointment
|
|
||||||
const bufferMinutes = await resolveBufferMinutesForAppointment(db, overrunning);
|
|
||||||
const overrunEnd = newEndTime;
|
|
||||||
const effectiveEnd = new Date(overrunEnd.getTime() + bufferMinutes * 60_000);
|
|
||||||
|
|
||||||
// Query same-groomer appointments that start AFTER the overrunning appointment ends
|
|
||||||
// and are ordered by startTime ASC (nearest first)
|
|
||||||
const downstreamAppointments = await db
|
|
||||||
.select()
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.staffId, groomerId),
|
|
||||||
gt(appointments.startTime, overrunning.endTime),
|
|
||||||
or(
|
|
||||||
eq(appointments.status, "scheduled"),
|
|
||||||
eq(appointments.status, "confirmed")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(asc(appointments.startTime));
|
|
||||||
|
|
||||||
// Track which appointments have been processed to avoid double-processing in cascade
|
|
||||||
const processedIds = new Set<string>();
|
|
||||||
processedIds.add(overrunningAppointmentId);
|
|
||||||
|
|
||||||
let currentOverrunEnd = effectiveEnd;
|
|
||||||
|
|
||||||
for (const downstream of downstreamAppointments) {
|
|
||||||
if (processedIds.has(downstream.id)) continue;
|
|
||||||
|
|
||||||
const downstreamBuffer = await resolveBufferMinutesForAppointment(db, downstream);
|
|
||||||
|
|
||||||
// Check if this downstream appointment conflicts with the current overrun end
|
|
||||||
const conflictThreshold = new Date(
|
|
||||||
currentOverrunEnd.getTime() + downstreamBuffer * 60_000
|
|
||||||
);
|
|
||||||
|
|
||||||
if (conflictThreshold <= downstream.startTime) {
|
|
||||||
// No conflict — cascade is complete
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conflict detected — need to shift this appointment
|
|
||||||
const shiftDeltaMs = conflictThreshold.getTime() - downstream.startTime.getTime();
|
|
||||||
const newStartTime = new Date(downstream.startTime.getTime() + shiftDeltaMs);
|
|
||||||
const newEndTime = new Date(downstream.endTime.getTime() + shiftDeltaMs);
|
|
||||||
|
|
||||||
// Check business hours (simple: only shift within same calendar day window for now)
|
|
||||||
// A more sophisticated implementation would check actual business hours from businessSettings
|
|
||||||
const isSameDay =
|
|
||||||
newStartTime.toDateString() === downstream.startTime.toDateString();
|
|
||||||
|
|
||||||
if (!isSameDay) {
|
|
||||||
result.flaggedForReview.push({
|
|
||||||
id: downstream.id,
|
|
||||||
reason: `Shifted appointment would fall on a different day (${newStartTime.toDateString()})`,
|
|
||||||
requestedStartTime: newStartTime,
|
|
||||||
requestedEndTime: newEndTime,
|
|
||||||
});
|
|
||||||
// Continue cascade check — we still process downstream appointments
|
|
||||||
currentOverrunEnd = newEndTime;
|
|
||||||
processedIds.add(downstream.id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the shift
|
|
||||||
await db
|
|
||||||
.update(appointments)
|
|
||||||
.set({
|
|
||||||
startTime: newStartTime,
|
|
||||||
endTime: newEndTime,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(appointments.id, downstream.id));
|
|
||||||
|
|
||||||
result.shifted.push({
|
|
||||||
id: downstream.id,
|
|
||||||
oldStartTime: downstream.startTime,
|
|
||||||
oldEndTime: downstream.endTime,
|
|
||||||
newStartTime,
|
|
||||||
newEndTime,
|
|
||||||
shiftDeltaMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update current overrun end for next iteration
|
|
||||||
currentOverrunEnd = newEndTime;
|
|
||||||
processedIds.add(downstream.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send notifications for all shifted appointments
|
|
||||||
for (const shifted of result.shifted) {
|
|
||||||
await notifyShiftedAppointment(db, shifted);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if an appointment update represents an overrun that triggers cascade logic.
|
|
||||||
*/
|
|
||||||
export function isOverrun({
|
|
||||||
originalEndTime,
|
|
||||||
newEndTime,
|
|
||||||
originalStartTime,
|
|
||||||
newStartTime,
|
|
||||||
status,
|
|
||||||
currentTime,
|
|
||||||
bufferMinutes,
|
|
||||||
}: {
|
|
||||||
originalEndTime: Date;
|
|
||||||
newEndTime: Date;
|
|
||||||
originalStartTime: Date;
|
|
||||||
newStartTime?: Date;
|
|
||||||
status: string;
|
|
||||||
currentTime: Date;
|
|
||||||
bufferMinutes: number;
|
|
||||||
}): boolean {
|
|
||||||
// Case 1: endTime extended beyond original
|
|
||||||
if (newEndTime > originalEndTime) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: status transition where current time exceeds endTime + bufferMinutes
|
|
||||||
// This handles cases where an appointment ran long but wasn't explicitly rescheduled
|
|
||||||
if (
|
|
||||||
(status === "in_progress" || status === "completed") &&
|
|
||||||
currentTime > new Date(originalEndTime.getTime() + bufferMinutes * 60_000)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveBufferMinutesForAppointment(
|
|
||||||
db: Db,
|
|
||||||
appt: AppointmentWithGroomer
|
|
||||||
): Promise<number> {
|
|
||||||
// First check if the appointment has an explicit bufferMinutes override
|
|
||||||
if (appt.bufferMinutes > 0) {
|
|
||||||
return appt.bufferMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to buffer time rules based on service + pet characteristics
|
|
||||||
const [pet] = await db
|
|
||||||
.select({ sizeCategory: pets.sizeCategory, coatType: pets.coatType })
|
|
||||||
.from(pets)
|
|
||||||
.where(eq(pets.id, appt.petId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!pet) return 0;
|
|
||||||
|
|
||||||
return resolveBufferMinutes({
|
|
||||||
serviceId: appt.serviceId,
|
|
||||||
sizeCategory: pet.sizeCategory,
|
|
||||||
coatType: pet.coatType,
|
|
||||||
db,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function notifyShiftedAppointment(
|
|
||||||
db: Db,
|
|
||||||
shifted: ShiftedAppointment
|
|
||||||
): Promise<void> {
|
|
||||||
const [row] = await db
|
|
||||||
.select({
|
|
||||||
clientName: clients.name,
|
|
||||||
clientEmail: clients.email,
|
|
||||||
clientEmailOptOut: clients.emailOptOut,
|
|
||||||
petName: pets.name,
|
|
||||||
serviceName: services.name,
|
|
||||||
groomerName: staff.name,
|
|
||||||
appointmentStartTime: appointments.startTime,
|
|
||||||
})
|
|
||||||
.from(appointments)
|
|
||||||
.innerJoin(clients, eq(clients.id, appointments.clientId))
|
|
||||||
.innerJoin(pets, eq(pets.id, appointments.petId))
|
|
||||||
.innerJoin(services, eq(services.id, appointments.serviceId))
|
|
||||||
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
|
||||||
.where(eq(appointments.id, shifted.id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!row) return;
|
|
||||||
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
|
|
||||||
|
|
||||||
if (!clientEmail || clientEmailOptOut) return;
|
|
||||||
if (!petName || !serviceName) return;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[cascade] Notifying shift for appointment ${shifted.id}: ` +
|
|
||||||
`${shifted.oldStartTime.toISOString()} → ${shifted.newStartTime.toISOString()}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await sendEmail(
|
|
||||||
buildRescheduleNotificationEmail(clientEmail, {
|
|
||||||
clientName,
|
|
||||||
petName,
|
|
||||||
serviceName,
|
|
||||||
groomerName: groomerName ?? null,
|
|
||||||
oldStartTime: shifted.oldStartTime,
|
|
||||||
newStartTime: shifted.newStartTime,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -21,10 +21,6 @@ import {
|
|||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||||
import {
|
|
||||||
detectAndCascadeOverrun,
|
|
||||||
isOverrun,
|
|
||||||
} from "../lib/cascade.js";
|
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
async function withRetry<T>(
|
async function withRetry<T>(
|
||||||
@@ -588,7 +584,6 @@ appointmentsRouter.patch(
|
|||||||
// (fixes #18). Also falls back to the existing staffId when staffId is
|
// (fixes #18). Also falls back to the existing staffId when staffId is
|
||||||
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
||||||
let row: typeof appointments.$inferSelect | undefined;
|
let row: typeof appointments.$inferSelect | undefined;
|
||||||
let originalEndTime: Date | undefined;
|
|
||||||
try {
|
try {
|
||||||
row = await db.transaction(async (tx) => {
|
row = await db.transaction(async (tx) => {
|
||||||
const [current] = await tx
|
const [current] = await tx
|
||||||
@@ -600,9 +595,6 @@ appointmentsRouter.patch(
|
|||||||
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve original endTime for cascade detection after update
|
|
||||||
originalEndTime = current.endTime;
|
|
||||||
|
|
||||||
const start = updateFields.startTime
|
const start = updateFields.startTime
|
||||||
? new Date(updateFields.startTime)
|
? new Date(updateFields.startTime)
|
||||||
: current.startTime;
|
: current.startTime;
|
||||||
@@ -692,29 +684,6 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
// Cascade delay prevention: detect overrun and shift downstream appointments
|
|
||||||
if (
|
|
||||||
originalEndTime &&
|
|
||||||
updateFields.endTime &&
|
|
||||||
isOverrun({
|
|
||||||
originalEndTime,
|
|
||||||
newEndTime: new Date(updateFields.endTime),
|
|
||||||
originalStartTime: row.startTime,
|
|
||||||
status: row.status,
|
|
||||||
currentTime: new Date(),
|
|
||||||
bufferMinutes: row.bufferMinutes ?? 0,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
const cascadeResult = await detectAndCascadeOverrun({
|
|
||||||
db,
|
|
||||||
overrunningAppointmentId: id,
|
|
||||||
newEndTime: new Date(updateFields.endTime),
|
|
||||||
originalEndTime,
|
|
||||||
});
|
|
||||||
return c.json({ ...row, cascade: cascadeResult });
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(row);
|
return c.json(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,13 +38,11 @@ bookRouter.get("/services", async (c) => {
|
|||||||
|
|
||||||
// ─── GET /api/book/availability ─────────────────────────────────────────────
|
// ─── GET /api/book/availability ─────────────────────────────────────────────
|
||||||
// Public: return ISO startTime strings for slots where ≥1 groomer is free
|
// Public: return ISO startTime strings for slots where ≥1 groomer is free
|
||||||
// Query params: serviceId (uuid), date (YYYY-MM-DD), petSizeCategory, petCoatType
|
// Query params: serviceId (uuid), date (YYYY-MM-DD)
|
||||||
|
|
||||||
bookRouter.get("/availability", async (c) => {
|
bookRouter.get("/availability", async (c) => {
|
||||||
const serviceId = c.req.query("serviceId");
|
const serviceId = c.req.query("serviceId");
|
||||||
const dateStr = c.req.query("date");
|
const dateStr = c.req.query("date");
|
||||||
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
|
|
||||||
const petCoatType = c.req.query("petCoatType") ?? undefined;
|
|
||||||
|
|
||||||
if (!serviceId || !dateStr) {
|
if (!serviceId || !dateStr) {
|
||||||
return c.json({ error: "serviceId and date are required" }, 400);
|
return c.json({ error: "serviceId and date are required" }, 400);
|
||||||
@@ -60,12 +58,6 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
.where(and(eq(services.id, serviceId), eq(services.active, true)));
|
.where(and(eq(services.id, serviceId), eq(services.active, true)));
|
||||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||||
|
|
||||||
// Buffer-aware duration: extra time for large/x-large or complex coats
|
|
||||||
const extraBuffer = (petSizeCategory === "large" || petSizeCategory === "x-large")
|
|
||||||
? (service.defaultBufferMinutes ?? 0)
|
|
||||||
: 0;
|
|
||||||
const durationMinutes = service.durationMinutes + extraBuffer;
|
|
||||||
|
|
||||||
const groomers = await db
|
const groomers = await db
|
||||||
.select({ id: staff.id })
|
.select({ id: staff.id })
|
||||||
.from(staff)
|
.from(staff)
|
||||||
@@ -97,7 +89,7 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
|
|
||||||
const slots = generateAvailableSlots({
|
const slots = generateAvailableSlots({
|
||||||
dateStr,
|
dateStr,
|
||||||
durationMinutes,
|
durationMinutes: service.durationMinutes,
|
||||||
groomerIds: groomers.map((g) => g.id),
|
groomerIds: groomers.map((g) => g.id),
|
||||||
booked,
|
booked,
|
||||||
});
|
});
|
||||||
@@ -120,12 +112,6 @@ const bookingSchema = z.object({
|
|||||||
petName: z.string().min(1).max(200),
|
petName: z.string().min(1).max(200),
|
||||||
petSpecies: z.string().min(1).max(100),
|
petSpecies: z.string().min(1).max(100),
|
||||||
petBreed: z.string().max(100).optional(),
|
petBreed: z.string().max(100).optional(),
|
||||||
petSizeCategory: z
|
|
||||||
.enum(["small", "medium", "large", "x-large"])
|
|
||||||
.optional(),
|
|
||||||
petCoatType: z
|
|
||||||
.enum(["smooth", "double", "curly", "wire", "long", "hairless"])
|
|
||||||
.optional(),
|
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,7 +129,7 @@ bookRouter.post(
|
|||||||
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
|
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
|
||||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||||
|
|
||||||
let end = new Date(start.getTime() + service.durationMinutes * 60_000);
|
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
|
||||||
|
|
||||||
// Find all active groomers
|
// Find all active groomers
|
||||||
const groomers = await db
|
const groomers = await db
|
||||||
@@ -205,18 +191,11 @@ bookRouter.post(
|
|||||||
name: body.petName,
|
name: body.petName,
|
||||||
species: body.petSpecies,
|
species: body.petSpecies,
|
||||||
breed: body.petBreed ?? null,
|
breed: body.petBreed ?? null,
|
||||||
sizeCategory: body.petSizeCategory ?? null,
|
|
||||||
coatType: body.petCoatType ?? null,
|
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
const pet = petInserted[0];
|
const pet = petInserted[0];
|
||||||
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
|
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
|
||||||
|
|
||||||
// Buffer-aware end time: large/x-large pets add service bufferMinutes
|
|
||||||
if (body.petSizeCategory === "large" || body.petSizeCategory === "x-large") {
|
|
||||||
end = new Date(start.getTime() + (service.durationMinutes + (service.defaultBufferMinutes ?? 0)) * 60_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert appointment in a transaction to guard against race conditions
|
// Insert appointment in a transaction to guard against race conditions
|
||||||
let appointment;
|
let appointment;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -201,52 +201,3 @@ export function buildWaitlistNotificationEmail(
|
|||||||
<p>— Groom Book</p>`,
|
<p>— Groom Book</p>`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Reschedule notification email ────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface RescheduleEmailData {
|
|
||||||
clientName: string;
|
|
||||||
petName: string;
|
|
||||||
serviceName: string;
|
|
||||||
groomerName: string | null;
|
|
||||||
oldStartTime: Date;
|
|
||||||
newStartTime: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRescheduleNotificationEmail(
|
|
||||||
to: string,
|
|
||||||
data: RescheduleEmailData
|
|
||||||
): Mail.Options {
|
|
||||||
const oldTime = formatDateTime(data.oldStartTime);
|
|
||||||
const newTime = formatDateTime(data.newStartTime);
|
|
||||||
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
|
|
||||||
return {
|
|
||||||
to,
|
|
||||||
subject: `Appointment Rescheduled — ${data.petName}'s appointment has been moved`,
|
|
||||||
text: [
|
|
||||||
`Hi ${data.clientName},`,
|
|
||||||
``,
|
|
||||||
`Your appointment has been rescheduled.`,
|
|
||||||
``,
|
|
||||||
` Pet: ${data.petName}`,
|
|
||||||
` Service: ${data.serviceName}`,
|
|
||||||
` Was: ${oldTime}${groomer}`,
|
|
||||||
` Now: ${newTime}${groomer}`,
|
|
||||||
``,
|
|
||||||
`If you have any questions or need to make changes, please contact us.`,
|
|
||||||
``,
|
|
||||||
`— Groom Book`,
|
|
||||||
].join("\n"),
|
|
||||||
html: `
|
|
||||||
<p>Hi ${data.clientName},</p>
|
|
||||||
<p>Your appointment has been <strong>rescheduled</strong>.</p>
|
|
||||||
<table style="border-collapse:collapse;margin:1em 0">
|
|
||||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
|
|
||||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
|
|
||||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#ef4444">Was</td><td style="text-decoration:line-through;color:#ef4444">${oldTime}${groomer}</td></tr>
|
|
||||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#10b981">Now</td><td style="color:#10b981">${newTime}${groomer}</td></tr>
|
|
||||||
</table>
|
|
||||||
<p>If you have any questions or need to make changes, please contact us.</p>
|
|
||||||
<p>— Groom Book</p>`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ interface BookingBody {
|
|||||||
petName: string;
|
petName: string;
|
||||||
petSpecies: string;
|
petSpecies: string;
|
||||||
petBreed: string;
|
petBreed: string;
|
||||||
petSizeCategory: string;
|
|
||||||
petCoatType: string;
|
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +123,6 @@ export function BookPage() {
|
|||||||
petName: "",
|
petName: "",
|
||||||
petSpecies: "",
|
petSpecies: "",
|
||||||
petBreed: "",
|
petBreed: "",
|
||||||
petSizeCategory: "",
|
|
||||||
petCoatType: "",
|
|
||||||
notes: "",
|
notes: "",
|
||||||
});
|
});
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
@@ -172,18 +168,14 @@ export function BookPage() {
|
|||||||
if (!selectedService || !date) return;
|
if (!selectedService || !date) return;
|
||||||
setSlotsLoading(true);
|
setSlotsLoading(true);
|
||||||
setSelectedSlot(null);
|
setSelectedSlot(null);
|
||||||
const params = new URLSearchParams({
|
fetch(
|
||||||
serviceId: selectedService.id,
|
`/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}`
|
||||||
date,
|
)
|
||||||
});
|
|
||||||
if (form.petSizeCategory) params.set("petSizeCategory", form.petSizeCategory);
|
|
||||||
if (form.petCoatType) params.set("petCoatType", form.petCoatType);
|
|
||||||
fetch(`/api/book/availability?${params}`)
|
|
||||||
.then((r) => r.json() as Promise<string[]>)
|
.then((r) => r.json() as Promise<string[]>)
|
||||||
.then(setSlots)
|
.then(setSlots)
|
||||||
.catch(() => setSlots([]))
|
.catch(() => setSlots([]))
|
||||||
.finally(() => setSlotsLoading(false));
|
.finally(() => setSlotsLoading(false));
|
||||||
}, [selectedService, date, form.petSizeCategory, form.petCoatType]);
|
}, [selectedService, date]);
|
||||||
|
|
||||||
function goToStep2(svc: Service) {
|
function goToStep2(svc: Service) {
|
||||||
setSelectedService(svc);
|
setSelectedService(svc);
|
||||||
@@ -222,8 +214,6 @@ export function BookPage() {
|
|||||||
petName: form.petName,
|
petName: form.petName,
|
||||||
petSpecies: form.petSpecies,
|
petSpecies: form.petSpecies,
|
||||||
petBreed: form.petBreed || undefined,
|
petBreed: form.petBreed || undefined,
|
||||||
petSizeCategory: form.petSizeCategory || undefined,
|
|
||||||
petCoatType: form.petCoatType || undefined,
|
|
||||||
notes: form.notes || undefined,
|
notes: form.notes || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -504,36 +494,6 @@ export function BookPage() {
|
|||||||
placeholder="Golden Retriever"
|
placeholder="Golden Retriever"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label style={label}>Pet size (optional, but encouraged)</label>
|
|
||||||
<select
|
|
||||||
style={input}
|
|
||||||
value={form.petSizeCategory}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, petSizeCategory: e.target.value }))}
|
|
||||||
>
|
|
||||||
<option value="">Select size…</option>
|
|
||||||
<option value="small">Small (under 15 lbs)</option>
|
|
||||||
<option value="medium">Medium (15–40 lbs)</option>
|
|
||||||
<option value="large">Large (40–80 lbs)</option>
|
|
||||||
<option value="x-large">X-Large (over 80 lbs)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={label}>Coat type (optional, but encouraged)</label>
|
|
||||||
<select
|
|
||||||
style={input}
|
|
||||||
value={form.petCoatType}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, petCoatType: e.target.value }))}
|
|
||||||
>
|
|
||||||
<option value="">Select coat type…</option>
|
|
||||||
<option value="smooth">Smooth</option>
|
|
||||||
<option value="double">Double</option>
|
|
||||||
<option value="curly">Curly</option>
|
|
||||||
<option value="wire">Wire</option>
|
|
||||||
<option value="long">Long</option>
|
|
||||||
<option value="hairless">Hairless</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label style={label}>Notes for groomer</label>
|
<label style={label}>Notes for groomer</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -568,7 +528,7 @@ export function BookPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
|
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
|
||||||
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
|
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
|
||||||
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes + ((form.petSizeCategory === "large" || form.petSizeCategory === "x-large") ? (selectedService.defaultBufferMinutes ?? 0) : 0))}</div>
|
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
|
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
|
||||||
@@ -639,8 +599,7 @@ export function BookPage() {
|
|||||||
setResult(null);
|
setResult(null);
|
||||||
setForm({
|
setForm({
|
||||||
serviceId: "", startTime: "", clientName: "", clientEmail: "",
|
serviceId: "", startTime: "", clientName: "", clientEmail: "",
|
||||||
clientPhone: "", petName: "", petSpecies: "", petBreed: "",
|
clientPhone: "", petName: "", petSpecies: "", petBreed: "", notes: "",
|
||||||
petSizeCategory: "", petCoatType: "", notes: "",
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -151,8 +151,6 @@ export const pets = pgTable(
|
|||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
species: text("species").notNull(),
|
species: text("species").notNull(),
|
||||||
breed: text("breed"),
|
breed: text("breed"),
|
||||||
sizeCategory: petSizeCategoryEnum("size_category"),
|
|
||||||
coatType: coatTypeEnum("coat_type"),
|
|
||||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||||
dateOfBirth: timestamp("date_of_birth"),
|
dateOfBirth: timestamp("date_of_birth"),
|
||||||
healthAlerts: text("health_alerts"),
|
healthAlerts: text("health_alerts"),
|
||||||
@@ -164,6 +162,8 @@ export const pets = pgTable(
|
|||||||
photoKey: text("photo_key"),
|
photoKey: text("photo_key"),
|
||||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
|
sizeCategory: petSizeCategoryEnum("size_category"),
|
||||||
|
coatType: coatTypeEnum("coat_type"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export interface Service {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
basePriceCents: number;
|
basePriceCents: number;
|
||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
defaultBufferMinutes: number;
|
|
||||||
active: boolean;
|
active: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user