Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93716afd1a | |||
| 503c3dbdbb | |||
| b3517bf746 | |||
| 604e79bab4 | |||
| 29015cffec | |||
| c67f731f69 | |||
| db3bcf8094 | |||
| 7836511baa | |||
| b69650af15 | |||
| b0d1a4def4 | |||
| 53ab415713 | |||
| a330e342e1 | |||
| 0f841e27fc | |||
| a7bcce8b80 | |||
| 5f1582a3b6 | |||
| c76ea93c29 | |||
| cd25d98384 | |||
| e9fceb78b3 | |||
| 0cae8adef8 | |||
| 674626ba1e | |||
| aa5686bed1 | |||
| 903fbf55d5 | |||
| 775e2e544b | |||
| fb9c922182 | |||
| 1cc48f0b88 | |||
| 1b8d7087c0 | |||
| d65d121a5d | |||
| b8fd7ec18f | |||
| 7bf9cf9734 | |||
| bf159f8b1f | |||
| 2f3d4d8d01 | |||
| db9bb31702 | |||
| b38db65dde | |||
| 3178f81b99 | |||
| 544d65959d | |||
| fe2e093b92 | |||
| 2af1671891 | |||
| ad80722eee | |||
| c811b58c62 | |||
| 1dfcdcc2cb | |||
| f74e034495 | |||
| 4c46cec4e3 | |||
| f38bb244a4 | |||
| 251b36b863 | |||
| 3c366ccc46 | |||
| ff149f75dc | |||
| 03bd2d0235 | |||
| 10ad5e7b04 | |||
| 4f85a4a432 | |||
| 560d33edf8 | |||
| 50e9e70935 | |||
| d59cb1ab1d | |||
| 740e46baf2 | |||
| b1b89966d9 | |||
| 25fd3308e0 | |||
| be07c8b758 | |||
| ff2851eda2 | |||
| abee344ca4 | |||
| 460ba78112 | |||
| ffe8aef035 | |||
| 2153505875 | |||
| 4aaf2a3b3f | |||
| 20ca93b36d | |||
| 9793283021 | |||
| 1cc6d53546 | |||
| bfe099deda | |||
| 47ccd1395c | |||
| ef79ac748c | |||
| 06846952a1 | |||
| d72485c08a | |||
| 4001691ae7 | |||
| b980e4177c | |||
| 6141dcb77d | |||
| 8ecbfbeee4 | |||
| 1da61fb466 | |||
| 77971a1ac9 | |||
| e539b6c904 | |||
| b797ac3ab1 | |||
| 6bddd6203d | |||
| 3c7820d785 | |||
| 9eb86004fc | |||
| 6046594a15 | |||
| b683c57d6c | |||
| 89505a2363 | |||
| 8e1e51be59 | |||
| ea7bf4f49b | |||
| 6e1e51fba7 | |||
| 5a8ea2fd14 | |||
| b00d6a8ca0 | |||
| f8ea417799 | |||
| 772f4df62f | |||
| edf2ef8f7e | |||
| 8182870d38 | |||
| 7f715ecdfc | |||
| 5df8837b5f | |||
| 0abb79010d | |||
| eab97b2ebd | |||
| f301b1a5a0 | |||
| c786544369 | |||
| 85c76b5209 | |||
| 2577e33c50 | |||
| d8dbec1be1 | |||
| 4a65c30d40 | |||
| cab17e0230 | |||
| b904418628 | |||
| 5ff54ce8f9 | |||
| a2cfdfef74 | |||
| ab9384d38e | |||
| 6ba6da08b2 | |||
| 29a726fa3d | |||
| cdf4d6c4b1 | |||
| ffb3cd139a | |||
| 85cff19c59 | |||
| 376180ab9d | |||
| da16ac8ac2 | |||
| 16dd513521 | |||
| 66a6659ccd | |||
| 71c229f83b | |||
| 1ef740c361 | |||
| d433c902b4 | |||
| dc3b3ddcb7 | |||
| 31997e33c0 | |||
| e118607fd6 | |||
| e1e13d5091 | |||
| 80b66fe20c | |||
| 67e2157975 | |||
| 4fa4859eaf | |||
| ca88385b8d | |||
| 3f2769a43a | |||
| 0ed87f9ed8 | |||
| 233e68769a | |||
| f7b8b7e668 | |||
| 1cce354413 | |||
| 648755eee5 | |||
| 77a6319459 | |||
| df07f2d6dc | |||
| dadabb0ea7 | |||
| d5a8b19322 | |||
| f4f522d5e6 | |||
| e8455195ee | |||
| ab4b9fe6fc |
@@ -7,3 +7,5 @@ apps/web/dist
|
||||
apps/api/dist
|
||||
packages/db/dist
|
||||
packages/types/dist
|
||||
.turbo
|
||||
screenshots/
|
||||
|
||||
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -20,6 +20,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -42,6 +44,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -62,6 +66,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -80,6 +86,8 @@ jobs:
|
||||
|
||||
- name: Run E2E tests
|
||||
run: pnpm --filter @groombook/e2e test
|
||||
env:
|
||||
PLAYWRIGHT_BASE_URL: http://host.docker.internal:8080
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
@@ -101,6 +109,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -119,18 +129,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
|
||||
@@ -142,12 +146,12 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
registry: git.farh.net
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push API image
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -157,10 +161,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
|
||||
@@ -170,10 +174,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
|
||||
@@ -183,10 +187,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
|
||||
@@ -196,10 +200,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
|
||||
@@ -208,19 +212,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: |
|
||||
@@ -237,8 +238,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-schema -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 -
|
||||
apiVersion: batch/v1
|
||||
@@ -253,7 +252,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:
|
||||
@@ -264,35 +263,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)
|
||||
@@ -303,6 +292,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '9.15.4'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -331,21 +322,13 @@ jobs:
|
||||
name: Update Infra Image Tags
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Generate infra repo token
|
||||
id: infra-token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ vars.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clone groombook/infra
|
||||
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: |
|
||||
@@ -362,30 +345,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
|
||||
|
||||
@@ -394,26 +372,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}"
|
||||
|
||||
# Create PR and merge immediately (no required checks on groombook/infra)
|
||||
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
|
||||
EXISTING_PR=$(curl -s \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.farh.net/api/v1/repos/groombook/infra/pulls?state=open&limit=50" \
|
||||
| jq -r ".[] | select(.head.label == \"chore/update-image-tags-${TAG}\") | .number" | head -1)
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
echo "PR #$EXISTING_PR already exists, merging"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.farh.net/api/v1/repos/groombook/infra/pulls/$EXISTING_PR/merge" \
|
||||
-d '{"Do":"merge"}'
|
||||
else
|
||||
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
|
||||
+13
@@ -8,3 +8,16 @@ dist/
|
||||
.turbo/
|
||||
coverage/
|
||||
minimax-output/
|
||||
|
||||
# Agent runtime artifacts — never commit
|
||||
.gh-token
|
||||
*.gh-token
|
||||
.config/gh/
|
||||
**/.config/gh/
|
||||
infra-repo
|
||||
infra-repo/
|
||||
**/instructions/.gh-token
|
||||
**/AGENT_HOME/**
|
||||
$AGENT_HOME/**
|
||||
.claude/
|
||||
.codex/
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Contributing to GroomBook
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
GroomBook uses a three-branch GitOps model:
|
||||
|
||||
| Branch | Environment | Purpose |
|
||||
|--------|-------------|---------|
|
||||
| `dev` | Development | Active development target — all feature/fix PRs target this branch |
|
||||
| `uat` | UAT / Staging | Promoted from `dev` by the CTO for acceptance testing |
|
||||
| `main` | Production | Promoted from `uat` by the CEO; triggers production deployment |
|
||||
|
||||
**Never open a PR directly to `uat` or `main`.** All work flows through `dev` first.
|
||||
|
||||
## Developer Workflow
|
||||
|
||||
1. **Branch from `dev`** — create a feature or fix branch:
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
git checkout -b feat/my-feature
|
||||
```
|
||||
|
||||
2. **Open a PR targeting `dev`** — include the issue identifier in the title and cc @cpfarhood:
|
||||
```bash
|
||||
gh pr create --base dev --title "feat: description (GRO-NNN)" \
|
||||
--body $'Closes GRO-NNN\n\ncc @cpfarhood'
|
||||
```
|
||||
|
||||
3. **Pipeline gates before merge to `dev`:**
|
||||
- QA (Lint Roller) reviews first — code quality, test coverage, CI pass
|
||||
- CTO (The Dogfather) reviews second — architecture and final approval
|
||||
- Both must approve; 2 approving reviews required by branch protection
|
||||
|
||||
## Promotion Flow
|
||||
|
||||
### Dev → UAT
|
||||
|
||||
After merging to `dev`, the CTO opens a PR from `dev` → `uat`:
|
||||
|
||||
```bash
|
||||
gh pr create --base uat --head dev \
|
||||
--title "chore: promote dev to uat (YYYY.MM.DD)" \
|
||||
--body $'Promoting dev to UAT for regression and security review.\n\ncc @cpfarhood'
|
||||
```
|
||||
|
||||
Gates:
|
||||
- Shedward Scissorhands runs regression/acceptance tests
|
||||
- Barkley Trimsworth performs security review
|
||||
- CTO approves and merges (1 approving review required)
|
||||
|
||||
### UAT → Main (Production)
|
||||
|
||||
After UAT passes, the CTO opens a PR from `uat` → `main` and assigns it to the CEO:
|
||||
|
||||
```bash
|
||||
gh pr create --base main --head uat \
|
||||
--title "chore: promote uat to main (YYYY.MM.DD)" \
|
||||
--body $'Promoting UAT to production.\n\ncc @cpfarhood'
|
||||
```
|
||||
|
||||
Gates:
|
||||
- CEO (Scrubs McBarkley) reviews for business alignment and merges
|
||||
- 1 approving review required; triggers auto-deploy to Production
|
||||
|
||||
## Branch Protection Summary
|
||||
|
||||
| Branch | Required Approvals | Who approves |
|
||||
|--------|--------------------|-------------|
|
||||
| `dev` | 2 | QA (Lint Roller) + CTO (The Dogfather) |
|
||||
| `uat` | 1 | CTO (The Dogfather) |
|
||||
| `main` | 1 | CEO (Scrubs McBarkley) |
|
||||
|
||||
Force-pushes and branch deletions are disabled on all three branches.
|
||||
|
||||
## Commit Style
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
- `feat:` — new feature
|
||||
- `fix:` — bug fix
|
||||
- `chore:` — maintenance (dependency updates, build config, promotions)
|
||||
- `docs:` — documentation only
|
||||
- `ci:` — CI/CD changes
|
||||
- `refactor:` — code restructure without behaviour change
|
||||
|
||||
Reference the Paperclip issue in the commit body: `Refs GRO-NNN`.
|
||||
|
||||
## Questions?
|
||||
|
||||
Open a Paperclip issue in the GRO project or ask in the team channel.
|
||||
+5
-1
@@ -12,6 +12,7 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build
|
||||
FROM deps AS builder
|
||||
RUN mkdir -p /home/node/.cache/node/corepack
|
||||
COPY packages/ packages/
|
||||
COPY apps/api/ apps/api/
|
||||
RUN pnpm --filter @groombook/types build && \
|
||||
@@ -34,6 +35,9 @@ COPY --from=builder /app/packages/types/dist packages/types/dist
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
EXPOSE 3000
|
||||
RUN apk add --no-cache curl
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/health || exit 1
|
||||
CMD ["node", "apps/api/dist/index.js"]
|
||||
|
||||
# Migrate stage — runs drizzle-kit migrate against the database
|
||||
@@ -46,4 +50,4 @@ CMD ["pnpm", "db:seed"]
|
||||
|
||||
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
||||
FROM builder AS reset
|
||||
CMD ["pnpm", "db:reset"]
|
||||
CMD ["pnpm", "db:reset"]
|
||||
@@ -23,6 +23,7 @@
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"stripe": "^22.0.0",
|
||||
"telnyx": "^1.23.0",
|
||||
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -27,12 +27,14 @@ const DISABLED_CLIENT = {
|
||||
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
|
||||
|
||||
let selectRows: Record<string, unknown>[] = [];
|
||||
let appointmentRows: Record<string, unknown>[] = [];
|
||||
let insertedValues: Record<string, unknown>[] = [];
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
let deletedId: string | null = null;
|
||||
|
||||
function resetMock() {
|
||||
selectRows = [];
|
||||
appointmentRows = [];
|
||||
insertedValues = [];
|
||||
updatedValues = [];
|
||||
deletedId = null;
|
||||
@@ -58,10 +60,19 @@ vi.mock("@groombook/db", () => {
|
||||
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
||||
);
|
||||
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => makeChainable(selectRows),
|
||||
from: (table: unknown) => {
|
||||
const tableName = (table as { _name?: string })._name;
|
||||
const rows = tableName === "appointments" ? appointmentRows : selectRows;
|
||||
return makeChainable(rows);
|
||||
},
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (vals: Record<string, unknown>) => {
|
||||
@@ -95,8 +106,10 @@ vi.mock("@groombook/db", () => {
|
||||
}),
|
||||
}),
|
||||
clients,
|
||||
appointments,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
or: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -182,10 +195,11 @@ describe("POST /clients", () => {
|
||||
expect(insertedValues[0]!.name).toBe("Charlie");
|
||||
});
|
||||
|
||||
it("creates a client with only required name field", async () => {
|
||||
const res = await jsonRequest("POST", "/clients", { name: "Dana" });
|
||||
it("creates a client with name and email", async () => {
|
||||
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(insertedValues[0]!.name).toBe("Dana");
|
||||
expect(insertedValues[0]!.email).toBe("dana@example.com");
|
||||
});
|
||||
|
||||
it("rejects empty name", async () => {
|
||||
|
||||
@@ -68,6 +68,7 @@ vi.mock("@groombook/db", () => {
|
||||
}),
|
||||
appointments,
|
||||
eq: () => ({}),
|
||||
and: (..._clauses: unknown[]) => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ vi.mock("@groombook/db", () => {
|
||||
}),
|
||||
staff,
|
||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||
and: vi.fn((..._clauses: unknown[]) => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -362,7 +363,7 @@ describe("requireRoleOrSuperUser", () => {
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/super user privileges required/i);
|
||||
expect(body.error).toMatch(/role.*not permitted/i);
|
||||
});
|
||||
|
||||
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
||||
@@ -370,7 +371,7 @@ describe("requireRoleOrSuperUser", () => {
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/super user privileges required/i);
|
||||
expect(body.error).toMatch(/role.*not permitted/i);
|
||||
});
|
||||
|
||||
it("allows a manager with multiple allowed roles", async () => {
|
||||
|
||||
+115
-14
@@ -19,7 +19,7 @@ import { impersonationRouter } from "./routes/impersonation.js";
|
||||
import { settingsRouter } from "./routes/settings.js";
|
||||
import { authProviderRouter } from "./routes/authProvider.js";
|
||||
import { searchRouter } from "./routes/search.js";
|
||||
import { getPresignedGetUrl } from "./lib/s3.js";
|
||||
import { getObject } from "./lib/s3.js";
|
||||
import { calendarRouter } from "./routes/calendar.js";
|
||||
import { setupRouter } from "./routes/setup.js";
|
||||
import { getDb, businessSettings, eq, staff } from "@groombook/db";
|
||||
@@ -33,11 +33,26 @@ import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||
const app = new Hono();
|
||||
|
||||
// Global middleware
|
||||
const TRUSTED_ORIGINS = (process.env.CORS_ORIGIN ?? "http://localhost:5173")
|
||||
.split(",")
|
||||
.map((o) => o.trim());
|
||||
|
||||
const ALLOWED_ORIGIN = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||
|
||||
app.use("*", logger());
|
||||
app.use(
|
||||
"/api/*",
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
|
||||
origin: (origin, ctx) => {
|
||||
if (!origin) {
|
||||
return ALLOWED_ORIGIN;
|
||||
}
|
||||
if (TRUSTED_ORIGINS.includes(origin)) {
|
||||
return origin;
|
||||
}
|
||||
ctx.status(403);
|
||||
return null;
|
||||
},
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
@@ -57,28 +72,99 @@ app.route("/api/webhooks/stripe", webhooksRouter);
|
||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||
app.route("/api/dev", devRouter);
|
||||
|
||||
// Magic bytes for allowed image types
|
||||
const ALLOWED_IMAGE_TYPES: Record<string, Uint8Array> = {
|
||||
"image/png": new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
"image/jpeg": new Uint8Array([0xff, 0xd8, 0xff]),
|
||||
"image/gif": new Uint8Array([0x47, 0x49, 0x46, 0x38]),
|
||||
"image/webp": new Uint8Array([0x52, 0x49, 0x46, 0x46]), // followed by size then WEBP
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that the given base64 content matches the declared MIME type
|
||||
* by checking magic bytes. Returns null if valid, or the field to clear if not.
|
||||
*/
|
||||
function validateLogoMagicBytes(
|
||||
logoBase64: string | null,
|
||||
logoMimeType: string | null
|
||||
): "logoBase64" | "logoMimeType" | null {
|
||||
if (!logoBase64 || !logoMimeType) return null;
|
||||
|
||||
const expectedMagic = ALLOWED_IMAGE_TYPES[logoMimeType];
|
||||
if (!expectedMagic) return "logoMimeType"; // unknown MIME type — reject
|
||||
|
||||
try {
|
||||
const binary = Buffer.from(logoBase64, "base64");
|
||||
// WebP needs a special check (RIFF....WEBP at offset 0, size at offset 4)
|
||||
if (logoMimeType === "image/webp") {
|
||||
if (binary.length < 12) return "logoBase64";
|
||||
const webpMagic = binary.slice(0, 4);
|
||||
const webpSig = binary.slice(8, 12);
|
||||
if (
|
||||
webpMagic[0] !== 0x52 ||
|
||||
webpMagic[1] !== 0x49 ||
|
||||
webpMagic[2] !== 0x46 ||
|
||||
webpMagic[3] !== 0x46 ||
|
||||
webpSig[0] !== 0x57 ||
|
||||
webpSig[1] !== 0x45 ||
|
||||
webpSig[2] !== 0x42 ||
|
||||
webpSig[3] !== 0x50
|
||||
) {
|
||||
return "logoBase64";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// All other types: check prefix
|
||||
if (binary.length < expectedMagic.length) return "logoBase64";
|
||||
for (let i = 0; i < expectedMagic.length; i++) {
|
||||
if (binary[i] !== expectedMagic[i]) return "logoBase64";
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return "logoBase64";
|
||||
}
|
||||
}
|
||||
|
||||
// Public logo proxy — no auth required, streams logo from S3 so browser never sees raw S3 URL
|
||||
app.get("/api/branding/logo", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db.select().from(businessSettings).limit(1);
|
||||
if (!row) return c.json({ error: "Settings not found" }, 404);
|
||||
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
|
||||
|
||||
const { body, contentType } = await getObject(row.logoKey);
|
||||
return new Response(Buffer.from(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Public branding endpoint — no auth required, returns business name/colors/logo
|
||||
app.get("/api/branding", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db.select().from(businessSettings).limit(1);
|
||||
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null };
|
||||
|
||||
let logoUrl: string | null = null;
|
||||
if (settings.logoKey) {
|
||||
try {
|
||||
logoUrl = await getPresignedGetUrl(settings.logoKey);
|
||||
} catch {
|
||||
// If S3 URL generation fails, fall back to legacy base64
|
||||
}
|
||||
}
|
||||
// Return the public proxy path so browser never sees a raw S3 URL
|
||||
const logoUrl = settings.logoKey ? "/api/branding/logo" : null;
|
||||
|
||||
// Defensive: validate magic bytes to prevent MIME type confusion attacks
|
||||
// via the legacy base64 logo fields
|
||||
const badField = validateLogoMagicBytes(settings.logoBase64 ?? null, settings.logoMimeType ?? null);
|
||||
const safeLogoBase64 = badField === "logoBase64" ? null : settings.logoBase64;
|
||||
const safeLogoMimeType = badField === "logoMimeType" ? null : settings.logoMimeType;
|
||||
|
||||
return c.json({
|
||||
businessName: settings.businessName,
|
||||
primaryColor: settings.primaryColor,
|
||||
accentColor: settings.accentColor,
|
||||
logoUrl,
|
||||
logoBase64: settings.logoBase64,
|
||||
logoMimeType: settings.logoMimeType,
|
||||
logoBase64: safeLogoBase64,
|
||||
logoMimeType: safeLogoMimeType,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,7 +213,7 @@ api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"
|
||||
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
||||
api.use("/admin/settings/*", requireSuperUser());
|
||||
api.use("/reports/*", requireRole("manager"));
|
||||
api.use("/invoices/*", requireRole("manager"));
|
||||
api.use("/invoices/*", requireRole("manager", "groomer"));
|
||||
api.use("/impersonation/*", requireRole("manager"));
|
||||
|
||||
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
|
||||
@@ -187,9 +273,24 @@ api.route("/search", searchRouter);
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
await initAuth();
|
||||
console.log(`API server listening on port ${port}`);
|
||||
serve({ fetch: app.fetch, port });
|
||||
const server = serve({ fetch: app.fetch, port });
|
||||
|
||||
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
||||
startReminderScheduler();
|
||||
|
||||
function shutdown() {
|
||||
console.log("Shutting down gracefully...");
|
||||
server.close(() => {
|
||||
console.log("HTTP server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => {
|
||||
console.error("Forced shutdown after timeout");
|
||||
process.exit(1);
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -89,13 +89,16 @@ export async function initAuth(): Promise<void> {
|
||||
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
||||
authInstance = betterAuth({
|
||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
||||
secret: BETTER_AUTH_SECRET!,
|
||||
baseURL: BETTER_AUTH_URL,
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
max: 10,
|
||||
window: 60,
|
||||
max: 100,
|
||||
window: 10,
|
||||
storage: "memory",
|
||||
customRules: {
|
||||
"/get-session": false,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
@@ -177,9 +180,9 @@ export async function initAuth(): Promise<void> {
|
||||
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||
|
||||
// Fetch OIDC discovery document to derive canonical provider URLs.
|
||||
// Replace the host of token/userinfo endpoints with internalBaseUrl when set,
|
||||
// while keeping authorizationUrl public for browser redirects.
|
||||
const issuerUrlObj = new URL(providerConfig.issuerUrl);
|
||||
const issuerHostname = issuerUrlObj.hostname;
|
||||
|
||||
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
||||
let oidcConfig: Record<string, string> = {};
|
||||
try {
|
||||
@@ -203,6 +206,14 @@ export async function initAuth(): Promise<void> {
|
||||
const tokenUrl = discovery.token_endpoint;
|
||||
const userInfoUrl = discovery.userinfo_endpoint;
|
||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||
const authzUrlObj = new URL(authzUrl);
|
||||
// Only validate authorizationUrl hostname against issuer — token/userinfo
|
||||
// may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls.
|
||||
if (authzUrlObj.hostname !== issuerHostname) {
|
||||
throw new Error(
|
||||
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
|
||||
);
|
||||
}
|
||||
oidcConfig = {
|
||||
authorizationUrl: authzUrl,
|
||||
tokenUrl: providerConfig.internalBaseUrl
|
||||
@@ -232,9 +243,12 @@ export async function initAuth(): Promise<void> {
|
||||
baseURL: BETTER_AUTH_URL,
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
max: 10,
|
||||
window: 60,
|
||||
max: 100,
|
||||
window: 10,
|
||||
storage: "memory",
|
||||
customRules: {
|
||||
"/get-session": false,
|
||||
},
|
||||
},
|
||||
account: {
|
||||
storeStateStrategy: "cookie" as const,
|
||||
|
||||
@@ -67,3 +67,41 @@ export async function deleteObject(key: string): Promise<void> {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Read an object from S3 and return its body buffer and content type. */
|
||||
export async function getObject(key: string): Promise<{ body: Buffer; contentType: string }> {
|
||||
const client = getS3Client();
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
const chunks: Uint8Array[] = [];
|
||||
// response.Body is a Readable stream; collect chunks into a buffer
|
||||
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const body = Buffer.concat(chunks);
|
||||
const contentType = response.ContentType ?? "application/octet-stream";
|
||||
return { body, contentType };
|
||||
}
|
||||
|
||||
/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */
|
||||
export async function putObject(
|
||||
key: string,
|
||||
body: Buffer | Uint8Array | string,
|
||||
contentType: string,
|
||||
contentLength: number
|
||||
): Promise<void> {
|
||||
const client = getS3Client();
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: getBucket(),
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { getDb, impersonationAuditLogs } from "@groombook/db";
|
||||
import type { PortalEnv } from "./portalSession.js";
|
||||
|
||||
/**
|
||||
* Server-side audit logging middleware for portal routes.
|
||||
* Applied after validatePortalSession in the middleware chain.
|
||||
*
|
||||
* After the route handler completes (await next()), inserts an audit log entry
|
||||
* into impersonationAuditLogs:
|
||||
* - sessionId: from c.get("portalSessionId")
|
||||
* - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments")
|
||||
* - pageVisited: c.req.path
|
||||
* - metadata: { method, statusCode: c.res.status }
|
||||
*
|
||||
* Log entries are written for both success and error responses.
|
||||
* Does NOT throw if audit logging fails — errors are logged but the user's
|
||||
* request is not affected.
|
||||
*/
|
||||
export const portalAudit: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||
await next();
|
||||
|
||||
const sessionId = c.get("portalSessionId");
|
||||
if (!sessionId) return;
|
||||
|
||||
const method = c.req.method;
|
||||
const routePath = c.req.path;
|
||||
const pageVisited = c.req.path;
|
||||
const statusCode = c.res.status;
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
await db
|
||||
.insert(impersonationAuditLogs)
|
||||
.values({
|
||||
sessionId,
|
||||
action: `${method} ${routePath}`,
|
||||
pageVisited,
|
||||
metadata: { method, statusCode },
|
||||
})
|
||||
.returning();
|
||||
} catch (err) {
|
||||
console.error("[portalAudit] Failed to write audit log:", err);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { and, eq, getDb, impersonationSessions } from "@groombook/db";
|
||||
|
||||
export interface PortalEnv {
|
||||
Variables: {
|
||||
portalClientId: string;
|
||||
portalSessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
|
||||
* Must be applied to all portal routes.
|
||||
*
|
||||
* Reads x-session-id from request headers, queries impersonationSessions for a row where
|
||||
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
||||
* Returns 401 if session is invalid/missing/expired.
|
||||
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
|
||||
*/
|
||||
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
c.set("portalClientId", session.clientId);
|
||||
c.set("portalSessionId", session.id);
|
||||
await next();
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { eq, getDb, staff } from "@groombook/db";
|
||||
import { and, eq, getDb, sql, staff } from "@groombook/db";
|
||||
|
||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||
export type StaffRow = typeof staff.$inferSelect;
|
||||
@@ -89,14 +89,31 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.oidcSub, jwt.sub));
|
||||
if (!fallbackRow) {
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||
403
|
||||
);
|
||||
if (fallbackRow) {
|
||||
c.set("staff", fallbackRow);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
c.set("staff", fallbackRow);
|
||||
await next();
|
||||
// Auto-link by email: staff record exists with matching email but no userId
|
||||
if (jwt.email) {
|
||||
const [byEmail] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`));
|
||||
if (byEmail) {
|
||||
await db
|
||||
.update(staff)
|
||||
.set({ userId: jwt.sub, updatedAt: new Date() })
|
||||
.where(eq(staff.id, byEmail.id));
|
||||
c.set("staff", { ...byEmail, userId: jwt.sub });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||
403
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -149,9 +166,9 @@ export function requireRoleOrSuperUser(
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
error: staffRow.isSuperUser
|
||||
? `Forbidden: role '${staffRow.role}' is not permitted`
|
||||
: "Forbidden: super user privileges required",
|
||||
error: hasAllowedRole
|
||||
? "Forbidden: super user privileges required"
|
||||
: `Forbidden: role '${staffRow.role}' is not permitted`,
|
||||
},
|
||||
403
|
||||
);
|
||||
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const appointmentGroupsRouter = new Hono();
|
||||
export const appointmentGroupsRouter = new Hono<AppEnv>();
|
||||
|
||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -49,6 +50,8 @@ appointmentGroupsRouter.get("/", async (c) => {
|
||||
const clientId = c.req.query("clientId");
|
||||
const from = c.req.query("from");
|
||||
const to = c.req.query("to");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const groupConditions = clientId
|
||||
? [eq(appointmentGroups.clientId, clientId)]
|
||||
@@ -88,6 +91,16 @@ appointmentGroupsRouter.get("/", async (c) => {
|
||||
}))
|
||||
.filter((g) => !from || g.appointments.length > 0);
|
||||
|
||||
if (isGroomer) {
|
||||
return c.json(
|
||||
result.filter((g) =>
|
||||
g.appointments.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
@@ -96,6 +109,8 @@ appointmentGroupsRouter.get("/", async (c) => {
|
||||
appointmentGroupsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [group] = await db
|
||||
.select()
|
||||
@@ -111,6 +126,7 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
||||
serviceId: appointments.serviceId,
|
||||
serviceName: services.name,
|
||||
staffId: appointments.staffId,
|
||||
batherStaffId: appointments.batherStaffId,
|
||||
staffName: staff.name,
|
||||
status: appointments.status,
|
||||
startTime: appointments.startTime,
|
||||
@@ -125,6 +141,15 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
||||
.where(eq(appointments.groupId, id))
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
if (
|
||||
isGroomer &&
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const [client] = await db
|
||||
.select({ name: clients.name, email: clients.email })
|
||||
.from(clients)
|
||||
@@ -140,6 +165,13 @@ appointmentGroupsRouter.post(
|
||||
zValidator("json", createGroupSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
if (staffRow?.role === "groomer") {
|
||||
return c.json(
|
||||
{ error: "Forbidden: groomers cannot create group bookings" },
|
||||
403
|
||||
);
|
||||
}
|
||||
const body = c.req.valid("json");
|
||||
const startTime = new Date(body.startTime);
|
||||
|
||||
@@ -244,6 +276,28 @@ appointmentGroupsRouter.patch(
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [group] = await db
|
||||
.select({ id: appointmentGroups.id })
|
||||
.from(appointmentGroups)
|
||||
.where(eq(appointmentGroups.id, id));
|
||||
if (!group) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const groupAppts = await db
|
||||
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.groupId, id));
|
||||
if (
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointmentGroups)
|
||||
@@ -261,6 +315,8 @@ appointmentGroupsRouter.patch(
|
||||
appointmentGroupsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [group] = await db
|
||||
.select({ id: appointmentGroups.id })
|
||||
@@ -268,6 +324,20 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
|
||||
.where(eq(appointmentGroups.id, id));
|
||||
if (!group) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const groupAppts = await db
|
||||
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.groupId, id));
|
||||
if (
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
|
||||
@@ -23,6 +23,27 @@ import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number,
|
||||
delayMs: number,
|
||||
context: string
|
||||
): Promise<void> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await fn();
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error(`[appointments] ${context}: ${lastError}`);
|
||||
}
|
||||
|
||||
export const appointmentsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createAppointmentSchema = z.object({
|
||||
@@ -41,6 +62,10 @@ const createAppointmentSchema = z.object({
|
||||
frequencyWeeks: z.number().int().min(1).max(52),
|
||||
count: z.number().int().min(2).max(52),
|
||||
})
|
||||
.refine(
|
||||
(r) => r.frequencyWeeks * r.count <= 52,
|
||||
{ message: "Recurrence series must not exceed 1 year" }
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -163,6 +188,28 @@ appointmentsRouter.post(
|
||||
}
|
||||
}
|
||||
|
||||
if (apptFields.batherStaffId) {
|
||||
const bathConflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, apptFields.batherStaffId),
|
||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (bathConflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!recurrence) {
|
||||
// Single appointment
|
||||
const [inserted] = await tx
|
||||
@@ -186,11 +233,54 @@ appointmentsRouter.post(
|
||||
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let first: typeof appointments.$inferSelect | undefined;
|
||||
const conflictingInstances: number[] = [];
|
||||
for (let i = 0; i < recurrence.count; i++) {
|
||||
const instanceStart = new Date(start.getTime() + i * intervalMs);
|
||||
const instanceEnd = new Date(
|
||||
instanceStart.getTime() + durationMs
|
||||
);
|
||||
|
||||
if (apptFields.staffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, apptFields.staffId),
|
||||
lt(appointments.startTime, instanceEnd),
|
||||
gte(appointments.endTime, instanceStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
conflictingInstances.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (apptFields.batherStaffId) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, apptFields.batherStaffId),
|
||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, instanceEnd),
|
||||
gte(appointments.endTime, instanceStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
conflictingInstances.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const [inserted] = await tx
|
||||
.insert(appointments)
|
||||
.values({
|
||||
@@ -201,9 +291,19 @@ appointmentsRouter.post(
|
||||
seriesIndex: i,
|
||||
})
|
||||
.returning();
|
||||
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
|
||||
if (i === 0) first = inserted;
|
||||
}
|
||||
|
||||
if (conflictingInstances.length > 0) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`Conflicts detected at occurrence(s): ${conflictingInstances.join(", ")}`
|
||||
),
|
||||
{ statusCode: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!first) throw new Error("No appointments created");
|
||||
return first;
|
||||
});
|
||||
@@ -221,9 +321,12 @@ appointmentsRouter.post(
|
||||
}
|
||||
|
||||
// Send confirmation email (fire-and-forget — never fails the request)
|
||||
sendConfirmationEmail(db, firstRow).catch((err) => {
|
||||
console.error("[appointments] Failed to send confirmation email:", err);
|
||||
});
|
||||
withRetry(
|
||||
() => sendConfirmationEmail(db, firstRow),
|
||||
2,
|
||||
1000,
|
||||
`Failed to send confirmation email for appointment ${firstRow.id}`
|
||||
);
|
||||
|
||||
return c.json(firstRow, 201);
|
||||
}
|
||||
@@ -235,44 +338,35 @@ async function sendConfirmationEmail(
|
||||
db: ReturnType<typeof getDb>,
|
||||
appt: typeof appointments.$inferSelect
|
||||
): Promise<void> {
|
||||
const [client] = await db
|
||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, appt.clientId))
|
||||
const [row] = await db
|
||||
.select({
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
clientEmailOptOut: clients.emailOptOut,
|
||||
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, appt.id))
|
||||
.limit(1);
|
||||
|
||||
if (!client || !client.email || client.emailOptOut) return;
|
||||
if (!row) return;
|
||||
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
|
||||
|
||||
const [pet] = await db
|
||||
.select({ name: pets.name })
|
||||
.from(pets)
|
||||
.where(eq(pets.id, appt.petId))
|
||||
.limit(1);
|
||||
|
||||
const [service] = await db
|
||||
.select({ name: services.name })
|
||||
.from(services)
|
||||
.where(eq(services.id, appt.serviceId))
|
||||
.limit(1);
|
||||
|
||||
let groomerName: string | null = null;
|
||||
if (appt.staffId) {
|
||||
const [groomer] = await db
|
||||
.select({ name: staff.name })
|
||||
.from(staff)
|
||||
.where(eq(staff.id, appt.staffId))
|
||||
.limit(1);
|
||||
groomerName = groomer?.name ?? null;
|
||||
}
|
||||
|
||||
if (!pet || !service) return;
|
||||
if (!clientEmail || clientEmailOptOut) return;
|
||||
if (!petName || !serviceName) return;
|
||||
|
||||
const sent = await sendEmail(
|
||||
buildConfirmationEmail(client.email, {
|
||||
clientName: client.name,
|
||||
petName: pet.name,
|
||||
serviceName: service.name,
|
||||
groomerName,
|
||||
buildConfirmationEmail(clientEmail, {
|
||||
clientName,
|
||||
petName,
|
||||
serviceName,
|
||||
groomerName: groomerName ?? null,
|
||||
startTime: appt.startTime,
|
||||
})
|
||||
);
|
||||
@@ -352,6 +446,76 @@ appointmentsRouter.patch(
|
||||
|
||||
let firstUpdated: typeof appointments.$inferSelect | undefined;
|
||||
for (const appt of affected) {
|
||||
const newStart =
|
||||
startDeltaMs !== 0
|
||||
? new Date(appt.startTime.getTime() + startDeltaMs)
|
||||
: appt.startTime;
|
||||
const newEnd =
|
||||
endDeltaMs !== 0
|
||||
? new Date(appt.endTime.getTime() + endDeltaMs)
|
||||
: appt.endTime;
|
||||
const newStaffId =
|
||||
updateFields.staffId !== undefined
|
||||
? updateFields.staffId
|
||||
: appt.staffId;
|
||||
const newBatherStaffId =
|
||||
updateFields.batherStaffId !== undefined
|
||||
? updateFields.batherStaffId
|
||||
: appt.batherStaffId;
|
||||
|
||||
if (
|
||||
newStaffId &&
|
||||
(startDeltaMs !== 0 ||
|
||||
endDeltaMs !== 0 ||
|
||||
updateFields.staffId !== undefined)
|
||||
) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.staffId, newStaffId),
|
||||
lt(appointments.startTime, newEnd),
|
||||
gte(appointments.endTime, newStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, appt.id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
newBatherStaffId &&
|
||||
(startDeltaMs !== 0 ||
|
||||
endDeltaMs !== 0 ||
|
||||
updateFields.batherStaffId !== undefined)
|
||||
) {
|
||||
const conflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, newBatherStaffId),
|
||||
eq(appointments.batherStaffId, newBatherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, newEnd),
|
||||
gte(appointments.endTime, newStart),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, appt.id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
const apptUpdate: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -387,6 +551,13 @@ appointmentsRouter.patch(
|
||||
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
||||
if (statusCode === 422)
|
||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||
if (statusCode === 409)
|
||||
return c.json(
|
||||
{
|
||||
error: "Staff member has a conflicting appointment at this time",
|
||||
},
|
||||
409
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -398,7 +569,8 @@ appointmentsRouter.patch(
|
||||
const needsConflictCheck =
|
||||
updateFields.startTime !== undefined ||
|
||||
updateFields.endTime !== undefined ||
|
||||
updateFields.staffId !== undefined;
|
||||
updateFields.staffId !== undefined ||
|
||||
updateFields.batherStaffId !== undefined;
|
||||
|
||||
const update: Record<string, unknown> = {
|
||||
...updateFields,
|
||||
@@ -434,6 +606,11 @@ appointmentsRouter.patch(
|
||||
updateFields.staffId !== undefined
|
||||
? updateFields.staffId
|
||||
: current.staffId;
|
||||
// Use provided batherStaffId (may be null to unassign); fall back to existing
|
||||
const batherStaffId =
|
||||
updateFields.batherStaffId !== undefined
|
||||
? updateFields.batherStaffId
|
||||
: current.batherStaffId;
|
||||
|
||||
if (end <= start) {
|
||||
throw Object.assign(new Error("end before start"), {
|
||||
@@ -461,6 +638,29 @@ appointmentsRouter.patch(
|
||||
}
|
||||
}
|
||||
|
||||
if (batherStaffId) {
|
||||
const bathConflicts = await tx
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(appointments.staffId, batherStaffId),
|
||||
eq(appointments.batherStaffId, batherStaffId)
|
||||
),
|
||||
lt(appointments.startTime, end),
|
||||
gte(appointments.endTime, start),
|
||||
ne(appointments.status, "cancelled"),
|
||||
ne(appointments.status, "no_show"),
|
||||
ne(appointments.id, id),
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (bathConflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(appointments)
|
||||
.set(update)
|
||||
@@ -535,9 +735,12 @@ appointmentsRouter.delete("/:id", async (c) => {
|
||||
|
||||
const apptDate = current.startTime.toISOString().slice(0, 10);
|
||||
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
|
||||
console.error("[appointments] Failed to notify waitlist:", err);
|
||||
});
|
||||
withRetry(
|
||||
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
||||
2,
|
||||
1000,
|
||||
`Failed to notify waitlist for appointment ${id}`
|
||||
);
|
||||
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
@@ -560,9 +763,12 @@ appointmentsRouter.delete("/:id", async (c) => {
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
|
||||
console.error("[appointments] Failed to notify waitlist:", err);
|
||||
});
|
||||
withRetry(
|
||||
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
||||
2,
|
||||
1000,
|
||||
`Failed to notify waitlist for appointment ${id}`
|
||||
);
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
+28
-12
@@ -102,7 +102,10 @@ bookRouter.get("/availability", async (c) => {
|
||||
|
||||
const bookingSchema = z.object({
|
||||
serviceId: z.string().uuid(),
|
||||
startTime: z.string().datetime(),
|
||||
startTime: z.string().datetime().refine(
|
||||
(dt) => new Date(dt) > new Date(),
|
||||
{ message: "Appointment must be in the future" }
|
||||
),
|
||||
clientName: z.string().min(1).max(200),
|
||||
clientEmail: z.string().email(),
|
||||
clientPhone: z.string().max(50).optional(),
|
||||
@@ -265,29 +268,36 @@ bookRouter.get("/confirm/:token", async (c) => {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Reject if appointment is in the past
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Idempotent confirm: if already confirmed, redirect to success
|
||||
if (appt.confirmationStatus === "confirmed") {
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
}
|
||||
|
||||
// Reject if already cancelled
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
await db
|
||||
const updated = await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(appointments.id, appt.id));
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.confirmationToken, token),
|
||||
eq(appointments.confirmationStatus, "pending")
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated.length === 0) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
});
|
||||
@@ -309,19 +319,15 @@ bookRouter.get("/cancel/:token", async (c) => {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Reject if appointment is in the past
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Reject if already cancelled (token was nullified — this path won't normally hit,
|
||||
// but guard against edge cases where token lookup still works)
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Single-use cancellation: nullify token after use
|
||||
await db
|
||||
const updated = await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "cancelled",
|
||||
@@ -329,7 +335,17 @@ bookRouter.get("/cancel/:token", async (c) => {
|
||||
confirmationToken: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(appointments.id, appt.id));
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.confirmationToken, token),
|
||||
eq(appointments.confirmationStatus, "pending")
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated.length === 0) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
@@ -84,7 +84,18 @@ calendarRouter.get("/:staffId.ics", async (c) => {
|
||||
.where(eq(staff.id, staffId))
|
||||
.limit(1);
|
||||
|
||||
if (!staffMember || staffMember.icalToken !== token) {
|
||||
if (!staffMember || !staffMember.icalToken) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
const storedToken = staffMember.icalToken;
|
||||
const incomingToken = token;
|
||||
const storedBuf = Buffer.from(storedToken, "utf8");
|
||||
const incomingBuf = Buffer.from(incomingToken, "utf8");
|
||||
if (
|
||||
storedBuf.length !== incomingBuf.length ||
|
||||
!timingSafeEqual(storedBuf, incomingBuf)
|
||||
) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ export const clientsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createClientSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email().optional(),
|
||||
email: z.string().email(),
|
||||
phone: z.string().max(50).optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
smsOptIn: z.boolean().optional(),
|
||||
smsConsentText: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -95,6 +97,7 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
|
||||
// Update a client (including status changes)
|
||||
const patchClientSchema = createClientSchema.partial().extend({
|
||||
status: z.enum(["active", "disabled"]).optional(),
|
||||
smsOptOut: z.boolean().optional(),
|
||||
});
|
||||
|
||||
clientsRouter.patch(
|
||||
@@ -107,13 +110,19 @@ clientsRouter.patch(
|
||||
|
||||
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
|
||||
|
||||
// When disabling, set disabledAt; when re-enabling, clear it
|
||||
if (body.status === "disabled") {
|
||||
setValues.disabledAt = now;
|
||||
} else if (body.status === "active") {
|
||||
setValues.disabledAt = null;
|
||||
}
|
||||
|
||||
if (body.smsOptOut === true) {
|
||||
setValues.smsOptIn = false;
|
||||
setValues.smsOptOutDate = now;
|
||||
delete setValues.smsOptOut;
|
||||
}
|
||||
delete setValues.smsOptOut;
|
||||
|
||||
const [row] = await db
|
||||
.update(clients)
|
||||
.set(setValues)
|
||||
@@ -135,9 +144,24 @@ clientsRouter.delete("/:id", async (c) => {
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const clientId = c.req.param("id");
|
||||
|
||||
const [existingAppt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.clientId, clientId))
|
||||
.limit(1);
|
||||
|
||||
if (existingAppt) {
|
||||
return c.json(
|
||||
{ error: "Cannot delete client with existing appointments. Cancel or reassign appointments first." },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.delete(clients)
|
||||
.where(eq(clients.id, c.req.param("id")))
|
||||
.where(eq(clients.id, clientId))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
|
||||
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const groomingLogsRouter = new Hono();
|
||||
export const groomingLogsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createLogSchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
@@ -20,6 +21,26 @@ groomingLogsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.query("petId");
|
||||
if (!petId) return c.json({ error: "petId is required" }, 400);
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
if (isGroomer) {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, petId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(groomingVisitLogs)
|
||||
@@ -33,11 +54,50 @@ groomingLogsRouter.post(
|
||||
zValidator("json", createLogSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { groomedAt, ...rest } = c.req.valid("json");
|
||||
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
if (isGroomer) {
|
||||
if (appointmentId) {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.id, appointmentId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
} else {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, petId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(groomingVisitLogs)
|
||||
.values({
|
||||
...rest,
|
||||
petId,
|
||||
appointmentId: appointmentId ?? null,
|
||||
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
||||
})
|
||||
.returning();
|
||||
@@ -47,10 +107,37 @@ groomingLogsRouter.post(
|
||||
|
||||
groomingLogsRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
const id = c.req.param("id");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
const [log] = await db
|
||||
.select()
|
||||
.from(groomingVisitLogs)
|
||||
.where(eq(groomingVisitLogs.id, id))
|
||||
.limit(1);
|
||||
if (!log) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const [appt] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.petId, log.petId),
|
||||
or(
|
||||
eq(appointments.staffId, staffRow.id),
|
||||
eq(appointments.batherStaffId, staffRow.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(groomingVisitLogs)
|
||||
.where(eq(groomingVisitLogs.id, c.req.param("id")))
|
||||
.where(eq(groomingVisitLogs.id, id))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
+264
-72
@@ -8,6 +8,7 @@ import {
|
||||
invoices,
|
||||
invoiceLineItems,
|
||||
invoiceTipSplits,
|
||||
refunds,
|
||||
appointments,
|
||||
services,
|
||||
clients,
|
||||
@@ -17,6 +18,14 @@ import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const invoicesRouter = new Hono<AppEnv>();
|
||||
|
||||
// Convert Zod validation errors from 422 to 400
|
||||
invoicesRouter.onError((err, c) => {
|
||||
if (err instanceof z.ZodError) {
|
||||
return c.json({ error: "Validation failed", issues: err.issues }, 400);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
const createInvoiceSchema = z.object({
|
||||
appointmentId: z.string().uuid().optional(),
|
||||
clientId: z.string().uuid(),
|
||||
@@ -41,56 +50,73 @@ const updateInvoiceSchema = z.object({
|
||||
taxCents: z.number().int().nonnegative().optional(),
|
||||
tipCents: z.number().int().nonnegative().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
tipSplits: z.array(
|
||||
z.object({
|
||||
staffId: z.string().uuid().nullable(),
|
||||
staffName: z.string().min(1).max(200),
|
||||
sharePct: z.number().min(0).max(100),
|
||||
})
|
||||
).optional(),
|
||||
});
|
||||
|
||||
// List invoices
|
||||
invoicesRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.req.query("clientId");
|
||||
const appointmentId = c.req.query("appointmentId");
|
||||
const status = c.req.query("status");
|
||||
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
|
||||
const offset = parseInt(c.req.query("offset") || "0", 10);
|
||||
|
||||
const conditions = [];
|
||||
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
||||
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
|
||||
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(invoices)
|
||||
.where(whereClause);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: invoices.id,
|
||||
appointmentId: invoices.appointmentId,
|
||||
clientId: invoices.clientId,
|
||||
clientName: clients.name,
|
||||
subtotalCents: invoices.subtotalCents,
|
||||
taxCents: invoices.taxCents,
|
||||
tipCents: invoices.tipCents,
|
||||
totalCents: invoices.totalCents,
|
||||
status: invoices.status,
|
||||
paymentMethod: invoices.paymentMethod,
|
||||
paidAt: invoices.paidAt,
|
||||
notes: invoices.notes,
|
||||
createdAt: invoices.createdAt,
|
||||
updatedAt: invoices.updatedAt,
|
||||
})
|
||||
.from(invoices)
|
||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||
.where(whereClause)
|
||||
.orderBy(invoices.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
||||
const listInvoicesQuerySchema = z.object({
|
||||
clientId: z.string().uuid().optional(),
|
||||
appointmentId: z.string().uuid().optional(),
|
||||
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
invoicesRouter.get(
|
||||
"/",
|
||||
zValidator("query", listInvoicesQuerySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
|
||||
|
||||
const conditions = [];
|
||||
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
||||
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
|
||||
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(invoices)
|
||||
.where(whereClause);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: invoices.id,
|
||||
appointmentId: invoices.appointmentId,
|
||||
clientId: invoices.clientId,
|
||||
clientName: clients.name,
|
||||
subtotalCents: invoices.subtotalCents,
|
||||
taxCents: invoices.taxCents,
|
||||
tipCents: invoices.tipCents,
|
||||
totalCents: invoices.totalCents,
|
||||
status: invoices.status,
|
||||
paymentMethod: invoices.paymentMethod,
|
||||
paidAt: invoices.paidAt,
|
||||
notes: invoices.notes,
|
||||
stripePaymentIntentId: invoices.stripePaymentIntentId,
|
||||
stripeRefundId: invoices.stripeRefundId,
|
||||
createdAt: invoices.createdAt,
|
||||
updatedAt: invoices.updatedAt,
|
||||
})
|
||||
.from(invoices)
|
||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||
.where(whereClause)
|
||||
.orderBy(invoices.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
||||
}
|
||||
);
|
||||
|
||||
// Get single invoice with line items and tip splits
|
||||
invoicesRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
@@ -104,7 +130,17 @@ invoicesRouter.get("/:id", async (c) => {
|
||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||
]);
|
||||
|
||||
return c.json({ ...invoice, lineItems, tipSplits });
|
||||
let cardLast4: string | null = null;
|
||||
let paymentStatus: string | null = null;
|
||||
if (invoice.stripePaymentIntentId) {
|
||||
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
||||
if (details) {
|
||||
cardLast4 = details.cardLast4;
|
||||
paymentStatus = details.paymentStatus;
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
|
||||
});
|
||||
|
||||
// Save tip splits for an invoice (replaces existing splits)
|
||||
@@ -117,8 +153,8 @@ const tipSplitSchema = z.object({
|
||||
})
|
||||
).min(1).refine(
|
||||
(splits) => {
|
||||
const total = splits.reduce((sum, s) => sum + s.sharePct, 0);
|
||||
return Math.abs(total - 100) < 0.01;
|
||||
const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
|
||||
return totalBps === 10000;
|
||||
},
|
||||
{ message: "Split percentages must sum to 100" }
|
||||
),
|
||||
@@ -162,12 +198,13 @@ invoicesRouter.post(
|
||||
}
|
||||
});
|
||||
|
||||
const splits = await db
|
||||
.select()
|
||||
.from(invoiceTipSplits)
|
||||
.where(eq(invoiceTipSplits.invoiceId, id));
|
||||
const [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
const [lineItems, tipSplits] = await Promise.all([
|
||||
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
|
||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||
]);
|
||||
|
||||
return c.json(splits, 201);
|
||||
return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -292,6 +329,13 @@ invoicesRouter.post("/from-appointment/:appointmentId", async (c) => {
|
||||
return c.json({ ...invoice, lineItems: [lineItem] }, 201);
|
||||
});
|
||||
|
||||
const ALLOWED_TRANSITIONS: Record<string, string[]> = {
|
||||
draft: ["pending", "void"],
|
||||
pending: ["draft", "paid", "void"],
|
||||
paid: ["void"],
|
||||
void: [],
|
||||
};
|
||||
|
||||
// Update invoice
|
||||
invoicesRouter.patch(
|
||||
"/:id",
|
||||
@@ -307,11 +351,33 @@ invoicesRouter.patch(
|
||||
.where(eq(invoices.id, id));
|
||||
if (!current) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (current.status === "void") {
|
||||
return c.json({ error: "Cannot modify a voided invoice" }, 422);
|
||||
if (body.status !== undefined) {
|
||||
const allowed = ALLOWED_TRANSITIONS[current.status] ?? [];
|
||||
if (!allowed.includes(body.status)) {
|
||||
return c.json(
|
||||
{ error: `Invalid status transition from ${current.status} to ${body.status}` },
|
||||
422
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
|
||||
const tipCents = body.tipCents ?? current.tipCents;
|
||||
|
||||
// Validate tip splits when marking invoice as paid
|
||||
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
|
||||
if (body.tipSplits.length === 0) {
|
||||
return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400);
|
||||
}
|
||||
const totalPct = body.tipSplits.reduce((sum, s) => sum + s.sharePct, 0);
|
||||
if (Math.abs(totalPct - 100) > 0.01) {
|
||||
return c.json({ error: "Tip split percentages must sum to 100%" }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Destructure tipSplits out — it belongs to a separate table, not the invoices column
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { tipSplits: _tipSplits, ...updateBody } = body as Record<string, unknown>;
|
||||
const update: Record<string, unknown> = { ...updateBody, updatedAt: new Date() };
|
||||
|
||||
// Auto-set paidAt when marking as paid
|
||||
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
||||
@@ -325,16 +391,42 @@ invoicesRouter.patch(
|
||||
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(invoices)
|
||||
.set(update)
|
||||
.where(eq(invoices.id, id))
|
||||
.returning();
|
||||
// Wrap tip split persistence and invoice update in a single atomic transaction
|
||||
const [updated, lineItems] = await db.transaction(async (tx) => {
|
||||
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
|
||||
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
|
||||
const splits = body.tipSplits;
|
||||
if (splits.length > 0) {
|
||||
let remaining = tipCents;
|
||||
const rows = splits.map((s, i) => {
|
||||
const isLast = i === splits.length - 1;
|
||||
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents);
|
||||
if (!isLast) remaining -= shareCents;
|
||||
return {
|
||||
invoiceId: id,
|
||||
staffId: s.staffId,
|
||||
staffName: s.staffName,
|
||||
sharePct: s.sharePct.toFixed(2),
|
||||
shareCents,
|
||||
};
|
||||
});
|
||||
await tx.insert(invoiceTipSplits).values(rows);
|
||||
}
|
||||
}
|
||||
|
||||
const lineItems = await db
|
||||
.select()
|
||||
.from(invoiceLineItems)
|
||||
.where(eq(invoiceLineItems.invoiceId, id));
|
||||
const [updatedInvoice] = await tx
|
||||
.update(invoices)
|
||||
.set(update)
|
||||
.where(eq(invoices.id, id))
|
||||
.returning();
|
||||
|
||||
const lineItems = await tx
|
||||
.select()
|
||||
.from(invoiceLineItems)
|
||||
.where(eq(invoiceLineItems.invoiceId, id));
|
||||
|
||||
return [updatedInvoice, lineItems];
|
||||
});
|
||||
|
||||
return c.json({ ...updated, lineItems });
|
||||
}
|
||||
@@ -342,10 +434,11 @@ invoicesRouter.patch(
|
||||
|
||||
// ─── Refund ───────────────────────────────────────────────────────────────────
|
||||
|
||||
import { processRefund } from "../services/payment.js";
|
||||
import { processRefund, getPaymentIntentDetails } from "../services/payment.js";
|
||||
|
||||
const refundSchema = z.object({
|
||||
amountCents: z.number().int().nonnegative().optional(),
|
||||
idempotencyKey: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
invoicesRouter.post(
|
||||
@@ -367,13 +460,112 @@ invoicesRouter.post(
|
||||
if (invoice.status !== "paid") {
|
||||
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
||||
}
|
||||
if (!invoice.stripePaymentIntentId) {
|
||||
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
||||
}
|
||||
|
||||
const result = await processRefund(id, body.amountCents);
|
||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||
return await db.transaction(async (tx) => {
|
||||
if (body.idempotencyKey) {
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(refunds)
|
||||
.where(eq(refunds.idempotencyKey, body.idempotencyKey));
|
||||
if (existing) {
|
||||
return c.json({ refundId: existing.stripeRefundId });
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ refundId: result.refundId });
|
||||
let refundId: string;
|
||||
|
||||
if (invoice.stripePaymentIntentId) {
|
||||
const result = await processRefund(id, body.amountCents);
|
||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||
refundId = result.refundId;
|
||||
} else {
|
||||
// Manual refund — no Stripe call needed
|
||||
refundId = `manual_${id}_${Date.now()}`;
|
||||
}
|
||||
|
||||
await tx.insert(refunds).values({
|
||||
invoiceId: id,
|
||||
stripeRefundId: refundId,
|
||||
idempotencyKey: body.idempotencyKey ?? null,
|
||||
amountCents: body.amountCents ?? null,
|
||||
});
|
||||
|
||||
return c.json({ refundId });
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Payment stats for admin dashboard
|
||||
invoicesRouter.get("/stats/summary", async (c) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
const [revenueResult] = await db
|
||||
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||
.from(invoices)
|
||||
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
|
||||
|
||||
const [outstandingResult] = await db
|
||||
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||
.from(invoices)
|
||||
.where(eq(invoices.status, "pending"));
|
||||
|
||||
const [refundsResult] = await db
|
||||
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
|
||||
.from(refunds)
|
||||
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
|
||||
|
||||
const methodBreakdown = await db
|
||||
.select({
|
||||
method: invoices.paymentMethod,
|
||||
total: sql<number>`count(*)`,
|
||||
})
|
||||
.from(invoices)
|
||||
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
|
||||
.groupBy(invoices.paymentMethod);
|
||||
|
||||
return c.json({
|
||||
revenueThisMonth: revenueResult?.total ?? 0,
|
||||
outstanding: outstandingResult?.total ?? 0,
|
||||
refundsThisMonth: refundsResult?.total ?? 0,
|
||||
methodBreakdown,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("stats/summary error:", err);
|
||||
return c.json({
|
||||
revenueThisMonth: 0,
|
||||
outstanding: 0,
|
||||
refundsThisMonth: 0,
|
||||
methodBreakdown: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
|
||||
invoicesRouter.get("/:id/stripe-details", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
let cardLast4: string | null = null;
|
||||
let paymentStatus: string | null = null;
|
||||
|
||||
if (invoice.stripePaymentIntentId) {
|
||||
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
||||
if (details) {
|
||||
cardLast4 = details.cardLast4;
|
||||
paymentStatus = details.paymentStatus;
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
stripePaymentIntentId: invoice.stripePaymentIntentId,
|
||||
stripeRefundId: invoice.stripeRefundId,
|
||||
cardLast4,
|
||||
paymentStatus,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -213,7 +213,11 @@ petsRouter.post(
|
||||
|
||||
// Delete the previous photo from storage to avoid orphaned objects
|
||||
if (pet.photoKey) {
|
||||
await deleteObject(pet.photoKey);
|
||||
try {
|
||||
await deleteObject(pet.photoKey);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete previous photo ${pet.photoKey}, orphaned object may remain:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
@@ -240,7 +244,11 @@ petsRouter.delete("/:petId/photo", async (c) => {
|
||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
|
||||
|
||||
await deleteObject(pet.photoKey);
|
||||
try {
|
||||
await deleteObject(pet.photoKey);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete photo ${pet.photoKey} from S3, orphaned object may remain:`, err);
|
||||
}
|
||||
await db
|
||||
.update(pets)
|
||||
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
|
||||
|
||||
+87
-197
@@ -1,33 +1,84 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, inArray } from "@groombook/db";
|
||||
import { eq, inArray } from "@groombook/db";
|
||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
||||
import { portalAudit } from "../middleware/portalAudit.js";
|
||||
import type { PortalEnv } from "../middleware/portalSession.js";
|
||||
|
||||
export const portalRouter = new Hono<AppEnv>();
|
||||
export const portalRouter = new Hono<PortalEnv>();
|
||||
|
||||
// ─── Session helper ───────────────────────────────────────────────────────────
|
||||
// Dev-mode session creation — must be registered BEFORE the /* middleware so it is
|
||||
// NOT subject to validatePortalSession/portalAudit (GRO-778 fix). This endpoint creates
|
||||
// the impersonation session and has no X-Impersonation-Session-Id header yet.
|
||||
const devSessionSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
});
|
||||
|
||||
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
|
||||
if (!sessionId) return null;
|
||||
const db = getDb();
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||
.limit(1);
|
||||
if (!session || session.expiresAt <= new Date()) return null;
|
||||
return session.clientId;
|
||||
}
|
||||
portalRouter.post(
|
||||
"/dev-session",
|
||||
zValidator("json", devSessionSchema),
|
||||
async (c) => {
|
||||
if (process.env.AUTH_DISABLED !== "true") {
|
||||
return c.json({ error: "Not available when auth is enabled" }, 403);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, body.clientId))
|
||||
.limit(1);
|
||||
if (!client) {
|
||||
return c.json({ error: "Client not found" }, 404);
|
||||
}
|
||||
|
||||
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
let staffId = DEMO_STAFF_ID;
|
||||
const [demoStaff] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.id, DEMO_STAFF_ID))
|
||||
.limit(1);
|
||||
|
||||
if (!demoStaff) {
|
||||
const [firstStaff] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.active, true))
|
||||
.limit(1);
|
||||
if (!firstStaff) {
|
||||
return c.json({ error: "No staff records found. Run the database seed." }, 500);
|
||||
}
|
||||
staffId = firstStaff.id;
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.insert(impersonationSessions)
|
||||
.values({
|
||||
staffId,
|
||||
clientId: body.clientId,
|
||||
reason: "dev-mode-client-portal",
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(session, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// Apply middleware to all portal routes
|
||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||
|
||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
portalRouter.get("/me", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||
if (!client) return c.json({ error: "Not found" }, 404);
|
||||
@@ -49,11 +100,8 @@ portalRouter.get("/services", async (c) => {
|
||||
|
||||
portalRouter.get("/appointments", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const now = new Date();
|
||||
const allAppts = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
@@ -93,27 +141,20 @@ portalRouter.get("/appointments", async (c) => {
|
||||
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
||||
}));
|
||||
|
||||
const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled");
|
||||
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
|
||||
|
||||
return c.json({ upcoming, past });
|
||||
return c.json({ appointments: appts });
|
||||
});
|
||||
|
||||
portalRouter.get("/pets", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
||||
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes })));
|
||||
});
|
||||
|
||||
portalRouter.get("/invoices", async (c) => {
|
||||
const db = getDb();
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||
const invoiceIds = clientInvoices.map(i => i.id);
|
||||
@@ -148,12 +189,7 @@ portalRouter.patch(
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -196,12 +232,7 @@ portalRouter.patch(
|
||||
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -250,12 +281,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
@@ -319,28 +345,7 @@ portalRouter.post(
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
|
||||
let clientId: string | null = null;
|
||||
if (sessionId) {
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.id, sessionId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (session && session.expiresAt > new Date()) {
|
||||
clientId = session.clientId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [entry] = await db
|
||||
.insert(waitlistEntries)
|
||||
@@ -364,26 +369,7 @@ portalRouter.patch(
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.id, sessionId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -392,7 +378,7 @@ portalRouter.patch(
|
||||
.limit(1);
|
||||
|
||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||
if (existing.clientId !== session.clientId) {
|
||||
if (existing.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
@@ -414,26 +400,7 @@ portalRouter.patch(
|
||||
portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(impersonationSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(impersonationSessions.id, sessionId),
|
||||
eq(impersonationSessions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!session || session.expiresAt <= new Date()) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
@@ -442,7 +409,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
.limit(1);
|
||||
|
||||
if (!entry) return c.json({ error: "Not found" }, 404);
|
||||
if (entry.clientId !== session.clientId) {
|
||||
if (entry.clientId !== clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
@@ -475,9 +442,7 @@ portalRouter.post(
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const invoiceRows = await db
|
||||
.select()
|
||||
@@ -514,9 +479,7 @@ portalRouter.post(
|
||||
);
|
||||
|
||||
portalRouter.get("/payment-methods", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const methods = await listPaymentMethods(clientId);
|
||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
@@ -524,9 +487,7 @@ portalRouter.get("/payment-methods", async (c) => {
|
||||
});
|
||||
|
||||
portalRouter.post("/payment-methods", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||
@@ -539,9 +500,7 @@ portalRouter.post("/payment-methods", async (c) => {
|
||||
});
|
||||
|
||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
const paymentMethodId = c.req.param("id");
|
||||
|
||||
@@ -559,73 +518,4 @@ portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||
const ok = await detachPaymentMethod(paymentMethodId);
|
||||
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
||||
// Allows the dev login selector to vend an impersonation session for a client
|
||||
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
||||
|
||||
const devSessionSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/dev-session",
|
||||
zValidator("json", devSessionSchema),
|
||||
async (c) => {
|
||||
if (process.env.AUTH_DISABLED !== "true") {
|
||||
return c.json({ error: "Not available when auth is enabled" }, 403);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// Verify client exists
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, body.clientId))
|
||||
.limit(1);
|
||||
if (!client) {
|
||||
return c.json({ error: "Client not found" }, 404);
|
||||
}
|
||||
|
||||
// Find a staff record to associate with the dev impersonation session.
|
||||
// Use the demo-manager if it exists (created by seed with known ID),
|
||||
// otherwise fall back to the first active staff record.
|
||||
// This avoids hardcoding a UUID that may not exist in all environments.
|
||||
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
let staffId = DEMO_STAFF_ID;
|
||||
const [demoStaff] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.id, DEMO_STAFF_ID))
|
||||
.limit(1);
|
||||
|
||||
if (!demoStaff) {
|
||||
// Fall back to any active staff member
|
||||
const [firstStaff] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.active, true))
|
||||
.limit(1);
|
||||
if (!firstStaff) {
|
||||
return c.json({ error: "No staff records found. Run the database seed." }, 500);
|
||||
}
|
||||
staffId = firstStaff.id;
|
||||
}
|
||||
|
||||
const [session] = await db
|
||||
.insert(impersonationSessions)
|
||||
.values({
|
||||
staffId,
|
||||
clientId: body.clientId,
|
||||
reason: "dev-mode-client-portal",
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(session, 201);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -286,6 +286,10 @@ reportsRouter.get("/clients", async (c) => {
|
||||
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
|
||||
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
|
||||
|
||||
const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(c.req.query("limit") ?? "20", 10) || 20));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const churnRisk = await db
|
||||
.select({
|
||||
clientId: clients.id,
|
||||
@@ -298,15 +302,34 @@ reportsRouter.get("/clients", async (c) => {
|
||||
.having(
|
||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||
)
|
||||
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`);
|
||||
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const [churnCountRow] = await db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(
|
||||
db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.leftJoin(appointments, eq(appointments.clientId, clients.id))
|
||||
.groupBy(clients.id)
|
||||
.having(
|
||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||
)
|
||||
.as("churn_count")
|
||||
);
|
||||
const churnRiskTotal = churnCountRow?.total ?? 0;
|
||||
|
||||
return c.json({
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
newClients,
|
||||
activeInPeriodCount: activeInPeriod.length,
|
||||
churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients
|
||||
churnRiskTotal: churnRisk.length,
|
||||
churnRisk,
|
||||
churnRiskTotal,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
basePriceCents: z.number().int().positive(),
|
||||
durationMinutes: z.number().int().positive(),
|
||||
durationMinutes: z.number().int().positive().max(480),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, businessSettings } from "@groombook/db";
|
||||
import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject } from "../lib/s3.js";
|
||||
import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js";
|
||||
import { requireSuperUser } from "../middleware/rbac.js";
|
||||
|
||||
export const settingsRouter = new Hono();
|
||||
@@ -100,6 +100,77 @@ settingsRouter.post(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/admin/settings/logo/upload
|
||||
* Proxy upload through the API server to avoid mixed-content issues with
|
||||
* pre-signed URLs that use the internal HTTP endpoint. The file is uploaded
|
||||
* directly to S3 from the server using the internal endpoint.
|
||||
*/
|
||||
settingsRouter.post("/logo/upload", requireSuperUser(), async (c) => {
|
||||
const db = getDb();
|
||||
|
||||
// Parse multipart form data (file field)
|
||||
const body = await c.req.parseBody({ all: true });
|
||||
const file = body["file"];
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return c.json({ error: "No file provided" }, 400);
|
||||
}
|
||||
|
||||
const contentType = file.type;
|
||||
if (!ALLOWED_LOGO_TYPES.has(contentType)) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
"contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp",
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const fileSizeBytes = file.size;
|
||||
if (fileSizeBytes > MAX_LOGO_SIZE) {
|
||||
return c.json({ error: "File must not exceed 512 KB" }, 400);
|
||||
}
|
||||
|
||||
const rows = await db.select().from(businessSettings).limit(1);
|
||||
if (!rows[0]) {
|
||||
return c.json({ error: "Settings not found" }, 404);
|
||||
}
|
||||
const settingsId = rows[0].id;
|
||||
|
||||
const ext = contentType.split("/")[1] ?? "png";
|
||||
const key = `logos/${settingsId}/${Date.now()}.${ext}`;
|
||||
|
||||
// Read file into buffer and upload directly to S3 (bypasses pre-signed URL)
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
await putObject(key, buffer, contentType, fileSizeBytes);
|
||||
|
||||
// Delete previous S3 object if any
|
||||
if (rows[0].logoKey) {
|
||||
await deleteObject(rows[0].logoKey);
|
||||
}
|
||||
|
||||
// Update database with new logo key
|
||||
const [updated] = await db
|
||||
.update(businessSettings)
|
||||
.set({
|
||||
logoKey: key,
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(businessSettings.id, settingsId))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return c.json({ error: "Settings not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({ ok: true, logoKey: updated.logoKey });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/settings/logo/confirm
|
||||
* Called after the client has successfully uploaded to the presigned URL.
|
||||
@@ -144,7 +215,8 @@ settingsRouter.post(
|
||||
|
||||
/**
|
||||
* GET /api/admin/settings/logo
|
||||
* Returns a presigned GET URL for the logo.
|
||||
* Proxies the logo from S3 so the browser never sees an S3 URL.
|
||||
* Returns the image bytes with proper Content-Type.
|
||||
*/
|
||||
settingsRouter.get("/logo", async (c) => {
|
||||
const db = getDb();
|
||||
@@ -153,8 +225,14 @@ settingsRouter.get("/logo", async (c) => {
|
||||
if (!row) return c.json({ error: "Settings not found" }, 404);
|
||||
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
|
||||
|
||||
const url = await getPresignedGetUrl(row.logoKey);
|
||||
return c.json({ url, logoKey: row.logoKey });
|
||||
const { body, contentType } = await getObject(row.logoKey);
|
||||
return new Response(Buffer.from(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,24 @@ import { z } from "zod/v3";
|
||||
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000;
|
||||
const RATE_LIMIT_MAX = 10;
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
|
||||
const entry = rateLimitMap.get(ip);
|
||||
const now = Date.now();
|
||||
if (!entry || now > entry.resetAt) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
||||
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
|
||||
}
|
||||
if (entry.count >= RATE_LIMIT_MAX) {
|
||||
return { allowed: false, remaining: 0 };
|
||||
}
|
||||
entry.count++;
|
||||
return { allowed: true, remaining: RATE_LIMIT_MAX - entry.count };
|
||||
}
|
||||
|
||||
export const setupRouter = new Hono<AppEnv>();
|
||||
|
||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||
@@ -185,52 +203,74 @@ const authProviderTestSchema = z.object({
|
||||
* After setup completes, this endpoint permanently returns 403.
|
||||
*/
|
||||
setupRouter.post("/auth-provider", async (c) => {
|
||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const { allowed, remaining } = rateLimitByIp(ip);
|
||||
c.res.headers.set("x-rate-limit-remaining", String(remaining));
|
||||
if (!allowed) {
|
||||
return c.json({ error: "Too many requests. Please try again later." }, 429);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Guard: only allow during fresh install (no super user yet)
|
||||
const [superUser] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
let row: typeof authProviderConfig.$inferSelect;
|
||||
try {
|
||||
row = await db.transaction(async (tx) => {
|
||||
const [superUser] = await tx
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
|
||||
if (superUser) {
|
||||
// Setup already completed — lock this endpoint permanently
|
||||
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, 403);
|
||||
}
|
||||
if (superUser) {
|
||||
throw Object.assign(new Error("setup-complete"), { code: 403 });
|
||||
}
|
||||
|
||||
// Guard: ensure no DB config already exists (should be redundant with status check but defensive)
|
||||
const [existingConfig] = await db
|
||||
.select({ id: authProviderConfig.id })
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
const [existingConfig] = await tx
|
||||
.select({ id: authProviderConfig.id })
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
|
||||
if (existingConfig) {
|
||||
return c.json({ error: "Auth provider is already configured." }, 409);
|
||||
}
|
||||
if (existingConfig) {
|
||||
throw Object.assign(new Error("config-exists"), { code: 409 });
|
||||
}
|
||||
|
||||
const body = authProviderBootstrapSchema.parse(await c.req.json());
|
||||
const body = authProviderBootstrapSchema.parse(await c.req.json());
|
||||
|
||||
// Encrypt clientSecret before storing
|
||||
const encryptedSecret = encryptSecret(body.clientSecret);
|
||||
const encryptedSecret = encryptSecret(body.clientSecret);
|
||||
|
||||
const [row] = await db
|
||||
.insert(authProviderConfig)
|
||||
.values({
|
||||
providerId: body.providerId,
|
||||
displayName: body.displayName,
|
||||
issuerUrl: body.issuerUrl,
|
||||
internalBaseUrl: body.internalBaseUrl ?? null,
|
||||
clientId: body.clientId,
|
||||
clientSecret: encryptedSecret,
|
||||
scopes: body.scopes,
|
||||
enabled: true,
|
||||
})
|
||||
.returning();
|
||||
const [configRow] = await tx
|
||||
.insert(authProviderConfig)
|
||||
.values({
|
||||
providerId: body.providerId,
|
||||
displayName: body.displayName,
|
||||
issuerUrl: body.issuerUrl,
|
||||
internalBaseUrl: body.internalBaseUrl ?? null,
|
||||
clientId: body.clientId,
|
||||
clientSecret: encryptedSecret,
|
||||
scopes: body.scopes,
|
||||
enabled: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
return c.json({ error: "Failed to save auth provider configuration." }, 500);
|
||||
if (!configRow) {
|
||||
throw Object.assign(new Error("insert-failed"), { code: 500 });
|
||||
}
|
||||
|
||||
return configRow;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const e = err as Error & { code?: number };
|
||||
if (e.message === "setup-complete") {
|
||||
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, e.code as 403);
|
||||
}
|
||||
if (e.message === "config-exists") {
|
||||
return c.json({ error: "Auth provider is already configured." }, e.code as 409);
|
||||
}
|
||||
if (e.message === "insert-failed") {
|
||||
return c.json({ error: "Failed to save auth provider configuration." }, e.code as 500);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return c.json({
|
||||
@@ -254,6 +294,13 @@ setupRouter.post("/auth-provider", async (c) => {
|
||||
* Only available when needsSetup is true (no super user = fresh install).
|
||||
*/
|
||||
setupRouter.post("/auth-provider/test", async (c) => {
|
||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
const { allowed, remaining } = rateLimitByIp(ip);
|
||||
c.res.headers.set("x-rate-limit-remaining", String(remaining));
|
||||
if (!allowed) {
|
||||
return c.json({ ok: false, error: "Too many requests. Please try again later." }, 429);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Guard: only allow during fresh install (no super user yet)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import Stripe from "stripe";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, invoices } from "@groombook/db";
|
||||
import { getStripeClient } from "../services/payment.js";
|
||||
|
||||
@@ -44,10 +45,13 @@ webhooksRouter.post("/stripe", async (c) => {
|
||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||
for (const invoiceId of invoiceIds) {
|
||||
if (!invoiceId) continue;
|
||||
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
||||
if (!parsed.success) continue;
|
||||
const invoiceIdTrimmed = invoiceId.trim();
|
||||
const [inv] = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
.where(eq(invoices.id, invoiceIdTrimmed))
|
||||
.limit(1);
|
||||
if (!inv) continue;
|
||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
||||
@@ -60,7 +64,7 @@ webhooksRouter.post("/stripe", async (c) => {
|
||||
stripePaymentIntentId: pi.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "payment_intent.payment_failed") {
|
||||
@@ -69,13 +73,16 @@ webhooksRouter.post("/stripe", async (c) => {
|
||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||
for (const invoiceId of invoiceIds) {
|
||||
if (!invoiceId) continue;
|
||||
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
||||
if (!parsed.success) continue;
|
||||
const invoiceIdTrimmed = invoiceId.trim();
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "charge.refunded") {
|
||||
|
||||
@@ -162,3 +162,19 @@ export async function createSetupIntent(customerId: string): Promise<{ clientSec
|
||||
|
||||
return { clientSecret: setupIntent.client_secret! };
|
||||
}
|
||||
|
||||
export async function getPaymentIntentDetails(
|
||||
paymentIntentId: string
|
||||
): Promise<{ cardLast4: string | null; paymentStatus: string | null } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["payment_method"] });
|
||||
const cardLast4 = pi.payment_method
|
||||
? (pi.payment_method as Stripe.PaymentMethod).card?.last4 ?? null
|
||||
: null;
|
||||
return {
|
||||
cardLast4,
|
||||
paymentStatus: pi.status ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
eq,
|
||||
getDb,
|
||||
gte,
|
||||
inArray,
|
||||
lt,
|
||||
appointments,
|
||||
clients,
|
||||
@@ -18,9 +19,10 @@ import {
|
||||
buildReminderEmail,
|
||||
sendEmail,
|
||||
} from "./email.js";
|
||||
import { smsSend } from "./sms.js";
|
||||
|
||||
const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
|
||||
|
||||
// How many hours before the appointment to send each reminder.
|
||||
// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2).
|
||||
function getReminderWindows(): { label: string; hours: number }[] {
|
||||
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
|
||||
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
|
||||
@@ -30,20 +32,14 @@ function getReminderWindows(): { label: string; hours: number }[] {
|
||||
];
|
||||
}
|
||||
|
||||
// Checks for upcoming appointments that need reminders and sends them.
|
||||
// Runs every minute — idempotent via reminder_logs unique constraint.
|
||||
export async function runReminderCheck(): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
|
||||
for (const window of getReminderWindows()) {
|
||||
// Target window: appointments starting between (hours - 1) and hours from now.
|
||||
// Running every minute means we check a 1-minute slice; the 1-hour window
|
||||
// ensures we catch appointments that started between heartbeats.
|
||||
const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000);
|
||||
const windowEnd = new Date(now.getTime() + window.hours * 3600_000);
|
||||
|
||||
// Find upcoming appointments in this time window that haven't been cancelled/completed
|
||||
const upcoming = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
@@ -64,56 +60,78 @@ export async function runReminderCheck(): Promise<void> {
|
||||
)
|
||||
);
|
||||
|
||||
for (const appt of upcoming) {
|
||||
// Check if reminder already sent (unique constraint prevents double-send)
|
||||
const existing = await db
|
||||
.select({ id: reminderLogs.id })
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.appointmentId, appt.id),
|
||||
eq(reminderLogs.reminderType, window.label)
|
||||
)
|
||||
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
|
||||
if (appointmentIds.length === 0) continue;
|
||||
|
||||
// Bulk check: which appointments already have email and SMS reminders sent?
|
||||
const sentRows = await db
|
||||
.select({ appointmentId: reminderLogs.appointmentId, channel: reminderLogs.channel })
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.reminderType, window.label),
|
||||
appointmentIds.length === 1
|
||||
? eq(reminderLogs.appointmentId, appointmentIds[0]!)
|
||||
: inArray(reminderLogs.appointmentId, appointmentIds)
|
||||
)
|
||||
.limit(1);
|
||||
);
|
||||
|
||||
if (existing.length > 0) continue; // already sent
|
||||
const sentEmail = new Set(
|
||||
sentRows.filter((r) => r.channel === "email").map((r) => r.appointmentId)
|
||||
);
|
||||
const sentSms = new Set(
|
||||
sentRows.filter((r) => r.channel === "sms").map((r) => r.appointmentId)
|
||||
);
|
||||
|
||||
// Fetch related records for the email
|
||||
const [client] = await db
|
||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, appt.clientId))
|
||||
.limit(1);
|
||||
// Bulk JOIN: fetch all client/pet/service/staff data in one query
|
||||
const joinedRows = await db
|
||||
.select({
|
||||
appointmentId: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
clientId: appointments.clientId,
|
||||
petId: appointments.petId,
|
||||
serviceId: appointments.serviceId,
|
||||
staffId: appointments.staffId,
|
||||
confirmationToken: appointments.confirmationToken,
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
clientEmailOptOut: clients.emailOptOut,
|
||||
clientSmsOptIn: clients.smsOptIn,
|
||||
clientPhone: clients.phone,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
staffName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(clients, eq(appointments.clientId, clients.id))
|
||||
.innerJoin(pets, eq(appointments.petId, pets.id))
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, windowStart),
|
||||
lt(appointments.startTime, windowEnd),
|
||||
eq(appointments.status, "scheduled")
|
||||
)
|
||||
);
|
||||
|
||||
if (!client || !client.email || client.emailOptOut) continue;
|
||||
const appointmentMap = new Map<string, typeof joinedRows[number]>();
|
||||
for (const row of joinedRows) {
|
||||
appointmentMap.set(row.appointmentId, row);
|
||||
}
|
||||
|
||||
const [pet] = await db
|
||||
.select({ name: pets.name })
|
||||
.from(pets)
|
||||
.where(eq(pets.id, appt.petId))
|
||||
.limit(1);
|
||||
for (const appt of upcoming) {
|
||||
const joined = appointmentMap.get(appt.id as string);
|
||||
if (!joined) continue;
|
||||
|
||||
const [service] = await db
|
||||
.select({ name: services.name })
|
||||
.from(services)
|
||||
.where(eq(services.id, appt.serviceId))
|
||||
.limit(1);
|
||||
const { clientName, clientEmail, clientEmailOptOut, clientSmsOptIn, clientPhone, petName, serviceName, staffName } = joined;
|
||||
|
||||
let groomerName: string | null = null;
|
||||
if (appt.staffId) {
|
||||
const [groomer] = await db
|
||||
.select({ name: staff.name })
|
||||
.from(staff)
|
||||
.where(eq(staff.id, appt.staffId))
|
||||
.limit(1);
|
||||
groomerName = groomer?.name ?? null;
|
||||
}
|
||||
if (!clientEmail || clientEmailOptOut) continue;
|
||||
if (!petName || !serviceName) continue;
|
||||
|
||||
if (!pet || !service) continue;
|
||||
const emailSent = sentEmail.has(appt.id as string);
|
||||
const smsSent = sentSms.has(appt.id as string);
|
||||
|
||||
// Ensure the appointment has a confirmation token before sending the reminder.
|
||||
// Generate one if it doesn't have one yet (e.g. pre-existing appointments).
|
||||
let confirmationToken = appt.confirmationToken;
|
||||
if (!confirmationToken) {
|
||||
confirmationToken = randomBytes(32).toString("hex");
|
||||
@@ -123,35 +141,59 @@ export async function runReminderCheck(): Promise<void> {
|
||||
.where(eq(appointments.id, appt.id));
|
||||
}
|
||||
|
||||
const sent = await sendEmail(
|
||||
buildReminderEmail(
|
||||
client.email,
|
||||
{
|
||||
clientName: client.name,
|
||||
petName: pet.name,
|
||||
serviceName: service.name,
|
||||
groomerName,
|
||||
startTime: appt.startTime,
|
||||
},
|
||||
window.hours,
|
||||
confirmationToken
|
||||
)
|
||||
);
|
||||
if (!emailSent) {
|
||||
const sent = await sendEmail(
|
||||
buildReminderEmail(
|
||||
clientEmail,
|
||||
{
|
||||
clientName,
|
||||
petName,
|
||||
serviceName,
|
||||
groomerName: staffName,
|
||||
startTime: appt.startTime,
|
||||
},
|
||||
window.hours,
|
||||
confirmationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (sent) {
|
||||
// Record send — ignore conflicts (race condition between instances)
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label })
|
||||
.onConflictDoNothing();
|
||||
if (sent) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
if (!smsSent && clientSmsOptIn && clientPhone) {
|
||||
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
||||
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
||||
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
||||
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
||||
const smsBody = [
|
||||
`Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`,
|
||||
`Service: ${serviceName}${staffName ? ` with ${staffName}` : ""}`,
|
||||
`Confirm: ${confirmUrl}`,
|
||||
`Cancel: ${cancelUrl}`,
|
||||
TCPA_OPT_OUT,
|
||||
].join(". ");
|
||||
try {
|
||||
const smsOk = await smsSend(clientPhone, smsBody);
|
||||
if (smsOk) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[reminders] SMS send failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Starts the cron scheduler. Call once at server startup.
|
||||
export function startReminderScheduler(): void {
|
||||
// Run every minute
|
||||
cron.schedule("* * * * *", () => {
|
||||
runReminderCheck().catch((err) => {
|
||||
console.error("[reminders] Error during reminder check:", err);
|
||||
@@ -163,8 +205,6 @@ export function startReminderScheduler(): void {
|
||||
console.log("[reminders] Reminder scheduler started");
|
||||
}
|
||||
|
||||
// Deletes expired sessions from the database.
|
||||
// Runs every minute alongside reminder checks.
|
||||
export async function runSessionCleanup(): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Telnyx } from "telnyx";
|
||||
import { createHmac } from "crypto";
|
||||
|
||||
export interface SmsProvider {
|
||||
sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>;
|
||||
validateWebhookSignature(req: Request): boolean;
|
||||
}
|
||||
|
||||
interface TelnyxSmsResult {
|
||||
message_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function createTelnyxClient(): Telnyx | null {
|
||||
const apiKey = process.env.TELNYX_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
return new Telnyx(apiKey);
|
||||
}
|
||||
|
||||
let _client: Telnyx | null | undefined;
|
||||
|
||||
function getClient(): Telnyx | null {
|
||||
if (_client === undefined) _client = createTelnyxClient();
|
||||
return _client;
|
||||
}
|
||||
|
||||
function getFromNumber(): string | null {
|
||||
return process.env.TELNYX_FROM_NUMBER ?? null;
|
||||
}
|
||||
|
||||
function isE164(phone: string): boolean {
|
||||
return /^\+[1-9]\d{7,14}$/.test(phone);
|
||||
}
|
||||
|
||||
export async function sendSms(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<{ messageId: string; status: string }> {
|
||||
const client = getClient();
|
||||
if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY.");
|
||||
|
||||
const from = getFromNumber();
|
||||
if (!from) throw new Error("TELNYX_FROM_NUMBER is not set");
|
||||
|
||||
if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`);
|
||||
if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
from,
|
||||
to,
|
||||
body,
|
||||
};
|
||||
|
||||
if (mediaUrls && mediaUrls.length > 0) {
|
||||
payload.media_urls = mediaUrls;
|
||||
}
|
||||
|
||||
const result = await client.messages.create(payload as Record<string, string | string[]>);
|
||||
const smsResult = result.data as unknown as TelnyxSmsResult;
|
||||
return {
|
||||
messageId: smsResult.message_id,
|
||||
status: smsResult.status,
|
||||
};
|
||||
}
|
||||
|
||||
export class TelnyxProvider implements SmsProvider {
|
||||
async sendSms(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<{ messageId: string; status: string }> {
|
||||
return sendSms(to, body, mediaUrls);
|
||||
}
|
||||
|
||||
validateWebhookSignature(req: Request): boolean {
|
||||
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
||||
if (!secret) return false;
|
||||
|
||||
const signature = req.headers.get("telnyx-signature");
|
||||
if (!signature) return false;
|
||||
|
||||
const payload = JSON.stringify(req.body);
|
||||
|
||||
try {
|
||||
const hmac = createHmac("sha256", secret);
|
||||
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
|
||||
|
||||
const sigBuf = Buffer.from(signature);
|
||||
const expBuf = Buffer.from(expected);
|
||||
|
||||
if (sigBuf.length !== expBuf.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < sigBuf.length; i++) {
|
||||
const sigByte = sigBuf[i] ?? 0;
|
||||
const expByte = expBuf[i] ?? 0;
|
||||
diff |= sigByte ^ expByte;
|
||||
}
|
||||
return diff === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _provider: SmsProvider | null | undefined;
|
||||
|
||||
export function createSmsProvider(): SmsProvider | null {
|
||||
if (_provider === undefined) {
|
||||
if (process.env.SMS_ENABLED !== "true") {
|
||||
_provider = null;
|
||||
return null;
|
||||
}
|
||||
switch (process.env.SMS_PROVIDER) {
|
||||
case "telnyx": {
|
||||
const client = getClient();
|
||||
if (!client) {
|
||||
_provider = null;
|
||||
return null;
|
||||
}
|
||||
_provider = new TelnyxProvider();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
_provider = null;
|
||||
}
|
||||
}
|
||||
return _provider;
|
||||
}
|
||||
|
||||
export async function smsSend(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<boolean> {
|
||||
const provider = createSmsProvider();
|
||||
if (!provider) return false;
|
||||
|
||||
await provider.sendSms(to, body, mediaUrls);
|
||||
return true;
|
||||
}
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
declare module "telnyx" {
|
||||
export interface MessageResult {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface MessagesCreateParams {
|
||||
from: string;
|
||||
to: string;
|
||||
body: string;
|
||||
media_urls?: string[];
|
||||
}
|
||||
|
||||
export class Telnyx {
|
||||
constructor(apiKey: string);
|
||||
messages: {
|
||||
create(params: Record<string, string | string[]>): Promise<MessageResult>;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -63,3 +63,52 @@ test("clicking a client shows their details", async ({ page }) => {
|
||||
// Email appears in both the list row and the detail panel once selected
|
||||
await expect(page.getByText("alice@example.com")).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("direct URL navigation to client detail fetches data and renders client name", async ({ page }) => {
|
||||
// Mock individual client fetch for direct navigation
|
||||
await page.route("/api/clients/client-1", (route) =>
|
||||
route.fulfill({ json: MOCK_CLIENTS[0] })
|
||||
);
|
||||
// Mock pets for this client
|
||||
await page.route("/api/pets**", (route) =>
|
||||
route.fulfill({ json: [] })
|
||||
);
|
||||
|
||||
await page.goto("/admin/clients/client-1");
|
||||
// Client name must be visible without any clicking
|
||||
await expect(page.getByText("Alice Johnson")).toBeVisible();
|
||||
// Should show back to list link
|
||||
await expect(page.getByText("← Back to list")).toBeVisible();
|
||||
});
|
||||
|
||||
test("direct URL navigation shows loading then client", async ({ page }) => {
|
||||
let resolvePets: (value: unknown) => void;
|
||||
const petsPromise = new Promise((resolve) => { resolvePets = resolve; });
|
||||
|
||||
await page.route("/api/clients/client-1", (route) =>
|
||||
route.fulfill({ json: MOCK_CLIENTS[0] })
|
||||
);
|
||||
await page.route("/api/pets**", async (route) => {
|
||||
await petsPromise;
|
||||
await route.fulfill({ json: [] });
|
||||
});
|
||||
|
||||
const navigationPromise = page.goto("/admin/clients/client-1");
|
||||
// Should show loading state briefly
|
||||
await expect(page.getByText("Loading client…")).toBeVisible();
|
||||
// Resolve pets and wait for navigation
|
||||
resolvePets!();
|
||||
await navigationPromise;
|
||||
// After data loads, client name is shown
|
||||
await expect(page.getByText("Alice Johnson")).toBeVisible();
|
||||
});
|
||||
|
||||
test("direct URL navigation shows error state on failure", async ({ page }) => {
|
||||
await page.route("/api/clients/nonexistent", (route) =>
|
||||
route.fulfill({ status: 404, json: { error: "Client not found" } })
|
||||
);
|
||||
|
||||
await page.goto("/admin/clients/nonexistent");
|
||||
await expect(page.getByText(/client not found/i)).toBeVisible();
|
||||
await expect(page.getByText("← Back to clients")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -44,6 +44,16 @@ test.beforeEach(async ({ page }) => {
|
||||
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/invoices/stats/summary")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
revenueThisMonth: 0,
|
||||
outstanding: 0,
|
||||
refundsThisMonth: 0,
|
||||
methodBreakdown: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/invoices")) {
|
||||
return route.fulfill({ json: { data: [], total: 0 } });
|
||||
}
|
||||
|
||||
@@ -20,3 +20,5 @@ FROM nginx:alpine AS runner
|
||||
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:80/ || exit 1
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
/**
|
||||
* Playwright configuration for GroomBook Web E2E tests.
|
||||
*
|
||||
* Targets the deployed dev environment at groombook.dev.farh.net.
|
||||
* Targets the deployed dev environment at dev.groombook.dev.
|
||||
* Uses the dev login selector (/login) for authentication — no hardcoded credentials.
|
||||
*
|
||||
* Run locally:
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
reporter: process.env.CI ? "github" : "list",
|
||||
|
||||
use: {
|
||||
baseURL: "https://groombook.dev.farh.net",
|
||||
baseURL: "https://dev.groombook.dev",
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
serviceWorkers: "block",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-r
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppointmentsPage } from "./pages/Appointments.js";
|
||||
import { ClientsPage } from "./pages/Clients.js";
|
||||
import { ClientDetailPage } from "./pages/ClientDetailPage.js";
|
||||
import { ServicesPage } from "./pages/Services.js";
|
||||
import { StaffPage } from "./pages/Staff.js";
|
||||
import { InvoicesPage } from "./pages/Invoices.js";
|
||||
@@ -12,7 +13,7 @@ import { SettingsPage } from "./pages/Settings.js";
|
||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||
import { SetupWizard } from "./pages/SetupWizard.jsx";
|
||||
import { SetupWizard } from "./pages/SetupWizard.tsx";
|
||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||
@@ -296,6 +297,7 @@ function AdminLayout() {
|
||||
<Routes>
|
||||
<Route path="/" element={<AppointmentsPage />} />
|
||||
<Route path="/clients" element={<ClientsPage />} />
|
||||
<Route path="/clients/:clientId" element={<ClientDetailPage />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/staff" element={<StaffPage />} />
|
||||
<Route path="/invoices" element={<InvoicesPage />} />
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("CustomerNotesSection", () => {
|
||||
"/api/portal/appointments/appt-1/notes",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Authorization": "Bearer test-session-id",
|
||||
"X-Impersonation-Session-Id": "test-session-id",
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -269,7 +269,7 @@ describe("ConfirmationSection", () => {
|
||||
"/api/portal/appointments/appt-1/confirm",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Authorization": "Bearer test-session-id",
|
||||
"X-Impersonation-Session-Id": "test-session-id",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ export function GlobalSearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
@@ -45,15 +46,18 @@ export function GlobalSearch() {
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
|
||||
if (res.ok) {
|
||||
const data: SearchResults = await res.json();
|
||||
setResults(data);
|
||||
setOpen(true);
|
||||
} else {
|
||||
setError("Search failed. Please try again.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("GlobalSearch: fetch error", err);
|
||||
} catch {
|
||||
setError("Search failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -160,7 +164,13 @@ export function GlobalSearch() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !hasResults && (
|
||||
{!loading && error && (
|
||||
<div style={{ padding: "12px 16px", fontSize: 13, color: "#dc2626" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !hasResults && (
|
||||
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
|
||||
No results found
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,12 @@ export function PetPhotoUpload({ petId, onUploaded }: Props) {
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setState({ status: "error", message: "File exceeds 50MB limit. Please choose a smaller image." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -112,9 +112,17 @@ export function AppointmentsPage() {
|
||||
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
||||
// null key = unassigned; staffId string = that groomer; undefined set = all visible
|
||||
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
|
||||
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
|
||||
|
||||
const weekEnd = addDays(weekStart, 6);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/invoices/stats/summary")
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => { if (data) setPaymentStats(data); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loadAppointments = useCallback(() => {
|
||||
const from = weekStart.toISOString();
|
||||
const to = addDays(weekStart, 7).toISOString();
|
||||
@@ -273,7 +281,15 @@ export function AppointmentsPage() {
|
||||
cascade !== "this_only"
|
||||
? `/api/appointments/${id}?cascade=${cascade}`
|
||||
: `/api/appointments/${id}`;
|
||||
await fetch(url, { method: "DELETE" });
|
||||
try {
|
||||
const res = await fetch(url, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : "Failed to delete appointment");
|
||||
}
|
||||
setSelectedAppt(null);
|
||||
await loadAppointments();
|
||||
}
|
||||
@@ -306,6 +322,24 @@ export function AppointmentsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Payment Stats Summary */}
|
||||
{paymentStats && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
|
||||
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</div>
|
||||
</div>
|
||||
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>${(paymentStats.outstanding / 100).toFixed(2)}</div>
|
||||
</div>
|
||||
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── View Mode + Groomer Filters ── */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
|
||||
@@ -819,8 +853,49 @@ function AppointmentDetail({
|
||||
}
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement;
|
||||
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const firstFocusable = focusableElements?.[0];
|
||||
firstFocusable?.focus();
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
if (!modalRef.current) return;
|
||||
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
@@ -833,6 +908,7 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
||||
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
||||
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
|
||||
|
||||
export function ClientDetailPage() {
|
||||
const { clientId } = useParams<{ clientId: string }>();
|
||||
const [client, setClient] = useState<Client | null>(null);
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [visitLogs, setVisitLogs] = useState<Record<string, GroomingVisitLog[]>>({});
|
||||
const [logsLoading, setLogsLoading] = useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [photoRevisions, setPhotoRevisions] = useState<Record<string, number>>({});
|
||||
|
||||
const handlePhotoUploaded = useCallback((petId: string) => {
|
||||
setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientId) {
|
||||
setError("No client ID provided");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const id = clientId!;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [clientRes, petsRes] = await Promise.all([
|
||||
fetch(`/api/clients/${encodeURIComponent(id)}`),
|
||||
fetch(`/api/pets?clientId=${encodeURIComponent(id)}`),
|
||||
]);
|
||||
|
||||
if (!clientRes.ok) {
|
||||
const err = await clientRes.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(err.error ?? `Client fetch failed: ${clientRes.status}`);
|
||||
}
|
||||
if (!petsRes.ok) {
|
||||
throw new Error(`Pets fetch failed: ${petsRes.status}`);
|
||||
}
|
||||
|
||||
setClient(await clientRes.json() as Client);
|
||||
setPets(await petsRes.json() as Pet[]);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load client");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
}, [clientId]);
|
||||
|
||||
async function loadVisitLogs(petId: string) {
|
||||
setLogsLoading((prev) => ({ ...prev, [petId]: true }));
|
||||
const r = await fetch(`/api/grooming-logs?petId=${encodeURIComponent(petId)}`);
|
||||
if (r.ok) {
|
||||
const logs = await r.json() as GroomingVisitLog[];
|
||||
setVisitLogs((prev) => ({ ...prev, [petId]: logs }));
|
||||
}
|
||||
setLogsLoading((prev) => ({ ...prev, [petId]: false }));
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: "2rem", textAlign: "center", color: "#6b7280", fontFamily: "system-ui, sans-serif" }}>
|
||||
Loading client…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !client) {
|
||||
return (
|
||||
<div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<Link to="/admin/clients" style={{ color: "#4f8a6f", fontSize: 13 }}>← Back to clients</Link>
|
||||
</div>
|
||||
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "1rem", color: "#991b1b" }}>
|
||||
{error ?? "Client not found"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "system-ui, sans-serif" }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: "1.5rem", gap: "1rem" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.25rem" }}>
|
||||
<h1 style={{ margin: 0, fontSize: 22 }}>{client.name}</h1>
|
||||
{client.status === "disabled" && (
|
||||
<span style={{ fontSize: 12, background: "#fef2f2", color: "#dc2626", padding: "0.15rem 0.5rem", borderRadius: 4, fontWeight: 500 }}>
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{client.email && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.email}</div>}
|
||||
{client.phone && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.phone}</div>}
|
||||
{client.address && <div style={{ fontSize: 13, color: "#6b7280" }}>{client.address}</div>}
|
||||
{client.notes && (
|
||||
<div style={{ fontSize: 13, marginTop: "0.4rem", background: "#fef9c3", padding: "0.4rem 0.6rem", borderRadius: 4, maxWidth: 500 }}>
|
||||
{client.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to="/admin/clients"
|
||||
style={{
|
||||
padding: "0.4rem 0.85rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
background: "#fff",
|
||||
color: "#374151",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
← Back to list
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Pets */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>Pets</h2>
|
||||
</div>
|
||||
|
||||
{pets.length === 0 ? (
|
||||
<p style={{ color: "#6b7280", fontSize: 14 }}>No pets on file for this client.</p>
|
||||
) : (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "0.75rem" }}>
|
||||
{pets.map((p) => (
|
||||
<div key={p.id} style={{ border: "1px solid #e5e7eb", borderRadius: 10, padding: "0.85rem", background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
|
||||
{/* Photo + header */}
|
||||
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.4rem" }}>
|
||||
<PetPhotoDisplay
|
||||
petId={p.id}
|
||||
size={56}
|
||||
key={`${p.id}-photo-${photoRevisions[p.id] ?? 0}`}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<strong style={{ fontSize: 15 }}>{p.name}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
|
||||
{p.species}{p.breed ? ` · ${p.breed}` : ""}
|
||||
</div>
|
||||
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
|
||||
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
|
||||
<div style={{ marginTop: "0.3rem" }}>
|
||||
<PetPhotoUpload petId={p.id} onUploaded={() => handlePhotoUploaded(p.id)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.healthAlerts && (
|
||||
<div style={{ fontSize: 12, marginTop: "0.35rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#dc2626" }}>
|
||||
<span style={{ fontWeight: 600 }}>⚠ Health alerts:</span> {p.healthAlerts}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grooming preferences */}
|
||||
{(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && (
|
||||
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
|
||||
{p.cutStyle && (
|
||||
<div style={{ fontSize: 12, color: "#374151" }}>
|
||||
<span style={{ fontWeight: 600 }}>Cut:</span> {p.cutStyle}
|
||||
</div>
|
||||
)}
|
||||
{p.shampooPreference && (
|
||||
<div style={{ fontSize: 12, color: "#374151" }}>
|
||||
<span style={{ fontWeight: 600 }}>Shampoo:</span> {p.shampooPreference}
|
||||
</div>
|
||||
)}
|
||||
{p.specialCareNotes && (
|
||||
<div style={{ fontSize: 12, marginTop: "0.2rem", background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#92400e" }}>
|
||||
<span style={{ fontWeight: 600 }}>Special care:</span> {p.specialCareNotes}
|
||||
</div>
|
||||
)}
|
||||
{p.groomingNotes && (
|
||||
<div style={{ fontSize: 12, marginTop: "0.2rem", color: "#374151" }}>
|
||||
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visit history */}
|
||||
{(() => {
|
||||
const logs = visitLogs[p.id];
|
||||
const loadingLogs = logsLoading[p.id];
|
||||
return (
|
||||
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.25rem" }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280" }}>VISIT HISTORY</div>
|
||||
{!logs && !loadingLogs && (
|
||||
<button
|
||||
onClick={() => { void loadVisitLogs(p.id); }}
|
||||
style={{ fontSize: 11, color: "#4f8a6f", background: "none", border: "none", cursor: "pointer", padding: 0 }}
|
||||
>
|
||||
Load history
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loadingLogs && <div style={{ fontSize: 11, color: "#9ca3af" }}>Loading…</div>}
|
||||
{logs && logs.length === 0 && <div style={{ fontSize: 11, color: "#9ca3af" }}>No visits yet</div>}
|
||||
{logs && logs.length > 0 && (
|
||||
<>
|
||||
{logs.slice(0, 3).map((log) => (
|
||||
<div key={log.id} style={{ fontSize: 11, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
|
||||
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
|
||||
{log.cutStyle && <span> · {log.cutStyle}</span>}
|
||||
{log.notes && <span> · {log.notes}</span>}
|
||||
</div>
|
||||
))}
|
||||
{logs.length > 3 && (
|
||||
<div style={{ fontSize: 11, color: "#6b7280" }}>+{logs.length - 3} more visits</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useEffect, useState, useCallback, useRef, useId } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
||||
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
||||
@@ -647,8 +647,7 @@ export function ClientsPage() {
|
||||
|
||||
{/* ── Client modal ── */}
|
||||
{showClientForm && (
|
||||
<Modal onClose={() => setShowClientForm(false)}>
|
||||
<h2 style={{ marginTop: 0 }}>{editingClient ? "Edit Client" : "New Client"}</h2>
|
||||
<Modal title={editingClient ? "Edit Client" : "New Client"} onClose={() => setShowClientForm(false)}>
|
||||
<form onSubmit={submitClient}>
|
||||
<Field label="Full name">
|
||||
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||
@@ -678,8 +677,7 @@ export function ClientsPage() {
|
||||
|
||||
{/* ── Pet modal ── */}
|
||||
{showPetForm && (
|
||||
<Modal onClose={() => setShowPetForm(false)}>
|
||||
<h2 style={{ marginTop: 0 }}>{editingPet ? "Edit Pet" : "Add Pet"}</h2>
|
||||
<Modal title={editingPet ? "Edit Pet" : "Add Pet"} onClose={() => setShowPetForm(false)}>
|
||||
<form onSubmit={submitPet}>
|
||||
<Field label="Pet name">
|
||||
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||
@@ -753,8 +751,7 @@ export function ClientsPage() {
|
||||
|
||||
{/* ── Visit log modal ── */}
|
||||
{showLogForm && logPetId && (
|
||||
<Modal onClose={() => setShowLogForm(false)}>
|
||||
<h2 style={{ marginTop: 0 }}>Log Grooming Visit</h2>
|
||||
<Modal title="Log Grooming Visit" onClose={() => setShowLogForm(false)}>
|
||||
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history…</p>}
|
||||
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
@@ -817,8 +814,7 @@ export function ClientsPage() {
|
||||
|
||||
{/* ── Delete confirmation modal ── */}
|
||||
{showDeleteConfirm && selectedClient && (
|
||||
<Modal onClose={() => setShowDeleteConfirm(false)}>
|
||||
<h2 style={{ marginTop: 0, color: "#dc2626" }}>Permanently Delete Client</h2>
|
||||
<Modal title="Permanently Delete Client" titleStyle={{ color: "#dc2626" }} onClose={() => setShowDeleteConfirm(false)}>
|
||||
<p style={{ fontSize: 14, color: "#374151" }}>
|
||||
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
|
||||
</p>
|
||||
@@ -856,13 +852,60 @@ export function ClientsPage() {
|
||||
|
||||
// ─── Shared UI ───────────────────────────────────────────────────────────────
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
function Modal({ children, onClose, title, titleStyle }: { children: React.ReactNode; onClose: () => void; title: string; titleStyle?: React.CSSProperties }) {
|
||||
const titleId = useId();
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement;
|
||||
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const firstFocusable = focusableElements?.[0];
|
||||
firstFocusable?.focus();
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
if (!modalRef.current) return;
|
||||
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}>
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}
|
||||
>
|
||||
<h2 id={titleId} style={{ marginTop: 0, ...titleStyle }}>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+223
-29
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -173,6 +173,21 @@ function InvoiceDetailModal({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
||||
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
||||
const [showRefundDialog, setShowRefundDialog] = useState(false);
|
||||
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
||||
const [refundAmount, setRefundAmount] = useState("");
|
||||
const [refundError, setRefundError] = useState<string | null>(null);
|
||||
const [refunding, setRefunding] = useState(false);
|
||||
|
||||
// Fetch current staff role to determine manager access
|
||||
const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
fetch("/api/staff/me")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setStaffMe(d))
|
||||
.catch(() => setStaffMe(null));
|
||||
}, []);
|
||||
const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser);
|
||||
|
||||
// Tip split state: array of {staffId, staffName, pct}
|
||||
const linkedAppt = invoice.appointmentId
|
||||
@@ -211,36 +226,41 @@ function InvoiceDetailModal({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const tipCents = Math.round(parseFloat(tipStr) * 100) || 0;
|
||||
// Real-time validation: prevent submit if tip splits don't sum to 100%
|
||||
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
||||
const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0);
|
||||
if (Math.abs(totalPct - 100) >= 0.01) {
|
||||
setError("Tip split percentages must sum to 100%");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const patchBody: {
|
||||
status: string;
|
||||
paymentMethod: string;
|
||||
tipCents: number;
|
||||
tipSplits?: Array<{ staffId: string | null; staffName: string; sharePct: number }>;
|
||||
} = { status: "paid", paymentMethod, tipCents };
|
||||
|
||||
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
||||
patchBody.tipSplits = tipSplits.map((r) => ({
|
||||
staffId: r.staffId,
|
||||
staffName: r.staffName,
|
||||
sharePct: r.pct,
|
||||
}));
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "paid", paymentMethod, tipCents }),
|
||||
body: JSON.stringify(patchBody),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
// Save tip splits if applicable and tip > 0
|
||||
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
||||
const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0);
|
||||
if (Math.abs(totalPct - 100) < 0.01) {
|
||||
const splitsRes = await fetch(`/api/invoices/${invoice.id}/tip-splits`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
splits: tipSplits.map((r) => ({
|
||||
staffId: r.staffId,
|
||||
staffName: r.staffName,
|
||||
sharePct: r.pct,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
if (!splitsRes.ok) console.warn("Tip split save failed (non-blocking)");
|
||||
}
|
||||
}
|
||||
|
||||
onUpdated();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to update");
|
||||
@@ -330,6 +350,19 @@ function InvoiceDetailModal({
|
||||
/>
|
||||
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
||||
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
||||
{invoice.stripePaymentIntentId && (
|
||||
<>
|
||||
{invoice.cardLast4 && (
|
||||
<SummaryRow label="Card" value={`•••• ${invoice.cardLast4}`} />
|
||||
)}
|
||||
{invoice.paymentStatus && (
|
||||
<SummaryRow label="Stripe status" value={invoice.paymentStatus} />
|
||||
)}
|
||||
{invoice.stripeRefundId && (
|
||||
<SummaryRow label="Refund" value="Refunded" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Tip Distribution ── */}
|
||||
@@ -447,11 +480,92 @@ function InvoiceDetailModal({
|
||||
</div>
|
||||
)}
|
||||
{(invoice.status === "paid" || invoice.status === "void") && (
|
||||
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
|
||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
|
||||
{invoice.stripeRefundId && (
|
||||
<div style={{ marginBottom: "0.75rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<span style={{ background: "#fef3c7", color: "#92400e", padding: "0.2rem 0.6rem", borderRadius: 4, fontSize: 13, fontWeight: 600 }}>Refunded</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
|
||||
{invoice.status === "paid" && !invoice.stripeRefundId && isManager && (
|
||||
<button onClick={() => setShowRefundDialog(true)} style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}>
|
||||
Refund
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{showRefundDialog && (
|
||||
<div style={{ marginTop: "1rem", border: "1px solid #e2e8f0", borderRadius: 8, padding: "1rem", background: "#f9fafb" }}>
|
||||
<p style={{ fontWeight: 600, margin: "0 0 0.75rem" }}>Process Refund</p>
|
||||
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
|
||||
<input type="radio" checked={refundType === "full"} onChange={() => setRefundType("full")} />
|
||||
Full refund
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
|
||||
<input type="radio" checked={refundType === "partial"} onChange={() => setRefundType("partial")} />
|
||||
Partial refund
|
||||
</label>
|
||||
</div>
|
||||
{refundType === "partial" && (
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
placeholder="Amount ($)"
|
||||
value={refundAmount}
|
||||
onChange={(e) => setRefundAmount(e.target.value)}
|
||||
style={{ ...inputStyle, width: 100 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>}
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setRefunding(true);
|
||||
setRefundError(null);
|
||||
try {
|
||||
if (refundType === "partial") {
|
||||
const parsed = parseFloat(refundAmount);
|
||||
if (isNaN(parsed) || parsed <= 0) {
|
||||
setRefundError("Please enter a valid amount greater than zero.");
|
||||
setRefunding(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const body = refundType === "partial" ? { amountCents: Math.round(parseFloat(refundAmount) * 100) } : {};
|
||||
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json()) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setShowRefundDialog(false);
|
||||
onUpdated();
|
||||
} catch (e: unknown) {
|
||||
setRefundError(e instanceof Error ? e.message : "Refund failed");
|
||||
} finally {
|
||||
setRefunding(false);
|
||||
}
|
||||
}}
|
||||
disabled={refunding}
|
||||
style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}
|
||||
>
|
||||
{refunding ? "Processing…" : "Process Refund"}
|
||||
</button>
|
||||
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -492,9 +606,17 @@ export function InvoicesPage() {
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
|
||||
|
||||
const LIMIT = 50;
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/invoices/stats/summary")
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => { if (data) setPaymentStats(data); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadInvoices(newOffset: number) {
|
||||
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
|
||||
if (statusFilter) params.set("status", statusFilter);
|
||||
@@ -573,6 +695,34 @@ export function InvoicesPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Payment Stats Summary */}
|
||||
{paymentStats && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
|
||||
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>{fmtMoney(paymentStats.revenueThisMonth)}</div>
|
||||
</div>
|
||||
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>{fmtMoney(paymentStats.outstanding)}</div>
|
||||
</div>
|
||||
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>{fmtMoney(paymentStats.refundsThisMonth)}</div>
|
||||
</div>
|
||||
{paymentStats.methodBreakdown.length > 0 && (
|
||||
<div style={{ background: "#f8fafc", border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||
<div style={{ fontSize: 12, color: "#475569", fontWeight: 600, marginBottom: "0.25rem" }}>By method</div>
|
||||
<div style={{ fontSize: 13, color: "#64748b" }}>
|
||||
{paymentStats.methodBreakdown.map((b) => (
|
||||
<div key={b.method ?? "unknown"}>{b.method ?? "other"}: {b.total}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoiceList.length === 0 ? (
|
||||
<p style={{ color: "#6b7280" }}>
|
||||
No invoices yet. Create one from a completed appointment.
|
||||
@@ -677,19 +827,63 @@ export function InvoicesPage() {
|
||||
// ─── Shared UI helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement;
|
||||
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const firstFocusable = focusableElements?.[0];
|
||||
firstFocusable?.focus();
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
if (!modalRef.current) return;
|
||||
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 8, padding: "1.5rem",
|
||||
maxWidth: 520, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
|
||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
}}>
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={{
|
||||
background: "#fff", borderRadius: 8, padding: "1.5rem",
|
||||
maxWidth: 520, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
|
||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -199,11 +199,11 @@ export function ReportsPage() {
|
||||
}
|
||||
|
||||
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
|
||||
summRes.json() as Promise<Summary>,
|
||||
revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }>,
|
||||
apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }>,
|
||||
svcRes.json() as Promise<{ rows: ServiceRow[] }>,
|
||||
clientRes.json() as Promise<ClientReport>,
|
||||
summRes.ok ? summRes.json() as Promise<Summary> : summRes.text().then(() => { throw new Error("summary response not ok"); }),
|
||||
revRes.ok ? revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }> : revRes.text().then(() => { throw new Error("revenue response not ok"); }),
|
||||
apptRes.ok ? apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }> : apptRes.text().then(() => { throw new Error("appointments response not ok"); }),
|
||||
svcRes.ok ? svcRes.json() as Promise<{ rows: ServiceRow[] }> : svcRes.text().then(() => { throw new Error("services response not ok"); }),
|
||||
clientRes.ok ? clientRes.json() as Promise<ClientReport> : clientRes.text().then(() => { throw new Error("clients response not ok"); }),
|
||||
]);
|
||||
|
||||
setSummary(summData);
|
||||
|
||||
@@ -27,6 +27,8 @@ interface AuthProviderForm {
|
||||
|
||||
const REDACTED = "••••••••";
|
||||
|
||||
const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||
|
||||
interface CurrentUser {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -87,24 +89,14 @@ export function SettingsPage() {
|
||||
fetch("/api/admin/settings")
|
||||
.then((r) => r.json())
|
||||
.then(async (data) => {
|
||||
let logoUrl: string | null = null;
|
||||
if (data.logoKey) {
|
||||
try {
|
||||
const logoRes = await fetch("/api/admin/settings/logo");
|
||||
if (logoRes.ok) {
|
||||
const logoData = await logoRes.json();
|
||||
logoUrl = logoData.url;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// The logo is now proxied through the API server so the browser
|
||||
// never receives an S3 URL — use the proxy path directly as the src.
|
||||
setForm({
|
||||
businessName: data.businessName ?? "GroomBook",
|
||||
primaryColor: data.primaryColor ?? "#4f8a6f",
|
||||
accentColor: data.accentColor ?? "#8b7355",
|
||||
logoKey: data.logoKey ?? null,
|
||||
logoUrl,
|
||||
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
|
||||
logoBase64: data.logoBase64 ?? null,
|
||||
logoMimeType: data.logoMimeType ?? null,
|
||||
});
|
||||
@@ -149,54 +141,28 @@ export function SettingsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const validTypes = ["image/png", "image/svg+xml", "image/jpeg", "image/webp"];
|
||||
const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setMessage({ type: "error", text: "Logo must be PNG, SVG, JPEG, or WebP." });
|
||||
setMessage({ type: "error", text: "Logo must be PNG, JPEG, GIF, or WebP." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Get presigned upload URL
|
||||
const uploadRes = await fetch("/api/admin/settings/logo/upload-url", {
|
||||
// Upload directly through the API server to avoid mixed-content issues
|
||||
// with pre-signed URLs that use the internal HTTP endpoint
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const uploadRes = await fetch("/api/admin/settings/logo/upload", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: file.type, fileSizeBytes: file.size }),
|
||||
body: formData,
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
const err = await uploadRes.json().catch(() => null);
|
||||
throw new Error(err?.error ?? "Failed to get upload URL");
|
||||
}
|
||||
const { uploadUrl, key } = await uploadRes.json();
|
||||
|
||||
// Step 2: PUT the file directly to S3
|
||||
const putRes = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": file.type },
|
||||
body: file,
|
||||
});
|
||||
if (!putRes.ok) {
|
||||
throw new Error("Failed to upload logo to storage");
|
||||
}
|
||||
|
||||
// Step 3: Confirm the upload
|
||||
const confirmRes = await fetch("/api/admin/settings/logo/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (!confirmRes.ok) {
|
||||
const err = await confirmRes.json().catch(() => null);
|
||||
throw new Error(err?.error ?? "Failed to confirm logo upload");
|
||||
}
|
||||
|
||||
// Step 4: Fetch the presigned GET URL for display
|
||||
const logoRes = await fetch("/api/admin/settings/logo");
|
||||
if (logoRes.ok) {
|
||||
const logoData = await logoRes.json();
|
||||
setForm((f) => ({ ...f, logoKey: key, logoUrl: logoData.url, logoBase64: null, logoMimeType: null }));
|
||||
} else {
|
||||
setForm((f) => ({ ...f, logoKey: key, logoUrl: null, logoBase64: null, logoMimeType: null }));
|
||||
throw new Error(err?.error ?? "Failed to upload logo");
|
||||
}
|
||||
const { logoKey } = await uploadRes.json();
|
||||
setForm((f) => ({ ...f, logoKey, logoUrl: `/api/admin/settings/logo?t=${Date.now()}`, logoBase64: null, logoMimeType: null }));
|
||||
setMessage({ type: "success", text: "Logo uploaded." });
|
||||
refresh();
|
||||
} catch (err: unknown) {
|
||||
@@ -326,7 +292,7 @@ issuerUrl: authForm.issuerUrl,
|
||||
|
||||
if (!loaded) return <p>Loading settings...</p>;
|
||||
|
||||
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
|
||||
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType && ALLOWED_LOGO_TYPES.has(form.logoMimeType) ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600 }}>
|
||||
@@ -393,7 +359,7 @@ issuerUrl: authForm.issuerUrl,
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/svg+xml,image/jpeg,image/webp"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={handleLogoChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
export { SetupWizard } from "./SetupWizard.jsx";
|
||||
export { SetupWizard } from "./SetupWizard.tsx";
|
||||
|
||||
@@ -2,16 +2,39 @@ import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useBranding } from "../BrandingContext.js";
|
||||
|
||||
export function SetupWizard({ onSetupComplete }) {
|
||||
interface SetupStatus {
|
||||
showAuthProviderStep?: boolean;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface AuthFormState {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
issuerUrl: string;
|
||||
internalBaseUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes: string;
|
||||
}
|
||||
|
||||
interface Step {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const { refresh: refreshBranding } = useBranding();
|
||||
|
||||
// Fetch setup status to determine if auth provider step is needed
|
||||
const [setupStatus, setSetupStatus] = useState(null); // null = loading
|
||||
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
|
||||
const [loadingStatus, setLoadingStatus] = useState(true);
|
||||
|
||||
// Auth provider form state
|
||||
const [authForm, setAuthForm] = useState({
|
||||
const [authForm, setAuthForm] = useState<AuthFormState>({
|
||||
providerId: "authentik",
|
||||
displayName: "",
|
||||
issuerUrl: "",
|
||||
@@ -21,16 +44,16 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
scopes: "openid profile email",
|
||||
});
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null); // {ok: boolean, error?: string}
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [businessName, setBusinessName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/setup/status")
|
||||
.then((r) => r.json())
|
||||
.then((r) => r.json() as Promise<SetupStatus>)
|
||||
.then((data) => {
|
||||
setSetupStatus(data);
|
||||
setLoadingStatus(false);
|
||||
@@ -40,8 +63,7 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Build steps dynamically based on setup status
|
||||
const STEPS = setupStatus?.showAuthProviderStep
|
||||
const STEPS: Step[] = setupStatus?.showAuthProviderStep
|
||||
? [
|
||||
{ id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
|
||||
{ id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." },
|
||||
@@ -63,9 +85,8 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
const isFirst = step === 0;
|
||||
const canGoBack = step > 0 && step < STEPS.length - 1;
|
||||
|
||||
// Determine if we can proceed - depends on which step we're on
|
||||
const canGoNext = (() => {
|
||||
if (step === STEPS.length - 1) return true; // done step
|
||||
if (step === STEPS.length - 1) return true;
|
||||
if (current?.id === "business") return businessName.trim().length > 0;
|
||||
if (current?.id === "auth") {
|
||||
return (
|
||||
@@ -94,9 +115,9 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
scopes: authForm.scopes,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const data = (await res.json()) as TestResult;
|
||||
setTestResult(data);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setTestResult({ ok: false, error: "Network error. Please try again." });
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
@@ -105,12 +126,10 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
|
||||
const handleNext = async () => {
|
||||
if (step === STEPS.length - 1) {
|
||||
// Done - redirect to admin
|
||||
navigate("/admin");
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit auth provider config
|
||||
if (current?.id === "auth") {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -129,12 +148,12 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const data = (await res.json()) as { error?: string };
|
||||
setError(data.error || "Failed to save auth provider configuration. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -142,7 +161,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// Submit business name and complete setup
|
||||
if (current?.id === "business" && businessName.trim()) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -153,16 +171,14 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
body: JSON.stringify({ businessName: businessName.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const data = (await res.json()) as { error?: string };
|
||||
setError(data.error || "Setup failed. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Refresh branding so the nav bar shows the new business name
|
||||
refreshBranding();
|
||||
// Clear needsSetup state in App so the redirect to /admin sticks
|
||||
if (onSetupComplete) onSetupComplete();
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -192,7 +208,7 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "0.6rem 0.85rem",
|
||||
borderRadius: 8,
|
||||
@@ -220,7 +236,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
maxWidth: 480,
|
||||
width: "100%",
|
||||
}}>
|
||||
{/* Progress dots */}
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
|
||||
{STEPS.map((_, i) => (
|
||||
<div
|
||||
@@ -237,38 +252,32 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
||||
Step {step + 1} of {STEPS.length}
|
||||
</p>
|
||||
|
||||
{/* Title */}
|
||||
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
||||
{current?.title}
|
||||
</h2>
|
||||
|
||||
{/* Description */}
|
||||
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
||||
{current?.description}
|
||||
</p>
|
||||
|
||||
{/* Step: Business name input */}
|
||||
{current?.id === "business" && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Happy Paws Grooming"
|
||||
value={businessName}
|
||||
onChange={(e) => setBusinessName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
|
||||
onKeyDown={(e) => e.key === "Enter" && canGoNext && void handleNext()}
|
||||
autoFocus
|
||||
style={inputStyle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step: Auth provider config form */}
|
||||
{current?.id === "auth" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
|
||||
{/* Provider ID */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Provider ID
|
||||
@@ -282,7 +291,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Display Name */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Display Name
|
||||
@@ -296,7 +304,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Issuer URL */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Issuer URL
|
||||
@@ -310,7 +317,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Internal Base URL (optional) */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
|
||||
@@ -324,7 +330,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Client ID */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Client ID
|
||||
@@ -338,7 +343,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Client Secret */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Client Secret
|
||||
@@ -352,7 +356,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scopes */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Scopes
|
||||
@@ -366,10 +369,9 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Test Connection button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
onClick={() => { void handleTestConnection(); }}
|
||||
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
|
||||
style={{
|
||||
padding: "0.45rem 0.85rem",
|
||||
@@ -387,7 +389,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
{testingConnection ? "Testing..." : "Test Connection"}
|
||||
</button>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
@@ -405,7 +406,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Super user info */}
|
||||
{current?.id === "superuser" && (
|
||||
<div style={{
|
||||
background: "#f0fdf4",
|
||||
@@ -420,7 +420,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Second admin info */}
|
||||
{current?.id === "admin" && (
|
||||
<div style={{
|
||||
background: "#fffbeb",
|
||||
@@ -434,7 +433,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p style={{
|
||||
margin: "0.5rem 0 0",
|
||||
@@ -449,7 +447,6 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
@@ -476,7 +473,7 @@ export function SetupWizard({ onSetupComplete }) {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
onClick={() => { void handleNext(); }}
|
||||
disabled={(!canGoNext && !isLast) || loading}
|
||||
style={{
|
||||
padding: "0.55rem 1.25rem",
|
||||
@@ -16,6 +16,7 @@ import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||
import { useBranding } from "../BrandingContext.js";
|
||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||
import type { ImpersonationSession } from "@groombook/types";
|
||||
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
||||
|
||||
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
|
||||
|
||||
@@ -34,7 +35,7 @@ export function CustomerPortal() {
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||
const [showReschedule, setShowReschedule] = useState(false);
|
||||
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
|
||||
const [rescheduleAppointment, setRescheduleAppointment] = useState<PortalAppointment | null>(null);
|
||||
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
||||
const [sessionExtended, setSessionExtended] = useState(false);
|
||||
const [clientName, setClientName] = useState<string>("");
|
||||
@@ -149,7 +150,7 @@ export function CustomerPortal() {
|
||||
const handleReschedule = useCallback((appointmentId: string) => {
|
||||
// Look up the full appointment from Dashboard's displayed data
|
||||
// The appointment was already fetched by Dashboard, so we use the ID to find it
|
||||
setRescheduleAppointment({ id: appointmentId } as Record<string, unknown>);
|
||||
setRescheduleAppointment({ id: appointmentId } as PortalAppointment);
|
||||
setShowReschedule(true);
|
||||
}, []);
|
||||
|
||||
@@ -227,7 +228,7 @@ export function CustomerPortal() {
|
||||
|
||||
{showReschedule && rescheduleAppointment && (
|
||||
<RescheduleFlow
|
||||
appointment={rescheduleAppointment as any}
|
||||
appointment={rescheduleAppointment}
|
||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||
sessionId={session?.id ?? null}
|
||||
/>
|
||||
@@ -325,7 +326,7 @@ export function CustomerPortal() {
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-h-screen">
|
||||
<main className="flex-1 min-h-screen overflow-hidden">
|
||||
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-stone-800">
|
||||
@@ -339,7 +340,7 @@ export function CustomerPortal() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 md:p-8 max-w-6xl">
|
||||
<div className="p-4 md:p-8 max-w-6xl w-full overflow-hidden">
|
||||
{renderSection()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Appointment {
|
||||
export interface Appointment {
|
||||
id: string;
|
||||
petId: string;
|
||||
serviceId: string;
|
||||
@@ -379,7 +379,7 @@ export function ConfirmationSection({
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionId) {
|
||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
||||
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||
}
|
||||
const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, {
|
||||
method: 'POST',
|
||||
@@ -455,7 +455,7 @@ function CancelAppointmentButton({
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionId) {
|
||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
||||
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||
}
|
||||
const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, {
|
||||
method: 'POST',
|
||||
@@ -507,7 +507,7 @@ export function CustomerNotesSection({
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (sessionId) {
|
||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
||||
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||
}
|
||||
const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, {
|
||||
method: 'PATCH',
|
||||
@@ -600,7 +600,7 @@ export function RescheduleFlow({
|
||||
setError(null);
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (sessionId) headers['Authorization'] = `Bearer ${sessionId}`;
|
||||
if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||
const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
@@ -784,7 +784,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${sessionId}`,
|
||||
'X-Impersonation-Session-Id': sessionId ?? '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
petId: selectedPet.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
||||
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
||||
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-wrap overflow-x-auto">
|
||||
{([
|
||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
||||
@@ -356,6 +356,48 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const completeModalRef = useRef<HTMLDivElement>(null);
|
||||
const paymentModalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus trap + Escape-to-close for both inline modals
|
||||
useEffect(() => {
|
||||
const modalRef = isComplete ? completeModalRef.current : paymentModalRef.current;
|
||||
if (!modalRef) return;
|
||||
|
||||
const previouslyFocused = document.activeElement as HTMLElement;
|
||||
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const focusableElements = modalRef.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const firstFocusable = focusableElements[0];
|
||||
firstFocusable?.focus();
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !modalRef) return;
|
||||
const focusables = modalRef.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [isComplete, onClose]);
|
||||
|
||||
const formatCents = (cents: number) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
||||
@@ -420,8 +462,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
||||
|
||||
if (isComplete) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
|
||||
<div role="dialog" aria-modal="true" className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div ref={completeModalRef} className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
@@ -440,8 +482,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
|
||||
<div role="dialog" aria-modal="true" className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div ref={paymentModalRef} className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2>
|
||||
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
|
||||
|
||||
@@ -27,8 +27,7 @@ interface Appointment {
|
||||
}
|
||||
|
||||
interface AppointmentsResponse {
|
||||
upcoming: Appointment[];
|
||||
past: Appointment[];
|
||||
appointments: Appointment[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -46,7 +45,7 @@ function buildHeaders(sessionId: string | null): Record<string, string> {
|
||||
|
||||
export function PetProfiles({ sessionId, readOnly }: Props) {
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
|
||||
const [appointments, setAppointments] = useState<AppointmentsResponse>({ appointments: [] });
|
||||
const [selectedPetId, setSelectedPetId] = useState<string>("");
|
||||
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
|
||||
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
||||
@@ -90,7 +89,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
||||
}, [sessionId]);
|
||||
|
||||
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
|
||||
const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
|
||||
const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date());
|
||||
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
|
||||
|
||||
function handlePetSave(updatedPet: Pet) {
|
||||
|
||||
@@ -119,3 +119,10 @@ uri
|
||||
database-url
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Auth secret name — always use groombook-auth (sealed secret name)
|
||||
*/}}
|
||||
{{- define "groombook.authSecretName" -}}
|
||||
{{- printf "%s" "groombook-auth" }}
|
||||
{{- end }}
|
||||
|
||||
@@ -50,6 +50,27 @@ spec:
|
||||
- name: OIDC_AUDIENCE
|
||||
value: {{ .Values.api.env.oidcAudience | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.api.env.internalBaseUrl }}
|
||||
- name: OIDC_INTERNAL_BASE
|
||||
value: {{ .Values.api.env.internalBaseUrl | quote }}
|
||||
{{- end }}
|
||||
- name: BETTER_AUTH_URL
|
||||
value: {{ .Values.api.env.betterAuthUrl | quote }}
|
||||
- name: OIDC_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "groombook.authSecretName" . }}
|
||||
key: OIDC_CLIENT_ID
|
||||
- name: OIDC_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "groombook.authSecretName" . }}
|
||||
key: OIDC_CLIENT_SECRET
|
||||
- name: BETTER_AUTH_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "groombook.authSecretName" . }}
|
||||
key: BETTER_AUTH_SECRET
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -18,6 +18,8 @@ api:
|
||||
corsOrigin: ""
|
||||
oidcIssuer: ""
|
||||
oidcAudience: groombook
|
||||
betterAuthUrl: ""
|
||||
internalBaseUrl: ""
|
||||
port: "3000"
|
||||
service:
|
||||
type: ClusterIP
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "refunds" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"invoice_id" uuid NOT NULL REFERENCES "invoices"("id") ON DELETE RESTRICT,
|
||||
"stripe_refund_id" text NOT NULL,
|
||||
"idempotency_key" text UNIQUE,
|
||||
"amount_cents" integer,
|
||||
"created_at" timestamp NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX "idx_refunds_invoice_id" ON "refunds"("invoice_id");
|
||||
CREATE INDEX "idx_refunds_idempotency_key" ON "refunds"("idempotency_key");
|
||||
@@ -0,0 +1,15 @@
|
||||
-- SMS opt-in fields for clients (idempotent)
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_in" boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_date" timestamp;
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_out_date" timestamp;
|
||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_text" text;
|
||||
|
||||
-- Add channel column to reminder_logs with default 'email' (idempotent)
|
||||
ALTER TABLE "reminder_logs" ADD COLUMN IF NOT EXISTS "channel" text NOT NULL DEFAULT 'email';
|
||||
|
||||
-- Drop old unique constraints if they exist (idempotent)
|
||||
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_key";
|
||||
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_unique";
|
||||
|
||||
-- Add new unique constraint with channel
|
||||
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel");
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Migration: 0029_db_indexes_constraints.sql
|
||||
-- Add missing indexes on appointments, pets, clients tables and NOT NULL constraint on clients.email
|
||||
|
||||
-- Backfill NULL emails before setting NOT NULL
|
||||
UPDATE clients SET email = concat('unknown-', id::text, '@placeholder.local') WHERE email IS NULL;
|
||||
|
||||
-- Add indexes on appointments table
|
||||
CREATE INDEX idx_appointments_client_id ON appointments(client_id);
|
||||
CREATE INDEX idx_appointments_staff_id ON appointments(staff_id);
|
||||
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
|
||||
CREATE INDEX idx_appointments_status ON appointments(status);
|
||||
|
||||
-- Add index on pets table
|
||||
CREATE INDEX idx_pets_client_id ON pets(client_id);
|
||||
|
||||
-- Add index on clients table
|
||||
CREATE INDEX idx_clients_email ON clients(email);
|
||||
|
||||
-- Set NOT NULL on clients.email (after backfill)
|
||||
ALTER TABLE clients ALTER COLUMN email SET NOT NULL;
|
||||
@@ -190,6 +190,20 @@
|
||||
"when": 1775568867192,
|
||||
"tag": "0026_stripe_payment",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1775655267192,
|
||||
"tag": "0027_refunds",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1775741667192,
|
||||
"tag": "0028_sms_reminders",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -71,6 +71,10 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
|
||||
address: "1 Main St, Springfield, CA 90000",
|
||||
notes: null,
|
||||
emailOptOut: false,
|
||||
smsOptIn: false,
|
||||
smsConsentDate: null,
|
||||
smsOptOutDate: null,
|
||||
smsConsentText: null,
|
||||
stripeCustomerId: null,
|
||||
status: "active",
|
||||
disabledAt: null,
|
||||
|
||||
+125
-82
@@ -102,43 +102,55 @@ export const verification = pgTable("verification", {
|
||||
|
||||
// ─── Tables ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const clients = pgTable("clients", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email"),
|
||||
phone: text("phone"),
|
||||
address: text("address"),
|
||||
notes: text("notes"),
|
||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
status: clientStatusEnum("status").notNull().default("active"),
|
||||
disabledAt: timestamp("disabled_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
export const clients = pgTable(
|
||||
"clients",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull(),
|
||||
phone: text("phone"),
|
||||
address: text("address"),
|
||||
notes: text("notes"),
|
||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
||||
smsConsentDate: timestamp("sms_consent_date"),
|
||||
smsOptOutDate: timestamp("sms_opt_out_date"),
|
||||
smsConsentText: text("sms_consent_text"),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
status: clientStatusEnum("status").notNull().default("active"),
|
||||
disabledAt: timestamp("disabled_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index("idx_clients_email").on(t.email)]
|
||||
);
|
||||
|
||||
export const pets = pgTable("pets", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
species: text("species").notNull(),
|
||||
breed: text("breed"),
|
||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||
dateOfBirth: timestamp("date_of_birth"),
|
||||
healthAlerts: text("health_alerts"),
|
||||
groomingNotes: text("grooming_notes"),
|
||||
cutStyle: text("cut_style"),
|
||||
shampooPreference: text("shampoo_preference"),
|
||||
specialCareNotes: text("special_care_notes"),
|
||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
||||
photoKey: text("photo_key"),
|
||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
export const pets = pgTable(
|
||||
"pets",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
species: text("species").notNull(),
|
||||
breed: text("breed"),
|
||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||
dateOfBirth: timestamp("date_of_birth"),
|
||||
healthAlerts: text("health_alerts"),
|
||||
groomingNotes: text("grooming_notes"),
|
||||
cutStyle: text("cut_style"),
|
||||
shampooPreference: text("shampoo_preference"),
|
||||
specialCareNotes: text("special_care_notes"),
|
||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
||||
photoKey: text("photo_key"),
|
||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index("idx_pets_client_id").on(t.clientId)]
|
||||
);
|
||||
|
||||
export const services = pgTable("services", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -188,51 +200,60 @@ export const appointmentGroups = pgTable("appointment_groups", {
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const appointments = pgTable("appointments", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "restrict" }),
|
||||
petId: uuid("pet_id")
|
||||
.notNull()
|
||||
.references(() => pets.id, { onDelete: "restrict" }),
|
||||
serviceId: uuid("service_id")
|
||||
.notNull()
|
||||
.references(() => services.id, { onDelete: "restrict" }),
|
||||
staffId: uuid("staff_id").references(() => staff.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Optional secondary staff (bather/assistant) for tip-split tracking
|
||||
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
status: appointmentStatusEnum("status").notNull().default("scheduled"),
|
||||
startTime: timestamp("start_time").notNull(),
|
||||
endTime: timestamp("end_time").notNull(),
|
||||
notes: text("notes"),
|
||||
// Override price at time of booking (null = use service base price)
|
||||
priceCents: integer("price_cents"),
|
||||
// Recurring series support
|
||||
seriesId: uuid("series_id").references(() => recurringSeries.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
seriesIndex: integer("series_index"),
|
||||
// Multi-pet group booking: links this appointment to others in the same visit
|
||||
groupId: uuid("group_id").references(() => appointmentGroups.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Customer confirmation/cancellation tracking
|
||||
// Values: "pending" | "confirmed" | "cancelled"
|
||||
confirmationStatus: text("confirmation_status").notNull().default("pending"),
|
||||
confirmedAt: timestamp("confirmed_at"),
|
||||
cancelledAt: timestamp("cancelled_at"),
|
||||
// Token for tokenized email confirm/cancel links (no auth required)
|
||||
confirmationToken: text("confirmation_token").unique(),
|
||||
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
|
||||
customerNotes: text("customer_notes"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
export const appointments = pgTable(
|
||||
"appointments",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "restrict" }),
|
||||
petId: uuid("pet_id")
|
||||
.notNull()
|
||||
.references(() => pets.id, { onDelete: "restrict" }),
|
||||
serviceId: uuid("service_id")
|
||||
.notNull()
|
||||
.references(() => services.id, { onDelete: "restrict" }),
|
||||
staffId: uuid("staff_id").references(() => staff.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Optional secondary staff (bather/assistant) for tip-split tracking
|
||||
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
status: appointmentStatusEnum("status").notNull().default("scheduled"),
|
||||
startTime: timestamp("start_time").notNull(),
|
||||
endTime: timestamp("end_time").notNull(),
|
||||
notes: text("notes"),
|
||||
// Override price at time of booking (null = use service base price)
|
||||
priceCents: integer("price_cents"),
|
||||
// Recurring series support
|
||||
seriesId: uuid("series_id").references(() => recurringSeries.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
seriesIndex: integer("series_index"),
|
||||
// Multi-pet group booking: links this appointment to others in the same visit
|
||||
groupId: uuid("group_id").references(() => appointmentGroups.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
// Customer confirmation/cancellation tracking
|
||||
// Values: "pending" | "confirmed" | "cancelled"
|
||||
confirmationStatus: text("confirmation_status").notNull().default("pending"),
|
||||
confirmedAt: timestamp("confirmed_at"),
|
||||
cancelledAt: timestamp("cancelled_at"),
|
||||
// Token for tokenized email confirm/cancel links (no auth required)
|
||||
confirmationToken: text("confirmation_token").unique(),
|
||||
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
|
||||
customerNotes: text("customer_notes"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("idx_appointments_client_id").on(t.clientId),
|
||||
index("idx_appointments_staff_id").on(t.staffId),
|
||||
index("idx_appointments_start_time").on(t.startTime),
|
||||
index("idx_appointments_status").on(t.status),
|
||||
]
|
||||
);
|
||||
|
||||
export const invoices = pgTable(
|
||||
"invoices",
|
||||
@@ -300,8 +321,28 @@ export const invoiceTipSplits = pgTable(
|
||||
(t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)]
|
||||
);
|
||||
|
||||
// Refund records with idempotency key support
|
||||
export const refunds = pgTable(
|
||||
"refunds",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
invoiceId: uuid("invoice_id")
|
||||
.notNull()
|
||||
.references(() => invoices.id, { onDelete: "restrict" }),
|
||||
stripeRefundId: text("stripe_refund_id").notNull(),
|
||||
idempotencyKey: text("idempotency_key").unique(),
|
||||
amountCents: integer("amount_cents"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("idx_refunds_invoice_id").on(t.invoiceId),
|
||||
index("idx_refunds_idempotency_key").on(t.idempotencyKey),
|
||||
]
|
||||
);
|
||||
|
||||
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
|
||||
// reminder_type values: "confirmation", "24h", "2h"
|
||||
// channel values: "email", "sms"
|
||||
export const reminderLogs = pgTable(
|
||||
"reminder_logs",
|
||||
{
|
||||
@@ -311,9 +352,11 @@ export const reminderLogs = pgTable(
|
||||
.references(() => appointments.id, { onDelete: "cascade" }),
|
||||
// "confirmation" | "24h" | "2h"
|
||||
reminderType: text("reminder_type").notNull(),
|
||||
// "email" | "sms"
|
||||
channel: text("channel").notNull().default("email"),
|
||||
sentAt: timestamp("sent_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [unique().on(t.appointmentId, t.reminderType)]
|
||||
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)]
|
||||
);
|
||||
|
||||
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||
|
||||
+69
-2
@@ -398,6 +398,7 @@ async function seedKnownUsers() {
|
||||
id: ADMIN_STAFF_ID,
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
oidcSub: adminEmail,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
@@ -458,6 +459,37 @@ async function seedKnownUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
||||
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
||||
for (let i = 0; i < groomerCount; i++) {
|
||||
const email = groomerEmails[i]!;
|
||||
const name = groomerNames[i]!;
|
||||
// Use deterministic IDs in the 00000000-0000-0000-0000-000000000005+ range
|
||||
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
||||
const [existingGroomer] = await db
|
||||
.select()
|
||||
.from(schema.staff)
|
||||
.where(eq(schema.staff.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (existingGroomer) {
|
||||
console.log(`✓ Staff groomer '${existingGroomer.name}' already exists — skipping`);
|
||||
} else {
|
||||
await db.insert(schema.staff).values({
|
||||
id: staffId,
|
||||
name,
|
||||
email,
|
||||
oidcSub: email,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
});
|
||||
console.log(`✓ Created staff groomer '${name}' (${email})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||
@@ -567,7 +599,7 @@ async function seed() {
|
||||
|
||||
// ── Staff ──
|
||||
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
|
||||
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: false })
|
||||
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 })
|
||||
);
|
||||
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) =>
|
||||
({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })
|
||||
@@ -612,6 +644,7 @@ async function seed() {
|
||||
id: ADMIN_STAFF_ID,
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
oidcSub: adminEmail,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
active: true,
|
||||
@@ -623,6 +656,31 @@ async function seed() {
|
||||
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
||||
}
|
||||
|
||||
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
||||
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
||||
for (let i = 0; i < groomerCount; i++) {
|
||||
const email = groomerEmails[i]!;
|
||||
const name = groomerNames[i]!;
|
||||
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
||||
await db.insert(schema.staff)
|
||||
.values({
|
||||
id: staffId,
|
||||
name,
|
||||
email,
|
||||
oidcSub: email,
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
active: true,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.staff.email,
|
||||
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
|
||||
});
|
||||
console.log(`✓ Upserted groomer '${name}' (${email})`);
|
||||
}
|
||||
|
||||
// ── Services ──
|
||||
// Upsert services using name as unique key. With deterministic IDs in
|
||||
// servicesDef and TRUNCATE clearing downstream tables first, this is
|
||||
@@ -825,6 +883,7 @@ async function seed() {
|
||||
let appointmentCount = 0;
|
||||
let invoiceCount = 0;
|
||||
let visitLogCount = 0;
|
||||
let paidInvoiceCounter = 0;
|
||||
|
||||
// Process in batches per client to keep memory manageable
|
||||
const apptBatchSize = 100;
|
||||
@@ -919,6 +978,10 @@ async function seed() {
|
||||
|
||||
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
||||
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
||||
paidInvoiceCounter++;
|
||||
const stripePaymentIntentId = invoiceStatus === "paid"
|
||||
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
|
||||
: null;
|
||||
|
||||
invoiceBatch.push({
|
||||
id: invoiceId,
|
||||
@@ -931,6 +994,7 @@ async function seed() {
|
||||
status: invoiceStatus,
|
||||
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
||||
paidAt,
|
||||
stripePaymentIntentId,
|
||||
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
||||
});
|
||||
|
||||
@@ -1034,13 +1098,16 @@ async function seed() {
|
||||
const taxCents = Math.round(effectivePrice * 0.08);
|
||||
const totalCents = effectivePrice + taxCents + tipCents;
|
||||
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
||||
paidInvoiceCounter++;
|
||||
|
||||
invoiceBatch.push({
|
||||
id: invoiceId, appointmentId: apptId, clientId,
|
||||
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
||||
status: "paid" as const,
|
||||
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
||||
paidAt, notes: null,
|
||||
paidAt,
|
||||
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
|
||||
notes: null,
|
||||
});
|
||||
lineItemBatch.push({
|
||||
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface Pet {
|
||||
shampooPreference: string | null;
|
||||
specialCareNotes: string | null;
|
||||
customFields: Record<string, string>;
|
||||
photoKey?: string;
|
||||
photoUploadedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -150,10 +152,16 @@ export interface Invoice {
|
||||
status: InvoiceStatus;
|
||||
paymentMethod: PaymentMethod | null;
|
||||
paidAt: string | null;
|
||||
stripePaymentIntentId: string | null;
|
||||
stripeRefundId: string | null;
|
||||
paymentFailureReason: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lineItems?: InvoiceLineItem[];
|
||||
// Transient fields populated from Stripe API (not stored in DB)
|
||||
cardLast4?: string | null;
|
||||
paymentStatus?: string | null;
|
||||
tipSplits?: InvoiceTipSplit[];
|
||||
}
|
||||
|
||||
|
||||
Generated
+43
-53
@@ -43,6 +43,9 @@ importers:
|
||||
stripe:
|
||||
specifier: ^22.0.0
|
||||
version: 22.0.1(@types/node@22.19.15)
|
||||
telnyx:
|
||||
specifier: ^1.23.0
|
||||
version: 1.27.0
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
@@ -177,7 +180,7 @@ importers:
|
||||
version: 22.19.15
|
||||
drizzle-kit:
|
||||
specifier: ^0.30.4
|
||||
version: 0.30.6
|
||||
version: 0.30.4
|
||||
tsx:
|
||||
specifier: ^4.19.0
|
||||
version: 4.21.0
|
||||
@@ -1696,9 +1699,6 @@ packages:
|
||||
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@petamoriken/float16@3.9.3':
|
||||
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -2830,8 +2830,8 @@ packages:
|
||||
dom-accessibility-api@0.6.3:
|
||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||
|
||||
drizzle-kit@0.30.6:
|
||||
resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
|
||||
drizzle-kit@0.30.4:
|
||||
resolution: {integrity: sha512-B2oJN5UkvwwNHscPWXDG5KqAixu7AUzZ3qbe++KU9SsQ+cZWR4DXEPYcvWplyFAno0dhRJECNEhNxiDmFaPGyQ==}
|
||||
hasBin: true
|
||||
|
||||
drizzle-orm@0.38.4:
|
||||
@@ -2955,10 +2955,6 @@ packages:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
env-paths@3.0.0:
|
||||
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
es-abstract@1.24.1:
|
||||
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3162,11 +3158,6 @@ packages:
|
||||
functions-have-names@1.2.3:
|
||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||
|
||||
gel@2.2.0:
|
||||
resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
generator-function@2.0.1:
|
||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3434,10 +3425,6 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
isexe@3.1.5:
|
||||
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3619,6 +3606,9 @@ packages:
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -3851,6 +3841,10 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qs@6.15.1:
|
||||
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
randombytes@2.1.0:
|
||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||
|
||||
@@ -4046,10 +4040,6 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shell-quote@1.8.3:
|
||||
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4188,6 +4178,10 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
telnyx@1.27.0:
|
||||
resolution: {integrity: sha512-cVbP3jEW4TbmNL5U0UbZc3OkLg+6dHRnMYByYfJnrGw5ZRn0XKb17Hx3fLMWmGgRFow7eqVP4hlCogbIB6T3+w==}
|
||||
engines: {node: ^6 || >=8}
|
||||
|
||||
temp-dir@2.0.0:
|
||||
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4262,6 +4256,9 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tweetnacl@1.0.3:
|
||||
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -4351,6 +4348,10 @@ packages:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
|
||||
uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
hasBin: true
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
@@ -4487,11 +4488,6 @@ packages:
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
which@4.0.0:
|
||||
resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
|
||||
engines: {node: ^16.13.0 || >=18.0.0}
|
||||
hasBin: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6223,8 +6219,6 @@ snapshots:
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.40.0': {}
|
||||
|
||||
'@petamoriken/float16@3.9.3': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
@@ -7420,13 +7414,12 @@ snapshots:
|
||||
|
||||
dom-accessibility-api@0.6.3: {}
|
||||
|
||||
drizzle-kit@0.30.6:
|
||||
drizzle-kit@0.30.4:
|
||||
dependencies:
|
||||
'@drizzle-team/brocli': 0.10.2
|
||||
'@esbuild-kit/esm-loader': 2.6.5
|
||||
esbuild: 0.19.12
|
||||
esbuild-register: 3.6.0(esbuild@0.19.12)
|
||||
gel: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7463,8 +7456,6 @@ snapshots:
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
env-paths@3.0.0: {}
|
||||
|
||||
es-abstract@1.24.1:
|
||||
dependencies:
|
||||
array-buffer-byte-length: 1.0.2
|
||||
@@ -7826,17 +7817,6 @@ snapshots:
|
||||
|
||||
functions-have-names@1.2.3: {}
|
||||
|
||||
gel@2.2.0:
|
||||
dependencies:
|
||||
'@petamoriken/float16': 3.9.3
|
||||
debug: 4.4.3
|
||||
env-paths: 3.0.0
|
||||
semver: 7.7.4
|
||||
shell-quote: 1.8.3
|
||||
which: 4.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
generator-function@2.0.1: {}
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
@@ -8101,8 +8081,6 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isexe@3.1.5: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
@@ -8271,6 +8249,8 @@ snapshots:
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash.sortby@4.7.0: {}
|
||||
@@ -8469,6 +8449,10 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qs@6.15.1:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
randombytes@2.1.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -8703,8 +8687,6 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shell-quote@1.8.3: {}
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -8858,6 +8840,14 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
telnyx@1.27.0:
|
||||
dependencies:
|
||||
lodash.isplainobject: 4.0.6
|
||||
qs: 6.15.1
|
||||
safe-buffer: 5.2.1
|
||||
tweetnacl: 1.0.3
|
||||
uuid: 9.0.1
|
||||
|
||||
temp-dir@2.0.0: {}
|
||||
|
||||
tempy@0.6.0:
|
||||
@@ -8928,6 +8918,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tweetnacl@1.0.3: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -9024,6 +9016,8 @@ snapshots:
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
@@ -9201,10 +9195,6 @@ snapshots:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
which@4.0.0:
|
||||
dependencies:
|
||||
isexe: 3.1.5
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
|
||||
Reference in New Issue
Block a user