Compare commits

..

6 Commits

Author SHA1 Message Date
Paperclip 0d73532054 GRO-607: Replace mock payment flow with real Stripe Elements
- Install @stripe/stripe-js and @stripe/react-stripe-js
- Replace BillingPayments mock delay with real Stripe Elements:
  - Fetch publishableKey from GET /api/portal/config
  - Lazy load Stripe via loadStripe()
  - Wrap payment modal in <Elements> with PaymentElement
  - Use stripe.confirmPayment() with clientSecret from pay/pay-multiple endpoints
  - Support multi-invoice selection and single invoice payment
- Add "Save card for future payments" checkbox (setup_future_usage)
- Add payment method management: list saved cards, delete via DELETE endpoint
- Proper error handling for payment failures
- Autopay toggle (UI-only, Phase 2 backend pending)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:48:23 +00:00
Paperclip dcf5740489 GRO-606: Add payment API endpoints (pay invoice, payment methods, refunds)
Portal routes (client-facing):
- POST /api/portal/invoices/:id/pay - create PaymentIntent for single invoice
- POST /api/portal/invoices/pay-multiple - create PaymentIntent for multiple invoices
- GET /api/portal/payment-methods - list saved payment methods
- POST /api/portal/payment-methods - create SetupIntent for saving new card
- DELETE /api/portal/payment-methods/:id - detach payment method
- GET /api/portal/config - return Stripe publishable key

Admin routes:
- POST /api/invoices/:id/refund - manager-only refund endpoint

Validation:
- Cannot pay draft, void, or already-paid invoices
- Multi-invoice: all must belong to same client and be pending
- Refund requires invoice to be paid with stripePaymentIntentId

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:46:09 +00:00
Paperclip e15977ba82 GRO-605: Stripe SDK integration + payment service
- Add stripe dependency to @groombook/api
- Implement payment.ts service with:
  - getOrCreateStripeCustomer: look up or create Stripe customer
  - createPaymentIntent: create payment intent for invoice(s)
  - processRefund: full or partial refund
  - listPaymentMethods: list saved cards
  - attachPaymentMethod / detachPaymentMethod: manage saved cards
