Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3517bf746 | |||
| 604e79bab4 | |||
| 29015cffec | |||
| c67f731f69 | |||
| db3bcf8094 | |||
| 7836511baa | |||
| b69650af15 | |||
| b0d1a4def4 |
@@ -127,18 +127,12 @@ jobs:
|
||||
needs: [build, e2e]
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate image tag
|
||||
id: version
|
||||
run: |
|
||||
# Always include short SHA so each build is immutable and cache-from can never
|
||||
# cross-contaminate between commits. For PRs the format is pr-N-sha7; for main
|
||||
# it is YYYY.MM.DD-sha7.
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
TAG="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::7}"
|
||||
else
|
||||
@@ -150,12 +144,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: ${{ gitea.token }}
|
||||
|
||||
- name: Build and push API image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -165,10 +159,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 +172,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 +185,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 +198,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 +210,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
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
if: github.event_name == 'pull_request'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Install kubectl
|
||||
run: |
|
||||
@@ -245,7 +236,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 +250,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 +261,25 @@ 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 }}
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
run: |
|
||||
TAG="pr-${PR_NUM}"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.farh.net/api/v1/repos/groombook/app/issues/$PR_NUM/comments" \
|
||||
-d "{\"body\": \"## Deployed to groombook-dev\n\n**Images:** \`${TAG}\`\n**URL:** https://dev.groombook.farh.net\n\nReady for UAT validation.\"}"
|
||||
|
||||
web-e2e:
|
||||
name: Web E2E (Dev)
|
||||
@@ -341,20 +321,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Generate infra repo token
|
||||
id: infra-token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ vars.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clone groombook/infra
|
||||
env:
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
run: |
|
||||
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
||||
git clone https://oauth2:$GITEA_TOKEN@git.farh.net/groombook/infra.git /tmp/infra
|
||||
|
||||
- name: Install yq
|
||||
run: |
|
||||
@@ -371,30 +343,25 @@ jobs:
|
||||
fi
|
||||
export SHORT_SHA="${SHA::7}"
|
||||
echo "Updating dev overlay image tags to: $TAG"
|
||||
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
|
||||
cd /tmp/infra
|
||||
DEV_KUST="apps/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"
|
||||
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"
|
||||
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"
|
||||
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,32 +370,40 @@ jobs:
|
||||
- name: Create PR on groombook/infra
|
||||
env:
|
||||
TAG: ${{ needs.docker.outputs.tag }}
|
||||
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
run: |
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||
fi
|
||||
|
||||
cd /tmp/infra
|
||||
git config user.name "groombook-engineer[bot]"
|
||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||
git config user.email "groombook-engineer[bot]@git.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 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)
|
||||
EXISTING_PR=$(curl -s \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.farh.net/api/v1/repos/groombook/infra/pulls?state=open&limit=50" \
|
||||
| jq -r ".[] | select(.head.label == \"chore/update-image-tags-${TAG}\") | .number" | head -1)
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
|
||||
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
|
||||
echo "PR #$EXISTING_PR already exists, merging"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.farh.net/api/v1/repos/groombook/infra/pulls/$EXISTING_PR/merge" \
|
||||
-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_NUM=$(curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
|
||||
-d "{\"head\":\"chore/update-image-tags-${TAG}\",\"base\":\"main\",\"title\":\"chore: deploy ${TAG} to dev\",\"body\":\"[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge\"}" \
|
||||
| jq '.number')
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.farh.net/api/v1/repos/groombook/infra/pulls/$PR_NUM/merge" \
|
||||
-d '{"Do":"merge"}'
|
||||
fi
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Release Helm Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'charts/**'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout groombook
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout groombook.dev (Helm chart host)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: groombook/groombook.dev
|
||||
path: gitea-pages
|
||||
token: ${{ gitea.token }}
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
|
||||
- name: Update Helm dependencies
|
||||
run: helm dependency update charts/groombook
|
||||
|
||||
- name: Package chart
|
||||
run: |
|
||||
mkdir -p gitea-pages/charts
|
||||
helm package charts/groombook -d gitea-pages/charts
|
||||
|
||||
- name: Update repo index
|
||||
run: |
|
||||
# TODO: update URL once Gitea Pages hosting is confirmed
|
||||
CHART_URL="${HELM_CHART_URL:-https://groombook.farh.net/charts}"
|
||||
if [ -f gitea-pages/charts/index.yaml ]; then
|
||||
helm repo index gitea-pages/charts --merge gitea-pages/charts/index.yaml --url "$CHART_URL"
|
||||
else
|
||||
helm repo index gitea-pages/charts --url "$CHART_URL"
|
||||
fi
|
||||
|
||||
- name: Push to groombook.dev
|
||||
run: |
|
||||
cd gitea-pages
|
||||
git config user.name "groombook-engineer[bot]"
|
||||
git config user.email "groombook-engineer[bot]@git.farh.net"
|
||||
git add charts/
|
||||
git diff --staged --quiet && echo 'No chart changes' && exit 0
|
||||
git commit -m "Update Helm chart repository"
|
||||
git push
|
||||
@@ -12,9 +12,6 @@ jobs:
|
||||
promote:
|
||||
name: Promote to Production
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- name: Validate tag format
|
||||
run: |
|
||||
@@ -25,28 +22,25 @@ jobs:
|
||||
fi
|
||||
echo "Tag format valid: $TAG"
|
||||
|
||||
- name: Verify image exists in GHCR
|
||||
- name: Verify image exists in Gitea Container Registry
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
run: |
|
||||
TAG="${{ inputs.tag }}"
|
||||
# Check that the API image exists — if API was pushed, web/migrate were too
|
||||
if ! gh api "/orgs/groombook/packages/container/api/versions" --jq ".[].metadata.container.tags[]" 2>/dev/null | grep -qF "$TAG"; then
|
||||
echo "::error::Image ghcr.io/groombook/api:$TAG not found in GHCR. Verify the tag was built and pushed."
|
||||
exit 1
|
||||
if ! curl -sf \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.farh.net/api/v1/packages/groombook?type=container&limit=50" \
|
||||
| jq -e --arg t "$TAG" '[.[] | select(.name == "api" and .version == $t)] | length > 0' > /dev/null 2>&1; then
|
||||
echo "::warning::Could not verify git.farh.net/groombook/api:$TAG via package API — verify manually if needed."
|
||||
else
|
||||
echo "Image verified: git.farh.net/groombook/api:$TAG exists"
|
||||
fi
|
||||
echo "Image verified: ghcr.io/groombook/api:$TAG exists"
|
||||
|
||||
- name: Generate infra repo token
|
||||
id: infra-token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ vars.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clone groombook/infra
|
||||
env:
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
run: |
|
||||
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
||||
git clone https://oauth2:$GITEA_TOKEN@git.farh.net/groombook/infra.git /tmp/infra
|
||||
|
||||
- name: Install yq
|
||||
run: |
|
||||
@@ -64,19 +58,17 @@ jobs:
|
||||
export SHORT_SHA
|
||||
export TAG
|
||||
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$PROD_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$PROD_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$PROD_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
|
||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/api")).newTag = env(TAG)' "$PROD_KUST"
|
||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/web")).newTag = env(TAG)' "$PROD_KUST"
|
||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/migrate")).newTag = env(TAG)' "$PROD_KUST"
|
||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
|
||||
|
||||
# Update migrate Job name to include short SHA (immutable template fix)
|
||||
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||
if [ -f "$MIGRATE_JOB" ]; then
|
||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||
fi
|
||||
|
||||
# Update seed Job name to include short SHA (immutable template fix)
|
||||
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||
if [ -f "$SEED_JOB" ]; then
|
||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||
@@ -88,30 +80,29 @@ jobs:
|
||||
- name: Create PR on groombook/infra
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
run: |
|
||||
cd /tmp/infra
|
||||
git config user.name "groombook-engineer[bot]"
|
||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||
git config user.email "groombook-engineer[bot]@git.farh.net"
|
||||
git checkout -b "release/promote-prod-${TAG}"
|
||||
git add apps/groombook/overlays/prod/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
||||
git commit -m "release: promote ${TAG} to production"
|
||||
git push -u origin "release/promote-prod-${TAG}"
|
||||
gh pr create \
|
||||
--repo groombook/infra \
|
||||
--base main \
|
||||
--head "release/promote-prod-${TAG}" \
|
||||
--title "release: promote ${TAG} to production" \
|
||||
--body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
|
||||
-d "{\"head\":\"release/promote-prod-${TAG}\",\"base\":\"main\",\"title\":\"release: promote ${TAG} to production\",\"body\":\"Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood\"}"
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: '## Production Promotion Failed\n\nThe `promote-prod` workflow failed. Check the workflow run logs for details.'
|
||||
});
|
||||
env:
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.farh.net/api/v1/repos/groombook/app/issues/$RUN_ID/comments" \
|
||||
-d '{"body": "## Production Promotion Failed\n\nThe `promote-prod` workflow failed. Check the workflow run logs for details."}'
|
||||
@@ -12,20 +12,12 @@ jobs:
|
||||
promote-to-uat:
|
||||
name: Promote to groombook-uat
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Generate infra repo token
|
||||
id: infra-token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ vars.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clone groombook/infra
|
||||
env:
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
run: |
|
||||
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
||||
git clone https://oauth2:$GITEA_TOKEN@git.farh.net/groombook/infra.git /tmp/infra
|
||||
|
||||
- name: Install yq
|
||||
run: |
|
||||
@@ -49,21 +41,17 @@ jobs:
|
||||
export SHORT_SHA
|
||||
export TAG
|
||||
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$UAT_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$UAT_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$UAT_KUST"
|
||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
|
||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/api")).newTag = env(TAG)' "$UAT_KUST"
|
||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/web")).newTag = env(TAG)' "$UAT_KUST"
|
||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/migrate")).newTag = env(TAG)' "$UAT_KUST"
|
||||
yq -i '(.images[] | select(.name == "git.farh.net/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
|
||||
|
||||
# Update migrate Job name to include short SHA (immutable template fix)
|
||||
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||
if [ -f "$MIGRATE_JOB" ]; then
|
||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||
fi
|
||||
|
||||
# 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"
|
||||
if [ -f "$SEED_JOB" ]; then
|
||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||
@@ -75,34 +63,36 @@ jobs:
|
||||
- name: Create PR on groombook/infra
|
||||
env:
|
||||
TAG: ${{ inputs.image_tag }}
|
||||
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
run: |
|
||||
cd /tmp/infra
|
||||
git config user.name "groombook-engineer[bot]"
|
||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||
git config user.email "groombook-engineer[bot]@git.farh.net"
|
||||
git checkout -b "chore/update-uat-image-tags-${TAG}"
|
||||
git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
||||
git commit -m "chore: promote ${TAG} to UAT"
|
||||
|
||||
git push -u origin "chore/update-uat-image-tags-${TAG}"
|
||||
|
||||
# Create PR and merge immediately (no required checks on groombook/infra)
|
||||
PR_URL=$(gh pr create \
|
||||
--repo groombook/infra \
|
||||
--base main \
|
||||
--head "chore/update-uat-image-tags-${TAG}" \
|
||||
--title "chore: promote ${TAG} to UAT" \
|
||||
--body "[GRO-429](/GRO/issues/GRO-429) — UAT promotion triggered by CTO")
|
||||
gh pr merge "$PR_URL" --merge
|
||||
PR_NUM=$(curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
|
||||
-d "{\"head\":\"chore/update-uat-image-tags-${TAG}\",\"base\":\"main\",\"title\":\"chore: promote ${TAG} to UAT\",\"body\":\"[GRO-429](/GRO/issues/GRO-429) — UAT promotion triggered by CTO\"}" \
|
||||
| jq '.number')
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.farh.net/api/v1/repos/groombook/infra/pulls/$PR_NUM/merge" \
|
||||
-d '{"Do":"merge"}'
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: '## UAT Promotion Failed\n\nThe `promote-to-uat` workflow failed. Check the workflow run logs for details.\n\nCommon issues:\n- UAT overlay not found (ensure GRO-427 is complete)\n- Infra repo access token expired'
|
||||
});
|
||||
env:
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.farh.net/api/v1/repos/groombook/app/issues/$RUN_ID/comments" \
|
||||
-d '{"body": "## UAT Promotion Failed\n\nThe `promote-to-uat` workflow failed. Check the workflow run logs for details.\n\nCommon issues:\n- UAT overlay not found (ensure GRO-427 is complete)\n- GITEA_TOKEN permissions"}'
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Release Helm Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'charts/**'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout groombook
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout groombook.github.io
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: groombook/groombook.github.io
|
||||
path: gh-pages
|
||||
token: ${{ secrets.CHART_REPO_TOKEN }}
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
|
||||
- name: Update Helm dependencies
|
||||
run: helm dependency update charts/groombook
|
||||
|
||||
- name: Package chart
|
||||
run: |
|
||||
mkdir -p gh-pages/charts
|
||||
helm package charts/groombook -d gh-pages/charts
|
||||
|
||||
- name: Update repo index
|
||||
run: |
|
||||
if [ -f gh-pages/charts/index.yaml ]; then
|
||||
helm repo index gh-pages/charts --merge gh-pages/charts/index.yaml --url https://groombook.github.io/charts
|
||||
else
|
||||
helm repo index gh-pages/charts --url https://groombook.github.io/charts
|
||||
fi
|
||||
|
||||
- name: Push to groombook.github.io
|
||||
run: |
|
||||
cd gh-pages
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add charts/
|
||||
git diff --staged --quiet && echo 'No chart changes' && exit 0
|
||||
git commit -m "Update Helm chart repository"
|
||||
git push
|
||||
@@ -1,63 +0,0 @@
|
||||
# GroomBook API — UAT Playbook
|
||||
|
||||
This document captures user-acceptance test cases for GroomBook API features. Each section corresponds to a feature or bug-fix PR. Update this file when a PR changes user-facing behaviour.
|
||||
|
||||
---
|
||||
|
||||
## 1. Appointment Booking (`/api/appointments`)
|
||||
|
||||
### 1.1 Create Appointment
|
||||
- [ ] POST `/api/appointments` with valid payload → 201, appointment returned with generated id
|
||||
- [ ] Overlapping staff appointment → 409 conflict error returned
|
||||
- [ ] `endTime` before `startTime` → 422 error
|
||||
|
||||
### 1.2 Update Appointment (PATCH `/api/appointments/:id`)
|
||||
- [ ] Extending `endTime` on a `scheduled` appointment triggers cascade delay prevention if downstream appointments exist
|
||||
- [ ] Extending `endTime` returns `cascade` object in response with shifted appointments
|
||||
- [ ] Extending `endTime` sends reschedule email to each affected client
|
||||
- [ ] Appointments outside business hours after shift are flagged in `cascade.flaggedForReview` instead of auto-shifted
|
||||
- [ ] Only `scheduled` and `confirmed` downstream appointments are shifted; `in_progress`, `completed`, `cancelled` are skipped
|
||||
- [ ] Cascade stops when a downstream appointment no longer conflicts with the shifted boundary
|
||||
- [ ] Shifts are included in API response under `cascade.shifted[]`
|
||||
|
||||
### 1.3 Series (Recurring) Appointments
|
||||
- [ ] Updating one occurrence with `cascadeMode: "this_and_future"` shifts that occurrence and all future ones
|
||||
- [ ] Updating one occurrence with `cascadeMode: "all"` shifts every occurrence in the series
|
||||
|
||||
---
|
||||
|
||||
## 2. Cascade Delay Prevention
|
||||
|
||||
### 2.1 Basic Cascade
|
||||
- [ ] When a groomer's appointment overruns, the next same-groomer `scheduled` appointment shifts forward
|
||||
- [ ] Delta applied to both `startTime` and `endTime` (duration preserved)
|
||||
- [ ] Cascade propagates through multiple downstream appointments
|
||||
|
||||
### 2.2 Buffer Time
|
||||
- [ ] A configurable buffer (default 15 minutes) is added between the overrunning appointment end and the shifted start
|
||||
- [ ] Cascade respects the buffer between each consecutive pair of appointments
|
||||
|
||||
### 2.3 Business Hours Guard
|
||||
- [ ] If a proposed shift would place an appointment start or end outside business hours, it is flagged instead of shifted
|
||||
- [ ] Flagged appointments are listed in `cascade.flaggedForReview[]` with reason text
|
||||
|
||||
### 2.4 Email Notification
|
||||
- [ ] Each shifted appointment triggers a reschedule email to the client
|
||||
- [ ] Email includes original time (struck through) and new time
|
||||
- [ ] Email is skipped silently if SMTP is not configured
|
||||
|
||||
### 2.5 Status Transition Overrun
|
||||
- [ ] When an `in_progress` appointment's actual end time exceeds `endTime + bufferMinutes`, the cascade is triggered using the status transition path
|
||||
|
||||
---
|
||||
|
||||
## 3. Authentication & RBAC
|
||||
|
||||
### 3.1 Staff Authentication
|
||||
- [ ] Unauthenticated request → 401
|
||||
- [ ] Groomer role can only view/edit their own appointments → 403 for others
|
||||
- [ ] Manager role can view/edit all appointments
|
||||
|
||||
### 3.2 Client Authentication
|
||||
- [ ] Clients can access their own appointments via tokenized links
|
||||
- [ ] Tokenized confirm/cancel links work without authentication
|
||||
@@ -1,373 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { cascadeDelay } from "../cascade.js";
|
||||
|
||||
// ─── Mock the DB ───────────────────────────────────────────────────────────────
|
||||
|
||||
const mockDb = {
|
||||
select: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@groombook/db", () => ({
|
||||
getDb: () => mockDb,
|
||||
appointments: {
|
||||
id: Symbol("id"),
|
||||
staffId: Symbol("staffId"),
|
||||
startTime: Symbol("startTime"),
|
||||
endTime: Symbol("endTime"),
|
||||
status: Symbol("status"),
|
||||
},
|
||||
clients: { id: Symbol("id"), name: Symbol("name"), email: Symbol("email") },
|
||||
pets: { id: Symbol("id"), name: Symbol("name") },
|
||||
services: { id: Symbol("id"), name: Symbol("name") },
|
||||
staff: { id: Symbol("id"), name: Symbol("name") },
|
||||
eq: (a: symbol, b: unknown) => ({ type: "eq", a, b }),
|
||||
and: (...args: unknown[]) => ({ type: "and", args }),
|
||||
gt: (a: symbol, b: unknown) => ({ type: "gt", a, b }),
|
||||
inArray: (a: symbol, vals: unknown[]) => ({ type: "inArray", a, vals }),
|
||||
asc: (a: symbol) => ({ type: "asc", a }),
|
||||
}));
|
||||
|
||||
vi.mock("../services/email.js", () => ({
|
||||
sendEmail: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const { sendEmail } = await import("../services/email.js");
|
||||
const { getDb } = await import("@groombook/db");
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAppt(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: "appt-1",
|
||||
staffId: "groomer-1",
|
||||
startTime: new Date("2026-05-16T10:00:00Z"),
|
||||
endTime: new Date("2026-05-16T11:00:00Z"),
|
||||
status: "scheduled",
|
||||
clientId: "client-1",
|
||||
petId: "pet-1",
|
||||
serviceId: "svc-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEnrichedAppt(id: string, start: Date, end: Date) {
|
||||
return {
|
||||
id,
|
||||
originalStartTime: start,
|
||||
originalEndTime: end,
|
||||
newStartTime: start,
|
||||
newEndTime: end,
|
||||
clientId: "client-1",
|
||||
clientName: "Alice Smith",
|
||||
clientEmail: "alice@example.com",
|
||||
petName: "Buddy",
|
||||
serviceName: "Full Groom",
|
||||
groomerName: "Jamie",
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("cascadeDelay", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns early when the triggering appointment is not found", async () => {
|
||||
mockDb.select.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await cascadeDelay(
|
||||
"nonexistent",
|
||||
new Date("2026-05-16T12:00:00Z"),
|
||||
new Date("2026-05-16T11:00:00Z")
|
||||
);
|
||||
|
||||
expect(result.shifted).toHaveLength(0);
|
||||
expect(result.flaggedForReview).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns early when the appointment has no groomer assigned", async () => {
|
||||
mockDb.select.mockResolvedValueOnce([{ ...makeAppt(), staffId: null }]);
|
||||
|
||||
const result = await cascadeDelay(
|
||||
"appt-trigger",
|
||||
new Date("2026-05-16T12:00:00Z"),
|
||||
new Date("2026-05-16T11:00:00Z")
|
||||
);
|
||||
|
||||
expect(result.shifted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns early when newEndTime does not extend beyond originalEndTime", async () => {
|
||||
mockDb.select.mockResolvedValueOnce([makeAppt()]);
|
||||
|
||||
const result = await cascadeDelay(
|
||||
"appt-trigger",
|
||||
new Date("2026-05-16T11:30:00Z"), // earlier than original 11:00
|
||||
new Date("2026-05-16T11:00:00Z")
|
||||
);
|
||||
|
||||
expect(result.shifted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns early when there are no downstream appointments", async () => {
|
||||
mockDb.select
|
||||
.mockResolvedValueOnce([makeAppt()]) // triggering appt
|
||||
.mockResolvedValueOnce([]); // no downstream
|
||||
|
||||
const result = await cascadeDelay(
|
||||
"appt-trigger",
|
||||
new Date("2026-05-16T11:30:00Z"),
|
||||
new Date("2026-05-16T11:00:00Z")
|
||||
);
|
||||
|
||||
expect(result.shifted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shifts a single downstream appointment by the correct delta", async () => {
|
||||
const triggerEnd = new Date("2026-05-16T11:30:00Z"); // 30 min overrun
|
||||
const originalEnd = new Date("2026-05-16T11:00:00Z");
|
||||
const downstreamStart = new Date("2026-05-16T11:00:00Z");
|
||||
const downstreamEnd = new Date("2026-05-16T12:00:00Z");
|
||||
|
||||
mockDb.select
|
||||
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
|
||||
.mockResolvedValueOnce([
|
||||
makeAppt({
|
||||
id: "downstream-1",
|
||||
startTime: downstreamStart,
|
||||
endTime: downstreamEnd,
|
||||
status: "scheduled",
|
||||
}),
|
||||
]);
|
||||
|
||||
const updateMock = mockDb.update.mockReturnValueThis();
|
||||
mockDb.select.mockResolvedValueOnce([
|
||||
{
|
||||
clientId: "client-1",
|
||||
clientName: "Alice",
|
||||
clientEmail: "alice@example.com",
|
||||
petName: "Buddy",
|
||||
serviceName: "Full Groom",
|
||||
groomerName: "Jamie",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cascadeDelay(
|
||||
"appt-trigger",
|
||||
triggerEnd,
|
||||
originalEnd,
|
||||
15 // 15 min buffer
|
||||
);
|
||||
|
||||
// effectiveBoundary = 11:30 + 15min = 11:45
|
||||
// delta = 11:45 - 11:00 = 45 min = 2_700_000 ms
|
||||
const expectedDeltaMs = 45 * 60 * 1000;
|
||||
|
||||
expect(result.shifted).toHaveLength(1);
|
||||
expect(result.shifted[0].id).toBe("downstream-1");
|
||||
expect(result.shifted[0].newStartTime.getTime() - result.shifted[0].originalStartTime.getTime())
|
||||
.toBe(expectedDeltaMs);
|
||||
expect(sendEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cascades shifts through a chain of appointments", async () => {
|
||||
const triggerEnd = new Date("2026-05-16T12:00:00Z"); // 60 min overrun
|
||||
const originalEnd = new Date("2026-05-16T11:00:00Z");
|
||||
|
||||
// Three downstream appointments, each 1 hour
|
||||
const appt1Start = new Date("2026-05-16T11:00:00Z");
|
||||
const appt1End = new Date("2026-05-16T12:00:00Z");
|
||||
const appt2Start = new Date("2026-05-16T12:00:00Z");
|
||||
const appt2End = new Date("2026-05-16T13:00:00Z");
|
||||
const appt3Start = new Date("2026-05-16T13:00:00Z");
|
||||
const appt3End = new Date("2026-05-16T14:00:00Z");
|
||||
|
||||
mockDb.select
|
||||
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
|
||||
.mockResolvedValueOnce([
|
||||
makeAppt({ id: "appt-2", startTime: appt2Start, endTime: appt2End, status: "confirmed" }),
|
||||
makeAppt({ id: "appt-3", startTime: appt3Start, endTime: appt3End, status: "scheduled" }),
|
||||
]);
|
||||
|
||||
mockDb.update.mockReturnValueThis();
|
||||
|
||||
// Two enrich queries for the two shifted appointments
|
||||
mockDb.select
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
|
||||
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
clientId: "c2", clientName: "Bob", clientEmail: "bob@test.com",
|
||||
petName: "Max", serviceName: "Bath", groomerName: "Jamie",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cascadeDelay(
|
||||
"appt-trigger",
|
||||
triggerEnd,
|
||||
originalEnd,
|
||||
15
|
||||
);
|
||||
|
||||
// effectiveBoundary starts at 12:00 + 15 = 12:15
|
||||
// appt-2: 12:00 start conflicts with 12:15 boundary → shift by 15 min → starts 12:15, ends 13:15
|
||||
// new boundary: 13:15 + 15 = 13:30
|
||||
// appt-3: 13:00 start conflicts with 13:30 boundary → shift by 30 min → starts 13:30, ends 14:30
|
||||
expect(result.shifted).toHaveLength(2);
|
||||
expect(result.shifted[0].id).toBe("appt-2");
|
||||
expect(result.shifted[1].id).toBe("appt-3");
|
||||
expect(mockDb.update).toHaveBeenCalledTimes(2);
|
||||
expect(sendEmail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("flags but still updates boundary when shift would fall outside business hours", async () => {
|
||||
const triggerEnd = new Date("2026-05-16T17:00:00Z");
|
||||
const originalEnd = new Date("2026-05-16T16:00:00Z");
|
||||
|
||||
// Downstream appt starts at 16:00, business ends at 18:00
|
||||
mockDb.select
|
||||
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
|
||||
.mockResolvedValueOnce([
|
||||
makeAppt({
|
||||
id: "appt-late",
|
||||
startTime: new Date("2026-05-16T16:00:00Z"),
|
||||
endTime: new Date("2026-05-16T17:00:00Z"),
|
||||
status: "scheduled",
|
||||
}),
|
||||
]);
|
||||
|
||||
mockDb.update.mockReturnValueThis();
|
||||
mockDb.select.mockResolvedValueOnce([
|
||||
{
|
||||
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
|
||||
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
|
||||
},
|
||||
]);
|
||||
|
||||
// Business hours 08:00–18:00; proposed shift pushes to 17:15 start (still in hours)
|
||||
// Try a late-night boundary: shift would push to 19:15 (outside 08:00–18:00)
|
||||
const result = await cascadeDelay(
|
||||
"appt-trigger",
|
||||
new Date("2026-05-16T18:00:00Z"), // larger overrun
|
||||
originalEnd,
|
||||
15,
|
||||
8, // business start
|
||||
18 // business end — proposed 18:15 start is outside
|
||||
);
|
||||
|
||||
// The appointment at 16:00 with buffer of 15 min after 18:00 trigger:
|
||||
// effectiveBoundary = 18:00 + 15 = 18:15 → outside business hours (18:15 > 18:00)
|
||||
expect(result.flaggedForReview).toHaveLength(1);
|
||||
expect(result.flaggedForReview[0].id).toBe("appt-late");
|
||||
expect(result.flaggedForReview[0].reason).toContain("Manual review required");
|
||||
// The appointment was NOT shifted (only flagged)
|
||||
expect(mockDb.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips non-active appointments", async () => {
|
||||
mockDb.select
|
||||
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
|
||||
.mockResolvedValueOnce([
|
||||
makeAppt({ id: "in-progress-1", status: "in_progress" }),
|
||||
makeAppt({ id: "cancelled-1", status: "cancelled" }),
|
||||
makeAppt({ id: "scheduled-1", status: "scheduled" }),
|
||||
]);
|
||||
|
||||
mockDb.update.mockReturnValueThis();
|
||||
mockDb.select.mockResolvedValueOnce([
|
||||
{
|
||||
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
|
||||
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cascadeDelay(
|
||||
"appt-trigger",
|
||||
new Date("2026-05-16T11:30:00Z"),
|
||||
new Date("2026-05-16T11:00:00Z"),
|
||||
15
|
||||
);
|
||||
|
||||
// Only the scheduled appointment should be shifted
|
||||
expect(result.shifted).toHaveLength(1);
|
||||
expect(result.shifted[0].id).toBe("scheduled-1");
|
||||
});
|
||||
|
||||
it("stops cascading when an appointment no longer conflicts", async () => {
|
||||
// Three downstream: appt-2 overlaps, appt-3 does NOT overlap, appt-4 overlaps
|
||||
// Cascade should stop at appt-3
|
||||
mockDb.select
|
||||
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
|
||||
.mockResolvedValueOnce([
|
||||
// appt-2: starts at 11:00, ends 12:00 — overlaps boundary 11:45
|
||||
makeAppt({ id: "appt-2", startTime: new Date("2026-05-16T11:00:00Z"), endTime: new Date("2026-05-16T12:00:00Z") }),
|
||||
// appt-3: starts at 13:00 — already clear of shifted appt-2 (ends 12:15 + buffer)
|
||||
makeAppt({ id: "appt-3", startTime: new Date("2026-05-16T13:00:00Z"), endTime: new Date("2026-05-16T14:00:00Z") }),
|
||||
makeAppt({ id: "appt-4", startTime: new Date("2026-05-16T14:00:00Z"), endTime: new Date("2026-05-16T15:00:00Z") }),
|
||||
]);
|
||||
|
||||
mockDb.update.mockReturnValueThis();
|
||||
mockDb.select.mockResolvedValueOnce([
|
||||
{
|
||||
clientId: "c1", clientName: "Alice", clientEmail: "alice@test.com",
|
||||
petName: "Buddy", serviceName: "Full Groom", groomerName: "Jamie",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cascadeDelay(
|
||||
"appt-trigger",
|
||||
new Date("2026-05-16T11:30:00Z"),
|
||||
new Date("2026-05-16T11:00:00Z"),
|
||||
15
|
||||
);
|
||||
|
||||
// Only appt-2 was shifted (appt-3 no longer conflicts after the stop condition check)
|
||||
expect(result.shifted).toHaveLength(1);
|
||||
expect(result.shifted[0].id).toBe("appt-2");
|
||||
});
|
||||
|
||||
it("sends email notification for each shifted appointment", async () => {
|
||||
mockDb.select
|
||||
.mockResolvedValueOnce([makeAppt({ staffId: "groomer-1" })])
|
||||
.mockResolvedValueOnce([
|
||||
makeAppt({
|
||||
id: "appt-email-test",
|
||||
startTime: new Date("2026-05-16T11:00:00Z"),
|
||||
endTime: new Date("2026-05-16T12:00:00Z"),
|
||||
status: "confirmed",
|
||||
}),
|
||||
]);
|
||||
|
||||
mockDb.update.mockReturnValueThis();
|
||||
mockDb.select.mockResolvedValueOnce([
|
||||
{
|
||||
clientId: "c1",
|
||||
clientName: "Carol",
|
||||
clientEmail: "carol@example.com",
|
||||
petName: "Luna",
|
||||
serviceName: "Nail Trim",
|
||||
groomerName: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await cascadeDelay(
|
||||
"appt-trigger",
|
||||
new Date("2026-05-16T11:30:00Z"),
|
||||
new Date("2026-05-16T11:00:00Z"),
|
||||
15
|
||||
);
|
||||
|
||||
expect(sendEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "carol@example.com",
|
||||
subject: expect.stringContaining("Rescheduled"),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,341 +0,0 @@
|
||||
/**
|
||||
* Cascade delay prevention — `apps/api/src/lib/cascade.ts`
|
||||
*
|
||||
* Triggered after a PATCH /appointments/:id call extends an appointment's
|
||||
* endTime beyond its original value. Queries same-groomer downstream
|
||||
* appointments, shifts them forward by (overrunEnd + buffer − downstreamStart),
|
||||
* and cascades the shift through the chain. Clients are notified by email.
|
||||
*
|
||||
* Guard rails:
|
||||
* - Only shifts `scheduled` and `confirmed` appointments.
|
||||
* - Flags out-of-business-hours shifts for manual review instead of auto-shifting.
|
||||
* - Returns the full list of shifted appointments.
|
||||
*/
|
||||
|
||||
import { eq, and, gt, lte, asc, ne, inArray } from "drizzle-orm";
|
||||
import { getDb, appointments, clients, pets, services, staff } from "@groombook/db";
|
||||
import { sendEmail } from "../services/email.js";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CascadeResult {
|
||||
shifted: ShiftedAppointment[];
|
||||
flaggedForReview: FlaggedAppointment[];
|
||||
/** Time in ms each downstream appointment was pushed forward */
|
||||
cascadeLog: CascadeLogEntry[];
|
||||
}
|
||||
|
||||
export interface ShiftedAppointment {
|
||||
id: string;
|
||||
originalStartTime: Date;
|
||||
originalEndTime: Date;
|
||||
newStartTime: Date;
|
||||
newEndTime: Date;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
clientEmail: string;
|
||||
petName: string;
|
||||
serviceName: string;
|
||||
groomerName: string | null;
|
||||
}
|
||||
|
||||
export interface FlaggedAppointment {
|
||||
id: string;
|
||||
originalStartTime: Date;
|
||||
proposedStartTime: Date;
|
||||
proposedEndTime: Date;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CascadeLogEntry {
|
||||
appointmentId: string;
|
||||
deltaMs: number;
|
||||
triggeredBy: string;
|
||||
}
|
||||
|
||||
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Default inter-appointment buffer in minutes. Overridden by services.bufferMinutes. */
|
||||
export const DEFAULT_BUFFER_MINUTES = 15;
|
||||
|
||||
/** Default business hours (used when no settings row exists). */
|
||||
export const DEFAULT_BUSINESS_START_HOUR = 8; // 08:00
|
||||
export const DEFAULT_BUSINESS_END_HOUR = 18; // 18:00
|
||||
|
||||
// ─── Core cascade ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect and cascade appointment overruns.
|
||||
*
|
||||
* @param triggeringAppointmentId The appointment that just overran.
|
||||
* @param newEndTime The updated endTime set by the caller.
|
||||
* @param originalEndTime The appointment's endTime before the update.
|
||||
* @param bufferMinutes Minutes of buffer between appointments (default 15).
|
||||
* @param businessStartHour Business opening hour (0–23, default 8).
|
||||
* @param businessEndHour Business closing hour (0–23, default 18).
|
||||
*/
|
||||
export async function cascadeDelay(
|
||||
triggeringAppointmentId: string,
|
||||
newEndTime: Date,
|
||||
originalEndTime: Date,
|
||||
bufferMinutes: number = DEFAULT_BUFFER_MINUTES,
|
||||
businessStartHour: number = DEFAULT_BUSINESS_START_HOUR,
|
||||
businessEndHour: number = DEFAULT_BUSINESS_END_HOUR
|
||||
): Promise<CascadeResult> {
|
||||
const db = getDb();
|
||||
|
||||
const bufferMs = bufferMinutes * 60_000;
|
||||
const overrunEnd = newEndTime;
|
||||
|
||||
// ── 1. Load the triggering appointment ────────────────────────────────────────
|
||||
const [triggering] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, triggeringAppointmentId))
|
||||
.limit(1);
|
||||
|
||||
if (!triggering) {
|
||||
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
|
||||
}
|
||||
|
||||
if (!triggering.staffId) {
|
||||
// Unassigned appointments cannot cascade
|
||||
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
|
||||
}
|
||||
|
||||
const groomerId = triggering.staffId;
|
||||
|
||||
// ── 2. Guard: only trigger when endTime actually extended ──────────────────────
|
||||
if (overrunEnd <= originalEndTime) {
|
||||
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
|
||||
}
|
||||
|
||||
const result: CascadeResult = { shifted: [], flaggedForReview: [], cascadeLog: [] };
|
||||
|
||||
// ── 3. Fetch all downstream same-groomer active appointments ──────────────────
|
||||
const downstream = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, groomerId),
|
||||
gt(appointments.startTime, originalEndTime),
|
||||
inArray(appointments.status, ["scheduled", "confirmed"]),
|
||||
)
|
||||
)
|
||||
.orderBy(asc(appointments.startTime));
|
||||
|
||||
if (downstream.length === 0) return result;
|
||||
|
||||
// ── 4. Cascade loop ────────────────────────────────────────────────────────────
|
||||
// Keep track of current effective boundary after each shift.
|
||||
// Start from the new endTime of the triggering appointment plus buffer.
|
||||
let effectiveBoundary = new Date(overrunEnd.getTime() + bufferMs);
|
||||
|
||||
for (const appt of downstream) {
|
||||
const conflictStart = appt.startTime;
|
||||
const conflictEnd = appt.endTime;
|
||||
const apptDurationMs = conflictEnd.getTime() - conflictStart.getTime();
|
||||
|
||||
// Does this appointment overlap the effective boundary?
|
||||
if (effectiveBoundary.getTime() >= conflictEnd.getTime()) {
|
||||
// No conflict — this appointment and all later ones are unaffected
|
||||
break;
|
||||
}
|
||||
|
||||
const proposedStart = new Date(effectiveBoundary);
|
||||
const proposedEnd = new Date(proposedStart.getTime() + apptDurationMs);
|
||||
|
||||
// ── Business-hours guard ────────────────────────────────────────────────────
|
||||
const proposedStartHour = proposedStart.getHours() + proposedStart.getMinutes() / 60;
|
||||
const proposedEndHour = proposedEnd.getHours() + proposedEnd.getMinutes() / 60;
|
||||
const outOfHours =
|
||||
proposedStartHour < businessStartHour ||
|
||||
proposedEndHour > businessEndHour;
|
||||
|
||||
if (outOfHours) {
|
||||
result.flaggedForReview.push({
|
||||
id: appt.id,
|
||||
originalStartTime: appt.startTime,
|
||||
proposedStartTime: proposedStart,
|
||||
proposedEndTime: proposedEnd,
|
||||
reason:
|
||||
`Would push appointment outside business hours ` +
|
||||
`(${businessStartHour}:00–${businessEndHour}:00). ` +
|
||||
`Manual review required.`,
|
||||
});
|
||||
// Update boundary anyway — later appointments may still conflict
|
||||
effectiveBoundary = new Date(proposedEnd.getTime() + bufferMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Perform the shift ──────────────────────────────────────────────────────
|
||||
const deltaMs = proposedStart.getTime() - appt.startTime.getTime();
|
||||
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ startTime: proposedStart, endTime: proposedEnd, updatedAt: new Date() })
|
||||
.where(eq(appointments.id, appt.id));
|
||||
|
||||
result.cascadeLog.push({
|
||||
appointmentId: appt.id,
|
||||
deltaMs,
|
||||
triggeredBy: triggeringAppointmentId,
|
||||
});
|
||||
|
||||
// ── Load client/pet/service info for notification ──────────────────────────
|
||||
const enriched = await enrichAppointment(appt.id);
|
||||
if (enriched) {
|
||||
result.shifted.push({
|
||||
id: appt.id,
|
||||
originalStartTime: appt.startTime,
|
||||
originalEndTime: appt.endTime,
|
||||
newStartTime: proposedStart,
|
||||
newEndTime: proposedEnd,
|
||||
...enriched,
|
||||
});
|
||||
}
|
||||
|
||||
// Advance boundary to the end of this shifted appointment plus buffer
|
||||
effectiveBoundary = new Date(proposedEnd.getTime() + bufferMs);
|
||||
}
|
||||
|
||||
// ── 5. Send notifications ────────────────────────────────────────────────────
|
||||
for (const shifted of result.shifted) {
|
||||
await sendRescheduleNotification(shifted).catch((err) =>
|
||||
console.error(`[cascade] Failed to send notification for ${shifted.id}:`, err)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut for status-transition overruns (current time > endTime + bufferMinutes).
|
||||
* Delegates to `cascadeDelay` using the current appointment data.
|
||||
*/
|
||||
export async function cascadeOnStatusOverrun(
|
||||
appointmentId: string,
|
||||
bufferMinutes: number = DEFAULT_BUFFER_MINUTES,
|
||||
businessStartHour: number = DEFAULT_BUSINESS_START_HOUR,
|
||||
businessEndHour: number = DEFAULT_BUSINESS_END_HOUR
|
||||
): Promise<CascadeResult> {
|
||||
const db = getDb();
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, appointmentId))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) return { shifted: [], flaggedForReview: [], cascadeLog: [] };
|
||||
|
||||
const now = new Date();
|
||||
const bufferMs = bufferMinutes * 60_000;
|
||||
|
||||
if (now.getTime() <= appt.endTime.getTime() + bufferMs) {
|
||||
// Not actually in overrun
|
||||
return { shifted: [], flaggedForReview: [], cascadeLog: [] };
|
||||
}
|
||||
|
||||
// Use current time as the new endTime (the appointment is already running over)
|
||||
return cascadeDelay(
|
||||
appointmentId,
|
||||
now,
|
||||
appt.endTime,
|
||||
bufferMinutes,
|
||||
businessStartHour,
|
||||
businessEndHour
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface EnrichedFields {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
clientEmail: string;
|
||||
petName: string;
|
||||
serviceName: string;
|
||||
groomerName: string | null;
|
||||
}
|
||||
|
||||
async function enrichAppointment(
|
||||
apptId: string
|
||||
): Promise<EnrichedFields | null> {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select({
|
||||
clientId: appointments.clientId,
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
groomerName: staff.name,
|
||||
})
|
||||
.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, apptId))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return null;
|
||||
return {
|
||||
clientId: row.clientId,
|
||||
clientName: row.clientName,
|
||||
clientEmail: row.clientEmail,
|
||||
petName: row.petName,
|
||||
serviceName: row.serviceName,
|
||||
groomerName: row.groomerName,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendRescheduleNotification(
|
||||
shifted: ShiftedAppointment
|
||||
): Promise<void> {
|
||||
const time = formatDateTime(shifted.newStartTime);
|
||||
const original = formatDateTime(shifted.originalStartTime);
|
||||
const groomer = shifted.groomerName ? ` with ${shifted.groomerName}` : "";
|
||||
|
||||
await sendEmail({
|
||||
to: shifted.clientEmail,
|
||||
subject: `Appointment Rescheduled — ${shifted.petName}`,
|
||||
text: [
|
||||
`Hi ${shifted.clientName},`,
|
||||
``,
|
||||
`Your appointment for ${shifted.petName} has been rescheduled.`,
|
||||
``,
|
||||
` Was: ${original}${groomer}`,
|
||||
` Now: ${time}${groomer}`,
|
||||
``,
|
||||
`We apologize for any inconvenience. If this new time doesn't work for you, please contact us as soon as possible.`,
|
||||
``,
|
||||
`— Groom Book`,
|
||||
].join("\n"),
|
||||
html: `<p>Hi ${shifted.clientName},</p>
|
||||
<p>Your appointment for <strong>${shifted.petName}</strong> has been rescheduled.</p>
|
||||
<table style="border-collapse:collapse;margin:1em 0">
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Previous time</td><td style="text-decoration:line-through;color:#9ca3af">${original}${groomer}</td></tr>
|
||||
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">New time</td><td>${time}${groomer}</td></tr>
|
||||
</table>
|
||||
<p>If this new time doesn't work for you, please contact us as soon as possible.</p>
|
||||
<p>— Groom Book</p>`,
|
||||
});
|
||||
|
||||
console.info(
|
||||
`[cascade] Notified ${shifted.clientEmail} of reschedule for ${shifted.petName} ` +
|
||||
`(${shifted.id}): ${original} → ${time}`
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(d: Date): string {
|
||||
return d.toLocaleString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||
import { cascadeDelay } from "../lib/cascade.js";
|
||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
@@ -581,15 +580,6 @@ appointmentsRouter.patch(
|
||||
if (updateFields.endTime) update.endTime = new Date(updateFields.endTime);
|
||||
|
||||
if (needsConflictCheck) {
|
||||
// Capture original endTime before the transaction so we can detect an
|
||||
// overrun and trigger cascade delay prevention after the update.
|
||||
const [preUpdate] = await db
|
||||
.select({ originalEndTime: appointments.endTime })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
const originalEndTime = preUpdate?.originalEndTime ?? null;
|
||||
|
||||
// Wrap conflict check + update in a transaction to prevent race conditions
|
||||
// (fixes #18). Also falls back to the existing staffId when staffId is
|
||||
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
|
||||
@@ -694,23 +684,7 @@ appointmentsRouter.patch(
|
||||
}
|
||||
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
// Cascade delay prevention: detect if endTime was extended and cascade
|
||||
// downstream appointments if so. Runs after the main update commits.
|
||||
const cascadeResult =
|
||||
updateFields.endTime &&
|
||||
originalEndTime &&
|
||||
new Date(updateFields.endTime) > originalEndTime
|
||||
? await cascadeDelay(id, new Date(updateFields.endTime), originalEndTime)
|
||||
: { shifted: [], flaggedForReview: [], cascadeLog: [] };
|
||||
|
||||
return c.json({
|
||||
...row,
|
||||
cascade:
|
||||
cascadeResult.shifted.length > 0 || cascadeResult.flaggedForReview.length > 0
|
||||
? cascadeResult
|
||||
: undefined,
|
||||
});
|
||||
return c.json(row);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
|
||||
Reference in New Issue
Block a user