- Lazy Stripe client initialization; graceful null return when keys not set

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:41:29 +00:00
Paperclip 4079ed9d26 GRO-600: Extend reminder scheduler to send SMS alongside email
- Import smsSend from ./sms.js
- Add TCPA opt-out constant
- Check email+SMS logs separately to allow independent sends
- Add smsOptIn and phoneE164 to client query
- Conditionally send SMS for opted-in clients with valid E.164 phone
- SMS message: pet name, service, groomer, confirm/cancel links, TCPA text
- SMS failures logged but don't block email delivery
- Feature flag: only attempts SMS when SMS service is initialized
- Idempotency: per-channel reminder log prevents duplicate sends
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:24:56 +00:00
Paperclip 1cc708e6b4 feat(gro-194): SMS provider service with Telnyx SDK integration
- Added telnyx npm package
- Created sms.ts with SmsProvider interface
- Implemented TelnyxProvider with sendSms() and validateWebhookSignature()
- Added createSmsProvider() factory function
- Added smsSend() convenience function that skips when SMS_ENABLED=false
- Provider abstraction allows future Twilio or other providers
- E.164 phone validation on send
- Webhook signature verification using HMAC-SHA256

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:22:28 +00:00
Paperclip 6b300626a0 GRO-598/GRO-194 Phase 1.1: SMS schema - add consent fields to clients, channel to reminderLogs, E.164 phone validation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:18:32 +00:00
159 changed files with 13568 additions and 9724 deletions
-2
View File
@@ -7,5 +7,3 @@ apps/web/dist
apps/api/dist
packages/db/dist
packages/types/dist
.turbo
screenshots/
-4
View File
@@ -11,10 +11,6 @@ AUTH_DISABLED=false
OIDC_ISSUER=https://authentik.example.com
OIDC_AUDIENCE=groombook
# ── Webhooks ─────────────────────────────────────────────────────────────────
# Telnyx webhook secret for validating inbound message webhooks.
TELNYX_WEBHOOK_SECRET=your-telnyx-webhook-secret-here
# ── Setup Wizard ─────────────────────────────────────────────────────────────
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
# super user exists in the database. Useful in dev/test environments where the
+113 -98
View File
@@ -2,10 +2,15 @@ name: CI
on:
push:
branches: [main, dev]
branches: [main]
pull_request:
branches: [main, dev]
branches: [main]
workflow_dispatch:
inputs:
ref:
description: "Branch or ref to run CI against"
required: false
default: "main"
jobs:
lint-typecheck:
@@ -15,8 +20,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -39,8 +42,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -61,8 +62,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -81,8 +80,14 @@ 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()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: apps/e2e/playwright-report/
retention-days: 7
- name: Stop Docker Compose stack
if: always()
@@ -96,8 +101,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -108,8 +111,6 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build all packages
env:
VITE_API_URL: ""
run: pnpm build
docker:
@@ -118,6 +119,9 @@ jobs:
needs: [build, e2e]
outputs:
tag: ${{ steps.version.outputs.tag }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
@@ -138,12 +142,12 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: git.farh.net
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push API image
uses: docker/build-push-action@v6
@@ -153,10 +157,10 @@ jobs:
target: runner
push: true
tags: |
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
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
- name: Build and push Migrate image
uses: docker/build-push-action@v6
@@ -166,10 +170,10 @@ jobs:
target: migrate
push: true
tags: |
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
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
- name: Build and push Seed image
uses: docker/build-push-action@v6
@@ -179,10 +183,10 @@ jobs:
target: seed
push: true
tags: |
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
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
- name: Build and push Reset image
uses: docker/build-push-action@v6
@@ -192,10 +196,10 @@ jobs:
target: reset
push: true
tags: |
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
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
- name: Build and push Web image
uses: docker/build-push-action@v6
@@ -204,16 +208,19 @@ jobs:
file: apps/web/Dockerfile
push: true
tags: |
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
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
deploy-dev:
name: Deploy PR to groombook-dev
runs-on: runners-groombook
needs: [docker]
if: github.event_name == 'pull_request'
permissions:
contents: read
pull-requests: write
steps:
- name: Install kubectl
run: |
@@ -230,6 +237,8 @@ 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
@@ -244,7 +253,7 @@ jobs:
restartPolicy: Never
containers:
- name: migrate
image: git.farh.net/groombook/migrate:$TAG
image: ghcr.io/groombook/migrate:$TAG
env:
- name: DATABASE_URL
valueFrom:
@@ -255,33 +264,35 @@ jobs:
kubectl wait --for=condition=complete "job/migrate-pr-$PR_NUM" \
-n groombook-dev --timeout=120s
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
# 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
# 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
env:
PR_NUM: ${{ github.event.pull_request.number }}
run: |
PR_NUM="$PR_NUM"
BODY=$(cat <<'EOFBODY'
## Deployed to groombook-dev
**Images:** `pr-'"$PR_NUM"'`
**URL:** https://dev.groombook.farh.net
Ready for UAT validation.
EOFBODY
)
curl -s -X POST "https://git.farh.net/api/v1/repos/groombook/app/issues/${PR_NUM}/comments" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"body\": $(echo "$BODY" | jq -Rs .)}"
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')
});
web-e2e:
name: Web E2E (Dev)
@@ -292,8 +303,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -310,15 +319,33 @@ jobs:
run: pnpm --filter @groombook/web test:e2e
timeout-minutes: 10
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-web-e2e-report
path: apps/web/playwright-report/
retention-days: 7
cd:
name: Update Infra Image Tags
runs-on: ubuntu-latest
needs: [docker]
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: write
pull-requests: write
steps:
- name: Generate infra repo token
id: infra-token
uses: tibdex/github-app-token@v2
with:
app_id: ${{ vars.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Clone groombook/infra
run: |
git clone https://oauth2:${{ secrets.REGISTRY_TOKEN }}@git.farh.net/groombook/infra.git /tmp/infra
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
- name: Install yq
run: |
@@ -337,24 +364,28 @@ jobs:
echo "Updating dev overlay image tags to: $TAG"
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
cd /tmp/infra
DEV_KUST="apps/overlays/dev/kustomization.yaml"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
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"
MIGRATE_JOB="apps/base/migrate-job.yaml"
# Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
if [ -f "$MIGRATE_JOB" ]; then
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
SEED_JOB="apps/base/seed-job.yaml"
# Update seed Job name to include short SHA (immutable template fix)
SEED_JOB="apps/groombook/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then
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
@@ -363,6 +394,7 @@ jobs:
- name: Create PR on groombook/infra
env:
TAG: ${{ needs.docker.outputs.tag }}
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
run: |
if [ -z "$TAG" ]; then
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
@@ -370,35 +402,18 @@ jobs:
cd /tmp/infra
git config user.name "groombook-engineer[bot]"
git config user.email "groombook-engineer@farh.net"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "chore/update-image-tags-${TAG}"
git add apps/overlays/dev/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
git push -u origin "chore/update-image-tags-${TAG}"
EXISTING_PR=$(curl -s "https://git.farh.net/api/v1/repos/groombook/infra/pulls?state=open&head=groombook:chore/update-image-tags-${TAG}" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" | jq -r '.[0].number')
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
curl -s -X PUT "https://git.farh.net/api/v1/repos/groombook/infra/pulls/${EXISTING_PR}/merge" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"do": "merge"}'
else
PR_RESPONSE=$(curl -s -X POST "https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{
\"base\": \"main\",
\"head\": \"chore/update-image-tags-${TAG}\",
\"title\": \"chore: deploy ${TAG} to dev\",
\"body\": \"[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge\"
}")
PR_NUM=$(echo "$PR_RESPONSE" | jq -r '.number')
echo "Created PR #$PR_NUM"
curl -s -X PUT "https://git.farh.net/api/v1/repos/groombook/infra/pulls/${PR_NUM}/merge" \
-H "Authorization: Bearer ${{ secrets.REGISTRY_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"do": "merge"}'
fi
# 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
+4 -26
View File
@@ -14,29 +14,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
steps:
- name: Validate tag format
run: |
TAG="${{ inputs.tag }}"
if ! echo "$TAG" | grep -qE '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[a-f0-9]{7}$'; then
echo "::error::Invalid tag format: '$TAG'. Expected format: YYYY.MM.DD-sha7 (e.g. 2026.03.28-f1b85bf)"
exit 1
fi
echo "Tag format valid: $TAG"
- name: Verify image exists in GHCR
env:
GH_TOKEN: ${{ secrets.GITHUB_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
fi
echo "Image verified: ghcr.io/groombook/api:$TAG exists"
- name: Generate infra repo token
id: infra-token
uses: tibdex/github-app-token@v2
@@ -58,7 +36,7 @@ jobs:
TAG: ${{ inputs.tag }}
run: |
cd /tmp/infra
PROD_KUST="apps/overlays/prod/kustomization.yaml"
PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml"
SHORT_SHA="${TAG##*-}"
export SHORT_SHA
@@ -70,14 +48,14 @@ jobs:
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/base/migrate-job.yaml"
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/base/seed-job.yaml"
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"
@@ -94,7 +72,7 @@ jobs:
git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "release/promote-prod-${TAG}"
git add apps/overlays/prod/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
git add apps/groombook/overlays/prod/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git commit -m "release: promote ${TAG} to production"
git push -u origin "release/promote-prod-${TAG}"
gh pr create \
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
run: |
echo "Updating UAT overlay image tags to: $TAG"
cd /tmp/infra
UAT_KUST="apps/overlays/uat/kustomization.yaml"
UAT_KUST="apps/groombook/overlays/uat/kustomization.yaml"
if [ ! -f "$UAT_KUST" ]; then
echo "ERROR: UAT overlay not found at $UAT_KUST. Ensure GRO-427 has been completed."
@@ -55,7 +55,7 @@ jobs:
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/base/migrate-job.yaml"
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"
@@ -64,7 +64,7 @@ jobs:
# Update seed Job name to include short SHA (immutable template fix)
# NOTE: Do NOT update the image tag here — let the Kustomize images transformer
# in the UAT overlay handle it via newTag. This avoids the immutable template issue.
SEED_JOB="apps/base/seed-job.yaml"
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"
@@ -81,7 +81,7 @@ jobs:
git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "chore/update-uat-image-tags-${TAG}"
git add apps/overlays/uat/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git commit -m "chore: promote ${TAG} to UAT"
git push -u origin "chore/update-uat-image-tags-${TAG}"
-13
View File
@@ -8,16 +8,3 @@ 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/
View File
-90
View File
@@ -1,90 +0,0 @@
# 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.
-283
View File
@@ -1,283 +0,0 @@
# UAT Playbook
## 1. Overview
GroomBook is an open-source, self-hostable pet grooming business management & CRM platform. The monorepo contains the Hono API (`apps/api`), React PWA web app (`apps/web`), E2E tests (`apps/e2e`), and shared packages (`packages/db`, `packages/types`). Tech stack: Hono + React 19 + Vite + PostgreSQL + Drizzle ORM + Authentik OIDC.
## 2. Environments
| Environment | URL | Notes |
|-------------|-----|-------|
| Dev | `https://dev.groombook.dev` | Development environment for active development |
| UAT | `https://uat.groombook.dev` | User Acceptance Testing environment |
| Production | `https://demo.groombook.dev` | Production/demo environment |
**Local Development:** Run `docker compose up --build` at repository root. Web app available at `localhost:8080`, API at `localhost:3000`.
## 3. Pre-conditions
- UAT environment is accessible at `https://uat.groombook.dev`
- Test accounts are seeded with the following personas:
- **Manager:** Full administrative access
- **Staff:** Limited access to assigned appointments and clients
- **Client:** Portal access to view and manage their own appointments
- OIDC is configured with Authentik at `https://auth.farh.net`
- Seed data is populated:
- Sample clients and pets
- Grooming services with pricing and duration
- Existing appointments
- Stripe test keys are configured for payment flow testing
- Email/SMS providers (Telnyx, etc.) are configured for notification testing
## 4. Test Cases
### 4.1 Authentication
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.1.1 | OIDC login (Authentik) | 1. Navigate to UAT environment<br>2. Click "Login with Authentik"<br>3. Enter test credentials<br>4. Authorize the application | User is redirected to app dashboard, session is established |
| TC-APP-4.1.2 | Email + password login (UAT Super) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-super@groombook.dev` and UAT super password<br>4. Submit | User is logged in and redirected to dashboard with manager access |
| TC-APP-4.1.3 | Email + password login (UAT Groomer) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-groomer@groombook.dev` and UAT groomer password<br>4. Submit | User is logged in and redirected to dashboard with staff/groomer access |
| TC-APP-4.1.4 | Email + password login (UAT Customer) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-customer@groombook.dev` and UAT customer password<br>4. Submit | User is logged in with client portal access |
| TC-APP-4.1.5 | Email + password login (UAT Tester) | 1. Navigate to UAT environment sign-in page<br>2. Select email+password flow<br>3. Enter `uat-tester@groombook.dev` and UAT tester password<br>4. Submit | User is logged in with staff/tester access |
| TC-APP-4.1.6 | Session persistence | 1. Log in as any user<br>2. Close browser tab<br>3. Reopen browser and navigate to UAT | User remains logged in, no re-authentication required |
| TC-APP-4.1.7 | Logout | 1. Log in as any user<br>2. Click logout button<br>3. Attempt to access protected route | User is logged out and redirected to login page |
| TC-APP-4.1.8 | RBAC - Manager access | 1. Log in as Manager (OIDC or email+password)<br>2. Navigate to Settings, Staff Management, Reports | All administrative features are accessible |
| TC-APP-4.1.9 | RBAC - Staff access | 1. Log in as Staff (OIDC or email+password)<br>2. Attempt to access Settings, Staff Management | Access denied or limited view, staff can only see assigned appointments |
| TC-APP-4.1.10 | RBAC - Client access | 1. Log in as Client (email+password)<br>2. Navigate to portal<br>3. Attempt to access admin areas | Client can only view their own appointments, pets, and profile |
| TC-APP-4.1.11 | Login after hourly reset | 1. Wait for or trigger `reset-demo-data` CronJob to run<br>2. Attempt email+password login as any UAT persona | Login succeeds — Better Auth credential accounts survive the reset cycle |
### 4.2 Setup Wizard / OOBE
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.2.1 | First-run setup | 1. Access fresh UAT environment with no configuration<br>2. Complete setup wizard: business name, hours, services | Configuration is saved, dashboard loads with setup complete |
| TC-APP-4.2.2 | Setup validation | 1. Start setup wizard<br>2. Leave required fields blank<br>3. Attempt to proceed | Validation errors displayed, cannot proceed without required fields |
| TC-APP-4.2.3 | Skip setup (if already configured) | 1. Access configured environment<br>2. Attempt to access setup wizard | Redirected to dashboard or setup is marked as complete |
### 4.3 Client Management
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.3.1 | Create new client | 1. Navigate to Clients page<br>2. Click "Add Client"<br>3. Fill in client details (name, email, phone, address)<br>4. Save | Client is created and appears in client list |
| TC-APP-4.3.2 | Edit client | 1. Select existing client<br>2. Click "Edit"<br>3. Modify client details<br>4. Save | Changes are saved and reflected in client profile |
| TC-APP-4.3.3 | Search clients | 1. Navigate to Clients page<br>2. Enter client name or email in search<br>3. Press Enter/submit | Search results display matching clients |
| TC-APP-4.3.4 | Archive client | 1. Select active client<br>2. Click "Archive"<br>3. Confirm action | Client is marked as archived, no longer appears in active client list |
### 4.4 Pet Management
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.4.1 | Add pet to client | 1. Select client<br>2. Click "Add Pet"<br>3. Fill in pet details (name, breed, weight, notes)<br>4. Save | Pet is added to client's pet list |
| TC-APP-4.4.2 | Edit pet information | 1. Select pet from client profile<br>2. Click "Edit"<br>3. Modify pet details<br>4. Save | Changes are saved and reflected |
| TC-APP-4.4.3 | View grooming history | 1. Select pet with past appointments<br>2. Navigate to "History" tab | All past grooming appointments and notes are displayed |
| TC-APP-4.4.4 | Add breed notes | 1. Edit pet<br>2. Add breed-specific notes (temperament, special handling)<br>3. Save | Notes are saved and visible to staff when scheduling |
### 4.5 Appointment Scheduling
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.5.1 | Create new appointment | 1. Navigate to Calendar or Appointments page<br>2. Click "New Appointment"<br>3. Select client, pet, service, staff, date/time<br>4. Save | Appointment is created and appears in calendar |
| TC-APP-4.5.2 | Modify appointment | 1. Select existing appointment<br>2. Click "Edit"<br>3. Change date/time, staff, or service<br>4. Save | Changes are saved and calendar updates |
| TC-APP-4.5.3 | Cancel appointment | 1. Select upcoming appointment<br>2. Click "Cancel"<br>3. Confirm and optionally select reason | Appointment is marked as cancelled, slot becomes available |
| TC-APP-4.5.4 | Calendar view (day/week/month) | 1. Navigate to Calendar<br>2. Switch between day, week, and month views | Calendar displays appointments in selected time range correctly |
| TC-APP-4.5.5 | Appointment groups | 1. Create multiple appointments for same time slot<br>2. View in calendar | Appointments are grouped/linked appropriately |
| TC-APP-4.5.6 | Appointment availability check | 1. Attempt to book appointment during unavailable slot | System shows conflict or prevents double-booking |
| TC-APP-4.5.7 | Booking wizard — size/coat selection | 1. Start new appointment booking wizard<br>2. Select a pet with sizeCategory and coatType set<br>3. Observe the service/slot selection step | Size and coat type dropdowns are displayed and persist the pet's existing values |
| TC-APP-4.5.8 | Large/X-Large pet slot duration reflects buffer | 1. Add a pet with sizeCategory = "large" or "x-large" to an appointment<br>2. Note the service duration<br>3. Complete booking and inspect the appointment | Appointment slot includes the service duration plus the configured buffer for the pet's size category |
| TC-APP-4.5.9 | Appointment overrun cascades downstream | 1. Book three consecutive same-groomer appointments (A → B → C)<br>2. Manually extend appointment A's endTime so it overlaps B's startTime by ≥15 min<br>3. Observe appointment B | Appointment B (and C if still overlapping) is automatically shifted forward by the overrun delta + buffer; no error thrown |
| TC-APP-4.5.10 | Cascaded appointments appear at new times | 1. Complete TC-APP-4.5.9<br>2. Check the calendar/list view | Appointments B and C are now shown at their shifted start/end times |
| TC-APP-4.5.11 | Client receives reschedule notification email | 1. Complete TC-APP-4.5.9<br>2. Check the client's email (or notification log) | Client receives an email with subject/lines indicating their appointment was rescheduled from original time to new time |
| TC-APP-4.5.12 | Appointment flagged when shift crosses day boundary | 1. Book appointment D for late afternoon (e.g. 17:30)<br>2. Extend a prior appointment so D would shift to the next day<br>3. Observe D | Appointment D is flagged for manual review and is NOT auto-shifted to the next day |
| TC-APP-4.5.13 | Only scheduled/confirmed appointments are cascaded | 1. Start a cascade scenario (TC-APP-4.5.9) where a downstream appointment is already `in_progress`<br>2. Complete the cascade | The `in_progress` appointment is not shifted; cascade continues to next eligible appointment |
### 4.6 Services
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.6.1 | List all services | 1. Navigate to Services page | All configured grooming services are listed |
| TC-APP-4.6.2 | Create new service | 1. Click "Add Service"<br>2. Enter service name, description, duration, price<br>3. Save | Service is created and appears in list |
| TC-APP-4.6.3 | Edit service | 1. Select existing service<br>2. Modify pricing or duration<br>3. Save | Changes are saved and reflected |
| TC-APP-4.6.4 | Deactivate service | 1. Select service<br>2. Click "Deactivate"<br>3. Confirm | Service is marked as inactive, not available for new appointments |
### 4.7 Staff Management
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.7.1 | List all staff | 1. Navigate to Staff page | All staff members are listed with roles and status |
| TC-APP-4.7.2 | Add new staff member | 1. Click "Add Staff"<br>2. Enter staff details and assign role<br>3. Save | Staff member is created and can be assigned to appointments |
| TC-APP-4.7.3 | Assign RBAC role | 1. Select staff member<br>2. Change role (e.g., from Staff to Manager)<br>3. Save | Role change takes effect immediately |
| TC-APP-4.7.4 | Impersonate client | 1. As Manager, select client<br>2. Click "Impersonate"<br>3. Verify audit log | Manager views client's perspective, action is logged |
| TC-APP-4.7.5 | End impersonation | 1. While impersonating, click "End Impersonation" | Session returns to Manager's view |
### 4.8 Invoicing & Payments
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.8.1 | Generate invoice | 1. Select completed appointment<br>2. Click "Generate Invoice"<br>3. Review invoice details | Invoice is created with correct services, pricing, and taxes |
| TC-APP-4.8.2 | Process Stripe payment | 1. Open invoice<br>2. Click "Pay Now"<br>3. Enter Stripe test card details<br>4. Submit | Payment is processed, invoice marked as paid |
| TC-APP-4.8.3 | Add tip | 1. Before or after payment, add tip amount<br>2. Save | Tip is added to invoice total |
| TC-APP-4.8.4 | Generate receipt | 1. After payment, click "Generate Receipt"<br>2. Download or view receipt | Receipt is generated with payment details |
| TC-APP-4.8.5 | Process refund | 1. Select paid invoice<br>2. Click "Refund"<br>3. Enter refund amount and reason<br>4. Confirm | Refund is processed via Stripe, invoice status updated |
| TC-APP-4.8.6 | Failed payment handling | 1. Attempt payment with declined card<br>2. Verify error handling | Appropriate error message displayed, invoice remains unpaid |
### 4.9 Customer Portal
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.9.1 | Client login | 1. Access portal URL<br>2. Log in with client credentials | Client lands on portal dashboard |
| TC-APP-4.9.2 | View appointments | 1. Navigate to "My Appointments"<br>2. Review upcoming and past appointments | All client's appointments are listed |
| TC-APP-4.9.3 | Confirm appointment | 1. Select upcoming appointment<br>2. Click "Confirm" | Appointment is marked as confirmed by client |
| TC-APP-4.9.4 | Cancel appointment | 1. Select upcoming appointment<br>2. Click "Cancel"<br>3. Provide reason | Appointment is cancelled, notification sent to business |
| TC-APP-4.9.5 | View appointment history | 1. Navigate to "History" tab | All past appointments with details are shown |
### 4.9.1 Communication Tab
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.9.6 | View message history (conversation exists) | 1. Log in as client with existing conversation<br>2. Navigate to Communication tab | Real message history is displayed (not mock data) |
| TC-APP-4.9.7 | Empty state (no conversation yet) | 1. Log in as client with no conversation<br>2. Navigate to Communication tab | Empty state is shown; app does not crash or show mock messages |
| TC-APP-4.9.8 | Composer disabled | 1. Log in as client<br>2. Navigate to Communication tab | Composer/Reply field is hidden or disabled with tooltip "Reply from your phone" |
| TC-APP-4.9.9 | Cross-tenant isolation | 1. As client A, retrieve session token<br>2. Attempt to fetch client B conversation via API | Request returns 403 or empty; client A cannot access client B messages |
### 4.10 Waitlist
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.10.1 | Add client to waitlist | 1. Navigate to Waitlist page<br>2. Click "Add to Waitlist"<br>3. Select client, pet, preferred dates<br>4. Save | Client is added to waitlist |
| TC-APP-4.10.2 | View waitlist | 1. Navigate to Waitlist page | All waitlisted requests are displayed with priority |
| TC-APP-4.10.3 | Promote to appointment | 1. Select waitlist entry<br>2. Click "Promote to Appointment"<br>3. Select available slot | Appointment is created from waitlist, entry removed |
| TC-APP-4.10.4 | Remove from waitlist | 1. Select waitlist entry<br>2. Click "Remove"<br>3. Confirm | Entry is removed from waitlist |
### 4.11 Search
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.11.1 | Global search for clients | 1. Use global search bar<br>2. Enter client name or email<br>3. Select "Clients" | Search returns matching clients |
| TC-APP-4.11.2 | Global search for pets | 1. Use global search bar<br>2. Enter pet name or breed<br>3. Select "Pets" | Search returns matching pets with owner info |
| TC-APP-4.11.3 | Search filters | 1. Perform search<br>2. Apply filters (date range, status, etc.) | Results are filtered according to criteria |
| TC-APP-4.11.4 | No results handling | 1. Search for non-existent term<br>2. Verify UI | "No results found" message displayed |
### 4.12 Reports
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.12.1 | Revenue dashboard | 1. Navigate to Reports > Revenue<br>2. Select date range | Revenue metrics displayed (total, by service, by staff) |
| TC-APP-4.12.2 | Staff utilization | 1. Navigate to Reports > Utilization<br>2. Select date range | Staff hours booked vs. available shown |
| TC-APP-4.12.3 | Trend analytics | 1. Navigate to Reports > Trends<br>2. Select metric and time period | Trend chart displays with data points |
| TC-APP-4.12.4 | Export report | 1. View any report<br>2. Click "Export"<br>3. Select format (CSV, PDF) | Report file is downloaded |
### 4.13 Calendar
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.13.1 | Generate iCal feed | 1. Navigate to Calendar<br>2. Click "iCal Feed"<br>3. Copy URL | iCal feed URL is generated for external calendar apps |
| TC-APP-4.13.2 | Calendar sync (external) | 1. Import iCal feed into external calendar (Google, Outlook)<br>2. Verify sync | Appointments appear in external calendar |
| TC-APP-4.13.3 | Calendar availability display | 1. View calendar in any view mode | Available and booked slots are visually distinct |
### 4.14 Email Reminders
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.14.1 | Configure email reminders | 1. Navigate to Settings > Notifications<br>2. Set reminder timing (24h, 1h before)<br>3. Save | Configuration is saved |
| TC-APP-4.14.2 | Verify reminder delivery | 1. Create appointment for tomorrow<br>2. Wait for reminder trigger<br>3. Check test email account | Reminder email is received with correct details |
| TC-APP-4.14.3 | SMS notification | 1. Configure SMS provider (Telnyx)<br>2. Enable SMS reminders<br>3. Create appointment | SMS is sent to client's phone number |
| TC-APP-4.14.4 | Notification preferences | 1. As client, access portal settings<br>2. Toggle email/SMS preferences | Preferences are respected for future notifications |
### 4.15 Grooming Logs
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.15.1 | Log grooming entry | 1. Select pet<br>2. Click "Add Grooming Log"<br>3. Enter details (date, services, notes, photos)<br>4. Save | Log entry is created and linked to pet |
| TC-APP-4.15.2 | View grooming history | 1. Select pet<br>2. Navigate to "Grooming History" | All log entries are displayed chronologically |
| TC-APP-4.15.3 | Add photos to log | 1. Create or edit grooming log<br>2. Upload before/after photos<br>3. Save | Photos are attached to log entry |
| TC-APP-4.15.4 | Edit grooming log | 1. Select existing log entry<br>2. Modify notes or services<br>3. Save | Changes are saved |
### 4.16 Settings
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.16.1 | Business settings | 1. Navigate to Settings > Business<br>2. Update business name, hours, contact info<br>3. Save | Settings are saved and reflected app-wide |
| TC-APP-4.16.2 | App configuration | 1. Navigate to Settings > App<br>2. Configure theme, time zone, date format<br>3. Save | Configuration takes effect immediately |
| TC-APP-4.16.3 | Payment settings | 1. Navigate to Settings > Payments<br>2. Configure Stripe keys, tax rates<br>3. Save | Payment settings are updated |
| TC-APP-4.16.4 | Notification settings | 1. Navigate to Settings > Notifications<br>2. Configure email/SMS providers and defaults<br>3. Save | Notification configuration is saved |
### 4.17 Mobile / PWA
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.17.1 | Install prompt | 1. Access app on mobile device (or DevTools mobile view)<br>2. Verify install prompt appears | "Add to Home Screen" prompt is shown |
| TC-APP-4.17.2 | Responsive design (mobile) | 1. Resize viewport to 390x844 (iPhone dimensions)<br>2. Navigate through app | All pages are usable and properly formatted |
| TC-APP-4.17.3 | Offline basics | 1. Load app<br>2. Enable offline mode in DevTools<br>3. Navigate to previously loaded pages | Cached content is displayed, offline indicator shown |
| TC-APP-4.17.4 | Touch interactions | 1. On mobile viewport, tap buttons, forms, and navigation<br>2. Verify responsiveness | All touch targets are accessible and responsive |
### 4.18 Navigation
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.18.1 | All major sections accessible | 1. Click each main navigation item<br>2. Verify page loads | All sections (Dashboard, Calendar, Clients, Pets, Appointments, Reports, Settings) load successfully |
| TC-APP-4.18.2 | No broken links | 1. Navigate through app<br>2. Click various links and buttons | No 404 errors or dead ends encountered |
| TC-APP-4.18.3 | No blank pages | 1. Navigate to each section and sub-section<br>2. Verify content is displayed | All pages render with appropriate content |
| TC-APP-4.18.4 | Back/forward navigation | 1. Navigate through multiple pages<br>2. Use browser back and forward buttons | Navigation history works correctly |
### 4.19 Error States
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.19.1 | Form with bad data | 1. On any form, enter invalid email, phone, or dates<br>2. Submit | Validation errors display specific issues |
| TC-APP-4.19.2 | Missing required fields | 1. On any form, leave required fields blank<br>2. Submit | Clear error messages indicate which fields are required |
| TC-APP-4.19.3 | Empty states | 1. Navigate to pages with no data (empty calendar, no clients)<br>2. Verify UI | Helpful empty state message with call-to-action displayed |
| TC-APP-4.19.4 | Network error handling | 1. Disable network in DevTools<br>2. Attempt actions that require API calls<br>3. Re-enable network | Appropriate error message shown, app recovers when network restored |
### 4.20 Staff Messages
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.20.1 | Staff messages inbox loads | 1. Log in as Staff<br>2. Navigate to Messages | Conversation list renders with client phone and last message preview |
| TC-APP-4.20.2 | Open conversation | 1. Select a conversation from the list | Full message thread loads chronologically |
| TC-APP-4.20.3 | Send message | 1. Type a reply and submit | Message appears in thread; POST /api/conversations/:id/messages succeeds |
| TC-APP-4.20.4 | Empty state | 1. Log in as Staff with no conversations | Empty state shown; no crash |
| TC-APP-4.20.5 | Unread indicator | 1. Client sends a new message | Thread marked unread until staff views it |
| TC-APP-4.20.6 | Cross-tenant isolation | 1. Staff from Business A attempts to read Business B conversations | 403 or empty response returned |
### 4.21 SMS Consent (STOP/HELP Keyword Handler)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-APP-4.21.1 | STOP → unsubscribe + auto-reply | 1. Send `STOP` (case-insensitive, with whitespace) from a subscribed client's phone number | Client is opted out (`smsOptIn=false`, `smsOptOutDate` set), event is logged, user receives auto-reply: "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe." |
| TC-APP-4.21.2 | START → resubscribe + auto-reply | 1. Send `START` (case-insensitive) from an opted-out client's phone number | Client is opted back in (`smsOptIn=true`, `smsConsentDate` updated, `smsOptOutDate` cleared), event is logged, user receives auto-reply: "You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply." |
| TC-APP-4.21.3 | HELP → no opt-in change + default reply | 1. Send `HELP` (case-insensitive) from any client's phone number | No change to opt-in state, no database update, event is logged, user receives auto-reply: "Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly." |
| TC-APP-4.21.4 | STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT → opt-out | 1. Send each alias from a subscribed client's phone | Same behaviour as STOP: opt-out applied, correct reply sent |
| TC-APP-4.21.5 | UNSTOP / YES / SUBSCRIBE → opt-in | 1. Send each alias from an opted-out client's phone | Same behaviour as START: opt-in applied, correct reply sent |
| TC-APP-4.21.6 | INFO → help reply | 1. Send `INFO` from any client's phone | Same behaviour as HELP: no state change, help reply returned |
| TC-APP-4.21.7 | Double STOP (idempotency) | 1. Send `STOP` from an already-opted-out client | Event is logged, no update call made, idempotent — no duplicate update |
| TC-APP-4.21.8 | Double START (idempotency) | 1. Send `START` from an already-subscribed client | Event is logged, no update call made, idempotent — no duplicate update |
| TC-APP-4.21.9 | Case insensitivity | 1. Send `stop`, `Stop`, `sToP`, ` stop ` from subscribed client | All variants are detected and handled as opt-out |
| TC-APP-4.21.10 | Whitespace trimming | 1. Send ` START ` or `\tSTOP\n` | Keywords are trimmed before matching |
| TC-APP-4.21.11 | Non-keyword messages ignored | 1. Send `STOP IT`, `help me`, `hello` | Returns null from `detectKeyword`, no consent event inserted, no reply sent |
| TC-APP-4.21.12 | Consent event audit log | 1. After any keyword, query `messageConsentEvents` table | Record exists with correct `clientId`, `businessId`, `kind`, and `source: "sms_keyword"` |
## 5. Pass/Fail Criteria
**Pass:** All test cases execute without errors. Expected results match actual results. No regressions are observed. All functionality works as documented.
**Fail:** Any unexpected result is encountered. For failures, document:
- Severity (Critical, High, Medium, Low)
- Steps to reproduce
- Actual vs. expected behavior
- Screenshot(s) if applicable
- Browser and device information
**Regressions:** If a previously working feature fails during this UAT run, it is considered a regression and must be addressed before the release can proceed.
## 6. Update Policy
**Any PR that changes user-facing behaviour MUST update this file.**
When modifying features that affect:
- User workflows (authentication, scheduling, payments, etc.)
- UI/UX (navigation, forms, responsive design)
- Configuration (settings, integrations)
- Data visibility (reports, search, filtering)
The corresponding test case(s) in Section 4 must be updated to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.5 — new appointment group scheduling feature").
+1 -5
View File
@@ -12,7 +12,6 @@ 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 && \
@@ -35,9 +34,6 @@ 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
@@ -50,4 +46,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"]
+1 -3
View File
@@ -23,15 +23,13 @@
"node-cron": "^3.0.3",
"nodemailer": "^6.9.16",
"stripe": "^22.0.0",
"telnyx": "^1.23.0",
"uuid": "^11.1.1",
"telnyx": "^6.41.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.18.0",
"tsx": "^4.19.2",
-210
View File
@@ -1,210 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { resolveBufferMinutes } from "../lib/buffer.js";
// ─── Mock types matching schema ─────────────────────────────────────────────
interface MockBufferTimeRule {
id: string;
serviceId: string;
sizeCategory: string | null;
coatType: string | null;
bufferMinutes: number;
}
interface MockService {
id: string;
name: string;
defaultBufferMinutes: number;
}
// ─── Mock db factory ─────────────────────────────────────────────────────────
// Simulates Drizzle query builder: db.select().from(t).where(eq(...)) → await → array
// For services we use db.select().from(t).where(eq(...)).limit(1) → await → first item
function createMockDb(rules: MockBufferTimeRule[], services: MockService[]) {
let callCount = 0;
return {
select: vi.fn(() => {
callCount++;
const rulesQuery = {
from: () => ({
where: () => rules, // await resolves directly to rules array
}),
};
const serviceQuery = {
from: () => ({
where: () => ({
limit: () => services, // await resolves to services array
}),
}),
};
// First select call → rules, second → services
return callCount === 1 ? rulesQuery : serviceQuery;
}),
} as any;
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("resolveBufferMinutes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns exact match when serviceId + sizeCategory + coatType all match", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: "medium", coatType: "short", bufferMinutes: 15 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: "medium", coatType: null, bufferMinutes: 10 },
{ id: "rule-3", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 5 },
],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "medium",
coatType: "short",
db,
});
expect(result).toBe(15);
});
it("returns service + size match when no exact match", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: "medium", coatType: null, bufferMinutes: 10 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 5 },
],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "medium",
coatType: "long",
db,
});
expect(result).toBe(10);
});
it("returns service + coat match when no exact or size match", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: null, coatType: "wire", bufferMinutes: 12 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 5 },
],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "large",
coatType: "wire",
db,
});
expect(result).toBe(12);
});
it("returns service-only match when no partial matches", async () => {
const db = createMockDb(
[{ id: "rule-1", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 7 }],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "large",
coatType: "long",
db,
});
expect(result).toBe(7);
});
it("falls back to service.defaultBufferMinutes when no rules exist", async () => {
const db = createMockDb([], [{ id: "svc-1", name: "Bath", defaultBufferMinutes: 8 }]);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "small",
coatType: "curly",
db,
});
expect(result).toBe(8);
});
it("falls back to 0 when no rules and no service default", async () => {
const db = createMockDb([], []);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "small",
coatType: null,
db,
});
expect(result).toBe(0);
});
it("exact match beats partial matches (priority verification)", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: "medium", coatType: "short", bufferMinutes: 20 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: "medium", coatType: null, bufferMinutes: 15 },
{ id: "rule-3", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 10 },
],
[{ id: "svc-1", name: "Groom", defaultBufferMinutes: 5 }]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "medium",
coatType: "short",
db,
});
// Exact match (20) should win over service+size (15) and service default (5)
expect(result).toBe(20);
});
it("handles null sizeCategory and null coatType at rule level", async () => {
const db = createMockDb(
[{ id: "rule-1", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 6 }],
[]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: null,
coatType: null,
db,
});
expect(result).toBe(6);
});
it("prefers service+size over service-only when both exist", async () => {
const db = createMockDb(
[
{ id: "rule-1", serviceId: "svc-1", sizeCategory: "large", coatType: null, bufferMinutes: 14 },
{ id: "rule-2", serviceId: "svc-1", sizeCategory: null, coatType: null, bufferMinutes: 3 },
],
[{ id: "svc-1", name: "Groom", defaultBufferMinutes: 1 }]
);
const result = await resolveBufferMinutes({
serviceId: "svc-1",
sizeCategory: "large",
coatType: "smooth",
db,
});
expect(result).toBe(14);
});
});
+3 -17
View File
@@ -27,14 +27,12 @@ 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;
@@ -60,19 +58,10 @@ 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: (table: unknown) => {
const tableName = (table as { _name?: string })._name;
const rows = tableName === "appointments" ? appointmentRows : selectRows;
return makeChainable(rows);
},
from: () => makeChainable(selectRows),
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
@@ -106,10 +95,8 @@ vi.mock("@groombook/db", () => {
}),
}),
clients,
appointments,
eq: vi.fn(),
and: vi.fn(),
or: vi.fn(),
};
});
@@ -195,11 +182,10 @@ describe("POST /clients", () => {
expect(insertedValues[0]!.name).toBe("Charlie");
});
it("creates a client with name and email", async () => {
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
it("creates a client with only required name field", async () => {
const res = await jsonRequest("POST", "/clients", { name: "Dana" });
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,7 +68,6 @@ vi.mock("@groombook/db", () => {
}),
appointments,
eq: () => ({}),
and: (..._clauses: unknown[]) => ({}),
};
});
@@ -1,318 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mock data ────────────────────────────────────────────────────────────────
const STAFF_ROW = {
id: "staff-uuid-1",
email: "groomer@groombook.com",
name: "Groomer",
role: "groomer" as const,
businessId: "business-uuid-1",
active: true,
userId: null,
oidcSub: null,
isSuperUser: false,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const BUSINESS_SETTINGS = {
id: "business-uuid-1",
businessName: "Test Salon",
};
const CONV_1 = {
id: "conv-uuid-1",
businessId: "business-uuid-1",
clientId: "client-uuid-1",
channel: "sms",
externalNumber: "+15551111111",
businessNumber: "+15552222222",
lastMessageAt: new Date("2025-01-10T10:00:00Z"),
status: "active",
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-10T10:00:00Z"),
staffReadAt: null,
};
const MSG_INBOUND_1 = {
id: "msg-uuid-1",
conversationId: "conv-uuid-1",
direction: "inbound",
body: "Hello",
status: "delivered",
sentByStaffId: null,
createdAt: new Date("2025-01-10T09:00:00Z"),
deliveredAt: new Date("2025-01-10T09:01:00Z"),
};
const MSG_OUTBOUND_1 = {
id: "msg-uuid-2",
conversationId: "conv-uuid-1",
direction: "outbound",
body: "Hi Alice!",
status: "delivered",
sentByStaffId: "staff-uuid-1",
createdAt: new Date("2025-01-10T10:00:00Z"),
deliveredAt: new Date("2025-01-10T10:01:00Z"),
};
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
let selectRows: Record<string, unknown>[] = [];
let selectRows2: Record<string, unknown>[] = [];
let selectRows3: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
let selectCallCount = 0;
function resetMock() {
selectRows = [];
selectRows2 = [];
selectRows3 = [];
updatedValues = [];
selectCallCount = 0;
}
function resetAll() {
resetMock();
vi.clearAllMocks();
}
const mockSendMessage = vi.hoisted(() => vi.fn());
vi.mock("@groombook/db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "innerJoin") {
return () => chain;
}
if (prop === "from") {
return (table: unknown) => {
const tableName = (table as { _name?: string })._name;
const rows = tableName === "businessSettings" ? [BUSINESS_SETTINGS] : selectRows;
return makeChainable(rows);
};
}
// @ts-expect-error proxy
return target[prop];
},
});
return chain;
}
const conversations = new Proxy(
{ _name: "conversations" },
{ get: (t, p) => (p === "_name" ? "conversations" : { table: "conversations", column: p }) }
);
const messages = new Proxy(
{ _name: "messages" },
{ get: (t, p) => (p === "_name" ? "messages" : { table: "messages", column: p }) }
);
const clients = new Proxy(
{ _name: "clients" },
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
);
const businessSettings = new Proxy(
{ _name: "businessSettings" },
{ get: (t, p) => (p === "_name" ? "businessSettings" : { table: "businessSettings", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: (table: unknown) => {
const tableName = (table as { _name?: string })._name;
if (tableName === "businessSettings") return makeChainable([BUSINESS_SETTINGS]);
if (tableName === "messages") {
// Return selectRows3 if it has data (POST re-query), else cycle through selectRows/selectRows2
if (selectRows3.length > 0) {
return makeChainable(selectRows3);
}
if (selectCallCount === 0 || selectCallCount === 1) {
const rows = selectCallCount === 0 ? selectRows : selectRows2;
selectCallCount++;
return makeChainable(rows);
}
return makeChainable(selectRows);
}
return makeChainable(selectRows);
},
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => {
updatedValues.push(vals);
return { returning: () => [vals] };
},
}),
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
return { returning: () => [{ ...vals, id: "msg-uuid-new" }] };
},
}),
}),
conversations,
messages,
clients,
businessSettings,
eq: vi.fn((a, b) => ({ type: "eq", a, b })),
and: vi.fn((...args) => ({ type: "and", args })),
desc: vi.fn((col) => ({ type: "desc", col })),
lt: vi.fn((a, b) => ({ type: "lt", a, b })),
sql: vi.fn(() => ({ __type: "sql" })),
isNull: vi.fn((col) => ({ type: "isNull", col })),
count: vi.fn((col) => ({ type: "count", col })),
};
});
vi.mock("../services/messaging/outbound.js", () => ({
sendMessage: mockSendMessage,
}));
// ─── App setup ────────────────────────────────────────────────────────────────
const { conversationsRouter } = await import("../routes/conversations.js");
const app = new Hono();
app.use("*", async (c, next) => {
// @ts-expect-error — test-only context injection
c.set("staff", STAFF_ROW);
await next();
});
app.route("/conversations", conversationsRouter);
function jsonRequest(method: string, path: string, body?: unknown) {
return app.request(path, {
method,
headers: { "Content-Type": "application/json" },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
beforeEach(() => resetAll());
// ─── GET /conversations ───────────────────────────────────────────────────────
describe("GET /api/conversations", () => {
it("returns conversations sorted by recency with unread count", async () => {
selectRows = [
{ ...CONV_1, clientName: "Alice", clientPhone: "+15551111111", channel: "sms" },
];
selectRows2 = [{ count: "1" }];
const res = await app.request("/conversations");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.items).toHaveLength(1);
expect(body.items[0]!.id).toBe("conv-uuid-1");
expect(body.items[0]!.clientName).toBe("Alice");
});
it("supports cursor-based pagination", async () => {
selectRows = [];
const res = await app.request("/conversations?cursor=conv-uuid-1&limit=1");
expect(res.status).toBe(200);
});
it("enforces max limit of 50", async () => {
selectRows = [];
const res = await app.request("/conversations?limit=200");
expect(res.status).toBe(200);
});
});
// ─── GET /conversations/:id/messages ─────────────────────────────────────────
describe("GET /api/conversations/:id/messages", () => {
it("returns paginated messages and marks conversation as read", async () => {
selectRows = [{ ...MSG_INBOUND_1 }, { ...MSG_OUTBOUND_1 }];
const res = await app.request("/conversations/conv-uuid-1/messages");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.items).toHaveLength(2);
expect(body.items[0]!.id).toBe("msg-uuid-1");
expect(updatedValues.some((u) => u.staffReadAt !== undefined)).toBe(true);
});
it("returns 404 when conversation belongs to different business", async () => {
selectRows = [];
const res = await app.request("/conversations/conv-uuid-other/messages");
expect(res.status).toBe(404);
});
it("returns 401 when not authenticated", async () => {
const appNoAuth = new Hono();
appNoAuth.route("/conversations", conversationsRouter);
const res = await appNoAuth.request("/conversations/conv-uuid-1/messages");
expect(res.status).toBe(401);
});
});
// ─── POST /conversations/:id/messages ─────────────────────────────────────────
describe("POST /api/conversations/:id/messages", () => {
beforeEach(() => {
resetMock();
vi.clearAllMocks();
selectRows = [{ ...CONV_1, clientName: "Alice", clientPhone: "+15551111111", channel: "sms" }];
selectRows2 = [];
selectRows3 = [{ id: "msg-uuid-new", conversationId: "conv-uuid-1", direction: "outbound" as const, body: "Hello Alice!", status: "queued" as const, sentByStaffId: "staff-uuid-1", createdAt: new Date(), deliveredAt: null }];
updatedValues = [];
});
it("sends via outbound service and returns 201", async () => {
mockSendMessage.mockResolvedValueOnce({
messageId: "msg-uuid-new",
providerMessageId: "provider-msg-1",
status: "queued",
suppressed: false,
});
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "Hello Alice!",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.id).toBe("msg-uuid-new");
});
it("returns 409 when client opted out", async () => {
mockSendMessage.mockResolvedValueOnce({ suppressed: true });
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "Hello",
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.error).toMatch(/opted out/i);
});
it("returns 404 for cross-tenant conversation", async () => {
selectRows = [];
const res = await jsonRequest("POST", "/conversations/conv-uuid-other/messages", {
body: "Hello",
});
expect(res.status).toBe(404);
});
it("rejects empty body", async () => {
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "",
});
expect(res.status).toBe(400);
});
it("rejects body over 1600 chars", async () => {
const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", {
body: "a".repeat(1601),
});
expect(res.status).toBe(400);
});
});
-158
View File
@@ -40,17 +40,11 @@ const APPOINTMENT = {
let selectSessionRow: Record<string, unknown> | null = null;
let selectAppointmentRow: Record<string, unknown> | null = null;
let updatedValues: Record<string, unknown>[] = [];
let selectBusinessSettingsRow: Record<string, unknown> | null = null;
let selectConversationRow: Record<string, unknown> | null = null;
let selectMessageRows: Record<string, unknown>[] = [];
function resetMock() {
selectSessionRow = null;
selectAppointmentRow = null;
updatedValues = [];
selectBusinessSettingsRow = null;
selectConversationRow = null;
selectMessageRows = [];
}
vi.mock("@groombook/db", () => {
@@ -78,26 +72,6 @@ vi.mock("@groombook/db", () => {
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
const businessSettings = new Proxy(
{ _name: "businessSettings" },
{ get: (t, p) => (p === "_name" ? "businessSettings" : { table: "businessSettings", column: p }) }
);
const conversations = new Proxy(
{ _name: "conversations" },
{ get: (t, p) => (p === "_name" ? "conversations" : { table: "conversations", column: p }) }
);
const messages = new Proxy(
{ _name: "messages" },
{ get: (t, p) => (p === "_name" ? "messages" : { table: "messages", column: p }) }
);
const impersonationAuditLogs = new Proxy(
{ _name: "impersonationAuditLogs" },
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
);
return {
getDb: () => ({
select: () => ({
@@ -108,15 +82,6 @@ const businessSettings = new Proxy(
if (table._name === "appointments") {
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
}
if (table._name === "businessSettings") {
return makeChainable(selectBusinessSettingsRow ? [selectBusinessSettingsRow] : []);
}
if (table._name === "conversations") {
return makeChainable(selectConversationRow ? [selectConversationRow] : []);
}
if (table._name === "messages") {
return makeChainable(selectMessageRows);
}
return makeChainable([]);
},
}),
@@ -134,21 +99,11 @@ const businessSettings = new Proxy(
}),
}),
}),
insert: () => ({
values: () => ({
returning: () => [],
}),
}),
}),
impersonationSessions,
appointments,
impersonationAuditLogs,
businessSettings,
conversations,
messages,
eq: vi.fn(),
and: vi.fn(),
desc: vi.fn((col: unknown) => ({ _name: "desc", col })),
};
});
@@ -465,117 +420,4 @@ describe("POST /portal/appointments/:id/cancel", () => {
);
expect(res.status).toBe(404);
});
});
// ─── Conversation routes ───────────────────────────────────────────────────────
const BUSINESS_ID = "880e8400-e29b-41d4-a716-446655440008";
const CONVERSATION_ID = "990e8400-e29b-41d4-a716-446655440009";
const CONVERSATION = {
id: CONVERSATION_ID,
clientId: CLIENT_ID,
businessId: BUSINESS_ID,
channel: "sms",
status: "active",
lastMessageAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
};
const MESSAGE_1 = {
id: "m1",
conversationId: CONVERSATION_ID,
direction: "inbound",
body: "Hello",
status: "delivered",
createdAt: new Date().toISOString(),
deliveredAt: new Date().toISOString(),
};
const MESSAGE_2 = {
id: "m2",
conversationId: CONVERSATION_ID,
direction: "outbound",
body: "Hi there!",
status: "delivered",
createdAt: new Date(Date.now() + 1000).toISOString(),
deliveredAt: new Date().toISOString(),
};
function jsonGet(path: string, headers?: Record<string, string>) {
return app.request(path, { method: "GET", headers });
}
describe("GET /portal/conversation", () => {
it("returns 204 when no conversation exists", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = null;
const res = await jsonGet("/portal/conversation", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(204);
});
it("returns conversation for the authenticated client", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = { ...CONVERSATION };
const res = await jsonGet("/portal/conversation", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe(CONVERSATION_ID);
expect(body.channel).toBe("sms");
expect(body.status).toBe("active");
});
it("returns 204 when client A's session has no conversation (cross-tenant isolation)", async () => {
// Cross-tenant isolation is enforced at the query level via portalClientId scoping.
// The mock cannot replicate eq() filtering — this test verifies the query is issued
// and no conversation is returned when the mock has no row for the session's clientId.
// Real DB: eq() on clientId ensures client A never sees client B's conversation.
selectSessionRow = { ...ACTIVE_SESSION, clientId: "client-a" };
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = null; // client-a has no conversation
const res = await jsonGet("/portal/conversation", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(204);
});
});
describe("GET /portal/conversation/messages", () => {
it("returns 204 when no conversation exists", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = null;
const res = await jsonGet("/portal/conversation/messages", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(204);
});
it("returns paginated messages", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = { ...CONVERSATION };
selectMessageRows = [MESSAGE_2, MESSAGE_1];
const res = await jsonGet("/portal/conversation/messages", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.messages).toHaveLength(2);
expect(body.messages[0].id).toBe("m2");
expect(body.messages[1].id).toBe("m1");
expect(body.nextCursor).toBeNull();
});
it("returns messages and nextCursor reflects if more exist", async () => {
// Note: the mock does not enforce limit(), so it returns all messages.
// nextCursor is null when all messages fit (mock behavior).
// Real DB enforces limit and sets nextCursor when messages.length === limit.
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = { ...CONVERSATION };
selectMessageRows = [MESSAGE_1, MESSAGE_2];
const res = await jsonGet("/portal/conversation/messages?limit=1", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.messages.length).toBeGreaterThan(0);
// mock has no limit enforcement, so nextCursor may be null
expect(body).toHaveProperty("nextCursor");
});
});
+2 -3
View File
@@ -78,7 +78,6 @@ vi.mock("@groombook/db", () => {
}),
staff,
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
and: vi.fn((..._clauses: unknown[]) => ({})),
};
});
@@ -363,7 +362,7 @@ describe("requireRoleOrSuperUser", () => {
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/role.*not permitted/i);
expect(body.error).toMatch(/super user privileges required/i);
});
it("blocks a non-super-user groomer from manager-only routes", async () => {
@@ -371,7 +370,7 @@ describe("requireRoleOrSuperUser", () => {
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/role.*not permitted/i);
expect(body.error).toMatch(/super user privileges required/i);
});
it("allows a manager with multiple allowed roles", async () => {
+14 -126
View File
@@ -8,7 +8,6 @@ import { petsRouter } from "./routes/pets.js";
import { servicesRouter } from "./routes/services.js";
import { appointmentsRouter } from "./routes/appointments.js";
import { waitlistRouter } from "./routes/waitlist.js";
import { conversationsRouter } from "./routes/conversations.js";
import { portalRouter } from "./routes/portal.js";
import { staffRouter } from "./routes/staff.js";
import { invoicesRouter } from "./routes/invoices.js";
@@ -20,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 { getObject } from "./lib/s3.js";
import { getPresignedGetUrl } from "./lib/s3.js";
import { calendarRouter } from "./routes/calendar.js";
import { setupRouter } from "./routes/setup.js";
import { getDb, businessSettings, eq, staff } from "@groombook/db";
@@ -29,32 +28,15 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup
import { devRouter } from "./routes/dev.js";
import { adminSeedRouter } from "./routes/admin/seed.js";
import { startReminderScheduler } from "./services/reminders.js";
import { webhooksRouter } from "./routes/stripe-webhooks.js";
import { telnyxWebhooksRouter } from "./routes/webhooks/telnyx.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: (origin, ctx) => {
if (!origin) {
return ALLOWED_ORIGIN;
}
if (TRUSTED_ORIGINS.includes(origin)) {
return origin;
}
ctx.status(403);
return null;
},
origin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
credentials: true,
})
);
@@ -68,108 +50,31 @@ app.route("/api/book", bookRouter);
// Public portal routes — client-facing, authenticated via impersonation session header
app.route("/api/portal", portalRouter);
// Public Stripe webhook endpoint — signature-verified, no auth required
app.route("/api/webhooks/stripe", webhooksRouter);
// Public Telnyx messaging webhook — signature-verified, no auth required
app.route("/api/webhooks/telnyx", telnyxWebhooksRouter);
// Dev/demo routes — config is always public, users endpoint is guarded internally
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 };
// 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;
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 c.json({
businessName: settings.businessName,
primaryColor: settings.primaryColor,
accentColor: settings.accentColor,
logoUrl,
logoBase64: safeLogoBase64,
logoMimeType: safeLogoMimeType,
logoBase64: settings.logoBase64,
logoMimeType: settings.logoMimeType,
});
});
@@ -218,7 +123,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", "groomer"));
api.use("/invoices/*", requireRole("manager"));
api.use("/impersonation/*", requireRole("manager"));
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
@@ -264,7 +169,6 @@ api.route("/pets", petsRouter);
api.route("/services", servicesRouter);
api.route("/appointments", appointmentsRouter);
api.route("/waitlist", waitlistRouter);
api.route("/conversations", conversationsRouter);
api.route("/staff", staffRouter);
api.route("/invoices", invoicesRouter);
api.route("/reports", reportsRouter);
@@ -275,29 +179,13 @@ api.route("/admin/settings", settingsRouter);
api.route("/admin/auth-provider", authProviderRouter);
api.route("/admin/seed", adminSeedRouter);
api.route("/search", searchRouter);
api.route("/conversations", conversationsRouter);
const port = Number(process.env.PORT ?? 3000);
await initAuth();
console.log(`API server listening on port ${port}`);
const server = serve({ fetch: app.fetch, port });
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;
+8 -28
View File
@@ -89,19 +89,13 @@ 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!,
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 100,
window: 10,
max: 10,
window: 60,
storage: "memory",
customRules: {
"/sign-in/social": { max: 10, window: 60 },
"/sign-in/email": { max: 10, window: 60 },
"/sign-up/email": { max: 5, window: 60 },
"/get-session": false,
},
},
plugins: [
genericOAuth({
@@ -183,9 +177,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);
const issuerUrlObj = new URL(providerConfig.issuerUrl);
const issuerHostname = issuerUrlObj.hostname;
// 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 discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
let oidcConfig: Record<string, string> = {};
try {
@@ -209,14 +203,6 @@ 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
@@ -246,15 +232,9 @@ export async function initAuth(): Promise<void> {
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 100,
window: 10,
max: 10,
window: 60,
storage: "memory",
customRules: {
"/sign-in/social": { max: 10, window: 60 },
"/sign-in/email": { max: 10, window: 60 },
"/sign-up/email": { max: 5, window: 60 },
"/get-session": false,
},
},
account: {
storeStateStrategy: "cookie" as const,
-66
View File
@@ -1,66 +0,0 @@
import { eq } from "@groombook/db";
import { bufferTimeRules, services, type Db } from "@groombook/db";
export async function resolveBufferMinutes({
serviceId,
sizeCategory,
coatType,
db,
}: {
serviceId: string;
sizeCategory: string | null;
coatType: string | null;
db: Db;
}): Promise<number> {
// Query all rules for this service in one DB call
const allRules = await db
.select()
.from(bufferTimeRules)
.where(eq(bufferTimeRules.serviceId, serviceId));
// Priority 1: exact match (serviceId + sizeCategory + coatType all match)
const exact = allRules.find(
(r) =>
r.sizeCategory === sizeCategory &&
r.coatType === coatType
);
if (exact) return exact.bufferMinutes;
// Priority 2: service + size, null coatType
const serviceSize = allRules.find(
(r) =>
r.sizeCategory === sizeCategory &&
r.coatType === null
);
if (serviceSize) return serviceSize.bufferMinutes;
// Priority 3: service + coat, null sizeCategory
const serviceCoat = allRules.find(
(r) =>
r.sizeCategory === null &&
r.coatType === coatType
);
if (serviceCoat) return serviceCoat.bufferMinutes;
// Priority 4: service only (null sizeCategory, null coatType)
const serviceOnly = allRules.find(
(r) =>
r.sizeCategory === null &&
r.coatType === null
);
if (serviceOnly) return serviceOnly.bufferMinutes;
// Priority 5: fallback to service.defaultBufferMinutes
const [service] = await db
.select({ defaultBufferMinutes: services.defaultBufferMinutes })
.from(services)
.where(eq(services.id, serviceId))
.limit(1);
if (service?.defaultBufferMinutes != null) {
return service.defaultBufferMinutes;
}
// Priority 6: final fallback to 0
return 0;
}
-281
View File
@@ -1,281 +0,0 @@
import { eq, and, gt, or, asc } from "@groombook/db";
import { appointments, clients, pets, services, staff, type Db } from "@groombook/db";
import { resolveBufferMinutes } from "./buffer.js";
import { sendEmail, buildRescheduleNotificationEmail } from "../services/email.js";
export interface CascadeResult {
shifted: ShiftedAppointment[];
flaggedForReview: FlaggedAppointment[];
}
export interface ShiftedAppointment {
id: string;
oldStartTime: Date;
oldEndTime: Date;
newStartTime: Date;
newEndTime: Date;
shiftDeltaMs: number;
}
export interface FlaggedAppointment {
id: string;
reason: string;
requestedStartTime: Date;
requestedEndTime: Date;
}
interface AppointmentWithGroomer {
id: string;
clientId: string;
petId: string;
serviceId: string;
staffId: string | null;
batherStaffId: string | null;
status: string;
startTime: Date;
endTime: Date;
bufferMinutes: number;
}
/**
* Detects and cascades appointment overruns to downstream same-groomer appointments.
*
* Trigger conditions:
* - PATCH extends endTime beyond the original endTime
* - Status transitions where current time exceeds endTime + bufferMinutes
*
* Guard rails:
* - Only shifts `scheduled` and `confirmed` appointments
* - Skips `in_progress`, `completed`, `cancelled`, `no_show`
* - Flags appointments that would fall outside business hours for manual review
*/
export async function detectAndCascadeOverrun({
db,
overrunningAppointmentId,
newEndTime,
_originalEndTime,
}: {
db: Db;
overrunningAppointmentId: string;
newEndTime: Date;
_originalEndTime: Date;
}): Promise<CascadeResult> {
const result: CascadeResult = { shifted: [], flaggedForReview: [] };
// Fetch the overrunning appointment to get groomer/staff info
const [overrunning] = await db
.select()
.from(appointments)
.where(eq(appointments.id, overrunningAppointmentId))
.limit(1);
if (!overrunning) return result;
const groomerId = overrunning.staffId;
if (!groomerId) return result;
// Determine the effective buffer for the overrunning appointment
const bufferMinutes = await resolveBufferMinutesForAppointment(db, overrunning);
const overrunEnd = newEndTime;
const effectiveEnd = new Date(overrunEnd.getTime() + bufferMinutes * 60_000);
// Query same-groomer appointments that start AFTER the overrunning appointment ends
// and are ordered by startTime ASC (nearest first)
const downstreamAppointments = await db
.select()
.from(appointments)
.where(
and(
eq(appointments.staffId, groomerId),
gt(appointments.startTime, overrunning.endTime),
or(
eq(appointments.status, "scheduled"),
eq(appointments.status, "confirmed")
)
)
)
.orderBy(asc(appointments.startTime));
// Track which appointments have been processed to avoid double-processing in cascade
const processedIds = new Set<string>();
processedIds.add(overrunningAppointmentId);
let currentOverrunEnd = effectiveEnd;
for (const downstream of downstreamAppointments) {
if (processedIds.has(downstream.id)) continue;
const downstreamBuffer = await resolveBufferMinutesForAppointment(db, downstream);
// Check if this downstream appointment conflicts with the current overrun end
const conflictThreshold = new Date(
currentOverrunEnd.getTime() + downstreamBuffer * 60_000
);
if (conflictThreshold <= downstream.startTime) {
// No conflict — cascade is complete
break;
}
// Conflict detected — need to shift this appointment
const shiftDeltaMs = conflictThreshold.getTime() - downstream.startTime.getTime();
const newStartTime = new Date(downstream.startTime.getTime() + shiftDeltaMs);
const newEndTime = new Date(downstream.endTime.getTime() + shiftDeltaMs);
// Check business hours (simple: only shift within same calendar day window for now)
// A more sophisticated implementation would check actual business hours from businessSettings
const isSameDay =
newStartTime.toDateString() === downstream.startTime.toDateString();
if (!isSameDay) {
result.flaggedForReview.push({
id: downstream.id,
reason: `Shifted appointment would fall on a different day (${newStartTime.toDateString()})`,
requestedStartTime: newStartTime,
requestedEndTime: newEndTime,
});
// Continue cascade check — we still process downstream appointments
currentOverrunEnd = newEndTime;
processedIds.add(downstream.id);
continue;
}
// Apply the shift
await db
.update(appointments)
.set({
startTime: newStartTime,
endTime: newEndTime,
updatedAt: new Date(),
})
.where(eq(appointments.id, downstream.id));
result.shifted.push({
id: downstream.id,
oldStartTime: downstream.startTime,
oldEndTime: downstream.endTime,
newStartTime,
newEndTime,
shiftDeltaMs,
});
// Update current overrun end for next iteration
currentOverrunEnd = newEndTime;
processedIds.add(downstream.id);
}
// Send notifications for all shifted appointments
for (const shifted of result.shifted) {
await notifyShiftedAppointment(db, shifted);
}
return result;
}
/**
* Determines if an appointment update represents an overrun that triggers cascade logic.
*/
export function isOverrun({
originalEndTime,
newEndTime,
_originalStartTime,
_newStartTime,
status,
currentTime,
bufferMinutes,
}: {
originalEndTime: Date;
newEndTime: Date;
_originalStartTime: Date;
_newStartTime?: Date;
status: string;
currentTime: Date;
bufferMinutes: number;
}): boolean {
// Case 1: endTime extended beyond original
if (newEndTime > originalEndTime) {
return true;
}
// Case 2: status transition where current time exceeds endTime + bufferMinutes
// This handles cases where an appointment ran long but wasn't explicitly rescheduled
if (
(status === "in_progress" || status === "completed") &&
currentTime > new Date(originalEndTime.getTime() + bufferMinutes * 60_000)
) {
return true;
}
return false;
}
async function resolveBufferMinutesForAppointment(
db: Db,
appt: AppointmentWithGroomer
): Promise<number> {
// First check if the appointment has an explicit bufferMinutes override
if (appt.bufferMinutes > 0) {
return appt.bufferMinutes;
}
// Fall back to buffer time rules based on service + pet characteristics
const [pet] = await db
.select({ sizeCategory: pets.sizeCategory, coatType: pets.coatType })
.from(pets)
.where(eq(pets.id, appt.petId))
.limit(1);
if (!pet) return 0;
return resolveBufferMinutes({
serviceId: appt.serviceId,
sizeCategory: pet.sizeCategory,
coatType: pet.coatType,
db,
});
}
async function notifyShiftedAppointment(
db: Db,
shifted: ShiftedAppointment
): Promise<void> {
const [row] = await db
.select({
clientName: clients.name,
clientEmail: clients.email,
clientEmailOptOut: clients.emailOptOut,
petName: pets.name,
serviceName: services.name,
groomerName: staff.name,
appointmentStartTime: appointments.startTime,
})
.from(appointments)
.innerJoin(clients, eq(clients.id, appointments.clientId))
.innerJoin(pets, eq(pets.id, appointments.petId))
.innerJoin(services, eq(services.id, appointments.serviceId))
.leftJoin(staff, eq(staff.id, appointments.staffId))
.where(eq(appointments.id, shifted.id))
.limit(1);
if (!row) return;
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
if (!clientEmail || clientEmailOptOut) return;
if (!petName || !serviceName) return;
console.log(
`[cascade] Notifying shift for appointment ${shifted.id}: ` +
`${shifted.oldStartTime.toISOString()}${shifted.newStartTime.toISOString()}`
);
await sendEmail(
buildRescheduleNotificationEmail(clientEmail, {
clientName,
petName,
serviceName,
groomerName: groomerName ?? null,
oldStartTime: shifted.oldStartTime,
newStartTime: shifted.newStartTime,
})
);
}
-38
View File
@@ -67,41 +67,3 @@ 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,
})
);
}
+1 -2
View File
@@ -23,8 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
}
export const authMiddleware: MiddlewareHandler = async (c, next) => {
const path = c.req.path;
if (path.startsWith("/api/auth/") || path.startsWith("/api/webhooks/")) {
if (c.req.path.startsWith("/api/auth/")) {
await next();
return;
}
-45
View File
@@ -1,45 +0,0 @@
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);
}
};
-40
View File
@@ -1,40 +0,0 @@
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();
};
+11 -28
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff } from "@groombook/db";
import { eq, getDb, staff } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
@@ -89,31 +89,14 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.select()
.from(staff)
.where(eq(staff.oidcSub, jwt.sub));
if (fallbackRow) {
c.set("staff", fallbackRow);
await next();
return;
if (!fallbackRow) {
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
);
}
// 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
);
c.set("staff", fallbackRow);
await next();
};
/**
@@ -166,9 +149,9 @@ export function requireRoleOrSuperUser(
}
return c.json(
{
error: hasAllowedRole
? "Forbidden: super user privileges required"
: `Forbidden: role '${staffRow.role}' is not permitted`,
error: staffRow.isSuperUser
? `Forbidden: role '${staffRow.role}' is not permitted`
: "Forbidden: super user privileges required",
},
403
);
+51
View File
@@ -0,0 +1,51 @@
import { Hono } from "hono";
import { getDb, businessSettings, reminderLogs, eq, sql, and, gte, lt } from "@groombook/db";
import { requireRole } from "../middleware/rbac.js";
import { createSmsProvider } from "../services/sms.js";
export const adminSmsRouter = new Hono();
adminSmsRouter.get("/status", requireManager(), async (c) => {
const db = getDb();
const [settings] = await db.select().from(businessSettings).limit(1);
const provider = createSmsProvider();
const smsEnabled = process.env.SMS_ENABLED === "true";
const providerName = process.env.SMS_PROVIDER ?? "none";
const fromNumber = process.env.TELNYX_FROM_NUMBER ?? null;
const connectionStatus = provider ? "connected" : "disconnected";
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const statsRows = await db
.select({
status: reminderLogs.deliveryStatus,
count: sql<number>`count(*)::int`,
})
.from(reminderLogs)
.where(
and(
eq(reminderLogs.channel, "sms"),
gte(reminderLogs.sentAt, startOfMonth)
)
)
.groupBy(reminderLogs.deliveryStatus);
const totals = { sent: 0, delivered: 0, failed: 0 };
for (const row of statsRows) {
if (row.status === "delivered") totals.delivered = row.count;
else if (row.status === "failed") totals.failed = row.count;
else totals.sent += row.count;
}
return c.json({
providerName,
fromNumber,
connectionStatus,
smsEnabled,
businessSmsEnabled: settings?.smsEnabled ?? false,
stats: totals,
});
});
+1 -71
View File
@@ -16,9 +16,8 @@ import {
services,
staff,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const appointmentGroupsRouter = new Hono<AppEnv>();
export const appointmentGroupsRouter = new Hono();
// ─── Schemas ──────────────────────────────────────────────────────────────────
@@ -50,8 +49,6 @@ 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)]
@@ -91,16 +88,6 @@ 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);
});
@@ -109,8 +96,6 @@ 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()
@@ -126,7 +111,6 @@ 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,
@@ -141,15 +125,6 @@ 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)
@@ -165,13 +140,6 @@ 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);
@@ -276,28 +244,6 @@ 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)
@@ -315,8 +261,6 @@ 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 })
@@ -324,20 +268,6 @@ 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() })
+43 -280
View File
@@ -21,33 +21,8 @@ import {
} from "@groombook/db";
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
import {
detectAndCascadeOverrun,
isOverrun,
} from "../lib/cascade.js";
import type { AppEnv } from "../middleware/rbac.js";
async function withRetry<T>(
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({
@@ -66,10 +41,6 @@ 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(),
});
@@ -192,28 +163,6 @@ 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
@@ -237,54 +186,11 @@ 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({
@@ -295,19 +201,9 @@ 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;
});
@@ -325,12 +221,9 @@ appointmentsRouter.post(
}
// Send confirmation email (fire-and-forget — never fails the request)
withRetry(
() => sendConfirmationEmail(db, firstRow),
2,
1000,
`Failed to send confirmation email for appointment ${firstRow.id}`
);
sendConfirmationEmail(db, firstRow).catch((err) => {
console.error("[appointments] Failed to send confirmation email:", err);
});
return c.json(firstRow, 201);
}
@@ -342,35 +235,44 @@ async function sendConfirmationEmail(
db: ReturnType<typeof getDb>,
appt: typeof appointments.$inferSelect
): Promise<void> {
const [row] = await db
.select({
clientName: clients.name,
clientEmail: clients.email,
clientEmailOptOut: clients.emailOptOut,
petName: pets.name,
serviceName: services.name,
groomerName: staff.name,
})
.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))
const [client] = await db
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
.from(clients)
.where(eq(clients.id, appt.clientId))
.limit(1);
if (!row) return;
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
if (!client || !client.email || client.emailOptOut) return;
if (!clientEmail || clientEmailOptOut) return;
if (!petName || !serviceName) return;
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;
const sent = await sendEmail(
buildConfirmationEmail(clientEmail, {
clientName,
petName,
serviceName,
groomerName: groomerName ?? null,
buildConfirmationEmail(client.email, {
clientName: client.name,
petName: pet.name,
serviceName: service.name,
groomerName,
startTime: appt.startTime,
})
);
@@ -450,76 +352,6 @@ 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(),
};
@@ -555,13 +387,6 @@ 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;
}
@@ -573,8 +398,7 @@ appointmentsRouter.patch(
const needsConflictCheck =
updateFields.startTime !== undefined ||
updateFields.endTime !== undefined ||
updateFields.staffId !== undefined ||
updateFields.batherStaffId !== undefined;
updateFields.staffId !== undefined;
const update: Record<string, unknown> = {
...updateFields,
@@ -588,7 +412,6 @@ appointmentsRouter.patch(
// (fixes #18). Also falls back to the existing staffId when staffId is
// omitted from the request, so rescheduling always checks conflicts (fixes #19).
let row: typeof appointments.$inferSelect | undefined;
let originalEndTime: Date | undefined;
try {
row = await db.transaction(async (tx) => {
const [current] = await tx
@@ -600,9 +423,6 @@ appointmentsRouter.patch(
throw Object.assign(new Error("not found"), { statusCode: 404 });
}
// Preserve original endTime for cascade detection after update
originalEndTime = current.endTime;
const start = updateFields.startTime
? new Date(updateFields.startTime)
: current.startTime;
@@ -614,11 +434,6 @@ 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"), {
@@ -646,29 +461,6 @@ 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)
@@ -692,29 +484,6 @@ appointmentsRouter.patch(
}
if (!row) return c.json({ error: "Not found" }, 404);
// Cascade delay prevention: detect overrun and shift downstream appointments
if (
originalEndTime &&
updateFields.endTime &&
isOverrun({
originalEndTime,
newEndTime: new Date(updateFields.endTime),
_originalStartTime: row.startTime,
status: row.status,
currentTime: new Date(),
bufferMinutes: row.bufferMinutes ?? 0,
})
) {
const cascadeResult = await detectAndCascadeOverrun({
db,
overrunningAppointmentId: id,
newEndTime: new Date(updateFields.endTime),
_originalEndTime: originalEndTime,
});
return c.json({ ...row, cascade: cascadeResult });
}
return c.json(row);
}
@@ -766,12 +535,9 @@ 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 });
withRetry(
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
2,
1000,
`Failed to notify waitlist for appointment ${id}`
);
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
console.error("[appointments] Failed to notify waitlist:", err);
});
return c.json({ ok: true });
}
@@ -794,12 +560,9 @@ appointmentsRouter.delete("/:id", async (c) => {
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
withRetry(
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
2,
1000,
`Failed to notify waitlist for appointment ${id}`
);
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
console.error("[appointments] Failed to notify waitlist:", err);
});
return c.json({ ok: true });
});
+15 -51
View File
@@ -38,12 +38,11 @@ bookRouter.get("/services", async (c) => {
// ─── GET /api/book/availability ─────────────────────────────────────────────
// Public: return ISO startTime strings for slots where ≥1 groomer is free
// Query params: serviceId (uuid), date (YYYY-MM-DD), petSizeCategory, petCoatType
// Query params: serviceId (uuid), date (YYYY-MM-DD)
bookRouter.get("/availability", async (c) => {
const serviceId = c.req.query("serviceId");
const dateStr = c.req.query("date");
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
if (!serviceId || !dateStr) {
return c.json({ error: "serviceId and date are required" }, 400);
@@ -59,12 +58,6 @@ bookRouter.get("/availability", async (c) => {
.where(and(eq(services.id, serviceId), eq(services.active, true)));
if (!service) return c.json({ error: "Service not found" }, 404);
// Buffer-aware duration: extra time for large/x-large or complex coats
const extraBuffer = (petSizeCategory === "large" || petSizeCategory === "xlarge")
? (service.defaultBufferMinutes ?? 0)
: 0;
const durationMinutes = service.durationMinutes + extraBuffer;
const groomers = await db
.select({ id: staff.id })
.from(staff)
@@ -96,7 +89,7 @@ bookRouter.get("/availability", async (c) => {
const slots = generateAvailableSlots({
dateStr,
durationMinutes,
durationMinutes: service.durationMinutes,
groomerIds: groomers.map((g) => g.id),
booked,
});
@@ -109,22 +102,13 @@ bookRouter.get("/availability", async (c) => {
const bookingSchema = z.object({
serviceId: z.string().uuid(),
startTime: z.string().datetime().refine(
(dt) => new Date(dt) > new Date(),
{ message: "Appointment must be in the future" }
),
startTime: z.string().datetime(),
clientName: z.string().min(1).max(200),
clientEmail: z.string().email(),
clientPhone: z.string().max(50).optional(),
petName: z.string().min(1).max(200),
petSpecies: z.string().min(1).max(100),
petBreed: z.string().max(100).optional(),
petSizeCategory: z
.enum(["small", "medium", "large", "xlarge"])
.optional(),
petCoatType: z
.enum(["smooth", "double", "curly", "wire", "long", "hairless"])
.optional(),
notes: z.string().max(2000).optional(),
});
@@ -142,7 +126,7 @@ bookRouter.post(
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
if (!service) return c.json({ error: "Service not found" }, 404);
let end = new Date(start.getTime() + service.durationMinutes * 60_000);
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
// Find all active groomers
const groomers = await db
@@ -204,18 +188,11 @@ bookRouter.post(
name: body.petName,
species: body.petSpecies,
breed: body.petBreed ?? null,
sizeCategory: body.petSizeCategory ?? null,
coatType: body.petCoatType ?? null,
})
.returning();
const pet = petInserted[0];
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
// Buffer-aware end time: large/x-large pets add service bufferMinutes
if (body.petSizeCategory === "large" || body.petSizeCategory === "xlarge") {
end = new Date(start.getTime() + (service.durationMinutes + (service.defaultBufferMinutes ?? 0)) * 60_000);
}
// Insert appointment in a transaction to guard against race conditions
let appointment;
try {
@@ -288,36 +265,29 @@ 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`);
}
const updated = await db
await db
.update(appointments)
.set({
confirmationStatus: "confirmed",
confirmedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(appointments.confirmationToken, token),
eq(appointments.confirmationStatus, "pending")
)
)
.returning();
if (updated.length === 0) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
.where(eq(appointments.id, appt.id));
return c.redirect(`${BASE_URL()}/booking/confirmed`);
});
@@ -339,15 +309,19 @@ 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`);
}
const updated = await db
// Single-use cancellation: nullify token after use
await db
.update(appointments)
.set({
confirmationStatus: "cancelled",
@@ -355,17 +329,7 @@ bookRouter.get("/cancel/:token", async (c) => {
confirmationToken: null,
updatedAt: new Date(),
})
.where(
and(
eq(appointments.confirmationToken, token),
eq(appointments.confirmationStatus, "pending")
)
)
.returning();
if (updated.length === 0) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
.where(eq(appointments.id, appt.id));
return c.redirect(`${BASE_URL()}/booking/cancelled`);
});
+2 -13
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { randomBytes } from "node:crypto";
import {
and,
eq,
@@ -84,18 +84,7 @@ calendarRouter.get("/:staffId.ics", async (c) => {
.where(eq(staff.id, staffId))
.limit(1);
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)
) {
if (!staffMember || staffMember.icalToken !== token) {
return c.text("Unauthorized", 401);
}
+27 -28
View File
@@ -4,16 +4,37 @@ import { z } from "zod/v3";
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
function normalizeE164(phone: string): string | null {
const digits = phone.replace(/\D/g, "");
if (digits.length === 10) return `+1${digits}`;
if (digits.length === 11 && digits.startsWith("1")) return `+${digits}`;
if (digits.length > 11 && digits.startsWith("1")) return `+${digits.slice(0, 11)}`;
return null;
}
function e164String() {
return z.string().transform((v, ctx) => {
if (!v) return v as unknown as undefined;
const normalized = normalizeE164(v);
if (!normalized) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid phone number. Must be a valid E.164 number (e.g. +12125551234).",
});
return z.NEVER;
}
return normalized;
});
}
export const clientsRouter = new Hono<AppEnv>();
const createClientSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().email(),
phone: z.string().max(50).optional(),
email: z.string().email().optional(),
phone: e164String().optional(),
address: z.string().max(500).optional(),
notes: z.string().max(2000).optional(),
smsOptIn: z.boolean().optional(),
smsConsentText: z.string().max(1000).optional(),
});
@@ -97,7 +118,6 @@ 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(
@@ -110,19 +130,13 @@ 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)
@@ -144,24 +158,9 @@ 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, clientId))
.where(eq(clients.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
-274
View File
@@ -1,274 +0,0 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import {
and,
eq,
desc,
lt,
sql,
getDb,
conversations,
messages,
clients,
businessSettings,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import { sendMessage } from "../services/messaging/outbound.js";
export const conversationsRouter = new Hono<AppEnv>();
const sendMessageSchema = z.object({
body: z.string().min(1).max(1600),
});
// GET /api/conversations — List conversations
conversationsRouter.get("/", async (c) => {
const db = getDb();
const staffRow = c.get("staff");
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
if (!settings) return c.json({ error: "Business not found" }, 404);
const cursor = c.req.query("cursor") || undefined;
const limit = Math.min(Number(c.req.query("limit") || "20"), 50);
let baseQuery = db
.select({
id: conversations.id,
clientId: conversations.clientId,
lastMessageAt: conversations.lastMessageAt,
status: conversations.status,
staffReadAt: conversations.staffReadAt,
clientName: clients.name,
clientPhone: clients.phone,
channel: conversations.channel,
})
.from(conversations)
.innerJoin(clients, eq(conversations.clientId, clients.id))
.where(eq(conversations.businessId, settings.id))
.orderBy(desc(conversations.lastMessageAt))
.limit(limit + 1);
if (cursor) {
const [cursorRow] = await db
.select({ lastMessageAt: conversations.lastMessageAt })
.from(conversations)
.where(eq(conversations.id, cursor))
.limit(1);
if (cursorRow?.lastMessageAt) {
baseQuery = db
.select({
id: conversations.id,
clientId: conversations.clientId,
lastMessageAt: conversations.lastMessageAt,
status: conversations.status,
staffReadAt: conversations.staffReadAt,
clientName: clients.name,
clientPhone: clients.phone,
channel: conversations.channel,
})
.from(conversations)
.innerJoin(clients, eq(conversations.clientId, clients.id))
.where(
and(
eq(conversations.businessId, settings.id),
lt(conversations.lastMessageAt, cursorRow.lastMessageAt)
)
)
.orderBy(desc(conversations.lastMessageAt))
.limit(limit + 1);
}
}
const rows = await baseQuery;
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
const items = await Promise.all(
rows.map(async (row) => {
const [unreadRow] = await db
.select({ count: sql<number>`count(*)` })
.from(messages)
.where(
and(
eq(messages.conversationId, row.id),
eq(messages.direction, "inbound"),
sql`${messages.createdAt} > COALESCE(${row.staffReadAt}, '1970-01-01'::timestamp)`
)
)
.limit(1);
const [lastMsg] = await db
.select({
body: messages.body,
direction: messages.direction,
createdAt: messages.createdAt,
})
.from(messages)
.where(eq(messages.conversationId, row.id))
.orderBy(desc(messages.createdAt))
.limit(1);
return {
id: row.id,
clientId: row.clientId,
clientName: row.clientName,
clientPhone: row.clientPhone,
channel: row.channel,
lastMessageAt: row.lastMessageAt,
status: row.status,
unreadCount: Number(unreadRow?.count ?? 0),
lastMessage: lastMsg ?? null,
};
})
);
const lastRow = rows[rows.length - 1];
const nextCursor = hasMore && lastRow ? lastRow.id : null;
return c.json({ items, nextCursor });
});
// GET /api/conversations/:id/messages — List messages for a conversation
conversationsRouter.get("/:id/messages", async (c) => {
const db = getDb();
const staffRow = c.get("staff");
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
const conversationId = c.req.param("id");
const cursor = c.req.query("cursor") || undefined;
const limit = Math.min(Number(c.req.query("limit") || "50"), 100);
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
if (!settings) return c.json({ error: "Business not found" }, 404);
const [conv] = await db
.select({ id: conversations.id })
.from(conversations)
.where(
and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id))
)
.limit(1);
if (!conv) return c.json({ error: "Not found" }, 404);
await db
.update(conversations)
.set({ staffReadAt: new Date() })
.where(eq(conversations.id, conversationId));
let query = db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
sentByStaffId: messages.sentByStaffId,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(desc(messages.createdAt))
.limit(limit + 1);
if (cursor) {
const [cursorRow] = await db
.select({ createdAt: messages.createdAt })
.from(messages)
.where(eq(messages.id, cursor))
.limit(1);
if (cursorRow?.createdAt) {
query = db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
sentByStaffId: messages.sentByStaffId,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(
and(
eq(messages.conversationId, conversationId),
lt(messages.createdAt, cursorRow.createdAt)
)
)
.orderBy(desc(messages.createdAt))
.limit(limit + 1);
}
}
const rows = await query;
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
const lastRow = rows[rows.length - 1];
const nextCursor = hasMore && lastRow ? lastRow.id : null;
return c.json({ items: rows, nextCursor });
});
// POST /api/conversations/:id/messages — Send a message
conversationsRouter.post(
"/:id/messages",
zValidator("json", sendMessageSchema),
async (c) => {
const db = getDb();
const staffRow = c.get("staff");
if (!staffRow) return c.json({ error: "Unauthorized" }, 401);
const conversationId = c.req.param("id");
const { body } = c.req.valid("json");
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
if (!settings) return c.json({ error: "Business not found" }, 404);
const [conv] = await db
.select({ id: conversations.id, clientId: conversations.clientId })
.from(conversations)
.where(
and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id))
)
.limit(1);
if (!conv) return c.json({ error: "Not found" }, 404);
const result = await sendMessage({
businessId: settings.id,
clientId: conv.clientId,
body,
sentByStaffId: staffRow.id,
});
if (result.suppressed) {
return c.json({ error: "Client has opted out of SMS" }, 409);
}
const [msg] = await db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
sentByStaffId: messages.sentByStaffId,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(eq(messages.id, result.messageId))
.limit(1);
return c.json(msg, 201);
}
);
+6 -93
View File
@@ -1,10 +1,9 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
export const groomingLogsRouter = new Hono<AppEnv>();
export const groomingLogsRouter = new Hono();
const createLogSchema = z.object({
petId: z.string().uuid(),
@@ -21,26 +20,6 @@ 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)
@@ -54,50 +33,11 @@ groomingLogsRouter.post(
zValidator("json", createLogSchema),
async (c) => {
const db = getDb();
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 { groomedAt, ...rest } = c.req.valid("json");
const [row] = await db
.insert(groomingVisitLogs)
.values({
...rest,
petId,
appointmentId: appointmentId ?? null,
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
})
.returning();
@@ -107,37 +47,10 @@ groomingLogsRouter.post(
groomingLogsRouter.delete("/:id", async (c) => {
const db = getDb();
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
const [row] = await db
.delete(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, id))
.where(eq(groomingVisitLogs.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
});
+71 -261
View File
@@ -8,24 +8,13 @@ import {
invoices,
invoiceLineItems,
invoiceTipSplits,
refunds,
appointments,
services,
clients,
sql,
} from "@groombook/db";
import type { AppEnv, StaffRole } from "../middleware/rbac.js";
import { requireRole } 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;
});
export const invoicesRouter = new Hono();
const createInvoiceSchema = z.object({
appointmentId: z.string().uuid().optional(),
@@ -51,73 +40,56 @@ 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
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("/", 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 });
});
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();
@@ -131,17 +103,7 @@ invoicesRouter.get("/:id", async (c) => {
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
]);
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 });
return c.json({ ...invoice, lineItems, tipSplits });
});
// Save tip splits for an invoice (replaces existing splits)
@@ -154,8 +116,8 @@ const tipSplitSchema = z.object({
})
).min(1).refine(
(splits) => {
const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
return totalBps === 10000;
const total = splits.reduce((sum, s) => sum + s.sharePct, 0);
return Math.abs(total - 100) < 0.01;
},
{ message: "Split percentages must sum to 100" }
),
@@ -199,13 +161,12 @@ invoicesRouter.post(
}
});
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)),
]);
const splits = await db
.select()
.from(invoiceTipSplits)
.where(eq(invoiceTipSplits.invoiceId, id));
return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201);
return c.json(splits, 201);
}
);
@@ -330,13 +291,6 @@ 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",
@@ -352,33 +306,11 @@ invoicesRouter.patch(
.where(eq(invoices.id, id));
if (!current) return c.json({ error: "Not found" }, 404);
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
);
}
if (current.status === "void") {
return c.json({ error: "Cannot modify a voided invoice" }, 422);
}
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() };
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
// Auto-set paidAt when marking as paid
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
@@ -392,42 +324,16 @@ invoicesRouter.patch(
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
}
// 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 [updated] = await db
.update(invoices)
.set(update)
.where(eq(invoices.id, id))
.returning();
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];
});
const lineItems = await db
.select()
.from(invoiceLineItems)
.where(eq(invoiceLineItems.invoiceId, id));
return c.json({ ...updated, lineItems });
}
@@ -435,11 +341,10 @@ invoicesRouter.patch(
// ─── Refund ───────────────────────────────────────────────────────────────────
import { processRefund, getPaymentIntentDetails } from "../services/payment.js";
import { processRefund } from "../services/payment.js";
const refundSchema = z.object({
amountCents: z.number().int().nonnegative().optional(),
idempotencyKey: z.string().max(255).optional(),
});
invoicesRouter.post(
@@ -462,107 +367,12 @@ invoicesRouter.post(
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
}
if (!invoice.stripePaymentIntentId) {
return c.json({ error: "Invoice has no Stripe payment intent" }, 422);
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
}
return await db.transaction(async (tx) => {
if (body.idempotencyKey) {
const [existing] = await tx
.select()
.from(refunds)
.where(eq(refunds.idempotencyKey, body.idempotencyKey));
if (existing) {
return c.json({ refundId: existing.stripeRefundId });
}
}
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
const refundId = result.refundId;
await tx.insert(refunds).values({
invoiceId: id,
stripeRefundId: refundId,
idempotencyKey: body.idempotencyKey ?? null,
amountCents: body.amountCents ?? null,
});
return c.json({ refundId });
});
return c.json({ refundId: result.refundId });
}
);
// Payment stats for admin dashboard
invoicesRouter.get("/stats/summary", requireRole("manager" as StaffRole), 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,
});
});
+2 -10
View File
@@ -213,11 +213,7 @@ petsRouter.post(
// Delete the previous photo from storage to avoid orphaned objects
if (pet.photoKey) {
try {
await deleteObject(pet.photoKey);
} catch (err) {
console.warn(`Failed to delete previous photo ${pet.photoKey}, orphaned object may remain:`, err);
}
await deleteObject(pet.photoKey);
}
const [row] = await db
@@ -244,11 +240,7 @@ 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);
try {
await deleteObject(pet.photoKey);
} catch (err) {
console.warn(`Failed to delete photo ${pet.photoKey} from S3, orphaned object may remain:`, err);
}
await deleteObject(pet.photoKey);
await db
.update(pets)
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
+245 -202
View File
@@ -1,84 +1,33 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, inArray, desc, lt } from "@groombook/db";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems, businessSettings, conversations, messages } from "@groombook/db";
import { validatePortalSession } from "../middleware/portalSession.js";
import { portalAudit } from "../middleware/portalAudit.js";
import type { PortalEnv } from "../middleware/portalSession.js";
import { and, 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";
export const portalRouter = new Hono<PortalEnv>();
export const portalRouter = new Hono<AppEnv>();
// 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(),
});
// ─── Session helper ───────────────────────────────────────────────────────────
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);
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;
}
// ─── GET routes ──────────────────────────────────────────────────────────────
portalRouter.get("/me", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
if (!client) return c.json({ error: "Not found" }, 404);
@@ -86,12 +35,6 @@ portalRouter.get("/me", async (c) => {
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
});
portalRouter.get("/config", async (c) => {
return c.json({
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
});
});
portalRouter.get("/services", async (c) => {
const db = getDb();
const allServices = await db.select().from(services).where(eq(services.active, true));
@@ -100,8 +43,11 @@ portalRouter.get("/services", async (c) => {
portalRouter.get("/appointments", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const now = new Date();
const allAppts = await db
.select({
id: appointments.id,
@@ -141,20 +87,27 @@ portalRouter.get("/appointments", async (c) => {
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
}));
return c.json({ appointments: appts });
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 });
});
portalRouter.get("/pets", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
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, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes })));
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 })));
});
portalRouter.get("/invoices", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
const invoiceIds = clientInvoices.map(i => i.id);
@@ -170,104 +123,11 @@ portalRouter.get("/invoices", async (c) => {
id: inv.id,
status: inv.status,
totalCents: inv.totalCents,
date: inv.createdAt,
createdAt: inv.createdAt,
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
})));
});
// ─── Conversation routes ──────────────────────────────────────────────────────
portalRouter.get("/conversation", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const [settings] = await db.select({ id: businessSettings.id }).from(businessSettings).limit(1);
if (!settings) return c.json({ error: "Business not configured" }, 500);
const businessId = settings.id;
const [conversation] = await db
.select({
id: conversations.id,
channel: conversations.channel,
lastMessageAt: conversations.lastMessageAt,
status: conversations.status,
createdAt: conversations.createdAt,
})
.from(conversations)
.where(and(eq(conversations.clientId, clientId), eq(conversations.businessId, businessId)))
.limit(1);
if (!conversation) {
return c.body(null, 204);
}
return c.json(conversation);
});
portalRouter.get("/conversation/messages", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const cursor = c.req.query("cursor") || undefined;
const limit = Math.min(Number(c.req.query("limit") || "50"), 100);
const [settings] = await db.select({ id: businessSettings.id }).from(businessSettings).limit(1);
if (!settings) return c.json({ error: "Business not configured" }, 500);
const businessId = settings.id;
const [conversation] = await db
.select({ id: conversations.id })
.from(conversations)
.where(and(eq(conversations.clientId, clientId), eq(conversations.businessId, businessId)))
.limit(1);
if (!conversation) {
return c.body(null, 204);
}
let query = db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(eq(messages.conversationId, conversation.id))
.orderBy(desc(messages.createdAt))
.limit(limit);
if (cursor) {
const [cursorMsg] = await db
.select({ createdAt: messages.createdAt })
.from(messages)
.where(eq(messages.id, cursor))
.limit(1);
if (cursorMsg) {
query = db
.select({
id: messages.id,
direction: messages.direction,
body: messages.body,
status: messages.status,
createdAt: messages.createdAt,
deliveredAt: messages.deliveredAt,
})
.from(messages)
.where(and(eq(messages.conversationId, conversation.id), lt(messages.createdAt, cursorMsg.createdAt)))
.orderBy(desc(messages.createdAt))
.limit(limit);
}
}
const messagesResult = await query;
const nextCursor = messagesResult.length === limit ? messagesResult[messagesResult.length - 1]!.id : null;
return c.json({ messages: messagesResult, nextCursor });
});
// ─── Appointment action routes ────────────────────────────────────────────────
const customerNotesSchema = z.object({
@@ -282,7 +142,12 @@ portalRouter.patch(
const db = getDb();
const id = c.req.param("id");
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db
.select()
@@ -325,7 +190,12 @@ portalRouter.patch(
portalRouter.post("/appointments/:id/confirm", async (c) => {
const db = getDb();
const id = c.req.param("id");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db
.select()
@@ -374,7 +244,12 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
portalRouter.post("/appointments/:id/cancel", async (c) => {
const db = getDb();
const id = c.req.param("id");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db
.select()
@@ -438,7 +313,28 @@ portalRouter.post(
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
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 [entry] = await db
.insert(waitlistEntries)
@@ -462,7 +358,26 @@ portalRouter.patch(
const db = getDb();
const id = c.req.param("id");
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
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 [existing] = await db
.select()
@@ -471,7 +386,7 @@ portalRouter.patch(
.limit(1);
if (!existing) return c.json({ error: "Not found" }, 404);
if (existing.clientId !== clientId) {
if (existing.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403);
}
@@ -493,7 +408,26 @@ portalRouter.patch(
portalRouter.delete("/waitlist/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const clientId = c.get("portalClientId");
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 [entry] = await db
.select()
@@ -502,7 +436,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
.limit(1);
if (!entry) return c.json({ error: "Not found" }, 404);
if (entry.clientId !== clientId) {
if (entry.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403);
}
@@ -519,12 +453,49 @@ portalRouter.delete("/waitlist/:id", async (c) => {
import {
createPaymentIntent,
listPaymentMethods,
attachPaymentMethod,
detachPaymentMethod,
createSetupIntent,
getOrCreateStripeCustomer,
getStripeClient,
} from "../services/payment.js";
const payInvoiceSchema = z.object({
invoiceId: z.string().uuid(),
});
portalRouter.post(
"/invoices/:id/pay",
zValidator("json", payInvoiceSchema),
async (c) => {
const db = getDb();
const invoiceId = 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 [invoice] = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.limit(1);
if (!invoice) return c.json({ error: "Not found" }, 404);
if (invoice.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
if (invoice.status === "draft" || invoice.status === "void") {
return c.json({ error: "Cannot pay a draft or void invoice" }, 422);
}
if (invoice.status === "paid") {
return c.json({ error: "Invoice is already paid" }, 422);
}
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const result = await createPaymentIntent(invoiceId, clientId);
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
}
);
const payMultipleSchema = z.object({
invoiceIds: z.array(z.string().uuid()).min(1),
});
@@ -535,7 +506,9 @@ portalRouter.post(
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const invoiceRows = await db
.select()
@@ -557,7 +530,6 @@ portalRouter.post(
}
const firstInvoice = invoiceRows[0];
if (!firstInvoice) return c.json({ error: "No invoices found" }, 400);
const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId);
if (!allSameClient) {
return c.json({ error: "All invoices must belong to the same client" }, 422);
@@ -572,7 +544,9 @@ portalRouter.post(
);
portalRouter.get("/payment-methods", async (c) => {
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const methods = await listPaymentMethods(clientId);
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
@@ -580,7 +554,9 @@ portalRouter.get("/payment-methods", async (c) => {
});
portalRouter.post("/payment-methods", async (c) => {
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const customerId = await getOrCreateStripeCustomer(clientId);
@@ -593,22 +569,89 @@ portalRouter.post("/payment-methods", async (c) => {
});
portalRouter.delete("/payment-methods/:id", async (c) => {
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const paymentMethodId = c.req.param("id");
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404);
const stripe = getStripeClient();
if (!stripe) return c.json({ error: "Payment service unavailable" }, 503);
const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);
if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) {
return c.json({ error: "Payment method not found" }, 404);
}
const ok = await detachPaymentMethod(paymentMethodId);
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
return c.json({ ok: true });
});
});
// ─── Config endpoint ─────────────────────────────────────────────────────────
portalRouter.get("/config", (c) => {
return c.json({
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
});
});
// ─── 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);
}
);
+3 -26
View File
@@ -286,10 +286,6 @@ 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,
@@ -302,34 +298,15 @@ 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`)
.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;
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`);
return c.json({
from: from.toISOString(),
to: to.toISOString(),
newClients,
activeInPeriodCount: activeInPeriod.length,
churnRisk,
churnRiskTotal,
page,
limit,
churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients
churnRiskTotal: churnRisk.length,
});
});
+1 -1
View File
@@ -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().max(480),
durationMinutes: z.number().int().positive(),
active: z.boolean().default(true),
});
+4 -82
View File
@@ -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, deleteObject, putObject, getObject } from "../lib/s3.js";
import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject } from "../lib/s3.js";
import { requireSuperUser } from "../middleware/rbac.js";
export const settingsRouter = new Hono();
@@ -100,77 +100,6 @@ 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.
@@ -215,8 +144,7 @@ settingsRouter.post(
/**
* GET /api/admin/settings/logo
* Proxies the logo from S3 so the browser never sees an S3 URL.
* Returns the image bytes with proper Content-Type.
* Returns a presigned GET URL for the logo.
*/
settingsRouter.get("/logo", async (c) => {
const db = getDb();
@@ -225,14 +153,8 @@ 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 { body, contentType } = await getObject(row.logoKey);
return new Response(Buffer.from(body), {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
const url = await getPresignedGetUrl(row.logoKey);
return c.json({ url, logoKey: row.logoKey });
});
/**
+37 -84
View File
@@ -4,24 +4,6 @@ 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
@@ -203,74 +185,52 @@ 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();
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);
// 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);
if (superUser) {
throw Object.assign(new Error("setup-complete"), { code: 403 });
}
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);
}
const [existingConfig] = await tx
.select({ id: authProviderConfig.id })
.from(authProviderConfig)
.where(eq(authProviderConfig.enabled, true))
.limit(1);
// 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);
if (existingConfig) {
throw Object.assign(new Error("config-exists"), { code: 409 });
}
if (existingConfig) {
return c.json({ error: "Auth provider is already configured." }, 409);
}
const body = authProviderBootstrapSchema.parse(await c.req.json());
const body = authProviderBootstrapSchema.parse(await c.req.json());
const encryptedSecret = encryptSecret(body.clientSecret);
// Encrypt clientSecret before storing
const encryptedSecret = encryptSecret(body.clientSecret);
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();
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();
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;
if (!row) {
return c.json({ error: "Failed to save auth provider configuration." }, 500);
}
return c.json({
@@ -294,13 +254,6 @@ 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)
-119
View File
@@ -1,119 +0,0 @@
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";
export const webhooksRouter = new Hono();
webhooksRouter.post("/stripe", async (c) => {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
return c.json({ error: "Webhook secret not configured" }, 503);
}
const signature = c.req.header("stripe-signature");
if (!signature) {
return c.json({ error: "Missing signature" }, 401);
}
let rawBody: string;
try {
rawBody = await c.req.text();
} catch {
return c.json({ error: "Could not read body" }, 400);
}
const stripe = getStripeClient();
if (!stripe) {
return c.json({ error: "Stripe not configured" }, 503);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Invalid signature";
return c.json({ error: message }, 401);
}
const db = getDb();
if (event.type === "payment_intent.succeeded") {
const pi = event.data.object as Stripe.PaymentIntent;
if (pi.metadata?.groombook_invoice_ids) {
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, invoiceIdTrimmed))
.limit(1);
if (!inv) continue;
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
await db
.update(invoices)
.set({
status: "paid",
paymentMethod: "card",
paidAt: new Date(),
stripePaymentIntentId: pi.id,
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceIdTrimmed));
}
}
} else if (event.type === "payment_intent.payment_failed") {
const pi = event.data.object as Stripe.PaymentIntent;
if (pi.metadata?.groombook_invoice_ids) {
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, invoiceIdTrimmed));
}
}
} else if (event.type === "charge.refunded") {
const charge = event.data.object as Stripe.Charge;
if (typeof charge.payment_intent === "string" && charge.payment_intent) {
const [inv] = await db
.select({ id: invoices.id })
.from(invoices)
.where(eq(invoices.stripePaymentIntentId, charge.payment_intent))
.limit(1);
if (inv) {
const refundId =
typeof charge.refunded === "boolean" && charge.refunded
? `ch_${charge.id}_refund`
: null;
await db
.update(invoices)
.set({
status: "void",
stripeRefundId: refundId,
updatedAt: new Date(),
})
.where(eq(invoices.id, inv.id));
}
}
} else if (event.type === "charge.dispute.created") {
const dispute = event.data.object as Stripe.Dispute;
console.error(
`[Stripe Webhook] Dispute created for payment intent: ${dispute.payment_intent}`
);
}
return c.json({ received: true });
});
+137
View File
@@ -0,0 +1,137 @@
import { Hono } from "hono";
import {
and,
eq,
getDb,
clients,
reminderLogs,
smsSend,
} from "@groombook/db";
import { TelnyxProvider } from "../services/sms.js";
export const webhooksRouter = new Hono();
const telnyxProvider = new TelnyxProvider();
const STOP_KEYWORDS = new Set(["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]);
const START_KEYWORDS = new Set(["START", "YES", "UNSTOP"]);
webhooksRouter.post("/sms/inbound", async (c) => {
if (!telnyxProvider.validateWebhookSignature(c.req.raw)) {
return c.json({ error: "Invalid signature" }, 401);
}
let body: Record<string, unknown>;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid JSON" }, 400);
}
const event = (body.data as Record<string, unknown>)?.event_type ?? body.event_type;
const payload = (body.data as Record<string, unknown>) ?? body;
if (event === "message.received") {
const fromField = payload.from;
const from = typeof fromField === "object" && fromField !== null
? (fromField as Record<string, unknown>).phone_number as string ?? (fromField as Record<string, unknown>).toString()
: String(fromField ?? "");
const text = String(payload.text ?? payload.body ?? "").trim().toUpperCase();
if (!from || !text) {
return c.json({ error: "Missing from or text" }, 400);
}
const db = getDb();
const [client] = await db
.select({ id: clients.id, smsOptIn: clients.smsOptIn })
.from(clients)
.where(eq(clients.phone, from))
.limit(1);
if (!client) {
return c.json({ received: true });
}
if (STOP_KEYWORDS.has(text)) {
await db
.update(clients)
.set({
smsOptIn: false,
smsOptOutDate: new Date(),
updatedAt: new Date(),
})
.where(eq(clients.id, client.id));
return c.json({ received: true });
}
if (START_KEYWORDS.has(text)) {
await db
.update(clients)
.set({
smsOptIn: true,
smsConsentDate: new Date(),
updatedAt: new Date(),
})
.where(eq(clients.id, client.id));
return c.json({ received: true });
}
if (text === "HELP") {
const supportUrl = process.env.SUPPORT_URL ?? "https://groombook.app/support";
await smsSend(from, `GroomBook appointment reminders. Reply STOP to opt out. For help, visit ${supportUrl}.`);
return c.json({ received: true });
}
return c.json({ received: true });
}
if (event === "message.finalized" || event === "message.status") {
const status = String(payload.status ?? "");
const toField = payload.to;
const toNumber = typeof toField === "object" && toField !== null
? (toField as Record<string, unknown>).phone_number as string ?? (toField as Record<string, unknown>).toString()
: String(toField ?? "");
if (!status || !toNumber) {
return c.json({ received: true });
}
const validDelivery = ["delivered", "sent", "failed", "sending", "queued"];
if (!validDelivery.includes(status)) {
return c.json({ received: true });
}
const db = getDb();
const [client] = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.phone, toNumber))
.limit(1);
if (client) {
const [log] = await db
.select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.channel, "sms")
)
)
.limit(1);
if (log) {
await db
.update(reminderLogs)
.set({ deliveryStatus: status })
.where(eq(reminderLogs.id, log.id));
}
}
return c.json({ received: true });
}
return c.json({ received: true });
});
-59
View File
@@ -1,59 +0,0 @@
import { Hono } from "hono";
import { validateTelnyxSignature } from "../../services/sms.js";
import {
handleMessageReceived,
handleMessageFinalized,
TelnyxMessageReceivedPayload,
} from "../../services/messaging/inbound.js";
export const telnyxWebhooksRouter = new Hono();
telnyxWebhooksRouter.post("/messaging", async (c) => {
const signature = c.req.header("telnyx-signature");
let rawBody: string;
try {
rawBody = await c.req.text();
} catch {
return c.json({ error: "Could not read body" }, 400);
}
if (!validateTelnyxSignature(rawBody, signature)) {
return c.json({ error: "Invalid signature" }, 401);
}
let payload: TelnyxMessageReceivedPayload;
try {
payload = JSON.parse(rawBody) as TelnyxMessageReceivedPayload;
} catch {
return c.json({ error: "Invalid JSON" }, 400);
}
const eventType = payload.data?.event_type;
if (!eventType) {
return c.json({ error: "Missing event_type" }, 400);
}
if (eventType === "message.received") {
try {
await handleMessageReceived(payload);
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
if (msg.startsWith("No business owns")) {
return c.json({ error: "Unknown messaging number" }, 404);
}
return c.json({ error: msg }, 500);
}
return c.json({ received: true });
}
if (eventType === "message.finalized") {
const result = await handleMessageFinalized(payload);
if (result) {
return c.json({ received: true, messageId: result.messageId, status: result.newStatus });
}
return c.json({ received: true, messageId: null });
}
return c.json({ received: true });
});
-49
View File
@@ -201,52 +201,3 @@ export function buildWaitlistNotificationEmail(
<p>— Groom Book</p>`,
};
}
// ─── Reschedule notification email ────────────────────────────────────────────
interface RescheduleEmailData {
clientName: string;
petName: string;
serviceName: string;
groomerName: string | null;
oldStartTime: Date;
newStartTime: Date;
}
export function buildRescheduleNotificationEmail(
to: string,
data: RescheduleEmailData
): Mail.Options {
const oldTime = formatDateTime(data.oldStartTime);
const newTime = formatDateTime(data.newStartTime);
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
return {
to,
subject: `Appointment Rescheduled — ${data.petName}'s appointment has been moved`,
text: [
`Hi ${data.clientName},`,
``,
`Your appointment has been rescheduled.`,
``,
` Pet: ${data.petName}`,
` Service: ${data.serviceName}`,
` Was: ${oldTime}${groomer}`,
` Now: ${newTime}${groomer}`,
``,
`If you have any questions or need to make changes, please contact us.`,
``,
`— Groom Book`,
].join("\n"),
html: `
<p>Hi ${data.clientName},</p>
<p>Your appointment has been <strong>rescheduled</strong>.</p>
<table style="border-collapse:collapse;margin:1em 0">
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#ef4444">Was</td><td style="text-decoration:line-through;color:#ef4444">${oldTime}${groomer}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#10b981">Now</td><td style="color:#10b981">${newTime}${groomer}</td></tr>
</table>
<p>If you have any questions or need to make changes, please contact us.</p>
<p>— Groom Book</p>`,
};
}
@@ -1,214 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { detectKeyword } from "../consent.js";
const mockDb = {
insert: vi.fn(),
update: vi.fn(),
select: vi.fn(),
};
vi.mock("@groombook/db", () => ({
getDb: () => mockDb,
clients: {},
messageConsentEvents: {},
businessSettings: {},
eq: vi.fn(),
}));
const { handleConsentKeyword } = await import("../consent.js");
describe("detectKeyword", () => {
it.each([
["STOP", "opt_out"],
["STOPALL", "opt_out"],
["UNSUBSCRIBE", "opt_out"],
["CANCEL", "opt_out"],
["END", "opt_out"],
["QUIT", "opt_out"],
])("opt-out keyword %s → opt_out", (keyword, expected) => {
expect(detectKeyword(keyword)).toEqual({ kind: expected });
});
it.each([
["START", "opt_in"],
["UNSTOP", "opt_in"],
["YES", "opt_in"],
["SUBSCRIBE", "opt_in"],
])("opt-in keyword %s → opt_in", (keyword, expected) => {
expect(detectKeyword(keyword)).toEqual({ kind: expected });
});
it.each([
["HELP", "help"],
["INFO", "help"],
])("help keyword %s → help", (keyword, expected) => {
expect(detectKeyword(keyword)).toEqual({ kind: expected });
});
it("is case insensitive", () => {
expect(detectKeyword("stop")).toEqual({ kind: "opt_out" });
expect(detectKeyword("Stop")).toEqual({ kind: "opt_out" });
expect(detectKeyword("sToP")).toEqual({ kind: "opt_out" });
});
it("trims whitespace", () => {
expect(detectKeyword(" STOP ")).toEqual({ kind: "opt_out" });
expect(detectKeyword("\tSTART\n")).toEqual({ kind: "opt_in" });
});
it("returns null for non-keyword messages", () => {
expect(detectKeyword("hello")).toBeNull();
expect(detectKeyword("STOP IT")).toBeNull();
expect(detectKeyword("help me")).toBeNull();
});
});
describe("handleConsentKeyword", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.insert.mockReturnValue({
values: vi.fn().mockResolvedValue([{ id: "event-1" }]),
} as any);
mockDb.update.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
} as any);
});
const baseOpts = {
clientId: "client-1",
businessId: "biz-1",
db: mockDb as unknown as ReturnType<typeof import("@groombook/db").getDb>,
};
describe("opt_out", () => {
it("inserts consent event with sms_keyword source", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(mockDb.insert).toHaveBeenCalledOnce();
});
it("sets smsOptIn=false and smsOptOutDate when currently opted in", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(mockDb.update).toHaveBeenCalled();
});
it("is idempotent — second opt-out logs event but skips client update", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(mockDb.update).not.toHaveBeenCalled();
});
it("returns unsubscribe reply text", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_out" });
expect(result.replyText).toBe(
"You have been unsubscribed and will no longer receive messages. Reply START to resubscribe."
);
});
});
describe("opt_in", () => {
it("sets smsOptIn=true and smsConsentDate when currently opted out", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false, smsConsentDate: null }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(mockDb.update).toHaveBeenCalled();
});
it("clears smsOptOutDate on opt-in after opt-out", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(mockDb.update).toHaveBeenCalled();
});
it("is idempotent — second opt-in skips client update", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: true }]),
}),
}),
} as any);
await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(mockDb.update).not.toHaveBeenCalled();
});
it("returns resubscribe reply text", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ smsOptIn: false }]),
}),
}),
} as any);
const result = await handleConsentKeyword({ ...baseOpts, kind: "opt_in" });
expect(result.replyText).toBe(
"You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply."
);
});
});
describe("help", () => {
it("returns default help reply without querying businessSettings", async () => {
const result = await handleConsentKeyword({ ...baseOpts, kind: "help" });
expect(mockDb.update).not.toHaveBeenCalled();
expect(mockDb.select).not.toHaveBeenCalled();
expect(result.replyText).toBe(
"Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly."
);
});
});
});
@@ -1,313 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
findOrCreateConversation,
upsertMessage,
handleMessageReceived,
handleMessageFinalized,
TelnyxMessageReceivedPayload,
} from "../inbound.js";
import * as schema from "@groombook/db";
vi.mock("@groombook/db", () => ({
getDb: vi.fn(),
conversations: { id: "", businessId: "", clientId: "", externalNumber: "", businessNumber: "", channel: "", lastMessageAt: null, status: "", createdAt: null, updatedAt: null },
messages: { id: "", conversationId: "", direction: "", body: "", status: "", providerMessageId: "", sentByStaffId: null, createdAt: null, deliveredAt: null, readByClientAt: null },
businessSettings: { id: "", messagingPhoneNumber: "" },
clients: { id: "", name: "", email: "", phone: "", status: "" },
eq: vi.fn(),
and: vi.fn(),
sql: vi.fn(),
}));
const mockDb = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnThis(),
};
vi.mocked(schema.getDb).mockReturnValue(mockDb as unknown as ReturnType<typeof schema.getDb>);
const makePayload = (
eventType: "message.received" | "message.sent" | "message.finalized",
messageId: string,
fromPhone: string,
toPhone: string,
body = "Hello"
): TelnyxMessageReceivedPayload => ({
data: {
id: "evt-1",
event_type: eventType,
payload: {
message: {
id: messageId,
from: { phone: fromPhone, carrier: "carrier" },
to: [{ phone: toPhone }],
body,
},
},
},
});
describe("signature validation via route", () => {
beforeEach(() => {
vi.resetModules();
});
it("returns 401 when telnyx-signature header is missing", async () => {
const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js");
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
const req = new Request("http://localhost/messaging", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
});
const res = await telnyxWebhooksRouter.fetch(req);
expect(res.status).toBe(401);
});
it("returns 401 when signature does not match", async () => {
process.env.TELNYX_WEBHOOK_SECRET = "test-secret";
const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js");
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
const req = new Request("http://localhost/messaging", {
method: "POST",
headers: {
"Content-Type": "application/json",
"telnyx-signature": "sha256=bad",
},
body: payload,
});
const res = await telnyxWebhooksRouter.fetch(req);
expect(res.status).toBe(401);
});
});
describe("findOrCreateConversation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.select.mockReset();
mockDb.from.mockReset();
mockDb.where.mockReset();
mockDb.limit.mockReset();
mockDb.insert.mockReset();
mockDb.update.mockReset();
mockDb.returning.mockReset();
});
it("returns existing conversation when found", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ id: "conv-1", clientId: "client-1" }]),
}),
}),
});
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
expect(result.id).toBe("conv-1");
});
it("creates new conversation when none exists", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "conv-2", clientId: "client-2" }]),
}),
});
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
expect(result.id).toBe("conv-2");
});
it("creates placeholder client for unknown phone then creates conversation", async () => {
mockDb.select
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "conv-3", clientId: "client-3" }]),
}),
});
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
expect(result.id).toBe("conv-3");
expect(result.clientId).toBe("client-3");
});
});
describe("upsertMessage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns isNew=false when message with providerMessageId already exists", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ id: "msg-existing" }]),
}),
}),
});
const result = await upsertMessage("msg-123", "conv-1", "inbound", "Hello", "received");
expect(result.isNew).toBe(false);
expect(result.id).toBe("msg-existing");
});
it("inserts new message and returns isNew=true", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "msg-new" }]),
}),
});
const result = await upsertMessage("msg-new-123", "conv-1", "inbound", "New message", "queued");
expect(result.isNew).toBe(true);
expect(result.id).toBe("msg-new");
});
});
describe("handleMessageReceived", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.select.mockReset();
mockDb.from.mockReset();
mockDb.where.mockReset();
mockDb.limit.mockReset();
mockDb.insert.mockReset();
mockDb.update.mockReset();
mockDb.returning.mockReset();
mockDb.select.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
}));
});
it("returns 404 when no business owns the to number", async () => {
const payload = makePayload("message.received", "msg-123", "+1555111", "+1555000");
await expect(handleMessageReceived(payload)).rejects.toThrow("No business owns messaging number");
});
it("creates conversation and message for valid inbound", async () => {
mockDb.select
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ id: "biz-1" }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
mockDb.insert
.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "client-new" }]),
}),
})
.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "conv-new", clientId: "client-new" }]),
}),
});
mockDb.update.mockReturnValueOnce({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({}),
}),
});
mockDb.insert.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "msg-new" }]),
}),
});
const payload = makePayload("message.received", "msg-abc", "+1555111", "+1555222", "Test message");
const result = await handleMessageReceived(payload);
expect(result.messageId).toBe("msg-new");
});
});
describe("handleMessageFinalized", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDb.select.mockReset();
mockDb.from.mockReset();
mockDb.where.mockReset();
mockDb.limit.mockReset();
mockDb.insert.mockReset();
mockDb.update.mockReset();
mockDb.returning.mockReset();
});
it("returns null when message not found", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
});
const payload = makePayload("message.finalized", "msg-unknown", "+1555111", "+1555222");
const result = await handleMessageFinalized(payload);
expect(result).toBeNull();
});
it("updates status to delivered for finalized inbound", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ id: "msg-1", status: "sent" }]),
}),
}),
});
mockDb.update.mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ id: "msg-1" }]),
}),
}),
});
const payload = makePayload("message.finalized", "msg-1", "+1555111", "+1555222");
const result = await handleMessageFinalized(payload);
expect(result?.newStatus).toBe("delivered");
});
});
@@ -1,200 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockSendSms = vi.fn();
const mockGetDb = vi.fn();
const mockUuidv4 = vi.fn();
vi.mock("../../sms.js", () => ({
sendSms: mockSendSms,
}));
vi.mock("@groombook/db", () => ({
getDb: () => mockGetDb(),
conversations: {},
messages: {},
clients: {},
businessSettings: {},
eq: vi.fn((a, b) => [a, b]),
and: vi.fn((...args) => args),
}));
vi.mock("uuid", () => ({
v4: () => mockUuidv4(),
}));
const { sendMessage, MissingTenantPhoneNumberError } = await import("../outbound.js");
describe("sendMessage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUuidv4.mockReturnValue("test-uuid");
});
function buildSelectMock(results: unknown[]) {
return vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(results),
}),
}),
});
}
it("returns suppressed=true when client has no phone", async () => {
mockGetDb.mockReturnValue({
select: buildSelectMock([{ phone: null, smsOptIn: true }]),
});
const result = await sendMessage({
businessId: "biz-1",
clientId: "client-1",
body: "Hello",
});
expect(result).toEqual({ suppressed: true });
expect(mockSendSms).not.toHaveBeenCalled();
});
it("returns suppressed=true when client has opted out of SMS", async () => {
mockGetDb.mockReturnValue({
select: buildSelectMock([{ phone: "+1234567890", smsOptIn: false }]),
});
const result = await sendMessage({
businessId: "biz-1",
clientId: "client-1",
body: "Hello",
});
expect(result).toEqual({ suppressed: true });
expect(mockSendSms).not.toHaveBeenCalled();
});
it("throws MissingTenantPhoneNumberError when tenant has no messaging phone", async () => {
mockGetDb.mockReturnValue({
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: null }]),
}),
}),
}),
});
await expect(
sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" })
).rejects.toThrow(MissingTenantPhoneNumberError);
});
it("persists provider message id on success", async () => {
const messageId = "msg-1";
const conversationId = "conv-1";
mockGetDb.mockReturnValue({
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: conversationId }]),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([{ id: messageId }]),
}),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}),
});
mockSendSms.mockResolvedValue({ messageId: "provider-msg-1", status: "sent" });
const result = await sendMessage({
businessId: "biz-1",
clientId: "client-1",
body: "Hello",
});
expect(result).toEqual({
messageId,
providerMessageId: "provider-msg-1",
status: "sent",
suppressed: false,
});
expect(mockSendSms).toHaveBeenCalledWith("+1234567890", "Hello", undefined);
});
it("persists error on Telnyx failure", async () => {
const messageId = "msg-1";
mockGetDb.mockReturnValue({
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([{ id: messageId }]),
}),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}),
});
mockSendSms.mockRejectedValue(new Error("Telnyx API error"));
await expect(
sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" })
).rejects.toThrow("Telnyx API error");
});
});
@@ -1,77 +0,0 @@
import { clients, messageConsentEvents, eq } from "@groombook/db";
import type { Db } from "@groombook/db";
export type KeywordKind = "opt_in" | "opt_out" | "help";
const OPT_OUT_KEYWORDS = new Set(["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]);
const OPT_IN_KEYWORDS = new Set(["START", "UNSTOP", "YES", "SUBSCRIBE"]);
const HELP_KEYWORDS = new Set(["HELP", "INFO"]);
export function detectKeyword(body: string): { kind: KeywordKind } | null {
const normalized = body.trim().toUpperCase();
if (OPT_OUT_KEYWORDS.has(normalized)) return { kind: "opt_out" };
if (OPT_IN_KEYWORDS.has(normalized)) return { kind: "opt_in" };
if (HELP_KEYWORDS.has(normalized)) return { kind: "help" };
return null;
}
export async function handleConsentKeyword(opts: {
clientId: string;
businessId: string;
kind: KeywordKind;
db: Db;
}): Promise<{ replyText: string }> {
const { clientId, businessId, kind, db: database } = opts;
await database.insert(messageConsentEvents).values({
clientId,
businessId,
kind,
source: "sms_keyword",
});
if (kind === "opt_out") {
const [existing] = await database
.select({ smsOptIn: clients.smsOptIn })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (existing?.smsOptIn !== false) {
await database
.update(clients)
.set({ smsOptIn: false, smsOptOutDate: new Date() })
.where(eq(clients.id, clientId));
}
return {
replyText: "You have been unsubscribed and will no longer receive messages. Reply START to resubscribe.",
};
}
if (kind === "opt_in") {
const [existing] = await database
.select({ smsOptIn: clients.smsOptIn, smsConsentDate: clients.smsConsentDate })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (existing?.smsOptIn !== true) {
await database
.update(clients)
.set({ smsOptIn: true, smsConsentDate: new Date(), smsOptOutDate: null })
.where(eq(clients.id, clientId));
}
return {
replyText:
"You have been resubscribed to messages. Reply STOP to unsubscribe. Msg & data rates may apply.",
};
}
// kind === "help"
const replyText =
"Reply STOP to unsubscribe or START to resubscribe. For help, contact your groomer directly.";
return { replyText };
}
-218
View File
@@ -1,218 +0,0 @@
import { getDb, conversations, messages, businessSettings, clients, eq, and } from "@groombook/db";
import { v4 as uuidv4 } from "uuid";
import { detectKeyword, handleConsentKeyword } from "./consent.js";
import { sendMessage } from "./outbound.js";
export interface TelnyxMessageReceivedPayload {
data: {
id: string;
event_type: "message.received" | "message.sent" | "message.finalized";
payload: {
message: {
id: string;
from: { phone: string; carrier?: string };
to: { phone: string }[];
body: string;
media?: Array<{ type: string; url: string }>;
};
recording?: unknown;
leg_count?: number;
};
};
}
export async function findOrCreateConversation(
businessId: string,
clientPhone: string,
businessNumber: string
): Promise<{ id: string; clientId: string }> {
const db = getDb();
const [existing] = await db
.select({ id: conversations.id, clientId: conversations.clientId })
.from(conversations)
.where(
and(
eq(conversations.businessId, businessId),
eq(conversations.externalNumber, clientPhone),
eq(conversations.businessNumber, businessNumber)
)
)
.limit(1);
if (existing) {
return { id: existing.id, clientId: existing.clientId };
}
const [existingClient] = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.phone, clientPhone))
.limit(1);
const clientId = existingClient?.id ?? uuidv4();
if (!existingClient) {
await db.insert(clients).values({
id: clientId,
name: clientPhone,
email: `sms-${uuidv4()}@placeholder.local`,
phone: clientPhone,
status: "active",
});
}
const [created] = await db
.insert(conversations)
.values({
id: crypto.randomUUID(),
businessId,
clientId,
channel: "sms",
externalNumber: clientPhone,
businessNumber,
lastMessageAt: new Date(),
status: "active",
})
.returning({ id: conversations.id, clientId: conversations.clientId });
if (!created) throw new Error("Failed to create conversation");
return { id: created.id, clientId: created.clientId };
}
export async function upsertMessage(
providerMessageId: string,
conversationId: string,
direction: "inbound" | "outbound",
body: string,
status: "queued" | "sent" | "delivered" | "failed" | "received",
sentByStaffId?: string
): Promise<{ id: string; isNew: boolean }> {
const db = getDb();
const [existing] = await db
.select({ id: messages.id })
.from(messages)
.where(eq(messages.providerMessageId, providerMessageId))
.limit(1);
if (existing) {
return { id: existing.id, isNew: false };
}
try {
const [inserted] = await db
.insert(messages)
.values({
id: crypto.randomUUID(),
conversationId,
direction,
body,
status,
providerMessageId,
sentByStaffId: sentByStaffId ?? null,
})
.returning({ id: messages.id });
if (!inserted) throw new Error("Failed to insert message");
return { id: inserted.id, isNew: true };
} catch (err) {
if (err instanceof Error && err.message.includes("unique")) {
const [existing] = await db
.select({ id: messages.id })
.from(messages)
.where(eq(messages.providerMessageId, providerMessageId))
.limit(1);
if (existing) return { id: existing.id, isNew: false };
}
throw err;
}
}
export async function resolveBusinessIdByMessagingNumber(toNumber: string): Promise<string | null> {
const db = getDb();
const [settings] = await db
.select({ id: businessSettings.id })
.from(businessSettings)
.where(eq(businessSettings.messagingPhoneNumber, toNumber))
.limit(1);
return settings?.id ?? null;
}
export async function handleMessageReceived(payload: TelnyxMessageReceivedPayload): Promise<{ conversationId: string; messageId: string }> {
const { message } = payload.data.payload;
const fromPhone = message.from.phone;
const toPhone = message.to[0]?.phone;
if (!toPhone) {
throw new Error("No recipient phone in payload");
}
const businessId = await resolveBusinessIdByMessagingNumber(toPhone);
if (!businessId) {
throw new Error(`No business owns messaging number: ${toPhone}`);
}
const { id: conversationId, clientId } = await findOrCreateConversation(businessId, fromPhone, toPhone);
await getDb()
.update(conversations)
.set({ lastMessageAt: new Date(), updatedAt: new Date() })
.where(eq(conversations.id, conversationId));
const { id: messageId } = await upsertMessage(
message.id,
conversationId,
"inbound",
message.body,
"received"
);
const keyword = detectKeyword(message.body ?? "");
if (keyword) {
const { replyText } = await handleConsentKeyword({
clientId,
businessId,
kind: keyword.kind,
db: getDb(),
});
await sendMessage({
businessId,
clientId,
body: replyText,
sentByStaffId: undefined,
});
}
return { conversationId, messageId };
}
export async function handleMessageFinalized(payload: TelnyxMessageReceivedPayload): Promise<{ messageId: string; newStatus: string } | null> {
const { message } = payload.data.payload;
if (!message.id) return null;
const db = getDb();
const [existing] = await db
.select({ id: messages.id, status: messages.status })
.from(messages)
.where(eq(messages.providerMessageId, message.id))
.limit(1);
if (!existing) return null;
let newStatus = existing.status;
if (payload.data.event_type === "message.finalized") {
newStatus = "delivered";
}
if (newStatus !== existing.status) {
await db
.update(messages)
.set({ status: newStatus, deliveredAt: new Date() })
.where(eq(messages.id, existing.id));
}
return { messageId: existing.id, newStatus };
}
-159
View File
@@ -1,159 +0,0 @@
import { getDb, conversations, messages, clients, businessSettings, eq, and } from "@groombook/db";
import { v4 as uuidv4 } from "uuid";
import { sendSms } from "../sms.js";
export interface SendMessageOptions {
businessId: string;
clientId: string;
body: string;
sentByStaffId?: string;
mediaUrls?: string[];
}
export interface SendMessageResult {
messageId: string;
providerMessageId: string;
status: string;
suppressed: false;
}
export interface SendMessageSuppressed {
suppressed: true;
}
export type SendMessageResponse = SendMessageResult | SendMessageSuppressed;
export class MissingTenantPhoneNumberError extends Error {
constructor() {
super("Tenant messagingPhoneNumber is not configured");
this.name = "MissingTenantPhoneNumberError";
}
}
async function findOrCreateConversation(
businessId: string,
clientId: string,
externalNumber: string,
businessNumber: string
): Promise<{ id: string }> {
const db = getDb();
const [existing] = await db
.select({ id: conversations.id })
.from(conversations)
.where(
and(
eq(conversations.businessId, businessId),
eq(conversations.externalNumber, externalNumber),
eq(conversations.businessNumber, businessNumber)
)
)
.limit(1);
if (existing) return { id: existing.id };
const [created] = await db
.insert(conversations)
.values({
id: uuidv4(),
businessId,
clientId,
channel: "sms",
externalNumber,
businessNumber,
lastMessageAt: new Date(),
status: "active",
})
.returning({ id: conversations.id });
if (!created) throw new Error("Failed to create conversation");
return { id: created.id };
}
async function resolveFromNumber(businessId: string): Promise<string | null> {
const db = getDb();
const [settings] = await db
.select({ messagingPhoneNumber: businessSettings.messagingPhoneNumber })
.from(businessSettings)
.where(eq(businessSettings.id, businessId))
.limit(1);
return settings?.messagingPhoneNumber ?? null;
}
export async function sendMessage(opts: SendMessageOptions): Promise<SendMessageResponse> {
const db = getDb();
const { businessId, clientId, body, sentByStaffId, mediaUrls } = opts;
const [client] = await db
.select({ phone: clients.phone, smsOptIn: clients.smsOptIn })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (!client?.phone) {
return { suppressed: true };
}
if (!client.smsOptIn) {
return { suppressed: true };
}
const from = await resolveFromNumber(businessId);
if (!from) throw new MissingTenantPhoneNumberError();
const to = client.phone;
const conversationId = (await findOrCreateConversation(businessId, clientId, to, from)).id;
const [queuedMessage] = await db
.insert(messages)
.values({
id: uuidv4(),
conversationId,
direction: "outbound",
body,
status: "queued",
sentByStaffId: sentByStaffId ?? null,
})
.returning({ id: messages.id });
if (!queuedMessage) throw new Error("Failed to insert queued message");
try {
const result = await sendSms(to, body, mediaUrls);
await db
.update(messages)
.set({
status: "sent",
providerMessageId: result.messageId,
})
.where(eq(messages.id, queuedMessage.id));
await db
.update(conversations)
.set({ lastMessageAt: new Date() })
.where(eq(conversations.id, conversationId));
return {
messageId: queuedMessage.id,
providerMessageId: result.messageId,
status: result.status,
suppressed: false,
};
} catch (err) {
const errorCode = err instanceof Error ? err.name : "UNKNOWN";
const errorMessage = err instanceof Error ? err.message : String(err);
await db
.update(messages)
.set({
status: "failed",
errorCode,
errorMessage,
})
.where(eq(messages.id, queuedMessage.id));
throw err;
}
}
+9 -27
View File
@@ -1,9 +1,9 @@
import Stripe from "stripe";
import { getDb, clients, eq, inArray, invoices } from "@groombook/db";
import { getDb, clients, eq, invoices } from "@groombook/db";
let _stripe: Stripe | null | undefined;
export function getStripeClient(): Stripe | null {
function getStripeClient(): Stripe | null {
if (_stripe === undefined) {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) return null;
@@ -43,13 +43,11 @@ export async function createPaymentIntent(
const db = getDb();
const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds];
const firstInvoiceId = invoiceIds[0];
if (!firstInvoiceId) return null;
const invoiceRows = await db
.select()
.from(invoices)
.where(eq(invoices.id, firstInvoiceId));
.where(eq(invoices.id, invoiceIds[0]));
const [invoice] = invoiceRows;
if (!invoice) return null;
@@ -59,8 +57,8 @@ export async function createPaymentIntent(
const allInvoices = await db
.select({ totalCents: invoices.totalCents })
.from(invoices)
.where(inArray(invoices.id, invoiceIds));
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
.where(eq(invoices.id, invoiceIds[0]));
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents);
}
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
@@ -84,10 +82,10 @@ export async function createPaymentIntent(
.where(eq(invoices.id, invId));
}
const clientSecret = paymentIntent.client_secret;
if (!clientSecret) return null;
return { clientSecret, paymentIntentId: paymentIntent.id };
return {
clientSecret: paymentIntent.client_secret!,
paymentIntentId: paymentIntent.id,
};
}
export async function processRefund(
@@ -162,19 +160,3 @@ 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,
};
}
+80 -79
View File
@@ -5,7 +5,6 @@ import {
eq,
getDb,
gte,
inArray,
lt,
appointments,
clients,
@@ -21,8 +20,8 @@ import {
} from "./email.js";
import { smsSend } from "./sms.js";
// TCPA-required opt-out text appended to every SMS reminder
const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
function getReminderWindows(): { label: string; hours: number }[] {
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
@@ -32,14 +31,20 @@ 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,
@@ -60,77 +65,69 @@ export async function runReminderCheck(): Promise<void> {
)
);
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)
)
);
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)
);
// 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")
)
);
const appointmentMap = new Map<string, typeof joinedRows[number]>();
for (const row of joinedRows) {
appointmentMap.set(row.appointmentId, row);
}
for (const appt of upcoming) {
const joined = appointmentMap.get(appt.id as string);
if (!joined) continue;
const [emailLog] = await db
.select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "email")
)
)
.limit(1);
const { clientName, clientEmail, clientEmailOptOut, clientSmsOptIn, clientPhone, petName, serviceName, staffName } = joined;
const [smsLog] = await db
.select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "sms")
)
)
.limit(1);
if (!clientEmail || clientEmailOptOut) continue;
if (!petName || !serviceName) continue;
// Fetch related records for the email
const [client] = await db
.select({
name: clients.name,
email: clients.email,
emailOptOut: clients.emailOptOut,
smsOptIn: clients.smsOptIn,
phoneE164: clients.phoneE164,
})
.from(clients)
.where(eq(clients.id, appt.clientId))
.limit(1);
const emailSent = sentEmail.has(appt.id as string);
const smsSent = sentSms.has(appt.id as string);
if (!client || !client.email || client.emailOptOut) continue;
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) continue;
let confirmationToken = appt.confirmationToken;
if (!confirmationToken) {
@@ -141,15 +138,15 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id));
}
if (!emailSent) {
if (!emailLog) {
const sent = await sendEmail(
buildReminderEmail(
clientEmail,
client.email,
{
clientName,
petName,
serviceName,
groomerName: staffName,
clientName: client.name,
petName: pet.name,
serviceName: service.name,
groomerName,
startTime: appt.startTime,
},
window.hours,
@@ -165,20 +162,20 @@ export async function runReminderCheck(): Promise<void> {
}
}
if (!smsSent && clientSmsOptIn && clientPhone) {
if (!smsLog && client.smsOptIn && client.phoneE164) {
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}` : ""}`,
`Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
`Confirm: ${confirmUrl}`,
`Cancel: ${cancelUrl}`,
TCPA_OPT_OUT,
].join(". ");
try {
const smsOk = await smsSend(clientPhone, smsBody);
const smsOk = await smsSend(client.phoneE164, smsBody);
if (smsOk) {
await db
.insert(reminderLogs)
@@ -193,7 +190,9 @@ export async function runReminderCheck(): Promise<void> {
}
}
// 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);
@@ -205,6 +204,8 @@ 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();
+27 -32
View File
@@ -1,5 +1,4 @@
import { Telnyx } from "telnyx";
import { createHmac } from "crypto";
export interface SmsProvider {
sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>;
@@ -32,35 +31,6 @@ function isE164(phone: string): boolean {
return /^\+[1-9]\d{7,14}$/.test(phone);
}
export function validateTelnyxSignature(
rawBody: string,
signature: string | undefined | null
): boolean {
if (!signature) return false;
const secret = process.env.TELNYX_WEBHOOK_SECRET;
if (!secret) return false;
try {
const hmac = createHmac("sha256", secret);
const expected = `sha256=${hmac.update(rawBody).digest("hex")}`;
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length) return false;
let diff = 0;
for (let i = 0; i < sigBuf.length; i++) {
const sigByte = sigBuf[i] ?? 0;
const expByte = expBuf[i] ?? 0;
diff |= sigByte ^ expByte;
}
return diff === 0;
} catch {
return false;
}
}
export async function sendSms(
to: string,
body: string,
@@ -103,7 +73,32 @@ export class TelnyxProvider implements SmsProvider {
}
validateWebhookSignature(req: Request): boolean {
return validateTelnyxSignature(JSON.stringify(req.body), req.headers.get("telnyx-signature"));
const secret = process.env.TELNYX_WEBHOOK_SECRET;
if (!secret) return false;
const signature = req.headers.get("telnyx-signature");
if (!signature) return false;
const payload = JSON.stringify(req.body);
try {
const { createHmac } = await import("crypto");
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++) {
diff |= sigBuf[i] ^ expBuf[i];
}
return diff === 0;
} catch {
return false;
}
}
}
@@ -142,4 +137,4 @@ export async function smsSend(
await provider.sendSms(to, body, mediaUrls);
return true;
}
}
-19
View File
@@ -1,19 +0,0 @@
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>;
};
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ export default defineConfig({
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:8080",
baseURL: "http://localhost:8080",
trace: "on-first-retry",
screenshot: "only-on-failure",
serviceWorkers: "block",
-49
View File
@@ -63,52 +63,3 @@ 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();
});
-10
View File
@@ -44,16 +44,6 @@ 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 } });
}
+3 -9
View File
@@ -72,15 +72,9 @@ test.describe("Portal Data Integrity", () => {
});
test("billing section renders without JS errors", async ({ page }) => {
// Mock portal billing endpoints
await page.route("**/api/portal/config**", (route) =>
route.fulfill({ json: { stripePublishableKey: "" } })
);
await page.route("**/api/portal/invoices**", (route) =>
route.fulfill({ json: [] })
);
await page.route("**/api/portal/payment-methods**", (route) =>
route.fulfill({ json: [] })
// Mock billing endpoint
await page.route("**/api/billing**", (route) =>
route.fulfill({ json: { invoices: [], balanceCents: 0 } })
);
const consoleErrors: string[] = [];
+1 -1
View File
@@ -1 +1 @@
VITE_API_URL=https://uat.groombook.dev
VITE_API_URL=
-4
View File
@@ -11,8 +11,6 @@ RUN pnpm install --frozen-lockfile
# Build
FROM deps AS builder
ARG VITE_API_URL=
ENV VITE_API_URL=
COPY packages/types/ packages/types/
COPY apps/web/ apps/web/
RUN pnpm --filter @groombook/web build
@@ -22,5 +20,3 @@ 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
+2 -2
View File
@@ -3,7 +3,7 @@ import { defineConfig, devices } from "@playwright/test";
/**
* Playwright configuration for GroomBook Web E2E tests.
*
* Targets the deployed dev environment at dev.groombook.dev.
* Targets the deployed dev environment at groombook.dev.farh.net.
* 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://dev.groombook.dev",
baseURL: "https://groombook.dev.farh.net",
trace: "on-first-retry",
screenshot: "only-on-failure",
serviceWorkers: "block",
-12
View File
@@ -3,22 +3,10 @@ server {
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Cache static assets
location ~* \.(js|css|png|svg|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
}
# Proxy API calls to the API service
+2 -10
View File
@@ -2,9 +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 { MessagesPage } from "./pages/Messages.js";
import { StaffPage } from "./pages/Staff.js";
import { InvoicesPage } from "./pages/Invoices.js";
import { BookPage } from "./pages/Book.js";
@@ -14,7 +12,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.tsx";
import { SetupWizard } from "./pages/SetupWizard.jsx";
import { CustomerPortal } from "./portal/CustomerPortal.js";
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
@@ -40,10 +38,7 @@ function LoginPage() {
const handleSocialLogin = async (provider: string) => {
setIsLoading(true);
setError(null);
// Use /admin as callback URL so Better-Auth redirects to the app's dashboard
// after the OAuth callback completes, rather than back to /login
const callbackURL = `${window.location.origin}/admin`;
const result = await signIn.social({ provider, callbackURL });
const result = await signIn.social({ provider, callbackURL: window.location.origin });
if (result?.error) {
setError(result.error.message ?? "Sign-in failed");
setIsLoading(false);
@@ -174,7 +169,6 @@ function LoginPage() {
const NAV_LINKS = [
{ to: "/admin", label: "Appointments" },
{ to: "/admin/messages", label: "Messages" },
{ to: "/admin/clients", label: "Clients" },
{ to: "/admin/services", label: "Services" },
{ to: "/admin/staff", label: "Staff" },
@@ -301,9 +295,7 @@ function AdminLayout() {
<main style={{ padding: "1.25rem 1.5rem" }}>
<Routes>
<Route path="/" element={<AppointmentsPage />} />
<Route path="/messages" element={<MessagesPage />} />
<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 />} />
+2 -2
View File
@@ -93,7 +93,7 @@ describe("CustomerNotesSection", () => {
"/api/portal/appointments/appt-1/notes",
expect.objectContaining({
headers: expect.objectContaining({
"X-Impersonation-Session-Id": "test-session-id",
"Authorization": "Bearer test-session-id",
}),
})
);
@@ -269,7 +269,7 @@ describe("ConfirmationSection", () => {
"/api/portal/appointments/appt-1/confirm",
expect.objectContaining({
headers: expect.objectContaining({
"X-Impersonation-Session-Id": "test-session-id",
"Authorization": "Bearer test-session-id",
}),
})
);
-151
View File
@@ -1,151 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MessagesPage } from "../pages/Messages.js";
const mockConversations = [
{
id: "conv-1",
clientId: "client-1",
clientName: "Alice Smith",
channel: "sms",
clientPhone: "+1234567890",
lastMessageAt: "2026-05-14T10:00:00Z",
lastMessage: { body: "Hello, is my dog ready?", direction: "inbound", createdAt: "2026-05-14T10:00:00Z" },
unreadCount: 2,
status: "active",
},
{
id: "conv-2",
clientId: "client-2",
clientName: "Bob Jones",
channel: "sms",
clientPhone: "+1987654321",
lastMessageAt: "2026-05-13T08:00:00Z",
lastMessage: { body: "Thanks for the update", direction: "outbound", createdAt: "2026-05-13T08:05:00Z" },
unreadCount: 0,
status: "active",
},
];
const mockMessages = [
{
id: "msg-1",
direction: "inbound" as const,
body: "Hello, is my dog ready?",
status: "delivered",
createdAt: "2026-05-14T10:00:00Z",
sentByStaffId: null,
},
{
id: "msg-2",
direction: "outbound" as const,
body: "Yes, she is all done!",
status: "delivered",
createdAt: "2026-05-14T10:05:00Z",
sentByStaffId: "staff-1",
},
];
const makeResponse = (data: unknown): Response => {
return {
ok: true,
json: () => Promise.resolve(data),
} as Response;
};
const makeResponseWithStatus = (data: unknown, status: number): Response => {
return {
ok: true,
status,
json: () => Promise.resolve(data),
} as Response;
};
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("MessagesPage", () => {
it("renders empty state when no conversations", async () => {
vi.mocked(global.fetch).mockResolvedValue(makeResponse({ items: [], nextCursor: null }));
render(<MessagesPage />);
await waitFor(() => {
expect(screen.getByText("No conversations yet")).toBeInTheDocument();
});
});
it("renders conversation list", async () => {
vi.mocked(global.fetch).mockResolvedValue(makeResponse({ items: mockConversations, nextCursor: null }));
render(<MessagesPage />);
await waitFor(() => {
expect(screen.getByText("Alice Smith")).toBeInTheDocument();
expect(screen.getByText("Bob Jones")).toBeInTheDocument();
});
const unreadBadges = screen.getAllByText("2");
expect(unreadBadges).toHaveLength(1);
});
it("loads and displays messages when thread is selected", async () => {
vi.mocked(global.fetch).mockImplementation((input) => {
const url = String(input);
if (url === "/api/conversations?limit=20") {
return Promise.resolve(makeResponse({ items: mockConversations, nextCursor: null }));
}
if (url === "/api/conversations/conv-1/messages?limit=50") {
return Promise.resolve(makeResponse({ items: mockMessages, nextCursor: null }));
}
return Promise.resolve(makeResponseWithStatus(null, 404));
});
render(<MessagesPage />);
await waitFor(() => screen.getByText("Alice Smith"));
fireEvent.click(screen.getByText("Alice Smith"));
await waitFor(() => {
expect(screen.getAllByText("Hello, is my dog ready?").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Yes, she is all done!")).toBeInTheDocument();
});
});
it("sends a message on form submit", async () => {
let capturedBody: unknown = null;
vi.mocked(global.fetch).mockImplementation((input, init) => {
const url = String(input);
if (url.includes("/messages") && init?.method === "POST") {
capturedBody = init?.body;
return Promise.resolve(makeResponseWithStatus({
id: "msg-new",
direction: "outbound",
body: "Test message",
status: "queued",
createdAt: new Date().toISOString(),
sentByStaffId: "staff-1",
}, 201));
}
return Promise.resolve(makeResponse({ items: mockConversations, nextCursor: null }));
});
render(<MessagesPage />);
await waitFor(() => screen.getByText("Alice Smith"));
fireEvent.click(screen.getByText("Alice Smith"));
await waitFor(() => screen.getByPlaceholderText("Type a message…"));
fireEvent.change(screen.getByPlaceholderText("Type a message…"), {
target: { value: "Test message" },
});
fireEvent.click(screen.getByText("Send"));
await waitFor(() => {
expect(capturedBody).toBe('{"body":"Test message"}');
});
});
});
+3 -13
View File
@@ -26,7 +26,6 @@ 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);
@@ -46,18 +45,15 @@ 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 {
setError("Search failed. Please try again.");
} catch (err) {
console.warn("GlobalSearch: fetch error", err);
} finally {
setLoading(false);
}
@@ -164,13 +160,7 @@ export function GlobalSearch() {
</div>
)}
{!loading && error && (
<div style={{ padding: "12px 16px", fontSize: 13, color: "#dc2626" }}>
{error}
</div>
)}
{!loading && !error && !hasResults && (
{!loading && !hasResults && (
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
No results found
</div>
@@ -71,12 +71,6 @@ 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;
-10
View File
@@ -82,13 +82,3 @@ input:focus, select:focus, textarea:focus {
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* ─── Scrollbar hide utility ─── */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL || window.location.origin,
baseURL: import.meta.env.VITE_API_URL ?? "",
});
export const { signIn, signOut, useSession, changePassword } = authClient;
+2 -78
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useEffect, useState, useCallback } from "react";
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -112,17 +112,9 @@ 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();
@@ -281,15 +273,7 @@ export function AppointmentsPage() {
cascade !== "this_only"
? `/api/appointments/${id}?cascade=${cascade}`
: `/api/appointments/${id}`;
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");
}
await fetch(url, { method: "DELETE" });
setSelectedAppt(null);
await loadAppointments();
}
@@ -322,24 +306,6 @@ 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>
@@ -853,49 +819,8 @@ 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,
@@ -908,7 +833,6 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
ref={modalRef}
style={{
background: "#fff",
borderRadius: 8,
+6 -47
View File
@@ -13,8 +13,6 @@ interface BookingBody {
petName: string;
petSpecies: string;
petBreed: string;
petSizeCategory: string;
petCoatType: string;
notes: string;
}
@@ -125,8 +123,6 @@ export function BookPage() {
petName: "",
petSpecies: "",
petBreed: "",
petSizeCategory: "",
petCoatType: "",
notes: "",
});
const [formError, setFormError] = useState<string | null>(null);
@@ -172,18 +168,14 @@ export function BookPage() {
if (!selectedService || !date) return;
setSlotsLoading(true);
setSelectedSlot(null);
const params = new URLSearchParams({
serviceId: selectedService.id,
date,
});
if (form.petSizeCategory) params.set("petSizeCategory", form.petSizeCategory);
if (form.petCoatType) params.set("petCoatType", form.petCoatType);
fetch(`/api/book/availability?${params}`)
fetch(
`/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}`
)
.then((r) => r.json() as Promise<string[]>)
.then(setSlots)
.catch(() => setSlots([]))
.finally(() => setSlotsLoading(false));
}, [selectedService, date, form.petSizeCategory, form.petCoatType]);
}, [selectedService, date]);
function goToStep2(svc: Service) {
setSelectedService(svc);
@@ -222,8 +214,6 @@ export function BookPage() {
petName: form.petName,
petSpecies: form.petSpecies,
petBreed: form.petBreed || undefined,
petSizeCategory: form.petSizeCategory || undefined,
petCoatType: form.petCoatType || undefined,
notes: form.notes || undefined,
}),
});
@@ -504,36 +494,6 @@ export function BookPage() {
placeholder="Golden Retriever"
/>
</div>
<div>
<label style={label}>Pet size (optional, but encouraged)</label>
<select
style={input}
value={form.petSizeCategory}
onChange={(e) => setForm((f) => ({ ...f, petSizeCategory: e.target.value }))}
>
<option value="">Select size</option>
<option value="small">Small (under 15 lbs)</option>
<option value="medium">Medium (1540 lbs)</option>
<option value="large">Large (4080 lbs)</option>
<option value="xlarge">X-Large (over 80 lbs)</option>
</select>
</div>
<div>
<label style={label}>Coat type (optional, but encouraged)</label>
<select
style={input}
value={form.petCoatType}
onChange={(e) => setForm((f) => ({ ...f, petCoatType: e.target.value }))}
>
<option value="">Select coat type</option>
<option value="smooth">Smooth</option>
<option value="double">Double</option>
<option value="curly">Curly</option>
<option value="wire">Wire</option>
<option value="long">Long</option>
<option value="hairless">Hairless</option>
</select>
</div>
<div>
<label style={label}>Notes for groomer</label>
<textarea
@@ -568,7 +528,7 @@ export function BookPage() {
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes + ((form.petSizeCategory === "large" || form.petSizeCategory === "xlarge") ? (selectedService.defaultBufferMinutes ?? 0) : 0))}</div>
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes)}</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
@@ -639,8 +599,7 @@ export function BookPage() {
setResult(null);
setForm({
serviceId: "", startTime: "", clientName: "", clientEmail: "",
clientPhone: "", petName: "", petSpecies: "", petBreed: "",
petSizeCategory: "", petCoatType: "", notes: "",
clientPhone: "", petName: "", petSpecies: "", petBreed: "", notes: "",
});
}}
>
-236
View File
@@ -1,236 +0,0 @@
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>
);
}
+11 -54
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useRef, useId } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
@@ -647,7 +647,8 @@ export function ClientsPage() {
{/* ── Client modal ── */}
{showClientForm && (
<Modal title={editingClient ? "Edit Client" : "New Client"} onClose={() => setShowClientForm(false)}>
<Modal onClose={() => setShowClientForm(false)}>
<h2 style={{ marginTop: 0 }}>{editingClient ? "Edit Client" : "New Client"}</h2>
<form onSubmit={submitClient}>
<Field label="Full name">
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
@@ -677,7 +678,8 @@ export function ClientsPage() {
{/* ── Pet modal ── */}
{showPetForm && (
<Modal title={editingPet ? "Edit Pet" : "Add Pet"} onClose={() => setShowPetForm(false)}>
<Modal onClose={() => setShowPetForm(false)}>
<h2 style={{ marginTop: 0 }}>{editingPet ? "Edit Pet" : "Add Pet"}</h2>
<form onSubmit={submitPet}>
<Field label="Pet name">
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
@@ -751,7 +753,8 @@ export function ClientsPage() {
{/* ── Visit log modal ── */}
{showLogForm && logPetId && (
<Modal title="Log Grooming Visit" onClose={() => setShowLogForm(false)}>
<Modal onClose={() => setShowLogForm(false)}>
<h2 style={{ marginTop: 0 }}>Log Grooming Visit</h2>
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history</p>}
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
<div style={{ marginBottom: "1rem" }}>
@@ -814,7 +817,8 @@ export function ClientsPage() {
{/* ── Delete confirmation modal ── */}
{showDeleteConfirm && selectedClient && (
<Modal title="Permanently Delete Client" titleStyle={{ color: "#dc2626" }} onClose={() => setShowDeleteConfirm(false)}>
<Modal onClose={() => setShowDeleteConfirm(false)}>
<h2 style={{ marginTop: 0, color: "#dc2626" }}>Permanently Delete Client</h2>
<p style={{ fontSize: 14, color: "#374151" }}>
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
</p>
@@ -852,60 +856,13 @@ export function ClientsPage() {
// ─── Shared UI ───────────────────────────────────────────────────────────────
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]);
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
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
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>
<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)" }}>
{children}
</div>
</div>
+29 -223
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from "react";
import { useEffect, useState } from "react";
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -173,21 +173,6 @@ 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
@@ -226,41 +211,36 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
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(patchBody),
body: JSON.stringify({ status: "paid", paymentMethod, tipCents }),
});
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");
@@ -350,19 +330,6 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
/>
{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 ── */}
@@ -480,92 +447,11 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
</div>
)}
{(invoice.status === "paid" || invoice.status === "void") && (
<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 style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
<button onClick={onClose} style={btnStyle}>Close</button>
</div>
)}
{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>
</Modal>
);
}
@@ -606,17 +492,9 @@ 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);
@@ -695,34 +573,6 @@ 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.
@@ -827,63 +677,19 @@ 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
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)",
}}
>
<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)",
}}>
{children}
</div>
</div>
-275
View File
@@ -1,275 +0,0 @@
import { useEffect, useState, useRef } from "react";
interface Conversation {
id: string;
clientId: string;
clientName: string;
channel: string;
clientPhone: string;
lastMessageAt: string | null;
unreadCount: number;
status: string;
lastMessage: { body: string | null; direction: string; createdAt: string } | null;
}
interface Message {
id: string;
direction: "inbound" | "outbound";
body: string | null;
status: string;
createdAt: string;
sentByStaffId: string | null;
}
function relativeTime(dateStr: string | null): string {
if (!dateStr) return "";
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function truncate(text: string | null, max: number): string {
if (!text) return "";
return text.length > max ? text.slice(0, max) + "…" : text;
}
export function MessagesPage() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [messages, setMessages] = useState<Message[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [messagesLoading, setMessagesLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [messageError, setMessageError] = useState<string | null>(null);
const [body, setBody] = useState("");
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
async function loadConversations() {
try {
const res = await fetch("/api/conversations?limit=20");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const data = json.items as Conversation[];
setConversations(data);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load conversations");
}
}
async function loadMessages(conversationId: string) {
setMessagesLoading(true);
setMessageError(null);
try {
const res = await fetch(`/api/conversations/${conversationId}/messages?limit=50`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setMessages((json.items as Message[]).reverse());
} catch (e: unknown) {
setMessageError(e instanceof Error ? e.message : "Failed to load messages");
} finally {
setMessagesLoading(false);
}
}
useEffect(() => {
loadConversations().finally(() => setLoading(false));
const interval = setInterval(loadConversations, 10000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (selectedId) {
loadMessages(selectedId);
} else {
setMessages([]);
}
}, [selectedId]);
useEffect(() => {
if (messages.length > 0) {
messagesEndRef.current?.scrollIntoView?.({ behavior: "smooth" });
}
}, [messages]);
async function handleSend(e: React.FormEvent) {
e.preventDefault();
if (!selectedId || !body.trim() || sending) return;
setSending(true);
setMessageError(null);
const optimistic: Message = {
id: `temp-${Date.now()}`,
direction: "outbound",
body: body.trim(),
status: "queued",
createdAt: new Date().toISOString(),
sentByStaffId: null,
};
setMessages((prev) => [...prev, optimistic]);
const currentBody = body;
setBody("");
try {
const res = await fetch(`/api/conversations/${selectedId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: currentBody }),
});
if (res.status === 409) {
const data = (await res.json()) as { error?: string };
setMessageError(data.error ?? "Client has opted out of SMS");
setMessages((prev) => prev.filter((m) => m.id !== optimistic.id));
return;
}
if (!res.ok) {
const data = (await res.json()) as { error?: string };
throw new Error(data.error ?? `HTTP ${res.status}`);
}
const sent = (await res.json()) as Message;
setMessages((prev) => prev.map((m) => (m.id === optimistic.id ? sent : m)));
loadConversations();
} catch (e: unknown) {
setMessageError(e instanceof Error ? e.message : "Failed to send message");
setMessages((prev) => prev.filter((m) => m.id !== optimistic.id));
} finally {
setSending(false);
}
}
return (
<div style={{ display: "flex", height: "calc(100vh - 90px)", fontFamily: "system-ui, sans-serif" }}>
{/* Thread list */}
<div style={{ width: 320, borderRight: "1px solid #e5e7eb", overflowY: "auto", background: "#fff" }}>
<div style={{ padding: "0.75rem 1rem", borderBottom: "1px solid #f3f4f6", fontWeight: 600, fontSize: 14, color: "#374151" }}>
Conversations
</div>
{loading ? (
<p style={{ padding: "1rem", color: "#6b7280", fontSize: 13 }}>Loading</p>
) : error ? (
<p style={{ padding: "1rem", color: "#ef4444", fontSize: 13 }}>{error}</p>
) : conversations.length === 0 ? (
<p style={{ padding: "1rem", color: "#6b7280", fontSize: 13 }}>No conversations yet</p>
) : (
conversations.map((conv) => (
<div
key={conv.id}
onClick={() => setSelectedId(conv.id)}
style={{
padding: "0.75rem 1rem",
borderBottom: "1px solid #f3f4f6",
cursor: "pointer",
background: selectedId === conv.id ? "#ecfdf5" : "transparent",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<span style={{ fontWeight: 500, fontSize: 14, color: "#1a202c" }}>{conv.clientName}</span>
{conv.unreadCount > 0 && (
<span style={{ background: "#10b981", color: "#fff", borderRadius: 10, padding: "1px 6px", fontSize: 11, fontWeight: 600 }}>
{conv.unreadCount}
</span>
)}
</div>
<div style={{ marginTop: 2, color: "#6b7280", fontSize: 12 }}>
{truncate(conv.lastMessage?.body ?? null, 60)}
</div>
<div style={{ marginTop: 2, color: "#9ca3af", fontSize: 11 }}>
{relativeTime(conv.lastMessageAt)}
</div>
</div>
))
)}
</div>
{/* Conversation view */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: "#f9fafb" }}>
{!selectedId ? (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#9ca3af" }}>
Select a conversation
</div>
) : messagesLoading ? (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#6b7280" }}>
Loading messages
</div>
) : (
<>
<div style={{ flex: 1, overflowY: "auto", padding: "1rem" }}>
{messages.map((msg) => (
<div
key={msg.id}
style={{
display: "flex",
flexDirection: "column",
alignItems: msg.direction === "outbound" ? "flex-end" : "flex-start",
marginBottom: "0.75rem",
}}
>
<div
style={{
maxWidth: "70%",
padding: "0.5rem 0.75rem",
borderRadius: 12,
background: msg.direction === "outbound" ? "var(--color-primary, #4f8a6f)" : "#fff",
color: msg.direction === "outbound" ? "#fff" : "#1a202c",
border: msg.direction === "inbound" ? "1px solid #e5e7eb" : "none",
fontSize: 14,
lineHeight: 1.5,
}}
>
{msg.body}
</div>
<span style={{ fontSize: 11, color: "#9ca3af", marginTop: 2 }}>
{new Date(msg.createdAt).toLocaleString()}
</span>
</div>
))}
<div ref={messagesEndRef} />
</div>
{messageError && (
<div style={{ margin: "0 1rem 0.5rem", padding: "0.5rem 0.75rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, color: "#991b1b", fontSize: 13 }}>
{messageError}
</div>
)}
<form onSubmit={handleSend} style={{ display: "flex", gap: "0.5rem", padding: "0.75rem 1rem", borderTop: "1px solid #e5e7eb", background: "#fff" }}>
<input
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Type a message…"
disabled={sending}
style={{ flex: 1, padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
/>
<button
type="submit"
disabled={sending || !body.trim()}
style={{
padding: "0.5rem 1rem",
background: "var(--color-primary, #4f8a6f)",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 14,
fontWeight: 500,
cursor: sending ? "wait" : "pointer",
opacity: sending ? 0.7 : 1,
}}
>
{sending ? "Sending…" : "Send"}
</button>
</form>
</>
)}
</div>
</div>
);
}
+5 -5
View File
@@ -199,11 +199,11 @@ export function ReportsPage() {
}
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
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"); }),
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>,
]);
setSummary(summData);
+53 -19
View File
@@ -27,8 +27,6 @@ interface AuthProviderForm {
const REDACTED = "••••••••";
const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
interface CurrentUser {
id: string;
name: string;
@@ -89,14 +87,24 @@ export function SettingsPage() {
fetch("/api/admin/settings")
.then((r) => r.json())
.then(async (data) => {
// 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.
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
}
}
setForm({
businessName: data.businessName ?? "GroomBook",
primaryColor: data.primaryColor ?? "#4f8a6f",
accentColor: data.accentColor ?? "#8b7355",
logoKey: data.logoKey ?? null,
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
logoUrl,
logoBase64: data.logoBase64 ?? null,
logoMimeType: data.logoMimeType ?? null,
});
@@ -141,28 +149,54 @@ export function SettingsPage() {
return;
}
const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
const validTypes = ["image/png", "image/svg+xml", "image/jpeg", "image/webp"];
if (!validTypes.includes(file.type)) {
setMessage({ type: "error", text: "Logo must be PNG, JPEG, GIF, or WebP." });
setMessage({ type: "error", text: "Logo must be PNG, SVG, JPEG, or WebP." });
return;
}
try {
// 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", {
// Step 1: Get presigned upload URL
const uploadRes = await fetch("/api/admin/settings/logo/upload-url", {
method: "POST",
body: formData,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, fileSizeBytes: file.size }),
});
if (!uploadRes.ok) {
const err = await uploadRes.json().catch(() => null);
throw new Error(err?.error ?? "Failed to upload logo");
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 }));
}
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) {
@@ -292,7 +326,7 @@ issuerUrl: authForm.issuerUrl,
if (!loaded) return <p>Loading settings...</p>;
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType && ALLOWED_LOGO_TYPES.has(form.logoMimeType) ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
return (
<div style={{ maxWidth: 600 }}>
@@ -359,7 +393,7 @@ issuerUrl: authForm.issuerUrl,
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
accept="image/png,image/svg+xml,image/jpeg,image/webp"
onChange={handleLogoChange}
style={{ display: "none" }}
/>
+1 -1
View File
@@ -1 +1 @@
export { SetupWizard } from "./SetupWizard.tsx";
export { SetupWizard } from "./SetupWizard.jsx";
@@ -2,39 +2,16 @@ import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useBranding } from "../BrandingContext.js";
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 }) {
export function SetupWizard({ onSetupComplete }) {
const navigate = useNavigate();
const { refresh: refreshBranding } = useBranding();
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
// Fetch setup status to determine if auth provider step is needed
const [setupStatus, setSetupStatus] = useState(null); // null = loading
const [loadingStatus, setLoadingStatus] = useState(true);
const [authForm, setAuthForm] = useState<AuthFormState>({
// Auth provider form state
const [authForm, setAuthForm] = useState({
providerId: "authentik",
displayName: "",
issuerUrl: "",
@@ -44,16 +21,16 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
scopes: "openid profile email",
});
const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [testResult, setTestResult] = useState(null); // {ok: boolean, error?: string}
const [step, setStep] = useState(0);
const [businessName, setBusinessName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState(null);
useEffect(() => {
fetch("/api/setup/status")
.then((r) => r.json() as Promise<SetupStatus>)
.then((r) => r.json())
.then((data) => {
setSetupStatus(data);
setLoadingStatus(false);
@@ -63,7 +40,8 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
});
}, []);
const STEPS: Step[] = setupStatus?.showAuthProviderStep
// Build steps dynamically based on setup status
const STEPS = 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." },
@@ -85,8 +63,9 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
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;
if (step === STEPS.length - 1) return true; // done step
if (current?.id === "business") return businessName.trim().length > 0;
if (current?.id === "auth") {
return (
@@ -115,9 +94,9 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
scopes: authForm.scopes,
}),
});
const data = (await res.json()) as TestResult;
const data = await res.json();
setTestResult(data);
} catch {
} catch (e) {
setTestResult({ ok: false, error: "Network error. Please try again." });
} finally {
setTestingConnection(false);
@@ -126,10 +105,12 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
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);
@@ -148,12 +129,12 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
}),
});
if (!res.ok) {
const data = (await res.json()) as { error?: string };
const data = await res.json();
setError(data.error || "Failed to save auth provider configuration. Please try again.");
setLoading(false);
return;
}
} catch {
} catch (e) {
setError("Network error. Please try again.");
setLoading(false);
return;
@@ -161,6 +142,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
setLoading(false);
}
// Submit business name and complete setup
if (current?.id === "business" && businessName.trim()) {
setLoading(true);
setError(null);
@@ -171,14 +153,16 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
body: JSON.stringify({ businessName: businessName.trim() }),
});
if (!res.ok) {
const data = (await res.json()) as { error?: string };
const data = await res.json();
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 {
} catch (e) {
setError("Network error. Please try again.");
setLoading(false);
return;
@@ -208,7 +192,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
);
}
const inputStyle: React.CSSProperties = {
const inputStyle = {
width: "100%",
padding: "0.6rem 0.85rem",
borderRadius: 8,
@@ -236,6 +220,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
maxWidth: 480,
width: "100%",
}}>
{/* Progress dots */}
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
{STEPS.map((_, i) => (
<div
@@ -252,32 +237,38 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
))}
</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 && void handleNext()}
onKeyDown={(e) => e.key === "Enter" && canGoNext && 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
@@ -291,6 +282,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
/>
</div>
{/* Display Name */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Display Name
@@ -304,6 +296,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
/>
</div>
{/* Issuer URL */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Issuer URL
@@ -317,6 +310,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
/>
</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>
@@ -330,6 +324,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
/>
</div>
{/* Client ID */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Client ID
@@ -343,6 +338,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
/>
</div>
{/* Client Secret */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Client Secret
@@ -356,6 +352,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
/>
</div>
{/* Scopes */}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Scopes
@@ -369,9 +366,10 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
/>
</div>
{/* Test Connection button */}
<button
type="button"
onClick={() => { void handleTestConnection(); }}
onClick={handleTestConnection}
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
style={{
padding: "0.45rem 0.85rem",
@@ -389,6 +387,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
{testingConnection ? "Testing..." : "Test Connection"}
</button>
{/* Test result */}
{testResult && (
<div style={{
padding: "0.5rem 0.75rem",
@@ -406,6 +405,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
</div>
)}
{/* Step: Super user info */}
{current?.id === "superuser" && (
<div style={{
background: "#f0fdf4",
@@ -420,6 +420,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
</div>
)}
{/* Step: Second admin info */}
{current?.id === "admin" && (
<div style={{
background: "#fffbeb",
@@ -433,6 +434,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
</div>
)}
{/* Error message */}
{error && (
<p style={{
margin: "0.5rem 0 0",
@@ -447,6 +449,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
</p>
)}
{/* Navigation buttons */}
<div style={{
display: "flex",
gap: "0.75rem",
@@ -473,7 +476,7 @@ export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void
</button>
)}
<button
onClick={() => { void handleNext(); }}
onClick={handleNext}
disabled={(!canGoNext && !isLast) || loading}
style={{
padding: "0.55rem 1.25rem",
+7 -7
View File
@@ -16,7 +16,6 @@ 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";
@@ -35,7 +34,7 @@ export function CustomerPortal() {
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false);
const [showReschedule, setShowReschedule] = useState(false);
const [rescheduleAppointment, setRescheduleAppointment] = useState<PortalAppointment | null>(null);
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
const [session, setSession] = useState<ImpersonationSession | null>(null);
const [sessionExtended, setSessionExtended] = useState(false);
const [clientName, setClientName] = useState<string>("");
@@ -150,7 +149,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 PortalAppointment);
setRescheduleAppointment({ id: appointmentId } as Record<string, unknown>);
setShowReschedule(true);
}, []);
@@ -170,7 +169,7 @@ export function CustomerPortal() {
case "billing":
return <BillingPayments readOnly={!!isReadOnly} sessionId={sessionId} />;
case "messages":
return <Communication readOnly={!!isReadOnly} sessionId={sessionId} />;
return <Communication readOnly={!!isReadOnly} />;
case "settings":
return <AccountSettings readOnly={!!isReadOnly} sessionId={sessionId} />;
}
@@ -227,8 +226,9 @@ export function CustomerPortal() {
)}
{showReschedule && rescheduleAppointment && (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<RescheduleFlow
appointment={rescheduleAppointment}
appointment={rescheduleAppointment as any}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
sessionId={session?.id ?? null}
/>
@@ -326,7 +326,7 @@ export function CustomerPortal() {
)}
{/* Main Content */}
<main className="flex-1 min-h-screen overflow-hidden">
<main className="flex-1 min-h-screen">
<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">
@@ -340,7 +340,7 @@ export function CustomerPortal() {
</div>
</div>
</div>
<div className="p-4 md:p-8 max-w-6xl w-full overflow-hidden">
<div className="p-4 md:p-8 max-w-6xl">
{renderSection()}
</div>
</main>
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
export interface Appointment {
interface Appointment {
id: string;
petId: string;
serviceId: string;
@@ -379,7 +379,7 @@ export function ConfirmationSection({
try {
const headers: Record<string, string> = {};
if (sessionId) {
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
headers['Authorization'] = `Bearer ${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['X-Impersonation-Session-Id'] = sessionId ?? '';
headers['Authorization'] = `Bearer ${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['X-Impersonation-Session-Id'] = sessionId ?? '';
headers['Authorization'] = `Bearer ${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['X-Impersonation-Session-Id'] = sessionId ?? '';
if (sessionId) headers['Authorization'] = `Bearer ${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',
'X-Impersonation-Session-Id': sessionId ?? '',
Authorization: `Bearer ${sessionId}`,
},
body: JSON.stringify({
petId: selectedPet.id,
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { loadStripe } from "@stripe/stripe-js";
import { useState, useEffect } from "react";
import { loadStripe, type Stripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
@@ -27,7 +27,7 @@ interface BillingPaymentsProps {
function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [packages] = useState<{ name: string; remaining: number }[]>([]);
const [packages, setPackages] = useState<{ name: string; remaining: number }[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
<div className="flex gap-2">
{([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
@@ -356,48 +356,6 @@ 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);
@@ -440,10 +398,7 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
const { error: stripeError } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: saveCard
? { setup_future_usage: "off_session" }
: undefined,
redirect: "if_required",
confirmParams: saveCard ? { setup_future_usage: "off_session" } : undefined,
});
if (stripeError) {
@@ -462,8 +417,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
if (isComplete) {
return (
<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="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 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" />
@@ -482,8 +437,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
}
return (
<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="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 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">
@@ -1,159 +0,0 @@
export interface Conversation {
id: string;
channel: string;
lastMessageAt: string | null;
status: string;
createdAt: string;
}
export interface Message {
id: string;
direction: "inbound" | "outbound";
body: string | null;
status: string;
createdAt: string;
deliveredAt: string | null;
}
export interface MessagesResponse {
messages: Message[];
nextCursor: string | null;
}
export async function fetchConversation(sessionId: string): Promise<Conversation | null> {
const res = await fetch("/api/portal/conversation", {
headers: { "X-Impersonation-Session-Id": sessionId },
});
if (res.status === 204) return null;
if (!res.ok) throw new Error("Failed to fetch conversation");
return res.json();
}
export async function fetchMessages(
sessionId: string,
cursor?: string,
limit?: number
): Promise<MessagesResponse> {
const params = new URLSearchParams();
if (cursor) params.set("cursor", cursor);
if (limit) params.set("limit", String(limit));
const query = params.toString();
const res = await fetch(`/api/portal/conversation/messages${query ? `?${query}` : ""}`, {
headers: { "X-Impersonation-Session-Id": sessionId },
});
if (res.status === 204) return { messages: [], nextCursor: null };
if (!res.ok) throw new Error("Failed to fetch messages");
return res.json();
}
import { useState, useEffect } from "react";
export function useConversation(sessionId: string | null): {
conversation: Conversation | null;
loading: boolean;
error: string | null;
} {
const [conversation, setConversation] = useState<Conversation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!sessionId) {
setLoading(false);
setConversation(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
fetchConversation(sessionId)
.then((conv) => {
if (!cancelled) {
setConversation(conv);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "An error occurred");
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [sessionId]);
return { conversation, loading, error };
}
export function useMessages(sessionId: string | null): {
messages: Message[];
loading: boolean;
error: string | null;
loadMore: () => void;
hasMore: boolean;
} {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
useEffect(() => {
if (!sessionId) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
setMessages([]);
setCursor(undefined);
setHasMore(false);
fetchMessages(sessionId)
.then((res) => {
if (!cancelled) {
setMessages(res.messages);
setCursor(res.nextCursor ?? undefined);
setHasMore(res.nextCursor !== null);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "An error occurred");
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [sessionId]);
const loadMore = () => {
if (loadingMore || !hasMore || !sessionId) return;
setLoadingMore(true);
fetchMessages(sessionId, cursor)
.then((res) => {
setMessages((prev) => [...prev, ...res.messages]);
setCursor(res.nextCursor ?? undefined);
setHasMore(res.nextCursor !== null);
setLoadingMore(false);
})
.catch(() => {
setLoadingMore(false);
});
};
return { messages, loading, error, loadMore, hasMore };
}
+70 -105
View File
@@ -1,7 +1,14 @@
import { useState, useEffect } from "react";
import { Bell, Mail, Smartphone } from "lucide-react";
import { useConversation, useMessages } from "./Communication.api.js";
import type { Message as ApiMessage } from "./Communication.api.js";
import { Send, Check, CheckCheck, Bell, Mail, Smartphone, Megaphone, FileText, CreditCard } from "lucide-react";
interface Message {
id: string;
sender: "customer" | "business";
senderName: string;
text: string;
timestamp: string;
read: boolean;
}
interface NotificationCategory {
email: boolean;
@@ -18,11 +25,10 @@ interface NotificationPreferences {
}
interface Props {
sessionId: string | null;
readOnly: boolean;
}
export function Communication({ sessionId, readOnly }: Props) {
export function Communication({ readOnly }: Props) {
const [tab, setTab] = useState<"messages" | "notifications">("messages");
return (
@@ -47,23 +53,17 @@ export function Communication({ sessionId, readOnly }: Props) {
</button>
</div>
{tab === "messages" && <MessageThread sessionId={sessionId} readOnly={readOnly} />}
{tab === "messages" && <MessageThread readOnly={readOnly} />}
{tab === "notifications" && <NotificationPreferences readOnly={readOnly} />}
</div>
);
}
interface MessageThreadProps {
sessionId: string | null;
readOnly: boolean;
}
function MessageThread({ sessionId, readOnly: _readOnly }: MessageThreadProps) {
function MessageThread({ readOnly }: { readOnly: boolean }) {
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState("");
const [businessName, setBusinessName] = useState<string>("Business");
const { conversation, loading: convLoading, error: convError } = useConversation(sessionId);
const { messages, loading: msgLoading, error: msgError, loadMore, hasMore } = useMessages(sessionId);
useEffect(() => {
async function fetchBranding() {
try {
@@ -79,57 +79,19 @@ function MessageThread({ sessionId, readOnly: _readOnly }: MessageThreadProps) {
fetchBranding();
}, []);
const loading = convLoading || msgLoading;
const error = convError || msgError;
if (loading) {
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50 flex items-center justify-center">
<div className="animate-pulse text-stone-400 text-sm">Loading messages...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
<p className="text-sm font-medium text-stone-800">{businessName}</p>
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-red-500 text-sm">{error}</p>
</div>
</div>
);
}
if (!conversation) {
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
<p className="text-sm font-medium text-stone-800">{businessName}</p>
<p className="text-xs text-stone-400">Usually replies within a few hours</p>
</div>
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-8">
<div className="w-12 h-12 rounded-full bg-stone-100 flex items-center justify-center">
<Mail size={20} className="text-stone-400" />
</div>
<p className="text-stone-500 text-sm text-center">No conversation yet</p>
<p className="text-stone-400 text-xs text-center">Messages with {businessName} will appear here once you start texting.</p>
</div>
<div className="border-t border-stone-200 p-3 flex gap-2">
<div
className="flex-1 border border-stone-200 rounded-lg px-3 py-2 text-sm text-stone-400 bg-stone-50 flex items-center justify-center gap-2"
title="Reply from your phone"
>
Reply from your phone
</div>
</div>
</div>
);
}
const handleSend = () => {
if (!newMessage.trim() || readOnly) return;
const msg: Message = {
id: `m-${Date.now()}`,
sender: "customer",
senderName: "You",
text: newMessage.trim(),
timestamp: new Date().toISOString(),
read: false,
};
setMessages([...messages, msg]);
setNewMessage("");
};
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
@@ -142,46 +104,49 @@ function MessageThread({ sessionId, readOnly: _readOnly }: MessageThreadProps) {
{messages.length === 0 ? (
<p className="text-stone-400 text-center text-sm italic">No messages yet</p>
) : (
messages.map((msg: ApiMessage) => {
const sender = msg.direction === "inbound" ? "customer" : "business";
return (
<div key={msg.id} className={`flex ${sender === "customer" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
sender === "customer"
? "bg-(--color-accent) text-white rounded-br-md"
: "bg-stone-100 text-stone-800 rounded-bl-md"
}`}>
{msg.body && <p className="text-sm">{msg.body}</p>}
<div className={`flex items-center gap-1 mt-1 ${sender === "customer" ? "justify-end" : ""}`}>
<span className={`text-xs ${sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
{new Date(msg.createdAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
</span>
</div>
messages.map(msg => (
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
msg.sender === "customer"
? "bg-(--color-accent) text-white rounded-br-md"
: "bg-stone-100 text-stone-800 rounded-bl-md"
}`}>
<p className="text-sm">{msg.text}</p>
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
</span>
{msg.sender === "customer" && (
msg.read
? <CheckCheck size={12} className="text-white/60" />
: <Check size={12} className="text-white/60" />
)}
</div>
</div>
);
})
)}
{hasMore && (
<div className="flex justify-center">
<button
onClick={loadMore}
className="text-sm text-(--color-accent) hover:underline"
>
Load more
</button>
</div>
</div>
))
)}
</div>
<div className="border-t border-stone-200 p-3 flex gap-2">
<div
className="flex-1 border border-stone-200 rounded-lg px-3 py-2 text-sm text-stone-400 bg-stone-50 flex items-center justify-center gap-2"
title="Reply from your phone"
>
Reply from your phone
{!readOnly && (
<div className="border-t border-stone-200 p-3 flex gap-2">
<input
type="text"
value={newMessage}
onChange={e => setNewMessage(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleSend()}
placeholder="Type a message..."
className="flex-1 border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)/30 focus:border-(--color-accent)"
/>
<button
onClick={handleSend}
disabled={!newMessage.trim()}
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg hover:bg-(--color-accent-hover) disabled:opacity-50"
>
<Send size={16} />
</button>
</div>
</div>
)}
</div>
);
}
@@ -211,10 +176,10 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
const categories: { key: PrefKey; label: string; desc: string; icon: typeof Bell }[] = [
{ key: "appointmentReminders", label: "Appointment Reminders", desc: "Upcoming appointment notifications", icon: Bell },
{ key: "vaccinationAlerts", label: "Vaccination Alerts", desc: "Expiration and renewal reminders", icon: Mail },
{ key: "promotional", label: "Promotions & Offers", desc: "Deals and seasonal specials", icon: Smartphone },
{ key: "reportCards", label: "Report Cards", desc: "Grooming report card delivery", icon: Mail },
{ key: "invoiceReceipts", label: "Invoice & Receipts", desc: "Payment confirmations", icon: Bell },
{ key: "vaccinationAlerts", label: "Vaccination Alerts", desc: "Expiration and renewal reminders", icon: FileText },
{ key: "promotional", label: "Promotions & Offers", desc: "Deals and seasonal specials", icon: Megaphone },
{ key: "reportCards", label: "Report Cards", desc: "Grooming report card delivery", icon: FileText },
{ key: "invoiceReceipts", label: "Invoice & Receipts", desc: "Payment confirmations", icon: CreditCard },
];
const channels: { key: ChannelKey; label: string; icon: typeof Mail }[] = [
@@ -271,4 +236,4 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
);
}
export default Communication;
export default Communication;
+5 -4
View File
@@ -27,7 +27,8 @@ interface Appointment {
}
interface AppointmentsResponse {
appointments: Appointment[];
upcoming: Appointment[];
past: Appointment[];
}
interface Props {
@@ -45,7 +46,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>({ appointments: [] });
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
const [selectedPetId, setSelectedPetId] = useState<string>("");
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
const [editingPetId, setEditingPetId] = useState<string | null>(null);
@@ -89,7 +90,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
}, [sessionId]);
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date());
const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
function handlePetSave(updatedPet: Pet) {
@@ -182,7 +183,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
)}
{/* Tabs */}
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto scrollbar-hide">
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
{([
{ id: "info", label: "Basic Info", icon: PawPrint },
{ id: "medical", label: "Medical", icon: Heart },
-7
View File
@@ -119,10 +119,3 @@ uri
database-url
{{- end -}}
{{- end }}
{{/*
Auth secret name — always use groombook-auth (sealed secret name)
*/}}
{{- define "groombook.authSecretName" -}}
{{- printf "%s" "groombook-auth" }}
{{- end }}
@@ -50,27 +50,6 @@ spec:
- name: OIDC_AUDIENCE
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:
-2
View File
@@ -18,8 +18,6 @@ api:
corsOrigin: ""
oidcIssuer: ""
oidcAudience: groombook
betterAuthUrl: ""
internalBaseUrl: ""
port: "3000"
service:
type: ClusterIP
-2
View File
@@ -50,8 +50,6 @@ services:
dockerfile: apps/web/Dockerfile
ports:
- "8080:80"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- api
-309
View File
@@ -1,309 +0,0 @@
# 10DLC Pilot Tenant Registration Runbook
Authored for GRO-106 Phase 1.
---
## Pre-Flight Checklist
Before starting Telnyx registration, collect the following:
| Item | Details |
|------|---------|
| Legal business name | Exact name on EIN / business registration |
| EIN (Employer Identification Number) | 9-digit IRS format: XX-XXXXXXX |
| Business type | Sole Proprietor / LLC / Corporation |
| Primary contact email | General contact address (postmaster@, info@, etc.) |
| Primary contact phone | Direct line for carrier verification |
| Website URL | Must be live and contain privacy policy |
| Sample message templates | See [Sample Templates](#sample-message-templates) below |
| Messaging use case | Customer Care / Account Notification |
---
## Step 1 — Telnyx Account Requirements
- Active Telnyx account with billing configured.
- Role required: **Admin** or **Super User** to register brands and campaigns.
---
## Step 2 — Brand Registration
### Via Telnyx Console
1. Log in to [Telnyx Portal](https://portal.telnyx.com).
2. Navigate to **Messaging → A2P 10DLC → Brands**.
3. Click **Register Brand**.
4. Fill in:
- **Brand Name**: Legal business name
- **Legal Company Name**: Exact EIN name
- **Company Type**: Select from dropdown
- **EIN**: XX-XXXXXXX
- **Primary Contact**: Name, email, phone
- **Website**: Must be accessible
- **BusinessVertical**: Select appropriate vertical
5. Acknowledge the **Terms of Service**.
6. Submit.
### Via API
```bash
curl -X POST https://api.telnyx.com/v2/10dlc/brands \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Your Legal Business Name",
"legal_company_name": "Your Legal Business Name",
"company_type": "llc",
"ein": "XX-XXXXXXX",
"primary_contact": {
"name": "Jane Doe",
"email": "compliance@example.com",
"phone": "+1XXXXXXXXXX"
},
"website": "https://www.example.com",
"business_vertical": "PROFESSIONAL_SERVICES"
}'
```
**Response fields to record:**
- `brand_id` — required for campaign registration
- `brand_score` — affects campaign vetting speed
### Expected Fees
| Fee Type | Amount |
|----------|--------|
| Brand registration fee | ~$0 (no direct fee from Telnyx) |
| Campaign registration fee | ~$15$25 per campaign (Telnyx fee, subject to change) |
| Carrier fees | Passed through from T-Mobile/AT&T/Verizon |
### Expected Approval Window
- **Vetting by Telnyx**: 13 business days after submission.
- **Carrier (T-Mobile/AT&T/Verizon) review**: 25 business days after Telnyx approval.
- Total end-to-end: **38 business days**.
---
## Step 3 — Campaign Registration
### Use Case Selection
- **Primary**: Customer Care
- **Secondary**: Account Notification
### Via Telnyx Console
1. Navigate to **Messaging → A2P 10DLC → Campaigns**.
2. Click **Register Campaign**.
3. Select **Brand** (use the brand registered in Step 2).
4. Fill in:
- **Campaign Name**: e.g., `groombook-pilot-customer-care`
- **Use Case**: Customer Care / Account Notification
- **Sample Messages**: Paste exactly the templates from [Sample Templates](#sample-message-templates) below.
- **Description**: Brief description of messaging program
- **Estimated Volume**: Enter monthly estimate (e.g., 500)
5. Submit.
### Via API
```bash
curl -X POST https://api.telnyx.com/v2/10dlc/campaigns \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"brand_id": "YOUR_BRAND_ID",
"name": "groombook-pilot-customer-care",
"use_case": "CUSTOMER_CARE",
"sample_messages": [
"Hi {{first_name}}, this is a reminder from {{business_name}} that your appointment is scheduled for {{date}} at {{time}}. Reply STOP to opt out.",
"Your appointment with {{business_name}} is confirmed for {{date}}. Need to reschedule? Reply HELP or call us at {{phone}}."
],
"description": "Appointment reminders and account notifications for grooming clients",
"estimated_monthly_volume": 500
}'
```
**Response fields to record:**
- `campaign_id` — required for messaging profile
- `status` — initially `PENDING`, transitions to `ACTIVE` after carrier approval
### Campaign Vetting — STOP/HELP Language Requirements
Every campaign **must** include compliant STOP/HELP messaging. The following must appear in your sample messages or be included in your terms of service:
- **STOP**: Users can text `STOP` to opt out of all messages.
- **HELP**: Users can text `HELP` to receive contact information.
Example STOP/HELP block:
```
Text STOP to opt out. Text HELP for help. Msg & data rates may apply.
```
---
## Step 4 — Messaging Profile + Phone Number Provisioning
### Create Messaging Profile
1. In Telnyx Portal, navigate to **Messaging → Messaging Profiles**.
2. Click **Create Messaging Profile**.
3. Name it (e.g., `groombook-pilot-prod`).
4. Copy the **Messaging Profile ID** (`messaging_profile_id`) — record this in the DB.
### Provision a 10DLC Phone Number
1. Navigate to **Messaging → Phone Numbers**.
2. Search for a number in your desired area code.
3. Confirm the number is 10DLC-capable.
4. Purchase the number.
### Associate Number with Messaging Profile
```bash
# Assign number to messaging profile
curl -X PATCH https://api.telnyx.com/v2/phone_numbers/YOUR_PHONE_NUMBER_ID \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"messaging_profile_id": "YOUR_MESSAGING_PROFILE_ID"
}'
```
---
## Step 5 — Record in Database
Once GRO-981 lands, record the following against the business record:
### SQL Path (when GRO-981 is complete)
```sql
UPDATE businesses
SET
messaging_phone_number = '+1XXXXXXXXXX',
telnyx_messaging_profile_id = 'YOUR_MESSAGING_PROFILE_ID',
telnyx_brand_id = 'YOUR_BRAND_ID',
telnyx_campaign_id = 'YOUR_CAMPAIGN_ID',
telnyx_brand_status = 'APPROVED',
telnyx_campaign_status = 'ACTIVE',
updated_at = NOW()
WHERE id = 'pilot_business_id';
```
### Manual Admin Path (before GRO-981)
Until GRO-981 is complete, use the Telnyx Portal to verify and record values manually in your internal ops sheet:
| Field | Value |
|-------|-------|
| `messagingPhoneNumber` | +1XXXXXXXXXX |
| `telnyxMessagingProfileId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `telnyxBrandId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `telnyxCampaignId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `brandStatus` | APPROVED / PENDING |
| `campaignStatus` | ACTIVE / PENDING |
---
## Sample Message Templates
These must match exactly what your system will send. Vetting reviewers compare templates against actual traffic.
### Transactional Appointment Reminder
```
Hi {{first_name}}, this is a reminder from {{business_name}} that your appointment is scheduled for {{date}} at {{time}}. Reply STOP to opt out. Msg & data rates may apply.
```
### Manual Staff Message
```
Your appointment with {{business_name}} is confirmed for {{date}}. Need to reschedule? Reply HELP for assistance or call us at {{phone}}. Msg & data rates may apply.
```
---
## Failure Modes + Retry Guidance
### Vetting Rejection — Brand
| Rejection Reason | Common Fix |
|-----------------|------------|
| Legal name mismatch with EIN | Ensure exact EIN name matches legal company name exactly |
| Website not accessible / missing privacy policy | Add privacy policy page to website before resubmitting |
| Incomplete primary contact | Provide direct phone and real email (no noreply) |
| High-risk business vertical | Contact Telnyx support for pre-screening before resubmitting |
### Campaign Rejection
| Rejection Reason | Common Fix |
|-----------------|------------|
| Sample messages do not match actual traffic | Update sample messages to match exactly what the system sends |
| Missing STOP/HELP language | Add compliant STOP/HELP block to sample messages |
| Volume estimate too low/high | Revise estimate to be realistic |
| Use case mismatch | Re-select use case that matches actual messaging |
### Re-submission
After fixing the rejection reason, re-submit via the same API endpoint. Telnyx will re-run vetting (typically 2448 hours).
---
## Cost Summary
### Telnyx Fees (as of 2026)
| Fee Type | Amount | Notes |
|----------|--------|-------|
| 10DLC number (monthly) | ~$1.00$2.50/number | Varies by type and area code |
| Outbound message | $0.005$0.015/message | Depends on destination carrier |
| Inbound message | Included | No charge for received messages |
| Campaign registration | ~$15$25 one-time | Per campaign, subject to change |
### Carrier Fees (T-Mobile / AT&T / Verizon)
| Carrier | Outbound Fee | Notes |
|---------|-------------|-------|
| T-Mobile | ~$0.005$0.01/message | Varies by message size (segment) |
| AT&T | ~$0.005$0.015/message | Varies by message size (segment) |
| Verizon | ~$0.005$0.01/message | Varies by message size (segment) |
**Note**: Carrier fees are subject to change. Check [Telnyx pricing page](https://telnyx.com/pricing) and carrier fee schedules for current rates.
### Example Monthly Cost (Pilot — 500 messages/month)
| Line Item | Cost |
|-----------|------|
| 1x 10DLC number | ~$2.00 |
| 500 outbound messages | ~$5.00$7.50 |
| Carrier pass-through | ~$2.50$7.50 |
| **Estimated Monthly Total** | **~$9.50$17.00** |
---
## Rollback / De-provisioning
If the pilot tenant must be de-provisioned:
1. Release the phone number: Telnyx Portal → Phone Numbers → Release.
2. Archive the campaign: set status to `INACTIVE` via API or console.
3. Remove DB record: clear `messagingPhoneNumber`, `telnyxMessagingProfileId`, `telnyxCampaignId` fields in the business record.
4. Brand can remain registered (no harm) but will not be used.
---
## Contacts
| Resource | Contact |
|----------|---------|
| Telnyx Support | support@telnyx.com |
| Telnyx Dashboard | portal.telnyx.com |
| Internal Engineering | Raise issue in GRO-106 |
---
_Owner: Engineering · Last updated: 2026-05-04_
-11
View File
@@ -1,11 +0,0 @@
# GroomBook Runbooks
Operational runbooks for GroomBook staff and operators.
| Runbook | Description | Status |
|---------|-------------|--------|
| [10DLC Pilot Registration](./10dlc-pilot-registration.md) | Register a pilot grooming business as an A2P 10DLC brand + campaign on Telnyx | Active |
---
_To add a runbook, create a markdown file in this directory and update this table._
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
API_HOST="https://api.minimax.io"
API_KEY="$MINIMAX_API_KEY"
OUTPUT_DIR="minimax-output"
mkdir -p "$OUTPUT_DIR"
# Diverse dog image prompts
declare -a PROMPTS=(
"A beautiful red Irish Setter with long flowing silky coat, standing proudly in golden hour sunlight, professional pet portrait photography, warm tones"
"A fluffy white Pomeranian puppy with thick fluffy coat, sitting alert with bright expression, studio white background, cute grooming"
"A black Schnauzer with distinctive full beard and mustache, freshly groomed with neat styling, professional grooming salon setting"
"A cream and white Cavalier King Charles Spaniel with silky coat, gentle sad eyes, soft warm indoor lighting, elegant pose"
"A brown and white Basset Hound with long droopy ears, lying down in relaxed pose, natural outdoor setting, peaceful expression"
"A black and tan miniature Dachshund with glossy coat, alert standing pose, warm studio lighting, detailed paws visible"
"A white fluffy Bichon Frise after professional grooming with rounded topknot, happy bouncy expression, bright cheerful background"
"A muscular fawn Boxer dog, athletic build, standing confidently outdoors in park, energetic expression, natural lighting"
"A blue merle Shetland Sheepdog with alert ears and fluffy coat, running happily, green grass field background, vibrant"
"A buff colored Cocker Spaniel with beautiful silky coat, friendly gentle expression, warm natural window lighting, indoor"
)
declare -a FILENAMES=(
"dog-setter-red-sunlit.png"
"dog-pomeranian-white-studio.png"
"dog-schnauzer-black-groomed.png"
"dog-cavalier-cream-gentle.png"
"dog-basset-brown-white.png"
"dog-dachshund-black-tan.png"
"dog-bichon-white-groomed.png"
"dog-boxer-fawn-athletic.png"
"dog-sheepdog-merle-running.png"
"dog-cocker-buff-friendly.png"
)
echo "Generating ${#PROMPTS[@]} diverse dog images..."
for i in "${!PROMPTS[@]}"; do
PROMPT="${PROMPTS[$i]}"
FILENAME="${FILENAMES[$i]}"
echo -n "[$((i+1))/${#PROMPTS[@]}] $FILENAME... "
RESPONSE=$(curl -s -X POST "${API_HOST}/v1/image_generation" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"model\":\"image-01\",\"prompt\":\"${PROMPT}\",\"image_count\":1}")
# Extract image URL from response
IMAGE_URL=$(echo "$RESPONSE" | grep -o '"image_urls":\["\([^"]*\)' | cut -d'"' -f4)
if [ -n "$IMAGE_URL" ]; then
curl -s "$IMAGE_URL" -o "$OUTPUT_DIR/$FILENAME" 2>/dev/null
if [ -f "$OUTPUT_DIR/$FILENAME" ] && [ -s "$OUTPUT_DIR/$FILENAME" ]; then
echo "✓"
else
echo "✗ (download failed)"
fi
else
echo "✗ (no URL)"
fi
done
echo "Done! Generated images in $OUTPUT_DIR/"
ls -lh "$OUTPUT_DIR"/dog-*.png 2>/dev/null | wc -l
+80
View File
@@ -0,0 +1,80 @@
#!/bin/bash
# Use the configured MiniMax API host
API_HOST="${MINIMAX_API_HOST:-https://api.minimax.io}"
API_KEY="$MINIMAX_API_KEY"
# Test endpoint - check which one works
echo "Testing API endpoints..."
echo "API_HOST: $API_HOST"
echo "API_KEY: ${API_KEY:0:15}..."
# Array of diverse dog images to generate
declare -a PROMPTS=(
"A beautiful red Irish Setter with flowing silky coat, standing proudly in a sunny garden, warm natural lighting, professional pet photography"
"A fluffy white Pomeranian with thick coat, sitting alert, bright studio background, cute expression"
"A black Schnauzer with distinctive beard, freshly groomed, professional salon setting, dignified pose"
"A cream-colored Cavalier King Charles Spaniel, silky coat, gentle expression, soft warm lighting"
"A brown and white Basset Hound, long ears, relaxed sitting pose, natural outdoor background"
"A black and tan Dachshund, elongated body, alert posture, warm studio lighting"
"A white Bichon Frise, fluffy groomed coat, happy expression, bright cheerful background"
"A fawn Boxer with muscular build, athletic posture, outdoor park setting, energetic expression"
"A merle Shetland Sheepdog, alert ears, running pose, green garden background"
"A buff-colored Cocker Spaniel, silky coat, friendly expression, warm natural light"
)
declare -a FILENAMES=(
"dog-setter-red-sunny.png"
"dog-pomeranian-white-alert.png"
"dog-schnauzer-groomed.png"
"dog-cavalier-cream.png"
"dog-basset-hound-outdoor.png"
"dog-dachshund-alert.png"
"dog-bichon-frise-happy.png"
"dog-boxer-athletic.png"
"dog-sheepdog-merle.png"
"dog-cocker-spaniel-buff.png"
)
mkdir -p minimax-output
echo "Generating ${#PROMPTS[@]} diverse dog images..."
for i in "${!PROMPTS[@]}"; do
PROMPT="${PROMPTS[$i]}"
FILENAME="${FILENAMES[$i]}"
echo "[$((i+1))/${#PROMPTS[@]}] Generating: $FILENAME"
# Make API request
RESPONSE=$(curl -s -X POST "${API_HOST}/v1/image_generation" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{
\"model\": \"image-01\",
\"prompt\": \"${PROMPT}\",
\"image_count\": 1
}")
# Check if response contains image data
if echo "$RESPONSE" | grep -q "data\|image_url\|file_content"; then
echo " ✓ Response received"
# Try to extract and save image data
# Different APIs format responses differently
IMAGE_DATA=$(echo "$RESPONSE" | grep -o '"file_content":"[^"]*' | head -1 | cut -d'"' -f4)
if [ -n "$IMAGE_DATA" ]; then
echo "$IMAGE_DATA" | base64 -d > "minimax-output/$FILENAME"
echo " ✓ Image saved to minimax-output/$FILENAME"
else
echo " ✗ Could not extract image data"
fi
else
echo " ✗ API response: ${RESPONSE:0:100}"
fi
sleep 1
done
echo "Image generation complete!"
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
import base64
import requests
import os
import json
api_key = os.environ.get("MINIMAX_API_KEY")
if not api_key:
raise ValueError("MINIMAX_API_KEY environment variable not set")
url = "https://api.minimax.io/v1/image_generation"
headers = {"Authorization": f"Bearer {api_key}"}
# Ensure output directory exists
os.makedirs("minimax-output", exist_ok=True)
prompts = [
{
"filename": "dog-puggle-fawn-playful.png",
"prompt": "Adorable fawn Puggle puppy with playful expression, compact muscular build, professional pet photography, studio lighting, photorealistic"
},
{
"filename": "dog-puggle-black-sitting.png",
"prompt": "Black and tan Puggle with alert sitting posture, pointed beagle-like ears, gentle eyes, professional studio lighting, photorealistic"
},
{
"filename": "dog-puggle-cream-groomed.png",
"prompt": "Cream Puggle freshly groomed with fluffy coat, happy expression, lying down comfortably, natural daylight, photorealistic"
},
{
"filename": "dog-puggle-tricolor-outdoor.png",
"prompt": "Tricolor Puggle in outdoor garden setting, alert playful pose, natural sunlight, professional pet photography, photorealistic"
},
{
"filename": "dog-puggle-fawn-grooming.png",
"prompt": "Fawn Puggle at grooming salon, gentle expression, compact muscular build with beagle-like features, professional grooming setup, warm lighting, photorealistic"
}
]
print(f"Generating {len(prompts)} Puggle images...")
for item in prompts:
filename = item["filename"]
prompt = item["prompt"]
print(f"\nGenerating {filename}...")
payload = {
"model": "image-01",
"prompt": prompt,
"aspect_ratio": "1:1",
"response_format": "base64",
}
try:
response = requests.post(url, headers=headers, json=payload, timeout=60)
response.raise_for_status()
data = response.json()
if "data" in data and "image_base64" in data["data"]:
images = data["data"]["image_base64"]
# Save the first (and usually only) image
output_path = f"minimax-output/{filename}"
with open(output_path, "wb") as f:
f.write(base64.b64decode(images[0]))
file_size = os.path.getsize(output_path)
print(f"✓ Saved {filename} ({file_size} bytes)")
else:
print(f"✗ Unexpected response format: {json.dumps(data, indent=2)}")
except requests.exceptions.RequestException as e:
print(f"✗ Error generating {filename}: {e}")
except Exception as e:
print(f"✗ Unexpected error for {filename}: {e}")
print("\n✓ Image generation complete!")
print("Files saved to minimax-output/")
@@ -0,0 +1,9 @@
ALTER TABLE "reminder_logs" DROP CONSTRAINT "reminder_logs_appointment_id_reminder_type_unique";--> statement-breakpoint
ALTER TABLE "business_settings" ADD COLUMN "logo_key" text;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_opt_in" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_consent_date" timestamp;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_opt_out_date" timestamp;--> statement-breakpoint
ALTER TABLE "clients" ADD COLUMN "sms_consent_text" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "image" text;--> statement-breakpoint
ALTER TABLE "reminder_logs" ADD COLUMN "channel" text DEFAULT 'email' NOT NULL;--> statement-breakpoint
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE("appointment_id","reminder_type","channel");
@@ -1,6 +0,0 @@
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;
ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
-11
View File
@@ -1,11 +0,0 @@
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,6 @@
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint
ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");--> statement-breakpoint
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;--> statement-breakpoint
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;--> statement-breakpoint
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
@@ -0,0 +1 @@
ALTER TABLE "business_settings" ADD COLUMN "sms_enabled" boolean NOT NULL DEFAULT false;

Some files were not shown because too many files have changed in this diff Show More