Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3604049222 | |||
| 8f2deaa6d7 | |||
| 9e8723de57 | |||
| e037748c85 | |||
| 93716afd1a | |||
| 503c3dbdbb | |||
| b3517bf746 | |||
| 604e79bab4 | |||
| 29015cffec | |||
| c67f731f69 | |||
| db3bcf8094 | |||
| 7836511baa | |||
| b69650af15 | |||
| b0d1a4def4 | |||
| d407b895be | |||
| 53ab415713 | |||
| a330e342e1 | |||
| 0f841e27fc | |||
| a7bcce8b80 | |||
| 5f1582a3b6 | |||
| c76ea93c29 | |||
| cd25d98384 | |||
| e9fceb78b3 | |||
| 0cae8adef8 | |||
| 674626ba1e | |||
| aa5686bed1 | |||
| 903fbf55d5 | |||
| 775e2e544b | |||
| fb9c922182 | |||
| 1cc48f0b88 | |||
| 1b8d7087c0 | |||
| d65d121a5d | |||
| b8fd7ec18f | |||
| 7bf9cf9734 | |||
| bf159f8b1f | |||
| 2f3d4d8d01 | |||
| db9bb31702 | |||
| b38db65dde | |||
| 3178f81b99 | |||
| 544d65959d | |||
| fe2e093b92 | |||
| 2af1671891 | |||
| ad80722eee | |||
| c811b58c62 | |||
| 1dfcdcc2cb | |||
| f74e034495 | |||
| 4c46cec4e3 | |||
| f38bb244a4 | |||
| 251b36b863 | |||
| 3c366ccc46 | |||
| ff149f75dc | |||
| 03bd2d0235 | |||
| 10ad5e7b04 | |||
| 4f85a4a432 | |||
| 560d33edf8 | |||
| 50e9e70935 | |||
| d59cb1ab1d | |||
| 740e46baf2 | |||
| b1b89966d9 | |||
| 25fd3308e0 | |||
| be07c8b758 | |||
| ff2851eda2 | |||
| abee344ca4 | |||
| 460ba78112 | |||
| ffe8aef035 | |||
| 2153505875 | |||
| 4aaf2a3b3f | |||
| 20ca93b36d | |||
| 9793283021 | |||
| 1cc6d53546 | |||
| bfe099deda | |||
| 47ccd1395c | |||
| ef79ac748c | |||
| 06846952a1 | |||
| d72485c08a | |||
| 4001691ae7 | |||
| b980e4177c | |||
| 6141dcb77d | |||
| 8ecbfbeee4 | |||
| 1da61fb466 | |||
| 77971a1ac9 | |||
| e539b6c904 | |||
| b797ac3ab1 | |||
| 6bddd6203d | |||
| 3c7820d785 | |||
| 9eb86004fc | |||
| 6046594a15 | |||
| b683c57d6c | |||
| 89505a2363 | |||
| 8e1e51be59 | |||
| ea7bf4f49b | |||
| 6e1e51fba7 | |||
| 5a8ea2fd14 | |||
| b00d6a8ca0 | |||
| f8ea417799 | |||
| 772f4df62f | |||
| edf2ef8f7e | |||
| 8182870d38 | |||
| 7f715ecdfc | |||
| 5df8837b5f | |||
| 0abb79010d | |||
| eab97b2ebd | |||
| f301b1a5a0 | |||
| c786544369 | |||
| 85c76b5209 | |||
| 2577e33c50 | |||
| d8dbec1be1 | |||
| 4a65c30d40 | |||
| cab17e0230 | |||
| b904418628 | |||
| 5ff54ce8f9 | |||
| a2cfdfef74 | |||
| ab9384d38e | |||
| 6ba6da08b2 | |||
| 29a726fa3d | |||
| cdf4d6c4b1 | |||
| ffb3cd139a | |||
| 85cff19c59 | |||
| 376180ab9d | |||
| da16ac8ac2 | |||
| 16dd513521 | |||
| 66a6659ccd | |||
| 71c229f83b | |||
| 1ef740c361 | |||
| d433c902b4 | |||
| dc3b3ddcb7 | |||
| 31997e33c0 | |||
| e118607fd6 | |||
| e1e13d5091 | |||
| 80b66fe20c | |||
| 67e2157975 | |||
| 4fa4859eaf | |||
| ca88385b8d | |||
| 3f2769a43a | |||
| 0ed87f9ed8 | |||
| 233e68769a | |||
| f7b8b7e668 | |||
| 1cce354413 | |||
| 648755eee5 | |||
| 77a6319459 | |||
| df07f2d6dc | |||
| dadabb0ea7 | |||
| d5a8b19322 | |||
| 4d1d94296f | |||
| c6800a6144 | |||
| 000e90a617 | |||
| 70e9465b68 | |||
| 8c3e0f9554 | |||
| f4f522d5e6 | |||
| e8455195ee | |||
| ab4b9fe6fc |
@@ -7,3 +7,5 @@ apps/web/dist
|
|||||||
apps/api/dist
|
apps/api/dist
|
||||||
packages/db/dist
|
packages/db/dist
|
||||||
packages/types/dist
|
packages/types/dist
|
||||||
|
.turbo
|
||||||
|
screenshots/
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
ref:
|
||||||
@@ -20,6 +20,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.4'
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -42,6 +44,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.4'
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -62,6 +66,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.4'
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -80,6 +86,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Run E2E tests
|
- name: Run E2E tests
|
||||||
run: pnpm --filter @groombook/e2e test
|
run: pnpm --filter @groombook/e2e test
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_BASE_URL: http://host.docker.internal:8080
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -101,6 +109,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.4'
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -119,18 +129,12 @@ jobs:
|
|||||||
needs: [build, e2e]
|
needs: [build, e2e]
|
||||||
outputs:
|
outputs:
|
||||||
tag: ${{ steps.version.outputs.tag }}
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Generate image tag
|
- name: Generate image tag
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
# Always include short SHA so each build is immutable and cache-from can never
|
|
||||||
# cross-contaminate between commits. For PRs the format is pr-N-sha7; for main
|
|
||||||
# it is YYYY.MM.DD-sha7.
|
|
||||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
TAG="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::7}"
|
TAG="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::7}"
|
||||||
else
|
else
|
||||||
@@ -142,12 +146,12 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: git.farh.net
|
||||||
username: ${{ github.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push API image
|
- name: Build and push API image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -157,10 +161,10 @@ jobs:
|
|||||||
target: runner
|
target: runner
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/groombook/api:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/api:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
|
||||||
cache-from: type=gha
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
|
||||||
|
|
||||||
- name: Build and push Migrate image
|
- name: Build and push Migrate image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -170,10 +174,10 @@ jobs:
|
|||||||
target: migrate
|
target: migrate
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/groombook/migrate:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/migrate:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
|
||||||
cache-from: type=gha
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
|
||||||
|
|
||||||
- name: Build and push Seed image
|
- name: Build and push Seed image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -183,10 +187,10 @@ jobs:
|
|||||||
target: seed
|
target: seed
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/groombook/seed:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/seed:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
|
||||||
cache-from: type=gha
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:seed
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max
|
||||||
|
|
||||||
- name: Build and push Reset image
|
- name: Build and push Reset image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -196,10 +200,10 @@ jobs:
|
|||||||
target: reset
|
target: reset
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/groombook/reset:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/reset:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
|
||||||
cache-from: type=gha
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
|
||||||
|
|
||||||
- name: Build and push Web image
|
- name: Build and push Web image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -208,19 +212,16 @@ jobs:
|
|||||||
file: apps/web/Dockerfile
|
file: apps/web/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/groombook/web:${{ steps.version.outputs.tag }}
|
git.farh.net/groombook/web:${{ steps.version.outputs.tag }}
|
||||||
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/web:latest' || '' }}
|
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }}
|
||||||
cache-from: type=gha
|
cache-from: type=registry,ref=git.farh.net/groombook/cache:web
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=registry,ref=git.farh.net/groombook/cache:web,mode=max
|
||||||
|
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
name: Deploy PR to groombook-dev
|
name: Deploy PR to groombook-dev
|
||||||
runs-on: runners-groombook
|
runs-on: ubuntu-latest
|
||||||
needs: [docker]
|
needs: [docker]
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install kubectl
|
- name: Install kubectl
|
||||||
run: |
|
run: |
|
||||||
@@ -237,8 +238,6 @@ jobs:
|
|||||||
TAG="pr-$PR_NUM-${SHA::7}"
|
TAG="pr-$PR_NUM-${SHA::7}"
|
||||||
echo "Deploying images tagged $TAG to groombook-dev..."
|
echo "Deploying images tagged $TAG to groombook-dev..."
|
||||||
|
|
||||||
# Run migration with PR image
|
|
||||||
kubectl delete job migrate-schema -n groombook-dev --ignore-not-found
|
|
||||||
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
|
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
|
||||||
cat <<EOF | kubectl apply -n groombook-dev -f -
|
cat <<EOF | kubectl apply -n groombook-dev -f -
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
@@ -253,7 +252,7 @@ jobs:
|
|||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
containers:
|
containers:
|
||||||
- name: migrate
|
- name: migrate
|
||||||
image: ghcr.io/groombook/migrate:$TAG
|
image: git.farh.net/groombook/migrate:$TAG
|
||||||
env:
|
env:
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
@@ -264,35 +263,25 @@ jobs:
|
|||||||
kubectl wait --for=condition=complete "job/migrate-pr-$PR_NUM" \
|
kubectl wait --for=condition=complete "job/migrate-pr-$PR_NUM" \
|
||||||
-n groombook-dev --timeout=120s
|
-n groombook-dev --timeout=120s
|
||||||
|
|
||||||
# Update deployments
|
kubectl set image deployment/api api=git.farh.net/groombook/api:$TAG -n groombook-dev
|
||||||
kubectl set image deployment/api api=ghcr.io/groombook/api:$TAG -n groombook-dev
|
kubectl set image deployment/web web=git.farh.net/groombook/web:$TAG -n groombook-dev
|
||||||
kubectl set image deployment/web web=ghcr.io/groombook/web:$TAG -n groombook-dev
|
|
||||||
|
|
||||||
# Wait for rollout
|
|
||||||
kubectl rollout status deployment/api -n groombook-dev --timeout=300s
|
kubectl rollout status deployment/api -n groombook-dev --timeout=300s
|
||||||
kubectl rollout status deployment/web -n groombook-dev --timeout=300s
|
kubectl rollout status deployment/web -n groombook-dev --timeout=300s
|
||||||
|
|
||||||
echo "Deployment complete."
|
echo "Deployment complete."
|
||||||
|
|
||||||
- name: Comment on PR
|
- name: Comment on PR
|
||||||
uses: actions/github-script@v7
|
env:
|
||||||
with:
|
PR_NUM: ${{ github.event.pull_request.number }}
|
||||||
script: |
|
GITEA_TOKEN: ${{ gitea.token }}
|
||||||
const pr = context.issue.number;
|
run: |
|
||||||
const tag = `pr-${pr}`;
|
TAG="pr-${PR_NUM}"
|
||||||
await github.rest.issues.createComment({
|
curl -s -X POST \
|
||||||
owner: context.repo.owner,
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
repo: context.repo.repo,
|
-H "Content-Type: application/json" \
|
||||||
issue_number: pr,
|
"https://git.farh.net/api/v1/repos/groombook/app/issues/$PR_NUM/comments" \
|
||||||
body: [
|
-d "{\"body\": \"## Deployed to groombook-dev\n\n**Images:** \`${TAG}\`\n**URL:** https://dev.groombook.farh.net\n\nReady for UAT validation.\"}"
|
||||||
'## Deployed to groombook-dev',
|
|
||||||
'',
|
|
||||||
`**Images:** \`${tag}\``,
|
|
||||||
'**URL:** https://dev.groombook.farh.net',
|
|
||||||
'',
|
|
||||||
'Ready for UAT validation.'
|
|
||||||
].join('\n')
|
|
||||||
});
|
|
||||||
|
|
||||||
web-e2e:
|
web-e2e:
|
||||||
name: Web E2E (Dev)
|
name: Web E2E (Dev)
|
||||||
@@ -303,6 +292,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.4'
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -331,21 +322,13 @@ jobs:
|
|||||||
name: Update Infra Image Tags
|
name: Update Infra Image Tags
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [docker]
|
needs: [docker]
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Generate infra repo token
|
|
||||||
id: infra-token
|
|
||||||
uses: tibdex/github-app-token@v2
|
|
||||||
with:
|
|
||||||
app_id: ${{ vars.GH_APP_ID }}
|
|
||||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
|
||||||
|
|
||||||
- name: Clone groombook/infra
|
- name: Clone groombook/infra
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ gitea.token }}
|
||||||
run: |
|
run: |
|
||||||
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
git clone https://oauth2:$GITEA_TOKEN@git.farh.net/groombook/infra.git /tmp/infra
|
||||||
|
|
||||||
- name: Install yq
|
- name: Install yq
|
||||||
run: |
|
run: |
|
||||||
@@ -362,30 +345,25 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
export SHORT_SHA="${SHA::7}"
|
export SHORT_SHA="${SHA::7}"
|
||||||
echo "Updating dev overlay image tags to: $TAG"
|
echo "Updating dev overlay image tags to: $TAG"
|
||||||
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
|
|
||||||
cd /tmp/infra
|
cd /tmp/infra
|
||||||
DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml"
|
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 == "git.farh.net/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 == "git.farh.net/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 == "git.farh.net/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 == "git.farh.net/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
|
yq -i '(.images[] | select(.name == "git.farh.net/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
|
||||||
|
|
||||||
# Update migrate Job name to include short SHA (immutable template fix)
|
|
||||||
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||||
if [ -f "$MIGRATE_JOB" ]; then
|
if [ -f "$MIGRATE_JOB" ]; then
|
||||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
||||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||||
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
|
|
||||||
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
|
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Update seed Job name to include short SHA (immutable template fix)
|
|
||||||
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||||
if [ -f "$SEED_JOB" ]; then
|
if [ -f "$SEED_JOB" ]; then
|
||||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
||||||
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
|
|
||||||
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
|
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -394,26 +372,40 @@ jobs:
|
|||||||
- name: Create PR on groombook/infra
|
- name: Create PR on groombook/infra
|
||||||
env:
|
env:
|
||||||
TAG: ${{ needs.docker.outputs.tag }}
|
TAG: ${{ needs.docker.outputs.tag }}
|
||||||
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
GITEA_TOKEN: ${{ gitea.token }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$TAG" ]; then
|
if [ -z "$TAG" ]; then
|
||||||
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd /tmp/infra
|
cd /tmp/infra
|
||||||
git config user.name "groombook-engineer[bot]"
|
git config user.name "groombook-engineer[bot]"
|
||||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
git config user.email "groombook-engineer[bot]@git.farh.net"
|
||||||
git checkout -b "chore/update-image-tags-${TAG}"
|
git checkout -b "chore/update-image-tags-${TAG}"
|
||||||
git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
git add apps/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 commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
|
||||||
|
|
||||||
git push -u origin "chore/update-image-tags-${TAG}"
|
git push -u origin "chore/update-image-tags-${TAG}"
|
||||||
|
|
||||||
# Create PR and merge immediately (no required checks on groombook/infra)
|
EXISTING_PR=$(curl -s \
|
||||||
PR_URL=$(gh pr create \
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
--repo groombook/infra \
|
"https://git.farh.net/api/v1/repos/groombook/infra/pulls?state=open&limit=50" \
|
||||||
--base main \
|
| jq -r ".[] | select(.head.label == \"chore/update-image-tags-${TAG}\") | .number" | head -1)
|
||||||
--head "chore/update-image-tags-${TAG}" \
|
if [ -n "$EXISTING_PR" ]; then
|
||||||
--title "chore: deploy ${TAG} to dev" \
|
echo "PR #$EXISTING_PR already exists, merging"
|
||||||
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
curl -s -X POST \
|
||||||
gh pr merge "$PR_URL" --merge
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://git.farh.net/api/v1/repos/groombook/infra/pulls/$EXISTING_PR/merge" \
|
||||||
|
-d '{"Do":"merge"}'
|
||||||
|
else
|
||||||
|
PR_NUM=$(curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
|
||||||
|
-d "{\"head\":\"chore/update-image-tags-${TAG}\",\"base\":\"main\",\"title\":\"chore: deploy ${TAG} to dev\",\"body\":\"[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge\"}" \
|
||||||
|
| jq '.number')
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://git.farh.net/api/v1/repos/groombook/infra/pulls/$PR_NUM/merge" \
|
||||||
|
-d '{"Do":"merge"}'
|
||||||
|
fi
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: Release Helm Chart
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'charts/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout groombook
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Checkout groombook.dev (Helm chart host)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: groombook/groombook.dev
|
||||||
|
path: gitea-pages
|
||||||
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Install Helm
|
||||||
|
uses: azure/setup-helm@v4
|
||||||
|
|
||||||
|
- name: Update Helm dependencies
|
||||||
|
run: helm dependency update charts/groombook
|
||||||
|
|
||||||
|
- name: Package chart
|
||||||
|
run: |
|
||||||
|
mkdir -p gitea-pages/charts
|
||||||
|
helm package charts/groombook -d gitea-pages/charts
|
||||||
|
|
||||||
|
- name: Update repo index
|
||||||
|
run: |
|
||||||
|
# TODO: update URL once Gitea Pages hosting is confirmed
|
||||||
|
CHART_URL="${HELM_CHART_URL:-https://groombook.farh.net/charts}"
|
||||||
|
if [ -f gitea-pages/charts/index.yaml ]; then
|
||||||
|
helm repo index gitea-pages/charts --merge gitea-pages/charts/index.yaml --url "$CHART_URL"
|
||||||
|
else
|
||||||
|
helm repo index gitea-pages/charts --url "$CHART_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Push to groombook.dev
|
||||||
|
run: |
|
||||||
|
cd gitea-pages
|
||||||
|
git config user.name "groombook-engineer[bot]"
|
||||||
|
git config user.email "groombook-engineer[bot]@git.farh.net"
|
||||||
|
git add charts/
|
||||||
|
git diff --staged --quiet && echo 'No chart changes' && exit 0
|
||||||
|
git commit -m "Update Helm chart repository"
|
||||||
|
git push
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
name: Promote to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Image tag to promote (e.g. 2026.03.28-f1b85bf)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
promote:
|
||||||
|
name: Promote to Production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
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 Gitea Container Registry
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ gitea.token }}
|
||||||
|
run: |
|
||||||
|
TAG="${{ inputs.tag }}"
|
||||||
|
if ! curl -sf \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://git.farh.net/api/v1/packages/groombook?type=container&limit=50" \
|
||||||
|
| jq -e --arg t "$TAG" '[.[] | select(.name == "api" and .version == $t)] | length > 0' > /dev/null 2>&1; then
|
||||||
|
echo "::warning::Could not verify git.farh.net/groombook/api:$TAG via package API — verify manually if needed."
|
||||||
|
else
|
||||||
|
echo "Image verified: git.farh.net/groombook/api:$TAG exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Clone groombook/infra
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ gitea.token }}
|
||||||
|
run: |
|
||||||
|
git clone https://oauth2:$GITEA_TOKEN@git.farh.net/groombook/infra.git /tmp/infra
|
||||||
|
|
||||||
|
- name: Install yq
|
||||||
|
run: |
|
||||||
|
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
|
||||||
|
sudo chmod +x /usr/local/bin/yq
|
||||||
|
|
||||||
|
- name: Update prod overlay image tags and base Job names
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
run: |
|
||||||
|
cd /tmp/infra
|
||||||
|
PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml"
|
||||||
|
|
||||||
|
SHORT_SHA="${TAG##*-}"
|
||||||
|
export SHORT_SHA
|
||||||
|
export TAG
|
||||||
|
|
||||||
|
yq -i '(.images[] | select(.name == "git.farh.net/groombook/api")).newTag = env(TAG)' "$PROD_KUST"
|
||||||
|
yq -i '(.images[] | select(.name == "git.farh.net/groombook/web")).newTag = env(TAG)' "$PROD_KUST"
|
||||||
|
yq -i '(.images[] | select(.name == "git.farh.net/groombook/migrate")).newTag = env(TAG)' "$PROD_KUST"
|
||||||
|
yq -i '(.images[] | select(.name == "git.farh.net/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C /tmp/infra diff --stat
|
||||||
|
|
||||||
|
- name: Create PR on groombook/infra
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
GITEA_TOKEN: ${{ gitea.token }}
|
||||||
|
run: |
|
||||||
|
cd /tmp/infra
|
||||||
|
git config user.name "groombook-engineer[bot]"
|
||||||
|
git config user.email "groombook-engineer[bot]@git.farh.net"
|
||||||
|
git checkout -b "release/promote-prod-${TAG}"
|
||||||
|
git add apps/groombook/overlays/prod/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
||||||
|
git commit -m "release: promote ${TAG} to production"
|
||||||
|
git push -u origin "release/promote-prod-${TAG}"
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
|
||||||
|
-d "{\"head\":\"release/promote-prod-${TAG}\",\"base\":\"main\",\"title\":\"release: promote ${TAG} to production\",\"body\":\"Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood\"}"
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ gitea.token }}
|
||||||
|
RUN_ID: ${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://git.farh.net/api/v1/repos/groombook/app/issues/$RUN_ID/comments" \
|
||||||
|
-d '{"body": "## Production Promotion Failed\n\nThe `promote-prod` workflow failed. Check the workflow run logs for details."}'
|
||||||
@@ -12,20 +12,12 @@ jobs:
|
|||||||
promote-to-uat:
|
promote-to-uat:
|
||||||
name: Promote to groombook-uat
|
name: Promote to groombook-uat
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Generate infra repo token
|
|
||||||
id: infra-token
|
|
||||||
uses: tibdex/github-app-token@v2
|
|
||||||
with:
|
|
||||||
app_id: ${{ vars.GH_APP_ID }}
|
|
||||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
|
||||||
|
|
||||||
- name: Clone groombook/infra
|
- name: Clone groombook/infra
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ gitea.token }}
|
||||||
run: |
|
run: |
|
||||||
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
git clone https://oauth2:$GITEA_TOKEN@git.farh.net/groombook/infra.git /tmp/infra
|
||||||
|
|
||||||
- name: Install yq
|
- name: Install yq
|
||||||
run: |
|
run: |
|
||||||
@@ -49,21 +41,17 @@ jobs:
|
|||||||
export SHORT_SHA
|
export SHORT_SHA
|
||||||
export TAG
|
export TAG
|
||||||
|
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$UAT_KUST"
|
yq -i '(.images[] | select(.name == "git.farh.net/groombook/api")).newTag = env(TAG)' "$UAT_KUST"
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$UAT_KUST"
|
yq -i '(.images[] | select(.name == "git.farh.net/groombook/web")).newTag = env(TAG)' "$UAT_KUST"
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$UAT_KUST"
|
yq -i '(.images[] | select(.name == "git.farh.net/groombook/migrate")).newTag = env(TAG)' "$UAT_KUST"
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
|
yq -i '(.images[] | select(.name == "git.farh.net/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
|
||||||
|
|
||||||
# Update migrate Job name to include short SHA (immutable template fix)
|
|
||||||
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||||
if [ -f "$MIGRATE_JOB" ]; then
|
if [ -f "$MIGRATE_JOB" ]; then
|
||||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
||||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Update seed Job name to include short SHA (immutable template fix)
|
|
||||||
# NOTE: Do NOT update the image tag here — let the Kustomize images transformer
|
|
||||||
# in the UAT overlay handle it via newTag. This avoids the immutable template issue.
|
|
||||||
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||||
if [ -f "$SEED_JOB" ]; then
|
if [ -f "$SEED_JOB" ]; then
|
||||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||||
@@ -75,34 +63,36 @@ jobs:
|
|||||||
- name: Create PR on groombook/infra
|
- name: Create PR on groombook/infra
|
||||||
env:
|
env:
|
||||||
TAG: ${{ inputs.image_tag }}
|
TAG: ${{ inputs.image_tag }}
|
||||||
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
GITEA_TOKEN: ${{ gitea.token }}
|
||||||
run: |
|
run: |
|
||||||
cd /tmp/infra
|
cd /tmp/infra
|
||||||
git config user.name "groombook-engineer[bot]"
|
git config user.name "groombook-engineer[bot]"
|
||||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
git config user.email "groombook-engineer[bot]@git.farh.net"
|
||||||
git checkout -b "chore/update-uat-image-tags-${TAG}"
|
git checkout -b "chore/update-uat-image-tags-${TAG}"
|
||||||
git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
||||||
git commit -m "chore: promote ${TAG} to UAT"
|
git commit -m "chore: promote ${TAG} to UAT"
|
||||||
|
|
||||||
git push -u origin "chore/update-uat-image-tags-${TAG}"
|
git push -u origin "chore/update-uat-image-tags-${TAG}"
|
||||||
|
|
||||||
# Create PR and merge immediately (no required checks on groombook/infra)
|
PR_NUM=$(curl -s -X POST \
|
||||||
PR_URL=$(gh pr create \
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
--repo groombook/infra \
|
-H "Content-Type: application/json" \
|
||||||
--base main \
|
"https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
|
||||||
--head "chore/update-uat-image-tags-${TAG}" \
|
-d "{\"head\":\"chore/update-uat-image-tags-${TAG}\",\"base\":\"main\",\"title\":\"chore: promote ${TAG} to UAT\",\"body\":\"[GRO-429](/GRO/issues/GRO-429) — UAT promotion triggered by CTO\"}" \
|
||||||
--title "chore: promote ${TAG} to UAT" \
|
| jq '.number')
|
||||||
--body "[GRO-429](/GRO/issues/GRO-429) — UAT promotion triggered by CTO")
|
curl -s -X POST \
|
||||||
gh pr merge "$PR_URL" --merge
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://git.farh.net/api/v1/repos/groombook/infra/pulls/$PR_NUM/merge" \
|
||||||
|
-d '{"Do":"merge"}'
|
||||||
|
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/github-script@v7
|
env:
|
||||||
with:
|
GITEA_TOKEN: ${{ gitea.token }}
|
||||||
script: |
|
RUN_ID: ${{ github.run_id }}
|
||||||
github.rest.issues.createComment({
|
run: |
|
||||||
owner: context.repo.owner,
|
curl -s -X POST \
|
||||||
repo: context.repo.repo,
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
issue_number: context.issue.number,
|
-H "Content-Type: application/json" \
|
||||||
body: '## UAT Promotion Failed\n\nThe `promote-to-uat` workflow failed. Check the workflow run logs for details.\n\nCommon issues:\n- UAT overlay not found (ensure GRO-427 is complete)\n- Infra repo access token expired'
|
"https://git.farh.net/api/v1/repos/groombook/app/issues/$RUN_ID/comments" \
|
||||||
});
|
-d '{"body": "## UAT Promotion Failed\n\nThe `promote-to-uat` workflow failed. Check the workflow run logs for details.\n\nCommon issues:\n- UAT overlay not found (ensure GRO-427 is complete)\n- GITEA_TOKEN permissions"}'
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
name: Release Helm Chart
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'charts/**'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout groombook
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Checkout groombook.github.io
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: groombook/groombook.github.io
|
|
||||||
path: gh-pages
|
|
||||||
token: ${{ secrets.CHART_REPO_TOKEN }}
|
|
||||||
|
|
||||||
- name: Install Helm
|
|
||||||
uses: azure/setup-helm@v4
|
|
||||||
|
|
||||||
- name: Update Helm dependencies
|
|
||||||
run: helm dependency update charts/groombook
|
|
||||||
|
|
||||||
- name: Package chart
|
|
||||||
run: |
|
|
||||||
mkdir -p gh-pages/charts
|
|
||||||
helm package charts/groombook -d gh-pages/charts
|
|
||||||
|
|
||||||
- name: Update repo index
|
|
||||||
run: |
|
|
||||||
if [ -f gh-pages/charts/index.yaml ]; then
|
|
||||||
helm repo index gh-pages/charts --merge gh-pages/charts/index.yaml --url https://groombook.github.io/charts
|
|
||||||
else
|
|
||||||
helm repo index gh-pages/charts --url https://groombook.github.io/charts
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Push to groombook.github.io
|
|
||||||
run: |
|
|
||||||
cd gh-pages
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add charts/
|
|
||||||
git diff --staged --quiet && echo 'No chart changes' && exit 0
|
|
||||||
git commit -m "Update Helm chart repository"
|
|
||||||
git push
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
name: Promote to Production
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: "Image tag to promote (e.g. 2026.03.28-f1b85bf)"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
promote:
|
|
||||||
name: Promote to Production
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Generate infra repo token
|
|
||||||
id: infra-token
|
|
||||||
uses: tibdex/github-app-token@v2
|
|
||||||
with:
|
|
||||||
app_id: ${{ vars.GH_APP_ID }}
|
|
||||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
|
||||||
|
|
||||||
- name: Clone groombook/infra
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
|
|
||||||
|
|
||||||
- name: Install yq
|
|
||||||
run: |
|
|
||||||
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
|
|
||||||
sudo chmod +x /usr/local/bin/yq
|
|
||||||
|
|
||||||
- name: Update prod overlay image tags and base Job names
|
|
||||||
env:
|
|
||||||
TAG: ${{ inputs.tag }}
|
|
||||||
run: |
|
|
||||||
cd /tmp/infra
|
|
||||||
PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml"
|
|
||||||
|
|
||||||
SHORT_SHA="${TAG##*-}"
|
|
||||||
export SHORT_SHA
|
|
||||||
export TAG
|
|
||||||
|
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$PROD_KUST"
|
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$PROD_KUST"
|
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$PROD_KUST"
|
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
|
|
||||||
|
|
||||||
# Update migrate Job name to include short SHA (immutable template fix)
|
|
||||||
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
|
||||||
if [ -f "$MIGRATE_JOB" ]; then
|
|
||||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
|
||||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update seed Job name to include short SHA (immutable template fix)
|
|
||||||
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
|
||||||
if [ -f "$SEED_JOB" ]; then
|
|
||||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
|
||||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
|
||||||
fi
|
|
||||||
|
|
||||||
git -C /tmp/infra diff --stat
|
|
||||||
|
|
||||||
- name: Create PR on groombook/infra
|
|
||||||
env:
|
|
||||||
TAG: ${{ inputs.tag }}
|
|
||||||
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
|
|
||||||
run: |
|
|
||||||
cd /tmp/infra
|
|
||||||
git config user.name "groombook-engineer[bot]"
|
|
||||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
|
||||||
git checkout -b "release/promote-prod-${TAG}"
|
|
||||||
git add apps/groombook/overlays/prod/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
|
||||||
git commit -m "release: promote ${TAG} to production"
|
|
||||||
git push -u origin "release/promote-prod-${TAG}"
|
|
||||||
gh pr create \
|
|
||||||
--repo groombook/infra \
|
|
||||||
--base main \
|
|
||||||
--head "release/promote-prod-${TAG}" \
|
|
||||||
--title "release: promote ${TAG} to production" \
|
|
||||||
--body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood"
|
|
||||||
|
|
||||||
- name: Notify on failure
|
|
||||||
if: failure()
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
body: '## Production Promotion Failed\n\nThe `promote-prod` workflow failed. Check the workflow run logs for details.'
|
|
||||||
});
|
|
||||||
+13
@@ -8,3 +8,16 @@ dist/
|
|||||||
.turbo/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
minimax-output/
|
minimax-output/
|
||||||
|
|
||||||
|
# Agent runtime artifacts — never commit
|
||||||
|
.gh-token
|
||||||
|
*.gh-token
|
||||||
|
.config/gh/
|
||||||
|
**/.config/gh/
|
||||||
|
infra-repo
|
||||||
|
infra-repo/
|
||||||
|
**/instructions/.gh-token
|
||||||
|
**/AGENT_HOME/**
|
||||||
|
$AGENT_HOME/**
|
||||||
|
.claude/
|
||||||
|
.codex/
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"gitea": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://git-mcp.farh.net/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${GITEA_TOKEN}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Contributing to GroomBook
|
||||||
|
|
||||||
|
## Branch Strategy
|
||||||
|
|
||||||
|
GroomBook uses a three-branch GitOps model:
|
||||||
|
|
||||||
|
| Branch | Environment | Purpose |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `dev` | Development | Active development target — all feature/fix PRs target this branch |
|
||||||
|
| `uat` | UAT / Staging | Promoted from `dev` by the CTO for acceptance testing |
|
||||||
|
| `main` | Production | Promoted from `uat` by the CEO; triggers production deployment |
|
||||||
|
|
||||||
|
**Never open a PR directly to `uat` or `main`.** All work flows through `dev` first.
|
||||||
|
|
||||||
|
## Developer Workflow
|
||||||
|
|
||||||
|
1. **Branch from `dev`** — create a feature or fix branch:
|
||||||
|
```bash
|
||||||
|
git checkout dev
|
||||||
|
git pull origin dev
|
||||||
|
git checkout -b feat/my-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Open a PR targeting `dev`** — include the issue identifier in the title and cc @cpfarhood:
|
||||||
|
```bash
|
||||||
|
gh pr create --base dev --title "feat: description (GRO-NNN)" \
|
||||||
|
--body $'Closes GRO-NNN\n\ncc @cpfarhood'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Pipeline gates before merge to `dev`:**
|
||||||
|
- QA (Lint Roller) reviews first — code quality, test coverage, CI pass
|
||||||
|
- CTO (The Dogfather) reviews second — architecture and final approval
|
||||||
|
- Both must approve; 2 approving reviews required by branch protection
|
||||||
|
|
||||||
|
## Promotion Flow
|
||||||
|
|
||||||
|
### Dev → UAT
|
||||||
|
|
||||||
|
After merging to `dev`, the CTO opens a PR from `dev` → `uat`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr create --base uat --head dev \
|
||||||
|
--title "chore: promote dev to uat (YYYY.MM.DD)" \
|
||||||
|
--body $'Promoting dev to UAT for regression and security review.\n\ncc @cpfarhood'
|
||||||
|
```
|
||||||
|
|
||||||
|
Gates:
|
||||||
|
- Shedward Scissorhands runs regression/acceptance tests
|
||||||
|
- Barkley Trimsworth performs security review
|
||||||
|
- CTO approves and merges (1 approving review required)
|
||||||
|
|
||||||
|
### UAT → Main (Production)
|
||||||
|
|
||||||
|
After UAT passes, the CTO opens a PR from `uat` → `main` and assigns it to the CEO:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr create --base main --head uat \
|
||||||
|
--title "chore: promote uat to main (YYYY.MM.DD)" \
|
||||||
|
--body $'Promoting UAT to production.\n\ncc @cpfarhood'
|
||||||
|
```
|
||||||
|
|
||||||
|
Gates:
|
||||||
|
- CEO (Scrubs McBarkley) reviews for business alignment and merges
|
||||||
|
- 1 approving review required; triggers auto-deploy to Production
|
||||||
|
|
||||||
|
## Branch Protection Summary
|
||||||
|
|
||||||
|
| Branch | Required Approvals | Who approves |
|
||||||
|
|--------|--------------------|-------------|
|
||||||
|
| `dev` | 2 | QA (Lint Roller) + CTO (The Dogfather) |
|
||||||
|
| `uat` | 1 | CTO (The Dogfather) |
|
||||||
|
| `main` | 1 | CEO (Scrubs McBarkley) |
|
||||||
|
|
||||||
|
Force-pushes and branch deletions are disabled on all three branches.
|
||||||
|
|
||||||
|
## Commit Style
|
||||||
|
|
||||||
|
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
- `feat:` — new feature
|
||||||
|
- `fix:` — bug fix
|
||||||
|
- `chore:` — maintenance (dependency updates, build config, promotions)
|
||||||
|
- `docs:` — documentation only
|
||||||
|
- `ci:` — CI/CD changes
|
||||||
|
- `refactor:` — code restructure without behaviour change
|
||||||
|
|
||||||
|
Reference the Paperclip issue in the commit body: `Refs GRO-NNN`.
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Open a Paperclip issue in the GRO project or ask in the team channel.
|
||||||
@@ -1,218 +1,43 @@
|
|||||||
# GroomBook
|
# GroomBook Monorepo — Archived
|
||||||
|
|
||||||
> **The open-source scheduling and client management platform built specifically for independent pet groomers** — giving you the tools of enterprise software without the enterprise price tag or vendor lock-in.
|
> **This repository has been archived and replaced by standalone repositories.**
|
||||||
|
|
||||||
**Built for groomers, not corporations.**
|
## Successor Repositories
|
||||||
|
|
||||||
---
|
| Repository | Description |
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
**Stop chasing confirmations**
|
|
||||||
- **Customer portal** — Clients confirm or cancel appointments on their own. Reduce no-shows with an automated waitlist.
|
|
||||||
|
|
||||||
**Your calendar, your way**
|
|
||||||
- **iCal calendar feed** — Push GroomBook appointments directly into Google Calendar or Apple Calendar. No app switching.
|
|
||||||
|
|
||||||
**Know every pet at a glance**
|
|
||||||
- **Client & pet records** — Detailed profiles with grooming history, preferences, and breed-specific notes. Full appointment notes for context on every regular.
|
|
||||||
- **Quick-find search** — Find clients and pets instantly without digging through spreadsheets.
|
|
||||||
|
|
||||||
**Staff access without stress**
|
|
||||||
- **Role-based access control (RBAC)** — Front desk sees bookings; only you see financials. Right access for every role.
|
|
||||||
|
|
||||||
**Everything else**
|
|
||||||
- **Appointment scheduling** — Calendar management for single or multiple groomers
|
|
||||||
- **Service management** — Pricing, duration, and service catalog
|
|
||||||
- **POS & invoicing** — Payments, tips, and receipt generation
|
|
||||||
- **Automated reminders** — SMS and email notifications
|
|
||||||
- **Reporting dashboard** — Revenue, utilization, and trend analytics
|
|
||||||
- **Staff impersonation** — Managers can view the customer portal as any client, with full audit logging and session controls
|
|
||||||
- **PWA** — Installable on mobile devices, works offline
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Try the Demo
|
|
||||||
|
|
||||||
[**Live Demo**](https://demo.groombook.app) — explore GroomBook without installing anything.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Docker Compose (recommended for indie groomers)
|
|
||||||
|
|
||||||
Run GroomBook on your own hardware in minutes. Everything you need is in the box — no subscription, no vendor lock-in.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/groombook/groombook.git
|
|
||||||
cd groombook
|
|
||||||
|
|
||||||
# Start everything (Postgres + database migrations + API + web UI)
|
|
||||||
docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Web UI**: http://localhost:8080
|
|
||||||
- **API**: http://localhost:3000
|
|
||||||
|
|
||||||
The default `docker-compose.yml` sets `AUTH_DISABLED=true` so you can explore the app without configuring an OIDC provider. **Important:** Disable this in any internet-facing deployment.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Backend | [Hono](https://hono.dev/) (TypeScript, Node.js) |
|
| [groombook/api](https://github.com/groombook/api) | Hono REST API (TypeScript, Node.js) |
|
||||||
| Frontend | React 19 + Vite + [vite-plugin-pwa](https://vite-pwa-org.netlify.app/) |
|
| [groombook/web](https://github.com/groombook/web) | React PWA frontend |
|
||||||
| Database | PostgreSQL via [CNPG](https://cloudnative-pg.io/) + [Drizzle ORM](https://orm.drizzle.team/) |
|
| [groombook/charts](https://github.com/groombook/charts) | Helm charts for Kubernetes deployment |
|
||||||
| Auth | OIDC via [Authentik](https://goauthentik.io/) |
|
|
||||||
| Infra | Kubernetes (namespace: `groombook`), Flux GitOps |
|
|
||||||
| CI | GitHub Actions (self-hosted `groombook-runners`) |
|
|
||||||
|
|
||||||
## Repository Structure
|
## What Changed
|
||||||
|
|
||||||
```
|
- **Monorepo split complete** — The former `apps/api`, `apps/web`, and `packages/*` are now standalone repos
|
||||||
groombook/
|
- **`@groombook/types`** — Inlined directly into `groombook/api` and `groombook/web`
|
||||||
├── apps/
|
- **E2E testing** — Now via Playwright MCP, no standalone repo needed
|
||||||
│ ├── api/ # Hono REST API
|
- **CI/CD** — Each repo has its own pipeline; see individual repos for status
|
||||||
│ └── web/ # React PWA
|
|
||||||
├── packages/
|
|
||||||
│ ├── db/ # Drizzle schema + migrations
|
|
||||||
│ └── types/ # Shared TypeScript types
|
|
||||||
├── .github/
|
|
||||||
│ └── workflows/ # CI/CD pipelines
|
|
||||||
└── docker-compose.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
## Getting Started
|
## Migration Notes
|
||||||
|
|
||||||
### Prerequisites
|
If you were cloning `groombook/groombook` for local development:
|
||||||
|
|
||||||
- Node.js >= 20
|
|
||||||
- pnpm >= 9 (`npm install -g pnpm`)
|
|
||||||
- Docker & Docker Compose (for local Postgres)
|
|
||||||
|
|
||||||
### Local Development
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repo
|
# API
|
||||||
git clone https://github.com/groombook/groombook.git
|
git clone https://github.com/groombook/api.git
|
||||||
cd groombook
|
cd api && pnpm install && pnpm dev
|
||||||
|
|
||||||
# Install dependencies
|
# Web (in a new terminal)
|
||||||
pnpm install
|
git clone https://github.com/groombook/web.git
|
||||||
|
cd web && pnpm install && pnpm dev
|
||||||
# Start local Postgres
|
|
||||||
docker compose up postgres -d
|
|
||||||
|
|
||||||
# Run database migrations
|
|
||||||
DATABASE_URL=postgres://groombook:groombook@localhost:5432/groombook pnpm db:migrate
|
|
||||||
|
|
||||||
# Start API and Web in parallel
|
|
||||||
pnpm dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
API will be available at http://localhost:3000
|
For full Docker Compose setup, see each repo's README.
|
||||||
Web will be available at http://localhost:5173
|
|
||||||
|
|
||||||
### Environment Variables
|
## Archive Info
|
||||||
|
|
||||||
#### API (`apps/api/.env`)
|
This repository was archived on 2026-05-14 as part of the monorepo decommission ([GRO-1081]).
|
||||||
|
The history is preserved but the repo is read-only.
|
||||||
```env
|
|
||||||
DATABASE_URL=postgres://groombook:groombook@localhost:5432/groombook
|
|
||||||
OIDC_ISSUER=https://authentik.example.com
|
|
||||||
OIDC_AUDIENCE=groombook
|
|
||||||
CORS_ORIGIN=http://localhost:5173
|
|
||||||
PORT=3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Unit tests (vitest)
|
|
||||||
pnpm test
|
|
||||||
|
|
||||||
# E2E tests (Playwright) — requires the full Docker Compose stack to be running
|
|
||||||
docker compose up -d --wait
|
|
||||||
pnpm --filter @groombook/e2e test
|
|
||||||
|
|
||||||
# Open the Playwright UI (interactive test runner)
|
|
||||||
pnpm --filter @groombook/e2e test:ui
|
|
||||||
|
|
||||||
# View the last E2E test report
|
|
||||||
pnpm --filter @groombook/e2e test:report
|
|
||||||
```
|
|
||||||
|
|
||||||
E2E tests target the Docker Compose stack (`http://localhost:8080`). They use API route mocking where needed so happy-path tests are deterministic without requiring seed data.
|
|
||||||
|
|
||||||
### Building
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Self-Hosting
|
|
||||||
|
|
||||||
### Production Configuration
|
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and configure:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Key variables to update for production:
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|---|---|
|
|
||||||
| `DATABASE_URL` | PostgreSQL connection string |
|
|
||||||
| `AUTH_DISABLED` | Set to `false` in production |
|
|
||||||
| `OIDC_ISSUER` | Authentik issuer URL |
|
|
||||||
| `OIDC_AUDIENCE` | OAuth2 audience (default: `groombook`) |
|
|
||||||
| `CORS_ORIGIN` | Public URL of the web frontend |
|
|
||||||
|
|
||||||
To use your `.env` file with Docker Compose:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file .env up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kubernetes (production-grade deployments)
|
|
||||||
|
|
||||||
See the [groombook/infra](https://github.com/groombook/infra) repository for Kubernetes manifests and Flux configuration.
|
|
||||||
|
|
||||||
Groom Book is deployed in the `groombook` Kubernetes namespace using:
|
|
||||||
- **CNPG** for PostgreSQL
|
|
||||||
- **Authentik** for OIDC authentication
|
|
||||||
- **Flux** for GitOps-managed deployments
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contributing
|
*For Kubernetes deployments, see [groombook/infra](https://github.com/groombook/infra) (private).*
|
||||||
|
|
||||||
GroomBook thrives on contributions from the grooming community. Whether you're a groomer with a feature request, a developer fixing a bug, or someone improving docs — we'd love your help.
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch (`git checkout -b feature/my-feature`)
|
|
||||||
3. Commit your changes
|
|
||||||
4. Open a pull request
|
|
||||||
|
|
||||||
All PRs require CI to pass before merge. See [CONTRIBUTING.md](./CONTRIBUTING.md) for details.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why GroomBook?
|
|
||||||
|
|
||||||
- **Open source** — You own your data. No vendor lock-in.
|
|
||||||
- **Purpose-built** — Features designed for grooming workflows, not generic scheduling.
|
|
||||||
- **Self-hosted or managed** — Run it yourself for free, or pay for hosted support (coming soon).
|
|
||||||
- **Community-driven** — Used and built by actual groomers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
AGPL-3.0
|
|
||||||
|
|
||||||
+5
-1
@@ -12,6 +12,7 @@ RUN pnpm install --frozen-lockfile
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
FROM deps AS builder
|
FROM deps AS builder
|
||||||
|
RUN mkdir -p /home/node/.cache/node/corepack
|
||||||
COPY packages/ packages/
|
COPY packages/ packages/
|
||||||
COPY apps/api/ apps/api/
|
COPY apps/api/ apps/api/
|
||||||
RUN pnpm --filter @groombook/types build && \
|
RUN pnpm --filter @groombook/types build && \
|
||||||
@@ -34,6 +35,9 @@ COPY --from=builder /app/packages/types/dist packages/types/dist
|
|||||||
RUN pnpm install --frozen-lockfile --prod
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
EXPOSE 3000
|
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"]
|
CMD ["node", "apps/api/dist/index.js"]
|
||||||
|
|
||||||
# Migrate stage — runs drizzle-kit migrate against the database
|
# Migrate stage — runs drizzle-kit migrate against the database
|
||||||
@@ -46,4 +50,4 @@ CMD ["pnpm", "db:seed"]
|
|||||||
|
|
||||||
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
||||||
FROM builder AS reset
|
FROM builder AS reset
|
||||||
CMD ["pnpm", "db:reset"]
|
CMD ["pnpm", "db:reset"]
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"stripe": "^22.0.0",
|
"stripe": "^22.0.0",
|
||||||
|
"telnyx": "^1.23.0",
|
||||||
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ const DISABLED_CLIENT = {
|
|||||||
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
|
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
let selectRows: Record<string, unknown>[] = [];
|
let selectRows: Record<string, unknown>[] = [];
|
||||||
|
let appointmentRows: Record<string, unknown>[] = [];
|
||||||
let insertedValues: Record<string, unknown>[] = [];
|
let insertedValues: Record<string, unknown>[] = [];
|
||||||
let updatedValues: Record<string, unknown>[] = [];
|
let updatedValues: Record<string, unknown>[] = [];
|
||||||
let deletedId: string | null = null;
|
let deletedId: string | null = null;
|
||||||
|
|
||||||
function resetMock() {
|
function resetMock() {
|
||||||
selectRows = [];
|
selectRows = [];
|
||||||
|
appointmentRows = [];
|
||||||
insertedValues = [];
|
insertedValues = [];
|
||||||
updatedValues = [];
|
updatedValues = [];
|
||||||
deletedId = null;
|
deletedId = null;
|
||||||
@@ -58,10 +60,19 @@ vi.mock("@groombook/db", () => {
|
|||||||
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
{ 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 {
|
return {
|
||||||
getDb: () => ({
|
getDb: () => ({
|
||||||
select: () => ({
|
select: () => ({
|
||||||
from: () => makeChainable(selectRows),
|
from: (table: unknown) => {
|
||||||
|
const tableName = (table as { _name?: string })._name;
|
||||||
|
const rows = tableName === "appointments" ? appointmentRows : selectRows;
|
||||||
|
return makeChainable(rows);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
insert: () => ({
|
insert: () => ({
|
||||||
values: (vals: Record<string, unknown>) => {
|
values: (vals: Record<string, unknown>) => {
|
||||||
@@ -95,8 +106,10 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
clients,
|
clients,
|
||||||
|
appointments,
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
and: vi.fn(),
|
and: vi.fn(),
|
||||||
|
or: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,10 +195,11 @@ describe("POST /clients", () => {
|
|||||||
expect(insertedValues[0]!.name).toBe("Charlie");
|
expect(insertedValues[0]!.name).toBe("Charlie");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a client with only required name field", async () => {
|
it("creates a client with name and email", async () => {
|
||||||
const res = await jsonRequest("POST", "/clients", { name: "Dana" });
|
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(insertedValues[0]!.name).toBe("Dana");
|
expect(insertedValues[0]!.name).toBe("Dana");
|
||||||
|
expect(insertedValues[0]!.email).toBe("dana@example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects empty name", async () => {
|
it("rejects empty name", async () => {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
appointments,
|
appointments,
|
||||||
eq: () => ({}),
|
eq: () => ({}),
|
||||||
|
and: (..._clauses: unknown[]) => ({}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
staff,
|
staff,
|
||||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||||
|
and: vi.fn((..._clauses: unknown[]) => ({})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -362,7 +363,7 @@ describe("requireRoleOrSuperUser", () => {
|
|||||||
const res = await app.request("/test");
|
const res = await app.request("/test");
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/super user privileges required/i);
|
expect(body.error).toMatch(/role.*not permitted/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
||||||
@@ -370,7 +371,7 @@ describe("requireRoleOrSuperUser", () => {
|
|||||||
const res = await app.request("/test");
|
const res = await app.request("/test");
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/super user privileges required/i);
|
expect(body.error).toMatch(/role.*not permitted/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows a manager with multiple allowed roles", async () => {
|
it("allows a manager with multiple allowed roles", async () => {
|
||||||
|
|||||||
+115
-14
@@ -19,7 +19,7 @@ import { impersonationRouter } from "./routes/impersonation.js";
|
|||||||
import { settingsRouter } from "./routes/settings.js";
|
import { settingsRouter } from "./routes/settings.js";
|
||||||
import { authProviderRouter } from "./routes/authProvider.js";
|
import { authProviderRouter } from "./routes/authProvider.js";
|
||||||
import { searchRouter } from "./routes/search.js";
|
import { searchRouter } from "./routes/search.js";
|
||||||
import { getPresignedGetUrl } from "./lib/s3.js";
|
import { getObject } from "./lib/s3.js";
|
||||||
import { calendarRouter } from "./routes/calendar.js";
|
import { calendarRouter } from "./routes/calendar.js";
|
||||||
import { setupRouter } from "./routes/setup.js";
|
import { setupRouter } from "./routes/setup.js";
|
||||||
import { getDb, businessSettings, eq, staff } from "@groombook/db";
|
import { getDb, businessSettings, eq, staff } from "@groombook/db";
|
||||||
@@ -33,11 +33,26 @@ import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
|||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
// Global middleware
|
// 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("*", logger());
|
||||||
app.use(
|
app.use(
|
||||||
"/api/*",
|
"/api/*",
|
||||||
cors({
|
cors({
|
||||||
origin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
|
origin: (origin, ctx) => {
|
||||||
|
if (!origin) {
|
||||||
|
return ALLOWED_ORIGIN;
|
||||||
|
}
|
||||||
|
if (TRUSTED_ORIGINS.includes(origin)) {
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
ctx.status(403);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -57,28 +72,99 @@ app.route("/api/webhooks/stripe", webhooksRouter);
|
|||||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||||
app.route("/api/dev", devRouter);
|
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
|
// Public branding endpoint — no auth required, returns business name/colors/logo
|
||||||
app.get("/api/branding", async (c) => {
|
app.get("/api/branding", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const [row] = await db.select().from(businessSettings).limit(1);
|
const [row] = await db.select().from(businessSettings).limit(1);
|
||||||
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null };
|
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null };
|
||||||
|
|
||||||
let logoUrl: string | null = null;
|
// Return the public proxy path so browser never sees a raw S3 URL
|
||||||
if (settings.logoKey) {
|
const logoUrl = settings.logoKey ? "/api/branding/logo" : null;
|
||||||
try {
|
|
||||||
logoUrl = await getPresignedGetUrl(settings.logoKey);
|
// Defensive: validate magic bytes to prevent MIME type confusion attacks
|
||||||
} catch {
|
// via the legacy base64 logo fields
|
||||||
// If S3 URL generation fails, fall back to legacy base64
|
const badField = validateLogoMagicBytes(settings.logoBase64 ?? null, settings.logoMimeType ?? null);
|
||||||
}
|
const safeLogoBase64 = badField === "logoBase64" ? null : settings.logoBase64;
|
||||||
}
|
const safeLogoMimeType = badField === "logoMimeType" ? null : settings.logoMimeType;
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
businessName: settings.businessName,
|
businessName: settings.businessName,
|
||||||
primaryColor: settings.primaryColor,
|
primaryColor: settings.primaryColor,
|
||||||
accentColor: settings.accentColor,
|
accentColor: settings.accentColor,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
logoBase64: settings.logoBase64,
|
logoBase64: safeLogoBase64,
|
||||||
logoMimeType: settings.logoMimeType,
|
logoMimeType: safeLogoMimeType,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,7 +213,7 @@ api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"
|
|||||||
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
||||||
api.use("/admin/settings/*", requireSuperUser());
|
api.use("/admin/settings/*", requireSuperUser());
|
||||||
api.use("/reports/*", requireRole("manager"));
|
api.use("/reports/*", requireRole("manager"));
|
||||||
api.use("/invoices/*", requireRole("manager"));
|
api.use("/invoices/*", requireRole("manager", "groomer"));
|
||||||
api.use("/impersonation/*", requireRole("manager"));
|
api.use("/impersonation/*", requireRole("manager"));
|
||||||
|
|
||||||
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
|
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
|
||||||
@@ -187,9 +273,24 @@ api.route("/search", searchRouter);
|
|||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
await initAuth();
|
await initAuth();
|
||||||
console.log(`API server listening on port ${port}`);
|
console.log(`API server listening on port ${port}`);
|
||||||
serve({ fetch: app.fetch, port });
|
const server = serve({ fetch: app.fetch, port });
|
||||||
|
|
||||||
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
||||||
startReminderScheduler();
|
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;
|
export default app;
|
||||||
|
|||||||
@@ -89,13 +89,16 @@ export async function initAuth(): Promise<void> {
|
|||||||
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
||||||
authInstance = betterAuth({
|
authInstance = betterAuth({
|
||||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||||
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
secret: BETTER_AUTH_SECRET!,
|
||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
max: 10,
|
max: 100,
|
||||||
window: 60,
|
window: 10,
|
||||||
storage: "memory",
|
storage: "memory",
|
||||||
|
customRules: {
|
||||||
|
"/get-session": false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
@@ -177,9 +180,9 @@ export async function initAuth(): Promise<void> {
|
|||||||
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
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 hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||||
|
|
||||||
// Fetch OIDC discovery document to derive canonical provider URLs.
|
const issuerUrlObj = new URL(providerConfig.issuerUrl);
|
||||||
// Replace the host of token/userinfo endpoints with internalBaseUrl when set,
|
const issuerHostname = issuerUrlObj.hostname;
|
||||||
// while keeping authorizationUrl public for browser redirects.
|
|
||||||
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
||||||
let oidcConfig: Record<string, string> = {};
|
let oidcConfig: Record<string, string> = {};
|
||||||
try {
|
try {
|
||||||
@@ -203,6 +206,14 @@ export async function initAuth(): Promise<void> {
|
|||||||
const tokenUrl = discovery.token_endpoint;
|
const tokenUrl = discovery.token_endpoint;
|
||||||
const userInfoUrl = discovery.userinfo_endpoint;
|
const userInfoUrl = discovery.userinfo_endpoint;
|
||||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
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 = {
|
oidcConfig = {
|
||||||
authorizationUrl: authzUrl,
|
authorizationUrl: authzUrl,
|
||||||
tokenUrl: providerConfig.internalBaseUrl
|
tokenUrl: providerConfig.internalBaseUrl
|
||||||
@@ -232,9 +243,12 @@ export async function initAuth(): Promise<void> {
|
|||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
max: 10,
|
max: 100,
|
||||||
window: 60,
|
window: 10,
|
||||||
storage: "memory",
|
storage: "memory",
|
||||||
|
customRules: {
|
||||||
|
"/get-session": false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
storeStateStrategy: "cookie" as const,
|
storeStateStrategy: "cookie" as const,
|
||||||
|
|||||||
@@ -67,3 +67,41 @@ export async function deleteObject(key: string): Promise<void> {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read an object from S3 and return its body buffer and content type. */
|
||||||
|
export async function getObject(key: string): Promise<{ body: Buffer; contentType: string }> {
|
||||||
|
const client = getS3Client();
|
||||||
|
const response = await client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: getBucket(),
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
// response.Body is a Readable stream; collect chunks into a buffer
|
||||||
|
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
const body = Buffer.concat(chunks);
|
||||||
|
const contentType = response.ContentType ?? "application/octet-stream";
|
||||||
|
return { body, contentType };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */
|
||||||
|
export async function putObject(
|
||||||
|
key: string,
|
||||||
|
body: Buffer | Uint8Array | string,
|
||||||
|
contentType: string,
|
||||||
|
contentLength: number
|
||||||
|
): Promise<void> {
|
||||||
|
const client = getS3Client();
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: getBucket(),
|
||||||
|
Key: key,
|
||||||
|
Body: body,
|
||||||
|
ContentType: contentType,
|
||||||
|
ContentLength: contentLength,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { MiddlewareHandler } from "hono";
|
||||||
|
import { getDb, impersonationAuditLogs } from "@groombook/db";
|
||||||
|
import type { PortalEnv } from "./portalSession.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side audit logging middleware for portal routes.
|
||||||
|
* Applied after validatePortalSession in the middleware chain.
|
||||||
|
*
|
||||||
|
* After the route handler completes (await next()), inserts an audit log entry
|
||||||
|
* into impersonationAuditLogs:
|
||||||
|
* - sessionId: from c.get("portalSessionId")
|
||||||
|
* - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments")
|
||||||
|
* - pageVisited: c.req.path
|
||||||
|
* - metadata: { method, statusCode: c.res.status }
|
||||||
|
*
|
||||||
|
* Log entries are written for both success and error responses.
|
||||||
|
* Does NOT throw if audit logging fails — errors are logged but the user's
|
||||||
|
* request is not affected.
|
||||||
|
*/
|
||||||
|
export const portalAudit: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||||
|
await next();
|
||||||
|
|
||||||
|
const sessionId = c.get("portalSessionId");
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
const method = c.req.method;
|
||||||
|
const routePath = c.req.path;
|
||||||
|
const pageVisited = c.req.path;
|
||||||
|
const statusCode = c.res.status;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
await db
|
||||||
|
.insert(impersonationAuditLogs)
|
||||||
|
.values({
|
||||||
|
sessionId,
|
||||||
|
action: `${method} ${routePath}`,
|
||||||
|
pageVisited,
|
||||||
|
metadata: { method, statusCode },
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[portalAudit] Failed to write audit log:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { MiddlewareHandler } from "hono";
|
||||||
|
import { and, eq, getDb, impersonationSessions } from "@groombook/db";
|
||||||
|
|
||||||
|
export interface PortalEnv {
|
||||||
|
Variables: {
|
||||||
|
portalClientId: string;
|
||||||
|
portalSessionId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
|
||||||
|
* Must be applied to all portal routes.
|
||||||
|
*
|
||||||
|
* Reads x-session-id from request headers, queries impersonationSessions for a row where
|
||||||
|
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
||||||
|
* Returns 401 if session is invalid/missing/expired.
|
||||||
|
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
|
||||||
|
*/
|
||||||
|
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!session || session.expiresAt <= new Date()) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set("portalClientId", session.clientId);
|
||||||
|
c.set("portalSessionId", session.id);
|
||||||
|
await next();
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { eq, getDb, staff } from "@groombook/db";
|
import { and, eq, getDb, sql, staff } from "@groombook/db";
|
||||||
|
|
||||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||||
export type StaffRow = typeof staff.$inferSelect;
|
export type StaffRow = typeof staff.$inferSelect;
|
||||||
@@ -89,14 +89,31 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
.select()
|
.select()
|
||||||
.from(staff)
|
.from(staff)
|
||||||
.where(eq(staff.oidcSub, jwt.sub));
|
.where(eq(staff.oidcSub, jwt.sub));
|
||||||
if (!fallbackRow) {
|
if (fallbackRow) {
|
||||||
return c.json(
|
c.set("staff", fallbackRow);
|
||||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
await next();
|
||||||
403
|
return;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
c.set("staff", fallbackRow);
|
// Auto-link by email: staff record exists with matching email but no userId
|
||||||
await next();
|
if (jwt.email) {
|
||||||
|
const [byEmail] = await db
|
||||||
|
.select()
|
||||||
|
.from(staff)
|
||||||
|
.where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`));
|
||||||
|
if (byEmail) {
|
||||||
|
await db
|
||||||
|
.update(staff)
|
||||||
|
.set({ userId: jwt.sub, updatedAt: new Date() })
|
||||||
|
.where(eq(staff.id, byEmail.id));
|
||||||
|
c.set("staff", { ...byEmail, userId: jwt.sub });
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.json(
|
||||||
|
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||||
|
403
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,9 +166,9 @@ export function requireRoleOrSuperUser(
|
|||||||
}
|
}
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: staffRow.isSuperUser
|
error: hasAllowedRole
|
||||||
? `Forbidden: role '${staffRow.role}' is not permitted`
|
? "Forbidden: super user privileges required"
|
||||||
: "Forbidden: super user privileges required",
|
: `Forbidden: role '${staffRow.role}' is not permitted`,
|
||||||
},
|
},
|
||||||
403
|
403
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import {
|
|||||||
services,
|
services,
|
||||||
staff,
|
staff,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const appointmentGroupsRouter = new Hono();
|
export const appointmentGroupsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -49,6 +50,8 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
const clientId = c.req.query("clientId");
|
const clientId = c.req.query("clientId");
|
||||||
const from = c.req.query("from");
|
const from = c.req.query("from");
|
||||||
const to = c.req.query("to");
|
const to = c.req.query("to");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
const groupConditions = clientId
|
const groupConditions = clientId
|
||||||
? [eq(appointmentGroups.clientId, clientId)]
|
? [eq(appointmentGroups.clientId, clientId)]
|
||||||
@@ -88,6 +91,16 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
}))
|
}))
|
||||||
.filter((g) => !from || g.appointments.length > 0);
|
.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);
|
return c.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,6 +109,8 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
appointmentGroupsRouter.get("/:id", async (c) => {
|
appointmentGroupsRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
const [group] = await db
|
const [group] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -111,6 +126,7 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
|||||||
serviceId: appointments.serviceId,
|
serviceId: appointments.serviceId,
|
||||||
serviceName: services.name,
|
serviceName: services.name,
|
||||||
staffId: appointments.staffId,
|
staffId: appointments.staffId,
|
||||||
|
batherStaffId: appointments.batherStaffId,
|
||||||
staffName: staff.name,
|
staffName: staff.name,
|
||||||
status: appointments.status,
|
status: appointments.status,
|
||||||
startTime: appointments.startTime,
|
startTime: appointments.startTime,
|
||||||
@@ -125,6 +141,15 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
|||||||
.where(eq(appointments.groupId, id))
|
.where(eq(appointments.groupId, id))
|
||||||
.orderBy(appointments.startTime);
|
.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
|
const [client] = await db
|
||||||
.select({ name: clients.name, email: clients.email })
|
.select({ name: clients.name, email: clients.email })
|
||||||
.from(clients)
|
.from(clients)
|
||||||
@@ -140,6 +165,13 @@ appointmentGroupsRouter.post(
|
|||||||
zValidator("json", createGroupSchema),
|
zValidator("json", createGroupSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
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 body = c.req.valid("json");
|
||||||
const startTime = new Date(body.startTime);
|
const startTime = new Date(body.startTime);
|
||||||
|
|
||||||
@@ -244,6 +276,28 @@ appointmentGroupsRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
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
|
const [updated] = await db
|
||||||
.update(appointmentGroups)
|
.update(appointmentGroups)
|
||||||
@@ -261,6 +315,8 @@ appointmentGroupsRouter.patch(
|
|||||||
appointmentGroupsRouter.delete("/:id", async (c) => {
|
appointmentGroupsRouter.delete("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
const [group] = await db
|
const [group] = await db
|
||||||
.select({ id: appointmentGroups.id })
|
.select({ id: appointmentGroups.id })
|
||||||
@@ -268,6 +324,20 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
|
|||||||
.where(eq(appointmentGroups.id, id));
|
.where(eq(appointmentGroups.id, id));
|
||||||
if (!group) return c.json({ error: "Not found" }, 404);
|
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
|
await db
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({ status: "cancelled", updatedAt: new Date() })
|
.set({ status: "cancelled", updatedAt: new Date() })
|
||||||
|
|||||||
@@ -23,6 +23,27 @@ import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
|||||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||||
import type { AppEnv } from "../middleware/rbac.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>();
|
export const appointmentsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
const createAppointmentSchema = z.object({
|
const createAppointmentSchema = z.object({
|
||||||
@@ -41,6 +62,10 @@ const createAppointmentSchema = z.object({
|
|||||||
frequencyWeeks: z.number().int().min(1).max(52),
|
frequencyWeeks: z.number().int().min(1).max(52),
|
||||||
count: z.number().int().min(2).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(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,6 +188,28 @@ appointmentsRouter.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (apptFields.batherStaffId) {
|
||||||
|
const bathConflicts = await tx
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, apptFields.batherStaffId),
|
||||||
|
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||||
|
),
|
||||||
|
lt(appointments.startTime, end),
|
||||||
|
gte(appointments.endTime, start),
|
||||||
|
ne(appointments.status, "cancelled"),
|
||||||
|
ne(appointments.status, "no_show"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (bathConflicts.length > 0) {
|
||||||
|
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!recurrence) {
|
if (!recurrence) {
|
||||||
// Single appointment
|
// Single appointment
|
||||||
const [inserted] = await tx
|
const [inserted] = await tx
|
||||||
@@ -186,11 +233,54 @@ appointmentsRouter.post(
|
|||||||
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
|
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
let first: typeof appointments.$inferSelect | undefined;
|
let first: typeof appointments.$inferSelect | undefined;
|
||||||
|
const conflictingInstances: number[] = [];
|
||||||
for (let i = 0; i < recurrence.count; i++) {
|
for (let i = 0; i < recurrence.count; i++) {
|
||||||
const instanceStart = new Date(start.getTime() + i * intervalMs);
|
const instanceStart = new Date(start.getTime() + i * intervalMs);
|
||||||
const instanceEnd = new Date(
|
const instanceEnd = new Date(
|
||||||
instanceStart.getTime() + durationMs
|
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
|
const [inserted] = await tx
|
||||||
.insert(appointments)
|
.insert(appointments)
|
||||||
.values({
|
.values({
|
||||||
@@ -201,9 +291,19 @@ appointmentsRouter.post(
|
|||||||
seriesIndex: i,
|
seriesIndex: i,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
|
||||||
if (i === 0) first = inserted;
|
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");
|
if (!first) throw new Error("No appointments created");
|
||||||
return first;
|
return first;
|
||||||
});
|
});
|
||||||
@@ -221,9 +321,12 @@ appointmentsRouter.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send confirmation email (fire-and-forget — never fails the request)
|
// Send confirmation email (fire-and-forget — never fails the request)
|
||||||
sendConfirmationEmail(db, firstRow).catch((err) => {
|
withRetry(
|
||||||
console.error("[appointments] Failed to send confirmation email:", err);
|
() => sendConfirmationEmail(db, firstRow),
|
||||||
});
|
2,
|
||||||
|
1000,
|
||||||
|
`Failed to send confirmation email for appointment ${firstRow.id}`
|
||||||
|
);
|
||||||
|
|
||||||
return c.json(firstRow, 201);
|
return c.json(firstRow, 201);
|
||||||
}
|
}
|
||||||
@@ -235,44 +338,35 @@ async function sendConfirmationEmail(
|
|||||||
db: ReturnType<typeof getDb>,
|
db: ReturnType<typeof getDb>,
|
||||||
appt: typeof appointments.$inferSelect
|
appt: typeof appointments.$inferSelect
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const [client] = await db
|
const [row] = await db
|
||||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
.select({
|
||||||
.from(clients)
|
clientName: clients.name,
|
||||||
.where(eq(clients.id, appt.clientId))
|
clientEmail: clients.email,
|
||||||
|
clientEmailOptOut: clients.emailOptOut,
|
||||||
|
petName: pets.name,
|
||||||
|
serviceName: services.name,
|
||||||
|
groomerName: staff.name,
|
||||||
|
})
|
||||||
|
.from(appointments)
|
||||||
|
.innerJoin(clients, eq(clients.id, appointments.clientId))
|
||||||
|
.innerJoin(pets, eq(pets.id, appointments.petId))
|
||||||
|
.innerJoin(services, eq(services.id, appointments.serviceId))
|
||||||
|
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
||||||
|
.where(eq(appointments.id, appt.id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!client || !client.email || client.emailOptOut) return;
|
if (!row) return;
|
||||||
|
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
|
||||||
|
|
||||||
const [pet] = await db
|
if (!clientEmail || clientEmailOptOut) return;
|
||||||
.select({ name: pets.name })
|
if (!petName || !serviceName) return;
|
||||||
.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(
|
const sent = await sendEmail(
|
||||||
buildConfirmationEmail(client.email, {
|
buildConfirmationEmail(clientEmail, {
|
||||||
clientName: client.name,
|
clientName,
|
||||||
petName: pet.name,
|
petName,
|
||||||
serviceName: service.name,
|
serviceName,
|
||||||
groomerName,
|
groomerName: groomerName ?? null,
|
||||||
startTime: appt.startTime,
|
startTime: appt.startTime,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -352,6 +446,76 @@ appointmentsRouter.patch(
|
|||||||
|
|
||||||
let firstUpdated: typeof appointments.$inferSelect | undefined;
|
let firstUpdated: typeof appointments.$inferSelect | undefined;
|
||||||
for (const appt of affected) {
|
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> = {
|
const apptUpdate: Record<string, unknown> = {
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -387,6 +551,13 @@ appointmentsRouter.patch(
|
|||||||
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
||||||
if (statusCode === 422)
|
if (statusCode === 422)
|
||||||
return c.json({ error: "endTime must be after startTime" }, 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;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +569,8 @@ appointmentsRouter.patch(
|
|||||||
const needsConflictCheck =
|
const needsConflictCheck =
|
||||||
updateFields.startTime !== undefined ||
|
updateFields.startTime !== undefined ||
|
||||||
updateFields.endTime !== undefined ||
|
updateFields.endTime !== undefined ||
|
||||||
updateFields.staffId !== undefined;
|
updateFields.staffId !== undefined ||
|
||||||
|
updateFields.batherStaffId !== undefined;
|
||||||
|
|
||||||
const update: Record<string, unknown> = {
|
const update: Record<string, unknown> = {
|
||||||
...updateFields,
|
...updateFields,
|
||||||
@@ -434,6 +606,11 @@ appointmentsRouter.patch(
|
|||||||
updateFields.staffId !== undefined
|
updateFields.staffId !== undefined
|
||||||
? updateFields.staffId
|
? updateFields.staffId
|
||||||
: current.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) {
|
if (end <= start) {
|
||||||
throw Object.assign(new Error("end before start"), {
|
throw Object.assign(new Error("end before start"), {
|
||||||
@@ -461,6 +638,29 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (batherStaffId) {
|
||||||
|
const bathConflicts = await tx
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, batherStaffId),
|
||||||
|
eq(appointments.batherStaffId, batherStaffId)
|
||||||
|
),
|
||||||
|
lt(appointments.startTime, end),
|
||||||
|
gte(appointments.endTime, start),
|
||||||
|
ne(appointments.status, "cancelled"),
|
||||||
|
ne(appointments.status, "no_show"),
|
||||||
|
ne(appointments.id, id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (bathConflicts.length > 0) {
|
||||||
|
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [updated] = await tx
|
const [updated] = await tx
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set(update)
|
.set(update)
|
||||||
@@ -535,9 +735,12 @@ appointmentsRouter.delete("/:id", async (c) => {
|
|||||||
|
|
||||||
const apptDate = current.startTime.toISOString().slice(0, 10);
|
const apptDate = current.startTime.toISOString().slice(0, 10);
|
||||||
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||||
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
|
withRetry(
|
||||||
console.error("[appointments] Failed to notify waitlist:", err);
|
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
||||||
});
|
2,
|
||||||
|
1000,
|
||||||
|
`Failed to notify waitlist for appointment ${id}`
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -560,9 +763,12 @@ appointmentsRouter.delete("/:id", async (c) => {
|
|||||||
.returning();
|
.returning();
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
|
withRetry(
|
||||||
console.error("[appointments] Failed to notify waitlist:", err);
|
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
||||||
});
|
2,
|
||||||
|
1000,
|
||||||
|
`Failed to notify waitlist for appointment ${id}`
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
+28
-12
@@ -102,7 +102,10 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
|
|
||||||
const bookingSchema = z.object({
|
const bookingSchema = z.object({
|
||||||
serviceId: z.string().uuid(),
|
serviceId: z.string().uuid(),
|
||||||
startTime: z.string().datetime(),
|
startTime: z.string().datetime().refine(
|
||||||
|
(dt) => new Date(dt) > new Date(),
|
||||||
|
{ message: "Appointment must be in the future" }
|
||||||
|
),
|
||||||
clientName: z.string().min(1).max(200),
|
clientName: z.string().min(1).max(200),
|
||||||
clientEmail: z.string().email(),
|
clientEmail: z.string().email(),
|
||||||
clientPhone: z.string().max(50).optional(),
|
clientPhone: z.string().max(50).optional(),
|
||||||
@@ -265,29 +268,36 @@ bookRouter.get("/confirm/:token", async (c) => {
|
|||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject if appointment is in the past
|
|
||||||
if (appt.startTime < new Date()) {
|
if (appt.startTime < new Date()) {
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotent confirm: if already confirmed, redirect to success
|
|
||||||
if (appt.confirmationStatus === "confirmed") {
|
if (appt.confirmationStatus === "confirmed") {
|
||||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject if already cancelled
|
|
||||||
if (appt.confirmationStatus === "cancelled") {
|
if (appt.confirmationStatus === "cancelled") {
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
const updated = await db
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({
|
.set({
|
||||||
confirmationStatus: "confirmed",
|
confirmationStatus: "confirmed",
|
||||||
confirmedAt: new Date(),
|
confirmedAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(appointments.id, appt.id));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.confirmationToken, token),
|
||||||
|
eq(appointments.confirmationStatus, "pending")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (updated.length === 0) {
|
||||||
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
|
}
|
||||||
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||||
});
|
});
|
||||||
@@ -309,19 +319,15 @@ bookRouter.get("/cancel/:token", async (c) => {
|
|||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject if appointment is in the past
|
|
||||||
if (appt.startTime < new Date()) {
|
if (appt.startTime < new Date()) {
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
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") {
|
if (appt.confirmationStatus === "cancelled") {
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-use cancellation: nullify token after use
|
const updated = await db
|
||||||
await db
|
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({
|
.set({
|
||||||
confirmationStatus: "cancelled",
|
confirmationStatus: "cancelled",
|
||||||
@@ -329,7 +335,17 @@ bookRouter.get("/cancel/:token", async (c) => {
|
|||||||
confirmationToken: null,
|
confirmationToken: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(appointments.id, appt.id));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.confirmationToken, token),
|
||||||
|
eq(appointments.confirmationStatus, "pending")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (updated.length === 0) {
|
||||||
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
|
}
|
||||||
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
@@ -84,7 +84,18 @@ calendarRouter.get("/:staffId.ics", async (c) => {
|
|||||||
.where(eq(staff.id, staffId))
|
.where(eq(staff.id, staffId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!staffMember || staffMember.icalToken !== token) {
|
if (!staffMember || !staffMember.icalToken) {
|
||||||
|
return c.text("Unauthorized", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedToken = staffMember.icalToken;
|
||||||
|
const incomingToken = token;
|
||||||
|
const storedBuf = Buffer.from(storedToken, "utf8");
|
||||||
|
const incomingBuf = Buffer.from(incomingToken, "utf8");
|
||||||
|
if (
|
||||||
|
storedBuf.length !== incomingBuf.length ||
|
||||||
|
!timingSafeEqual(storedBuf, incomingBuf)
|
||||||
|
) {
|
||||||
return c.text("Unauthorized", 401);
|
return c.text("Unauthorized", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ export const clientsRouter = new Hono<AppEnv>();
|
|||||||
|
|
||||||
const createClientSchema = z.object({
|
const createClientSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
email: z.string().email().optional(),
|
email: z.string().email(),
|
||||||
phone: z.string().max(50).optional(),
|
phone: z.string().max(50).optional(),
|
||||||
address: z.string().max(500).optional(),
|
address: z.string().max(500).optional(),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
|
smsOptIn: z.boolean().optional(),
|
||||||
|
smsConsentText: z.string().max(1000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -95,6 +97,7 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
|
|||||||
// Update a client (including status changes)
|
// Update a client (including status changes)
|
||||||
const patchClientSchema = createClientSchema.partial().extend({
|
const patchClientSchema = createClientSchema.partial().extend({
|
||||||
status: z.enum(["active", "disabled"]).optional(),
|
status: z.enum(["active", "disabled"]).optional(),
|
||||||
|
smsOptOut: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
clientsRouter.patch(
|
clientsRouter.patch(
|
||||||
@@ -107,13 +110,19 @@ clientsRouter.patch(
|
|||||||
|
|
||||||
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
|
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
|
||||||
|
|
||||||
// When disabling, set disabledAt; when re-enabling, clear it
|
|
||||||
if (body.status === "disabled") {
|
if (body.status === "disabled") {
|
||||||
setValues.disabledAt = now;
|
setValues.disabledAt = now;
|
||||||
} else if (body.status === "active") {
|
} else if (body.status === "active") {
|
||||||
setValues.disabledAt = null;
|
setValues.disabledAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.smsOptOut === true) {
|
||||||
|
setValues.smsOptIn = false;
|
||||||
|
setValues.smsOptOutDate = now;
|
||||||
|
delete setValues.smsOptOut;
|
||||||
|
}
|
||||||
|
delete setValues.smsOptOut;
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.update(clients)
|
.update(clients)
|
||||||
.set(setValues)
|
.set(setValues)
|
||||||
@@ -135,9 +144,24 @@ clientsRouter.delete("/:id", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
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
|
const [row] = await db
|
||||||
.delete(clients)
|
.delete(clients)
|
||||||
.where(eq(clients.id, c.req.param("id")))
|
.where(eq(clients.id, clientId))
|
||||||
.returning();
|
.returning();
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
|
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const groomingLogsRouter = new Hono();
|
export const groomingLogsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
const createLogSchema = z.object({
|
const createLogSchema = z.object({
|
||||||
petId: z.string().uuid(),
|
petId: z.string().uuid(),
|
||||||
@@ -20,6 +21,26 @@ groomingLogsRouter.get("/", async (c) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const petId = c.req.query("petId");
|
const petId = c.req.query("petId");
|
||||||
if (!petId) return c.json({ error: "petId is required" }, 400);
|
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
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(groomingVisitLogs)
|
.from(groomingVisitLogs)
|
||||||
@@ -33,11 +54,50 @@ groomingLogsRouter.post(
|
|||||||
zValidator("json", createLogSchema),
|
zValidator("json", createLogSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { groomedAt, ...rest } = c.req.valid("json");
|
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
|
if (isGroomer) {
|
||||||
|
if (appointmentId) {
|
||||||
|
const [appt] = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.id, appointmentId),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
} else {
|
||||||
|
const [appt] = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.petId, petId),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(groomingVisitLogs)
|
.insert(groomingVisitLogs)
|
||||||
.values({
|
.values({
|
||||||
...rest,
|
...rest,
|
||||||
|
petId,
|
||||||
|
appointmentId: appointmentId ?? null,
|
||||||
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -47,10 +107,37 @@ groomingLogsRouter.post(
|
|||||||
|
|
||||||
groomingLogsRouter.delete("/:id", async (c) => {
|
groomingLogsRouter.delete("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const [row] = await db
|
const id = c.req.param("id");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
|
const [log] = await db
|
||||||
|
.select()
|
||||||
|
.from(groomingVisitLogs)
|
||||||
|
.where(eq(groomingVisitLogs.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (!log) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
if (isGroomer) {
|
||||||
|
const [appt] = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.petId, log.petId),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
.delete(groomingVisitLogs)
|
.delete(groomingVisitLogs)
|
||||||
.where(eq(groomingVisitLogs.id, c.req.param("id")))
|
.where(eq(groomingVisitLogs.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
+264
-72
@@ -8,6 +8,7 @@ import {
|
|||||||
invoices,
|
invoices,
|
||||||
invoiceLineItems,
|
invoiceLineItems,
|
||||||
invoiceTipSplits,
|
invoiceTipSplits,
|
||||||
|
refunds,
|
||||||
appointments,
|
appointments,
|
||||||
services,
|
services,
|
||||||
clients,
|
clients,
|
||||||
@@ -17,6 +18,14 @@ import type { AppEnv } from "../middleware/rbac.js";
|
|||||||
|
|
||||||
export const invoicesRouter = new Hono<AppEnv>();
|
export const invoicesRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
// Convert Zod validation errors from 422 to 400
|
||||||
|
invoicesRouter.onError((err, c) => {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
return c.json({ error: "Validation failed", issues: err.issues }, 400);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
const createInvoiceSchema = z.object({
|
const createInvoiceSchema = z.object({
|
||||||
appointmentId: z.string().uuid().optional(),
|
appointmentId: z.string().uuid().optional(),
|
||||||
clientId: z.string().uuid(),
|
clientId: z.string().uuid(),
|
||||||
@@ -41,56 +50,73 @@ const updateInvoiceSchema = z.object({
|
|||||||
taxCents: z.number().int().nonnegative().optional(),
|
taxCents: z.number().int().nonnegative().optional(),
|
||||||
tipCents: z.number().int().nonnegative().optional(),
|
tipCents: z.number().int().nonnegative().optional(),
|
||||||
notes: z.string().max(2000).nullable().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
|
// List invoices
|
||||||
invoicesRouter.get("/", async (c) => {
|
const listInvoicesQuerySchema = z.object({
|
||||||
const db = getDb();
|
clientId: z.string().uuid().optional(),
|
||||||
const clientId = c.req.query("clientId");
|
appointmentId: z.string().uuid().optional(),
|
||||||
const appointmentId = c.req.query("appointmentId");
|
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
|
||||||
const status = c.req.query("status");
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
|
offset: z.coerce.number().int().min(0).default(0),
|
||||||
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
|
// Get single invoice with line items and tip splits
|
||||||
invoicesRouter.get("/:id", async (c) => {
|
invoicesRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -104,7 +130,17 @@ invoicesRouter.get("/:id", async (c) => {
|
|||||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return c.json({ ...invoice, lineItems, tipSplits });
|
let cardLast4: string | null = null;
|
||||||
|
let paymentStatus: string | null = null;
|
||||||
|
if (invoice.stripePaymentIntentId) {
|
||||||
|
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
||||||
|
if (details) {
|
||||||
|
cardLast4 = details.cardLast4;
|
||||||
|
paymentStatus = details.paymentStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save tip splits for an invoice (replaces existing splits)
|
// Save tip splits for an invoice (replaces existing splits)
|
||||||
@@ -117,8 +153,8 @@ const tipSplitSchema = z.object({
|
|||||||
})
|
})
|
||||||
).min(1).refine(
|
).min(1).refine(
|
||||||
(splits) => {
|
(splits) => {
|
||||||
const total = splits.reduce((sum, s) => sum + s.sharePct, 0);
|
const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
|
||||||
return Math.abs(total - 100) < 0.01;
|
return totalBps === 10000;
|
||||||
},
|
},
|
||||||
{ message: "Split percentages must sum to 100" }
|
{ message: "Split percentages must sum to 100" }
|
||||||
),
|
),
|
||||||
@@ -162,12 +198,13 @@ invoicesRouter.post(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const splits = await db
|
const [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||||
.select()
|
const [lineItems, tipSplits] = await Promise.all([
|
||||||
.from(invoiceTipSplits)
|
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
|
||||||
.where(eq(invoiceTipSplits.invoiceId, id));
|
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||||
|
]);
|
||||||
|
|
||||||
return c.json(splits, 201);
|
return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -292,6 +329,13 @@ invoicesRouter.post("/from-appointment/:appointmentId", async (c) => {
|
|||||||
return c.json({ ...invoice, lineItems: [lineItem] }, 201);
|
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
|
// Update invoice
|
||||||
invoicesRouter.patch(
|
invoicesRouter.patch(
|
||||||
"/:id",
|
"/:id",
|
||||||
@@ -307,11 +351,33 @@ invoicesRouter.patch(
|
|||||||
.where(eq(invoices.id, id));
|
.where(eq(invoices.id, id));
|
||||||
if (!current) return c.json({ error: "Not found" }, 404);
|
if (!current) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
if (current.status === "void") {
|
if (body.status !== undefined) {
|
||||||
return c.json({ error: "Cannot modify a voided invoice" }, 422);
|
const allowed = ALLOWED_TRANSITIONS[current.status] ?? [];
|
||||||
|
if (!allowed.includes(body.status)) {
|
||||||
|
return c.json(
|
||||||
|
{ error: `Invalid status transition from ${current.status} to ${body.status}` },
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
|
const tipCents = body.tipCents ?? current.tipCents;
|
||||||
|
|
||||||
|
// Validate tip splits when marking invoice as paid
|
||||||
|
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
|
||||||
|
if (body.tipSplits.length === 0) {
|
||||||
|
return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400);
|
||||||
|
}
|
||||||
|
const totalPct = body.tipSplits.reduce((sum, s) => sum + s.sharePct, 0);
|
||||||
|
if (Math.abs(totalPct - 100) > 0.01) {
|
||||||
|
return c.json({ error: "Tip split percentages must sum to 100%" }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destructure tipSplits out — it belongs to a separate table, not the invoices column
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { tipSplits: _tipSplits, ...updateBody } = body as Record<string, unknown>;
|
||||||
|
const update: Record<string, unknown> = { ...updateBody, updatedAt: new Date() };
|
||||||
|
|
||||||
// Auto-set paidAt when marking as paid
|
// Auto-set paidAt when marking as paid
|
||||||
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
||||||
@@ -325,16 +391,42 @@ invoicesRouter.patch(
|
|||||||
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
// Wrap tip split persistence and invoice update in a single atomic transaction
|
||||||
.update(invoices)
|
const [updated, lineItems] = await db.transaction(async (tx) => {
|
||||||
.set(update)
|
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
|
||||||
.where(eq(invoices.id, id))
|
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
|
||||||
.returning();
|
const splits = body.tipSplits;
|
||||||
|
if (splits.length > 0) {
|
||||||
|
let remaining = tipCents;
|
||||||
|
const rows = splits.map((s, i) => {
|
||||||
|
const isLast = i === splits.length - 1;
|
||||||
|
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents);
|
||||||
|
if (!isLast) remaining -= shareCents;
|
||||||
|
return {
|
||||||
|
invoiceId: id,
|
||||||
|
staffId: s.staffId,
|
||||||
|
staffName: s.staffName,
|
||||||
|
sharePct: s.sharePct.toFixed(2),
|
||||||
|
shareCents,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await tx.insert(invoiceTipSplits).values(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lineItems = await db
|
const [updatedInvoice] = await tx
|
||||||
.select()
|
.update(invoices)
|
||||||
.from(invoiceLineItems)
|
.set(update)
|
||||||
.where(eq(invoiceLineItems.invoiceId, id));
|
.where(eq(invoices.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const lineItems = await tx
|
||||||
|
.select()
|
||||||
|
.from(invoiceLineItems)
|
||||||
|
.where(eq(invoiceLineItems.invoiceId, id));
|
||||||
|
|
||||||
|
return [updatedInvoice, lineItems];
|
||||||
|
});
|
||||||
|
|
||||||
return c.json({ ...updated, lineItems });
|
return c.json({ ...updated, lineItems });
|
||||||
}
|
}
|
||||||
@@ -342,10 +434,11 @@ invoicesRouter.patch(
|
|||||||
|
|
||||||
// ─── Refund ───────────────────────────────────────────────────────────────────
|
// ─── Refund ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { processRefund } from "../services/payment.js";
|
import { processRefund, getPaymentIntentDetails } from "../services/payment.js";
|
||||||
|
|
||||||
const refundSchema = z.object({
|
const refundSchema = z.object({
|
||||||
amountCents: z.number().int().nonnegative().optional(),
|
amountCents: z.number().int().nonnegative().optional(),
|
||||||
|
idempotencyKey: z.string().max(255).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
invoicesRouter.post(
|
invoicesRouter.post(
|
||||||
@@ -367,13 +460,112 @@ invoicesRouter.post(
|
|||||||
if (invoice.status !== "paid") {
|
if (invoice.status !== "paid") {
|
||||||
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
||||||
}
|
}
|
||||||
if (!invoice.stripePaymentIntentId) {
|
|
||||||
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await processRefund(id, body.amountCents);
|
return await db.transaction(async (tx) => {
|
||||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
if (body.idempotencyKey) {
|
||||||
|
const [existing] = await tx
|
||||||
|
.select()
|
||||||
|
.from(refunds)
|
||||||
|
.where(eq(refunds.idempotencyKey, body.idempotencyKey));
|
||||||
|
if (existing) {
|
||||||
|
return c.json({ refundId: existing.stripeRefundId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ refundId: result.refundId });
|
let refundId: string;
|
||||||
|
|
||||||
|
if (invoice.stripePaymentIntentId) {
|
||||||
|
const result = await processRefund(id, body.amountCents);
|
||||||
|
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||||
|
refundId = result.refundId;
|
||||||
|
} else {
|
||||||
|
// Manual refund — no Stripe call needed
|
||||||
|
refundId = `manual_${id}_${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(refunds).values({
|
||||||
|
invoiceId: id,
|
||||||
|
stripeRefundId: refundId,
|
||||||
|
idempotencyKey: body.idempotencyKey ?? null,
|
||||||
|
amountCents: body.amountCents ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ refundId });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Payment stats for admin dashboard
|
||||||
|
invoicesRouter.get("/stats/summary", async (c) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
|
||||||
|
const [revenueResult] = await db
|
||||||
|
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||||
|
.from(invoices)
|
||||||
|
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
|
||||||
|
|
||||||
|
const [outstandingResult] = await db
|
||||||
|
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.status, "pending"));
|
||||||
|
|
||||||
|
const [refundsResult] = await db
|
||||||
|
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
|
||||||
|
.from(refunds)
|
||||||
|
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
|
||||||
|
|
||||||
|
const methodBreakdown = await db
|
||||||
|
.select({
|
||||||
|
method: invoices.paymentMethod,
|
||||||
|
total: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(invoices)
|
||||||
|
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
|
||||||
|
.groupBy(invoices.paymentMethod);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
revenueThisMonth: revenueResult?.total ?? 0,
|
||||||
|
outstanding: outstandingResult?.total ?? 0,
|
||||||
|
refundsThisMonth: refundsResult?.total ?? 0,
|
||||||
|
methodBreakdown,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("stats/summary error:", err);
|
||||||
|
return c.json({
|
||||||
|
revenueThisMonth: 0,
|
||||||
|
outstanding: 0,
|
||||||
|
refundsThisMonth: 0,
|
||||||
|
methodBreakdown: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
|
||||||
|
invoicesRouter.get("/:id/stripe-details", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const id = c.req.param("id");
|
||||||
|
|
||||||
|
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||||
|
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
let cardLast4: string | null = null;
|
||||||
|
let paymentStatus: string | null = null;
|
||||||
|
|
||||||
|
if (invoice.stripePaymentIntentId) {
|
||||||
|
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
||||||
|
if (details) {
|
||||||
|
cardLast4 = details.cardLast4;
|
||||||
|
paymentStatus = details.paymentStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
stripePaymentIntentId: invoice.stripePaymentIntentId,
|
||||||
|
stripeRefundId: invoice.stripeRefundId,
|
||||||
|
cardLast4,
|
||||||
|
paymentStatus,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -213,7 +213,11 @@ petsRouter.post(
|
|||||||
|
|
||||||
// Delete the previous photo from storage to avoid orphaned objects
|
// Delete the previous photo from storage to avoid orphaned objects
|
||||||
if (pet.photoKey) {
|
if (pet.photoKey) {
|
||||||
await deleteObject(pet.photoKey);
|
try {
|
||||||
|
await deleteObject(pet.photoKey);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to delete previous photo ${pet.photoKey}, orphaned object may remain:`, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
@@ -240,7 +244,11 @@ petsRouter.delete("/:petId/photo", async (c) => {
|
|||||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||||
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
|
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
|
||||||
|
|
||||||
await deleteObject(pet.photoKey);
|
try {
|
||||||
|
await deleteObject(pet.photoKey);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to delete photo ${pet.photoKey} from S3, orphaned object may remain:`, err);
|
||||||
|
}
|
||||||
await db
|
await db
|
||||||
.update(pets)
|
.update(pets)
|
||||||
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
|
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
|
||||||
|
|||||||
+87
-197
@@ -1,33 +1,84 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { and, eq, inArray } from "@groombook/db";
|
import { eq, inArray } from "@groombook/db";
|
||||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import { validatePortalSession } from "../middleware/portalSession.js";
|
||||||
|
import { portalAudit } from "../middleware/portalAudit.js";
|
||||||
|
import type { PortalEnv } from "../middleware/portalSession.js";
|
||||||
|
|
||||||
export const portalRouter = new Hono<AppEnv>();
|
export const portalRouter = new Hono<PortalEnv>();
|
||||||
|
|
||||||
// ─── Session helper ───────────────────────────────────────────────────────────
|
// Dev-mode session creation — must be registered BEFORE the /* middleware so it is
|
||||||
|
// NOT subject to validatePortalSession/portalAudit (GRO-778 fix). This endpoint creates
|
||||||
|
// the impersonation session and has no X-Impersonation-Session-Id header yet.
|
||||||
|
const devSessionSchema = z.object({
|
||||||
|
clientId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
|
portalRouter.post(
|
||||||
if (!sessionId) return null;
|
"/dev-session",
|
||||||
const db = getDb();
|
zValidator("json", devSessionSchema),
|
||||||
const [session] = await db
|
async (c) => {
|
||||||
.select()
|
if (process.env.AUTH_DISABLED !== "true") {
|
||||||
.from(impersonationSessions)
|
return c.json({ error: "Not available when auth is enabled" }, 403);
|
||||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
}
|
||||||
.limit(1);
|
|
||||||
if (!session || session.expiresAt <= new Date()) return null;
|
const db = getDb();
|
||||||
return session.clientId;
|
const body = c.req.valid("json");
|
||||||
}
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, body.clientId))
|
||||||
|
.limit(1);
|
||||||
|
if (!client) {
|
||||||
|
return c.json({ error: "Client not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
||||||
|
|
||||||
|
let staffId = DEMO_STAFF_ID;
|
||||||
|
const [demoStaff] = await db
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.id, DEMO_STAFF_ID))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!demoStaff) {
|
||||||
|
const [firstStaff] = await db
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.active, true))
|
||||||
|
.limit(1);
|
||||||
|
if (!firstStaff) {
|
||||||
|
return c.json({ error: "No staff records found. Run the database seed." }, 500);
|
||||||
|
}
|
||||||
|
staffId = firstStaff.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.insert(impersonationSessions)
|
||||||
|
.values({
|
||||||
|
staffId,
|
||||||
|
clientId: body.clientId,
|
||||||
|
reason: "dev-mode-client-portal",
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(session, 201);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply middleware to all portal routes
|
||||||
|
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||||
|
|
||||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
portalRouter.get("/me", async (c) => {
|
portalRouter.get("/me", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
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);
|
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||||
if (!client) return c.json({ error: "Not found" }, 404);
|
if (!client) return c.json({ error: "Not found" }, 404);
|
||||||
@@ -49,11 +100,8 @@ portalRouter.get("/services", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/appointments", async (c) => {
|
portalRouter.get("/appointments", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const allAppts = await db
|
const allAppts = await db
|
||||||
.select({
|
.select({
|
||||||
id: appointments.id,
|
id: appointments.id,
|
||||||
@@ -93,27 +141,20 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled");
|
return c.json({ appointments: appts });
|
||||||
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
|
|
||||||
|
|
||||||
return c.json({ upcoming, past });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.get("/pets", async (c) => {
|
portalRouter.get("/pets", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
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));
|
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||||
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes })));
|
||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.get("/invoices", async (c) => {
|
portalRouter.get("/invoices", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
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 clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||||
const invoiceIds = clientInvoices.map(i => i.id);
|
const invoiceIds = clientInvoices.map(i => i.id);
|
||||||
@@ -148,12 +189,7 @@ portalRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
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
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -196,12 +232,7 @@ portalRouter.patch(
|
|||||||
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
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
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -250,12 +281,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
|||||||
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
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
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -319,28 +345,7 @@ portalRouter.post(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
|
|
||||||
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
|
const [entry] = await db
|
||||||
.insert(waitlistEntries)
|
.insert(waitlistEntries)
|
||||||
@@ -364,26 +369,7 @@ portalRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
|
|
||||||
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
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -392,7 +378,7 @@ portalRouter.patch(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||||
if (existing.clientId !== session.clientId) {
|
if (existing.clientId !== clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,26 +400,7 @@ portalRouter.patch(
|
|||||||
portalRouter.delete("/waitlist/:id", async (c) => {
|
portalRouter.delete("/waitlist/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
|
|
||||||
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
|
const [entry] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -442,7 +409,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!entry) return c.json({ error: "Not found" }, 404);
|
if (!entry) return c.json({ error: "Not found" }, 404);
|
||||||
if (entry.clientId !== session.clientId) {
|
if (entry.clientId !== clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,9 +442,7 @@ portalRouter.post(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const invoiceRows = await db
|
const invoiceRows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -514,9 +479,7 @@ portalRouter.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
portalRouter.get("/payment-methods", async (c) => {
|
portalRouter.get("/payment-methods", async (c) => {
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const methods = await listPaymentMethods(clientId);
|
const methods = await listPaymentMethods(clientId);
|
||||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||||
@@ -524,9 +487,7 @@ portalRouter.get("/payment-methods", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.post("/payment-methods", async (c) => {
|
portalRouter.post("/payment-methods", async (c) => {
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||||
@@ -539,9 +500,7 @@ portalRouter.post("/payment-methods", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const clientId = c.get("portalClientId");
|
||||||
const clientId = await getClientIdFromSession(sessionId);
|
|
||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
|
|
||||||
const paymentMethodId = c.req.param("id");
|
const paymentMethodId = c.req.param("id");
|
||||||
|
|
||||||
@@ -559,73 +518,4 @@ portalRouter.delete("/payment-methods/:id", async (c) => {
|
|||||||
const ok = await detachPaymentMethod(paymentMethodId);
|
const ok = await detachPaymentMethod(paymentMethodId);
|
||||||
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
|
||||||
// Allows the dev login selector to vend an impersonation session for a client
|
|
||||||
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
|
||||||
|
|
||||||
const devSessionSchema = z.object({
|
|
||||||
clientId: z.string().uuid(),
|
|
||||||
});
|
|
||||||
|
|
||||||
portalRouter.post(
|
|
||||||
"/dev-session",
|
|
||||||
zValidator("json", devSessionSchema),
|
|
||||||
async (c) => {
|
|
||||||
if (process.env.AUTH_DISABLED !== "true") {
|
|
||||||
return c.json({ error: "Not available when auth is enabled" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
|
|
||||||
// Verify client exists
|
|
||||||
const [client] = await db
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.id, body.clientId))
|
|
||||||
.limit(1);
|
|
||||||
if (!client) {
|
|
||||||
return c.json({ error: "Client not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find a staff record to associate with the dev impersonation session.
|
|
||||||
// Use the demo-manager if it exists (created by seed with known ID),
|
|
||||||
// otherwise fall back to the first active staff record.
|
|
||||||
// This avoids hardcoding a UUID that may not exist in all environments.
|
|
||||||
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
|
||||||
|
|
||||||
let staffId = DEMO_STAFF_ID;
|
|
||||||
const [demoStaff] = await db
|
|
||||||
.select({ id: staff.id })
|
|
||||||
.from(staff)
|
|
||||||
.where(eq(staff.id, DEMO_STAFF_ID))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!demoStaff) {
|
|
||||||
// Fall back to any active staff member
|
|
||||||
const [firstStaff] = await db
|
|
||||||
.select({ id: staff.id })
|
|
||||||
.from(staff)
|
|
||||||
.where(eq(staff.active, true))
|
|
||||||
.limit(1);
|
|
||||||
if (!firstStaff) {
|
|
||||||
return c.json({ error: "No staff records found. Run the database seed." }, 500);
|
|
||||||
}
|
|
||||||
staffId = firstStaff.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [session] = await db
|
|
||||||
.insert(impersonationSessions)
|
|
||||||
.values({
|
|
||||||
staffId,
|
|
||||||
clientId: body.clientId,
|
|
||||||
reason: "dev-mode-client-portal",
|
|
||||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return c.json(session, 201);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -286,6 +286,10 @@ reportsRouter.get("/clients", async (c) => {
|
|||||||
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
|
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
|
||||||
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
|
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
|
const churnRisk = await db
|
||||||
.select({
|
.select({
|
||||||
clientId: clients.id,
|
clientId: clients.id,
|
||||||
@@ -298,15 +302,34 @@ reportsRouter.get("/clients", async (c) => {
|
|||||||
.having(
|
.having(
|
||||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||||
)
|
)
|
||||||
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`);
|
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const [churnCountRow] = await db
|
||||||
|
.select({ total: sql<number>`count(*)::int` })
|
||||||
|
.from(
|
||||||
|
db
|
||||||
|
.select({ id: clients.id })
|
||||||
|
.from(clients)
|
||||||
|
.leftJoin(appointments, eq(appointments.clientId, clients.id))
|
||||||
|
.groupBy(clients.id)
|
||||||
|
.having(
|
||||||
|
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||||
|
)
|
||||||
|
.as("churn_count")
|
||||||
|
);
|
||||||
|
const churnRiskTotal = churnCountRow?.total ?? 0;
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
from: from.toISOString(),
|
from: from.toISOString(),
|
||||||
to: to.toISOString(),
|
to: to.toISOString(),
|
||||||
newClients,
|
newClients,
|
||||||
activeInPeriodCount: activeInPeriod.length,
|
activeInPeriodCount: activeInPeriod.length,
|
||||||
churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients
|
churnRisk,
|
||||||
churnRiskTotal: churnRisk.length,
|
churnRiskTotal,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
|
|||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
basePriceCents: z.number().int().positive(),
|
basePriceCents: z.number().int().positive(),
|
||||||
durationMinutes: z.number().int().positive(),
|
durationMinutes: z.number().int().positive().max(480),
|
||||||
active: z.boolean().default(true),
|
active: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Hono } from "hono";
|
|||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { eq, getDb, businessSettings } from "@groombook/db";
|
import { eq, getDb, businessSettings } from "@groombook/db";
|
||||||
import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject } from "../lib/s3.js";
|
import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js";
|
||||||
import { requireSuperUser } from "../middleware/rbac.js";
|
import { requireSuperUser } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const settingsRouter = new Hono();
|
export const settingsRouter = new Hono();
|
||||||
@@ -100,6 +100,77 @@ settingsRouter.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/settings/logo/upload
|
||||||
|
* Proxy upload through the API server to avoid mixed-content issues with
|
||||||
|
* pre-signed URLs that use the internal HTTP endpoint. The file is uploaded
|
||||||
|
* directly to S3 from the server using the internal endpoint.
|
||||||
|
*/
|
||||||
|
settingsRouter.post("/logo/upload", requireSuperUser(), async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Parse multipart form data (file field)
|
||||||
|
const body = await c.req.parseBody({ all: true });
|
||||||
|
const file = body["file"];
|
||||||
|
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return c.json({ error: "No file provided" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = file.type;
|
||||||
|
if (!ALLOWED_LOGO_TYPES.has(contentType)) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp",
|
||||||
|
},
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSizeBytes = file.size;
|
||||||
|
if (fileSizeBytes > MAX_LOGO_SIZE) {
|
||||||
|
return c.json({ error: "File must not exceed 512 KB" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.select().from(businessSettings).limit(1);
|
||||||
|
if (!rows[0]) {
|
||||||
|
return c.json({ error: "Settings not found" }, 404);
|
||||||
|
}
|
||||||
|
const settingsId = rows[0].id;
|
||||||
|
|
||||||
|
const ext = contentType.split("/")[1] ?? "png";
|
||||||
|
const key = `logos/${settingsId}/${Date.now()}.${ext}`;
|
||||||
|
|
||||||
|
// Read file into buffer and upload directly to S3 (bypasses pre-signed URL)
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
await putObject(key, buffer, contentType, fileSizeBytes);
|
||||||
|
|
||||||
|
// Delete previous S3 object if any
|
||||||
|
if (rows[0].logoKey) {
|
||||||
|
await deleteObject(rows[0].logoKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update database with new logo key
|
||||||
|
const [updated] = await db
|
||||||
|
.update(businessSettings)
|
||||||
|
.set({
|
||||||
|
logoKey: key,
|
||||||
|
logoBase64: null,
|
||||||
|
logoMimeType: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(businessSettings.id, settingsId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return c.json({ error: "Settings not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true, logoKey: updated.logoKey });
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/settings/logo/confirm
|
* POST /api/admin/settings/logo/confirm
|
||||||
* Called after the client has successfully uploaded to the presigned URL.
|
* Called after the client has successfully uploaded to the presigned URL.
|
||||||
@@ -144,7 +215,8 @@ settingsRouter.post(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/settings/logo
|
* GET /api/admin/settings/logo
|
||||||
* Returns a presigned GET URL for the logo.
|
* Proxies the logo from S3 so the browser never sees an S3 URL.
|
||||||
|
* Returns the image bytes with proper Content-Type.
|
||||||
*/
|
*/
|
||||||
settingsRouter.get("/logo", async (c) => {
|
settingsRouter.get("/logo", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -153,8 +225,14 @@ settingsRouter.get("/logo", async (c) => {
|
|||||||
if (!row) return c.json({ error: "Settings not found" }, 404);
|
if (!row) return c.json({ error: "Settings not found" }, 404);
|
||||||
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
|
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
|
||||||
|
|
||||||
const url = await getPresignedGetUrl(row.logoKey);
|
const { body, contentType } = await getObject(row.logoKey);
|
||||||
return c.json({ url, logoKey: row.logoKey });
|
return new Response(Buffer.from(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "public, max-age=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,24 @@ import { z } from "zod/v3";
|
|||||||
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
|
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
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>();
|
export const setupRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||||
@@ -185,52 +203,74 @@ const authProviderTestSchema = z.object({
|
|||||||
* After setup completes, this endpoint permanently returns 403.
|
* After setup completes, this endpoint permanently returns 403.
|
||||||
*/
|
*/
|
||||||
setupRouter.post("/auth-provider", async (c) => {
|
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();
|
const db = getDb();
|
||||||
|
|
||||||
// Guard: only allow during fresh install (no super user yet)
|
let row: typeof authProviderConfig.$inferSelect;
|
||||||
const [superUser] = await db
|
try {
|
||||||
.select({ id: staff.id })
|
row = await db.transaction(async (tx) => {
|
||||||
.from(staff)
|
const [superUser] = await tx
|
||||||
.where(eq(staff.isSuperUser, true))
|
.select({ id: staff.id })
|
||||||
.limit(1);
|
.from(staff)
|
||||||
|
.where(eq(staff.isSuperUser, true))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
if (superUser) {
|
if (superUser) {
|
||||||
// Setup already completed — lock this endpoint permanently
|
throw Object.assign(new Error("setup-complete"), { code: 403 });
|
||||||
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, 403);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Guard: ensure no DB config already exists (should be redundant with status check but defensive)
|
const [existingConfig] = await tx
|
||||||
const [existingConfig] = await db
|
.select({ id: authProviderConfig.id })
|
||||||
.select({ id: authProviderConfig.id })
|
.from(authProviderConfig)
|
||||||
.from(authProviderConfig)
|
.where(eq(authProviderConfig.enabled, true))
|
||||||
.where(eq(authProviderConfig.enabled, true))
|
.limit(1);
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existingConfig) {
|
if (existingConfig) {
|
||||||
return c.json({ error: "Auth provider is already configured." }, 409);
|
throw Object.assign(new Error("config-exists"), { code: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = authProviderBootstrapSchema.parse(await c.req.json());
|
const body = authProviderBootstrapSchema.parse(await c.req.json());
|
||||||
|
|
||||||
// Encrypt clientSecret before storing
|
const encryptedSecret = encryptSecret(body.clientSecret);
|
||||||
const encryptedSecret = encryptSecret(body.clientSecret);
|
|
||||||
|
|
||||||
const [row] = await db
|
const [configRow] = await tx
|
||||||
.insert(authProviderConfig)
|
.insert(authProviderConfig)
|
||||||
.values({
|
.values({
|
||||||
providerId: body.providerId,
|
providerId: body.providerId,
|
||||||
displayName: body.displayName,
|
displayName: body.displayName,
|
||||||
issuerUrl: body.issuerUrl,
|
issuerUrl: body.issuerUrl,
|
||||||
internalBaseUrl: body.internalBaseUrl ?? null,
|
internalBaseUrl: body.internalBaseUrl ?? null,
|
||||||
clientId: body.clientId,
|
clientId: body.clientId,
|
||||||
clientSecret: encryptedSecret,
|
clientSecret: encryptedSecret,
|
||||||
scopes: body.scopes,
|
scopes: body.scopes,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!row) {
|
if (!configRow) {
|
||||||
return c.json({ error: "Failed to save auth provider configuration." }, 500);
|
throw Object.assign(new Error("insert-failed"), { code: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return configRow;
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as Error & { code?: number };
|
||||||
|
if (e.message === "setup-complete") {
|
||||||
|
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, e.code as 403);
|
||||||
|
}
|
||||||
|
if (e.message === "config-exists") {
|
||||||
|
return c.json({ error: "Auth provider is already configured." }, e.code as 409);
|
||||||
|
}
|
||||||
|
if (e.message === "insert-failed") {
|
||||||
|
return c.json({ error: "Failed to save auth provider configuration." }, e.code as 500);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -254,6 +294,13 @@ setupRouter.post("/auth-provider", async (c) => {
|
|||||||
* Only available when needsSetup is true (no super user = fresh install).
|
* Only available when needsSetup is true (no super user = fresh install).
|
||||||
*/
|
*/
|
||||||
setupRouter.post("/auth-provider/test", async (c) => {
|
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();
|
const db = getDb();
|
||||||
|
|
||||||
// Guard: only allow during fresh install (no super user yet)
|
// Guard: only allow during fresh install (no super user yet)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
import { z } from "zod/v3";
|
||||||
import { eq, getDb, invoices } from "@groombook/db";
|
import { eq, getDb, invoices } from "@groombook/db";
|
||||||
import { getStripeClient } from "../services/payment.js";
|
import { getStripeClient } from "../services/payment.js";
|
||||||
|
|
||||||
@@ -44,10 +45,13 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||||
for (const invoiceId of invoiceIds) {
|
for (const invoiceId of invoiceIds) {
|
||||||
if (!invoiceId) continue;
|
if (!invoiceId) continue;
|
||||||
|
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
||||||
|
if (!parsed.success) continue;
|
||||||
|
const invoiceIdTrimmed = invoiceId.trim();
|
||||||
const [inv] = await db
|
const [inv] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq(invoices.id, invoiceId))
|
.where(eq(invoices.id, invoiceIdTrimmed))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!inv) continue;
|
if (!inv) continue;
|
||||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
||||||
@@ -60,7 +64,7 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
stripePaymentIntentId: pi.id,
|
stripePaymentIntentId: pi.id,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, invoiceId));
|
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === "payment_intent.payment_failed") {
|
} else if (event.type === "payment_intent.payment_failed") {
|
||||||
@@ -69,13 +73,16 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||||
for (const invoiceId of invoiceIds) {
|
for (const invoiceId of invoiceIds) {
|
||||||
if (!invoiceId) continue;
|
if (!invoiceId) continue;
|
||||||
|
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
||||||
|
if (!parsed.success) continue;
|
||||||
|
const invoiceIdTrimmed = invoiceId.trim();
|
||||||
await db
|
await db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set({
|
.set({
|
||||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, invoiceId));
|
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === "charge.refunded") {
|
} else if (event.type === "charge.refunded") {
|
||||||
|
|||||||
@@ -162,3 +162,19 @@ export async function createSetupIntent(customerId: string): Promise<{ clientSec
|
|||||||
|
|
||||||
return { clientSecret: setupIntent.client_secret! };
|
return { clientSecret: setupIntent.client_secret! };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPaymentIntentDetails(
|
||||||
|
paymentIntentId: string
|
||||||
|
): Promise<{ cardLast4: string | null; paymentStatus: string | null } | null> {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
if (!stripe) return null;
|
||||||
|
|
||||||
|
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["payment_method"] });
|
||||||
|
const cardLast4 = pi.payment_method
|
||||||
|
? (pi.payment_method as Stripe.PaymentMethod).card?.last4 ?? null
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
cardLast4,
|
||||||
|
paymentStatus: pi.status ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
eq,
|
eq,
|
||||||
getDb,
|
getDb,
|
||||||
gte,
|
gte,
|
||||||
|
inArray,
|
||||||
lt,
|
lt,
|
||||||
appointments,
|
appointments,
|
||||||
clients,
|
clients,
|
||||||
@@ -18,9 +19,10 @@ import {
|
|||||||
buildReminderEmail,
|
buildReminderEmail,
|
||||||
sendEmail,
|
sendEmail,
|
||||||
} from "./email.js";
|
} from "./email.js";
|
||||||
|
import { smsSend } from "./sms.js";
|
||||||
|
|
||||||
|
const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
|
||||||
|
|
||||||
// How many hours before the appointment to send each reminder.
|
|
||||||
// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2).
|
|
||||||
function getReminderWindows(): { label: string; hours: number }[] {
|
function getReminderWindows(): { label: string; hours: number }[] {
|
||||||
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
|
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
|
||||||
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
|
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
|
||||||
@@ -30,20 +32,14 @@ function getReminderWindows(): { label: string; hours: number }[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks for upcoming appointments that need reminders and sends them.
|
|
||||||
// Runs every minute — idempotent via reminder_logs unique constraint.
|
|
||||||
export async function runReminderCheck(): Promise<void> {
|
export async function runReminderCheck(): Promise<void> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
for (const window of getReminderWindows()) {
|
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 windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000);
|
||||||
const windowEnd = new Date(now.getTime() + window.hours * 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
|
const upcoming = await db
|
||||||
.select({
|
.select({
|
||||||
id: appointments.id,
|
id: appointments.id,
|
||||||
@@ -64,56 +60,78 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const appt of upcoming) {
|
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
|
||||||
// Check if reminder already sent (unique constraint prevents double-send)
|
if (appointmentIds.length === 0) continue;
|
||||||
const existing = await db
|
|
||||||
.select({ id: reminderLogs.id })
|
// Bulk check: which appointments already have email and SMS reminders sent?
|
||||||
.from(reminderLogs)
|
const sentRows = await db
|
||||||
.where(
|
.select({ appointmentId: reminderLogs.appointmentId, channel: reminderLogs.channel })
|
||||||
and(
|
.from(reminderLogs)
|
||||||
eq(reminderLogs.appointmentId, appt.id),
|
.where(
|
||||||
eq(reminderLogs.reminderType, window.label)
|
and(
|
||||||
)
|
eq(reminderLogs.reminderType, window.label),
|
||||||
|
appointmentIds.length === 1
|
||||||
|
? eq(reminderLogs.appointmentId, appointmentIds[0]!)
|
||||||
|
: inArray(reminderLogs.appointmentId, appointmentIds)
|
||||||
)
|
)
|
||||||
.limit(1);
|
);
|
||||||
|
|
||||||
if (existing.length > 0) continue; // already sent
|
const sentEmail = new Set(
|
||||||
|
sentRows.filter((r) => r.channel === "email").map((r) => r.appointmentId)
|
||||||
|
);
|
||||||
|
const sentSms = new Set(
|
||||||
|
sentRows.filter((r) => r.channel === "sms").map((r) => r.appointmentId)
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch related records for the email
|
// Bulk JOIN: fetch all client/pet/service/staff data in one query
|
||||||
const [client] = await db
|
const joinedRows = await db
|
||||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
.select({
|
||||||
.from(clients)
|
appointmentId: appointments.id,
|
||||||
.where(eq(clients.id, appt.clientId))
|
startTime: appointments.startTime,
|
||||||
.limit(1);
|
clientId: appointments.clientId,
|
||||||
|
petId: appointments.petId,
|
||||||
|
serviceId: appointments.serviceId,
|
||||||
|
staffId: appointments.staffId,
|
||||||
|
confirmationToken: appointments.confirmationToken,
|
||||||
|
clientName: clients.name,
|
||||||
|
clientEmail: clients.email,
|
||||||
|
clientEmailOptOut: clients.emailOptOut,
|
||||||
|
clientSmsOptIn: clients.smsOptIn,
|
||||||
|
clientPhone: clients.phone,
|
||||||
|
petName: pets.name,
|
||||||
|
serviceName: services.name,
|
||||||
|
staffName: staff.name,
|
||||||
|
})
|
||||||
|
.from(appointments)
|
||||||
|
.innerJoin(clients, eq(appointments.clientId, clients.id))
|
||||||
|
.innerJoin(pets, eq(appointments.petId, pets.id))
|
||||||
|
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||||
|
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
gte(appointments.startTime, windowStart),
|
||||||
|
lt(appointments.startTime, windowEnd),
|
||||||
|
eq(appointments.status, "scheduled")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (!client || !client.email || client.emailOptOut) continue;
|
const appointmentMap = new Map<string, typeof joinedRows[number]>();
|
||||||
|
for (const row of joinedRows) {
|
||||||
|
appointmentMap.set(row.appointmentId, row);
|
||||||
|
}
|
||||||
|
|
||||||
const [pet] = await db
|
for (const appt of upcoming) {
|
||||||
.select({ name: pets.name })
|
const joined = appointmentMap.get(appt.id as string);
|
||||||
.from(pets)
|
if (!joined) continue;
|
||||||
.where(eq(pets.id, appt.petId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const [service] = await db
|
const { clientName, clientEmail, clientEmailOptOut, clientSmsOptIn, clientPhone, petName, serviceName, staffName } = joined;
|
||||||
.select({ name: services.name })
|
|
||||||
.from(services)
|
|
||||||
.where(eq(services.id, appt.serviceId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let groomerName: string | null = null;
|
if (!clientEmail || clientEmailOptOut) continue;
|
||||||
if (appt.staffId) {
|
if (!petName || !serviceName) continue;
|
||||||
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;
|
const emailSent = sentEmail.has(appt.id as string);
|
||||||
|
const smsSent = sentSms.has(appt.id as string);
|
||||||
|
|
||||||
// Ensure the appointment has a confirmation token before sending the reminder.
|
|
||||||
// Generate one if it doesn't have one yet (e.g. pre-existing appointments).
|
|
||||||
let confirmationToken = appt.confirmationToken;
|
let confirmationToken = appt.confirmationToken;
|
||||||
if (!confirmationToken) {
|
if (!confirmationToken) {
|
||||||
confirmationToken = randomBytes(32).toString("hex");
|
confirmationToken = randomBytes(32).toString("hex");
|
||||||
@@ -123,35 +141,59 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
.where(eq(appointments.id, appt.id));
|
.where(eq(appointments.id, appt.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const sent = await sendEmail(
|
if (!emailSent) {
|
||||||
buildReminderEmail(
|
const sent = await sendEmail(
|
||||||
client.email,
|
buildReminderEmail(
|
||||||
{
|
clientEmail,
|
||||||
clientName: client.name,
|
{
|
||||||
petName: pet.name,
|
clientName,
|
||||||
serviceName: service.name,
|
petName,
|
||||||
groomerName,
|
serviceName,
|
||||||
startTime: appt.startTime,
|
groomerName: staffName,
|
||||||
},
|
startTime: appt.startTime,
|
||||||
window.hours,
|
},
|
||||||
confirmationToken
|
window.hours,
|
||||||
)
|
confirmationToken
|
||||||
);
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (sent) {
|
if (sent) {
|
||||||
// Record send — ignore conflicts (race condition between instances)
|
await db
|
||||||
await db
|
.insert(reminderLogs)
|
||||||
.insert(reminderLogs)
|
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
|
||||||
.values({ appointmentId: appt.id, reminderType: window.label })
|
.onConflictDoNothing();
|
||||||
.onConflictDoNothing();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!smsSent && clientSmsOptIn && clientPhone) {
|
||||||
|
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
||||||
|
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
||||||
|
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
||||||
|
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
||||||
|
const smsBody = [
|
||||||
|
`Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`,
|
||||||
|
`Service: ${serviceName}${staffName ? ` with ${staffName}` : ""}`,
|
||||||
|
`Confirm: ${confirmUrl}`,
|
||||||
|
`Cancel: ${cancelUrl}`,
|
||||||
|
TCPA_OPT_OUT,
|
||||||
|
].join(". ");
|
||||||
|
try {
|
||||||
|
const smsOk = await smsSend(clientPhone, smsBody);
|
||||||
|
if (smsOk) {
|
||||||
|
await db
|
||||||
|
.insert(reminderLogs)
|
||||||
|
.values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[reminders] SMS send failed:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Starts the cron scheduler. Call once at server startup.
|
|
||||||
export function startReminderScheduler(): void {
|
export function startReminderScheduler(): void {
|
||||||
// Run every minute
|
|
||||||
cron.schedule("* * * * *", () => {
|
cron.schedule("* * * * *", () => {
|
||||||
runReminderCheck().catch((err) => {
|
runReminderCheck().catch((err) => {
|
||||||
console.error("[reminders] Error during reminder check:", err);
|
console.error("[reminders] Error during reminder check:", err);
|
||||||
@@ -163,8 +205,6 @@ export function startReminderScheduler(): void {
|
|||||||
console.log("[reminders] Reminder scheduler started");
|
console.log("[reminders] Reminder scheduler started");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletes expired sessions from the database.
|
|
||||||
// Runs every minute alongside reminder checks.
|
|
||||||
export async function runSessionCleanup(): Promise<void> {
|
export async function runSessionCleanup(): Promise<void> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { Telnyx } from "telnyx";
|
||||||
|
import { createHmac } from "crypto";
|
||||||
|
|
||||||
|
export interface SmsProvider {
|
||||||
|
sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>;
|
||||||
|
validateWebhookSignature(req: Request): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TelnyxSmsResult {
|
||||||
|
message_id: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTelnyxClient(): Telnyx | null {
|
||||||
|
const apiKey = process.env.TELNYX_API_KEY;
|
||||||
|
if (!apiKey) return null;
|
||||||
|
return new Telnyx(apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _client: Telnyx | null | undefined;
|
||||||
|
|
||||||
|
function getClient(): Telnyx | null {
|
||||||
|
if (_client === undefined) _client = createTelnyxClient();
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFromNumber(): string | null {
|
||||||
|
return process.env.TELNYX_FROM_NUMBER ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isE164(phone: string): boolean {
|
||||||
|
return /^\+[1-9]\d{7,14}$/.test(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendSms(
|
||||||
|
to: string,
|
||||||
|
body: string,
|
||||||
|
mediaUrls?: string[]
|
||||||
|
): Promise<{ messageId: string; status: string }> {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY.");
|
||||||
|
|
||||||
|
const from = getFromNumber();
|
||||||
|
if (!from) throw new Error("TELNYX_FROM_NUMBER is not set");
|
||||||
|
|
||||||
|
if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`);
|
||||||
|
if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`);
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mediaUrls && mediaUrls.length > 0) {
|
||||||
|
payload.media_urls = mediaUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.messages.create(payload as Record<string, string | string[]>);
|
||||||
|
const smsResult = result.data as unknown as TelnyxSmsResult;
|
||||||
|
return {
|
||||||
|
messageId: smsResult.message_id,
|
||||||
|
status: smsResult.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TelnyxProvider implements SmsProvider {
|
||||||
|
async sendSms(
|
||||||
|
to: string,
|
||||||
|
body: string,
|
||||||
|
mediaUrls?: string[]
|
||||||
|
): Promise<{ messageId: string; status: string }> {
|
||||||
|
return sendSms(to, body, mediaUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateWebhookSignature(req: Request): boolean {
|
||||||
|
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
||||||
|
if (!secret) return false;
|
||||||
|
|
||||||
|
const signature = req.headers.get("telnyx-signature");
|
||||||
|
if (!signature) return false;
|
||||||
|
|
||||||
|
const payload = JSON.stringify(req.body);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hmac = createHmac("sha256", secret);
|
||||||
|
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
|
||||||
|
|
||||||
|
const sigBuf = Buffer.from(signature);
|
||||||
|
const expBuf = Buffer.from(expected);
|
||||||
|
|
||||||
|
if (sigBuf.length !== expBuf.length) return false;
|
||||||
|
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < sigBuf.length; i++) {
|
||||||
|
const sigByte = sigBuf[i] ?? 0;
|
||||||
|
const expByte = expBuf[i] ?? 0;
|
||||||
|
diff |= sigByte ^ expByte;
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _provider: SmsProvider | null | undefined;
|
||||||
|
|
||||||
|
export function createSmsProvider(): SmsProvider | null {
|
||||||
|
if (_provider === undefined) {
|
||||||
|
if (process.env.SMS_ENABLED !== "true") {
|
||||||
|
_provider = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
switch (process.env.SMS_PROVIDER) {
|
||||||
|
case "telnyx": {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) {
|
||||||
|
_provider = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_provider = new TelnyxProvider();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
_provider = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function smsSend(
|
||||||
|
to: string,
|
||||||
|
body: string,
|
||||||
|
mediaUrls?: string[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
const provider = createSmsProvider();
|
||||||
|
if (!provider) return false;
|
||||||
|
|
||||||
|
await provider.sendSms(to, body, mediaUrls);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
Vendored
+19
@@ -0,0 +1,19 @@
|
|||||||
|
declare module "telnyx" {
|
||||||
|
export interface MessageResult {
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessagesCreateParams {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
body: string;
|
||||||
|
media_urls?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Telnyx {
|
||||||
|
constructor(apiKey: string);
|
||||||
|
messages: {
|
||||||
|
create(params: Record<string, string | string[]>): Promise<MessageResult>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,3 +63,52 @@ test("clicking a client shows their details", async ({ page }) => {
|
|||||||
// Email appears in both the list row and the detail panel once selected
|
// Email appears in both the list row and the detail panel once selected
|
||||||
await expect(page.getByText("alice@example.com")).toHaveCount(2);
|
await expect(page.getByText("alice@example.com")).toHaveCount(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("direct URL navigation to client detail fetches data and renders client name", async ({ page }) => {
|
||||||
|
// Mock individual client fetch for direct navigation
|
||||||
|
await page.route("/api/clients/client-1", (route) =>
|
||||||
|
route.fulfill({ json: MOCK_CLIENTS[0] })
|
||||||
|
);
|
||||||
|
// Mock pets for this client
|
||||||
|
await page.route("/api/pets**", (route) =>
|
||||||
|
route.fulfill({ json: [] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/admin/clients/client-1");
|
||||||
|
// Client name must be visible without any clicking
|
||||||
|
await expect(page.getByText("Alice Johnson")).toBeVisible();
|
||||||
|
// Should show back to list link
|
||||||
|
await expect(page.getByText("← Back to list")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct URL navigation shows loading then client", async ({ page }) => {
|
||||||
|
let resolvePets: (value: unknown) => void;
|
||||||
|
const petsPromise = new Promise((resolve) => { resolvePets = resolve; });
|
||||||
|
|
||||||
|
await page.route("/api/clients/client-1", (route) =>
|
||||||
|
route.fulfill({ json: MOCK_CLIENTS[0] })
|
||||||
|
);
|
||||||
|
await page.route("/api/pets**", async (route) => {
|
||||||
|
await petsPromise;
|
||||||
|
await route.fulfill({ json: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigationPromise = page.goto("/admin/clients/client-1");
|
||||||
|
// Should show loading state briefly
|
||||||
|
await expect(page.getByText("Loading client…")).toBeVisible();
|
||||||
|
// Resolve pets and wait for navigation
|
||||||
|
resolvePets!();
|
||||||
|
await navigationPromise;
|
||||||
|
// After data loads, client name is shown
|
||||||
|
await expect(page.getByText("Alice Johnson")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct URL navigation shows error state on failure", async ({ page }) => {
|
||||||
|
await page.route("/api/clients/nonexistent", (route) =>
|
||||||
|
route.fulfill({ status: 404, json: { error: "Client not found" } })
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/admin/clients/nonexistent");
|
||||||
|
await expect(page.getByText(/client not found/i)).toBeVisible();
|
||||||
|
await expect(page.getByText("← Back to clients")).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ test.beforeEach(async ({ page }) => {
|
|||||||
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
|
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")) {
|
if (url.includes("/api/invoices")) {
|
||||||
return route.fulfill({ json: { data: [], total: 0 } });
|
return route.fulfill({ json: { data: [], total: 0 } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,3 +20,5 @@ FROM nginx:alpine AS runner
|
|||||||
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
|
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:80/ || exit 1
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
/**
|
/**
|
||||||
* Playwright configuration for GroomBook Web E2E tests.
|
* Playwright configuration for GroomBook Web E2E tests.
|
||||||
*
|
*
|
||||||
* Targets the deployed dev environment at groombook.dev.farh.net.
|
* Targets the deployed dev environment at dev.groombook.dev.
|
||||||
* Uses the dev login selector (/login) for authentication — no hardcoded credentials.
|
* Uses the dev login selector (/login) for authentication — no hardcoded credentials.
|
||||||
*
|
*
|
||||||
* Run locally:
|
* Run locally:
|
||||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
|||||||
reporter: process.env.CI ? "github" : "list",
|
reporter: process.env.CI ? "github" : "list",
|
||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL: "https://groombook.dev.farh.net",
|
baseURL: "https://dev.groombook.dev",
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
serviceWorkers: "block",
|
serviceWorkers: "block",
|
||||||
|
|||||||
@@ -3,10 +3,22 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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
|
# Cache static assets
|
||||||
location ~* \.(js|css|png|svg|ico|woff2)$ {
|
location ~* \.(js|css|png|svg|ico|woff2)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
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
|
# Proxy API calls to the API service
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-r
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AppointmentsPage } from "./pages/Appointments.js";
|
import { AppointmentsPage } from "./pages/Appointments.js";
|
||||||
import { ClientsPage } from "./pages/Clients.js";
|
import { ClientsPage } from "./pages/Clients.js";
|
||||||
|
import { ClientDetailPage } from "./pages/ClientDetailPage.js";
|
||||||
import { ServicesPage } from "./pages/Services.js";
|
import { ServicesPage } from "./pages/Services.js";
|
||||||
import { StaffPage } from "./pages/Staff.js";
|
import { StaffPage } from "./pages/Staff.js";
|
||||||
import { InvoicesPage } from "./pages/Invoices.js";
|
import { InvoicesPage } from "./pages/Invoices.js";
|
||||||
@@ -12,7 +13,7 @@ import { SettingsPage } from "./pages/Settings.js";
|
|||||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||||
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||||
import { SetupWizard } from "./pages/SetupWizard.jsx";
|
import { SetupWizard } from "./pages/SetupWizard.tsx";
|
||||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
@@ -296,6 +297,7 @@ function AdminLayout() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<AppointmentsPage />} />
|
<Route path="/" element={<AppointmentsPage />} />
|
||||||
<Route path="/clients" element={<ClientsPage />} />
|
<Route path="/clients" element={<ClientsPage />} />
|
||||||
|
<Route path="/clients/:clientId" element={<ClientDetailPage />} />
|
||||||
<Route path="/services" element={<ServicesPage />} />
|
<Route path="/services" element={<ServicesPage />} />
|
||||||
<Route path="/staff" element={<StaffPage />} />
|
<Route path="/staff" element={<StaffPage />} />
|
||||||
<Route path="/invoices" element={<InvoicesPage />} />
|
<Route path="/invoices" element={<InvoicesPage />} />
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ describe("CustomerNotesSection", () => {
|
|||||||
"/api/portal/appointments/appt-1/notes",
|
"/api/portal/appointments/appt-1/notes",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.objectContaining({
|
headers: expect.objectContaining({
|
||||||
"Authorization": "Bearer test-session-id",
|
"X-Impersonation-Session-Id": "test-session-id",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -269,7 +269,7 @@ describe("ConfirmationSection", () => {
|
|||||||
"/api/portal/appointments/appt-1/confirm",
|
"/api/portal/appointments/appt-1/confirm",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.objectContaining({
|
headers: expect.objectContaining({
|
||||||
"Authorization": "Bearer test-session-id",
|
"X-Impersonation-Session-Id": "test-session-id",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function GlobalSearch() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<SearchResults | null>(null);
|
const [results, setResults] = useState<SearchResults | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -45,15 +46,18 @@ export function GlobalSearch() {
|
|||||||
|
|
||||||
debounceRef.current = setTimeout(async () => {
|
debounceRef.current = setTimeout(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
|
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data: SearchResults = await res.json();
|
const data: SearchResults = await res.json();
|
||||||
setResults(data);
|
setResults(data);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
|
} else {
|
||||||
|
setError("Search failed. Please try again.");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.warn("GlobalSearch: fetch error", err);
|
setError("Search failed. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -160,7 +164,13 @@ export function GlobalSearch() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !hasResults && (
|
{!loading && error && (
|
||||||
|
<div style={{ padding: "12px 16px", fontSize: 13, color: "#dc2626" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && !hasResults && (
|
||||||
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
|
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
|
||||||
No results found
|
No results found
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ export function PetPhotoUpload({ petId, onUploaded }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
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)) {
|
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||||
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
|
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
|
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -112,9 +112,17 @@ export function AppointmentsPage() {
|
|||||||
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
||||||
// null key = unassigned; staffId string = that groomer; undefined set = all visible
|
// null key = unassigned; staffId string = that groomer; undefined set = all visible
|
||||||
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
|
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);
|
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 loadAppointments = useCallback(() => {
|
||||||
const from = weekStart.toISOString();
|
const from = weekStart.toISOString();
|
||||||
const to = addDays(weekStart, 7).toISOString();
|
const to = addDays(weekStart, 7).toISOString();
|
||||||
@@ -273,7 +281,15 @@ export function AppointmentsPage() {
|
|||||||
cascade !== "this_only"
|
cascade !== "this_only"
|
||||||
? `/api/appointments/${id}?cascade=${cascade}`
|
? `/api/appointments/${id}?cascade=${cascade}`
|
||||||
: `/api/appointments/${id}`;
|
: `/api/appointments/${id}`;
|
||||||
await fetch(url, { method: "DELETE" });
|
try {
|
||||||
|
const res = await fetch(url, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json()) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : "Failed to delete appointment");
|
||||||
|
}
|
||||||
setSelectedAppt(null);
|
setSelectedAppt(null);
|
||||||
await loadAppointments();
|
await loadAppointments();
|
||||||
}
|
}
|
||||||
@@ -306,6 +322,24 @@ export function AppointmentsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ── */}
|
{/* ── View Mode + Groomer Filters ── */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
|
<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>
|
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
|
||||||
@@ -819,8 +853,49 @@ function AppointmentDetail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -833,6 +908,7 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
|
|||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={modalRef}
|
||||||
style={{
|
style={{
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useParams, Link } from "react-router-dom";
|
||||||
|
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
||||||
|
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
||||||
|
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
|
||||||
|
|
||||||
|
export function ClientDetailPage() {
|
||||||
|
const { clientId } = useParams<{ clientId: string }>();
|
||||||
|
const [client, setClient] = useState<Client | null>(null);
|
||||||
|
const [pets, setPets] = useState<Pet[]>([]);
|
||||||
|
const [visitLogs, setVisitLogs] = useState<Record<string, GroomingVisitLog[]>>({});
|
||||||
|
const [logsLoading, setLogsLoading] = useState<Record<string, boolean>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [photoRevisions, setPhotoRevisions] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
const handlePhotoUploaded = useCallback((petId: string) => {
|
||||||
|
setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clientId) {
|
||||||
|
setError("No client ID provided");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const id = clientId!;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [clientRes, petsRes] = await Promise.all([
|
||||||
|
fetch(`/api/clients/${encodeURIComponent(id)}`),
|
||||||
|
fetch(`/api/pets?clientId=${encodeURIComponent(id)}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!clientRes.ok) {
|
||||||
|
const err = await clientRes.json().catch(() => ({})) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `Client fetch failed: ${clientRes.status}`);
|
||||||
|
}
|
||||||
|
if (!petsRes.ok) {
|
||||||
|
throw new Error(`Pets fetch failed: ${petsRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setClient(await clientRes.json() as Client);
|
||||||
|
setPets(await petsRes.json() as Pet[]);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load client");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
}, [clientId]);
|
||||||
|
|
||||||
|
async function loadVisitLogs(petId: string) {
|
||||||
|
setLogsLoading((prev) => ({ ...prev, [petId]: true }));
|
||||||
|
const r = await fetch(`/api/grooming-logs?petId=${encodeURIComponent(petId)}`);
|
||||||
|
if (r.ok) {
|
||||||
|
const logs = await r.json() as GroomingVisitLog[];
|
||||||
|
setVisitLogs((prev) => ({ ...prev, [petId]: logs }));
|
||||||
|
}
|
||||||
|
setLogsLoading((prev) => ({ ...prev, [petId]: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "2rem", textAlign: "center", color: "#6b7280", fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
Loading client…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !client) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<Link to="/admin/clients" style={{ color: "#4f8a6f", fontSize: 13 }}>← Back to clients</Link>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "1rem", color: "#991b1b" }}>
|
||||||
|
{error ?? "Client not found"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: "1.5rem", gap: "1rem" }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.25rem" }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 22 }}>{client.name}</h1>
|
||||||
|
{client.status === "disabled" && (
|
||||||
|
<span style={{ fontSize: 12, background: "#fef2f2", color: "#dc2626", padding: "0.15rem 0.5rem", borderRadius: 4, fontWeight: 500 }}>
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{client.email && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.email}</div>}
|
||||||
|
{client.phone && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.phone}</div>}
|
||||||
|
{client.address && <div style={{ fontSize: 13, color: "#6b7280" }}>{client.address}</div>}
|
||||||
|
{client.notes && (
|
||||||
|
<div style={{ fontSize: 13, marginTop: "0.4rem", background: "#fef9c3", padding: "0.4rem 0.6rem", borderRadius: 4, maxWidth: 500 }}>
|
||||||
|
{client.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/admin/clients"
|
||||||
|
style={{
|
||||||
|
padding: "0.4rem 0.85rem",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "#fff",
|
||||||
|
color: "#374151",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Back to list
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pets */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18 }}>Pets</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pets.length === 0 ? (
|
||||||
|
<p style={{ color: "#6b7280", fontSize: 14 }}>No pets on file for this client.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "0.75rem" }}>
|
||||||
|
{pets.map((p) => (
|
||||||
|
<div key={p.id} style={{ border: "1px solid #e5e7eb", borderRadius: 10, padding: "0.85rem", background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
|
||||||
|
{/* Photo + header */}
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.4rem" }}>
|
||||||
|
<PetPhotoDisplay
|
||||||
|
petId={p.id}
|
||||||
|
size={56}
|
||||||
|
key={`${p.id}-photo-${photoRevisions[p.id] ?? 0}`}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
<strong style={{ fontSize: 15 }}>{p.name}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
|
||||||
|
{p.species}{p.breed ? ` · ${p.breed}` : ""}
|
||||||
|
</div>
|
||||||
|
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
|
||||||
|
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
|
||||||
|
<div style={{ marginTop: "0.3rem" }}>
|
||||||
|
<PetPhotoUpload petId={p.id} onUploaded={() => handlePhotoUploaded(p.id)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.healthAlerts && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: "0.35rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#dc2626" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>⚠ Health alerts:</span> {p.healthAlerts}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grooming preferences */}
|
||||||
|
{(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && (
|
||||||
|
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
|
||||||
|
{p.cutStyle && (
|
||||||
|
<div style={{ fontSize: 12, color: "#374151" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Cut:</span> {p.cutStyle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.shampooPreference && (
|
||||||
|
<div style={{ fontSize: 12, color: "#374151" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Shampoo:</span> {p.shampooPreference}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.specialCareNotes && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: "0.2rem", background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#92400e" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Special care:</span> {p.specialCareNotes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.groomingNotes && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: "0.2rem", color: "#374151" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Visit history */}
|
||||||
|
{(() => {
|
||||||
|
const logs = visitLogs[p.id];
|
||||||
|
const loadingLogs = logsLoading[p.id];
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.25rem" }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280" }}>VISIT HISTORY</div>
|
||||||
|
{!logs && !loadingLogs && (
|
||||||
|
<button
|
||||||
|
onClick={() => { void loadVisitLogs(p.id); }}
|
||||||
|
style={{ fontSize: 11, color: "#4f8a6f", background: "none", border: "none", cursor: "pointer", padding: 0 }}
|
||||||
|
>
|
||||||
|
Load history
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loadingLogs && <div style={{ fontSize: 11, color: "#9ca3af" }}>Loading…</div>}
|
||||||
|
{logs && logs.length === 0 && <div style={{ fontSize: 11, color: "#9ca3af" }}>No visits yet</div>}
|
||||||
|
{logs && logs.length > 0 && (
|
||||||
|
<>
|
||||||
|
{logs.slice(0, 3).map((log) => (
|
||||||
|
<div key={log.id} style={{ fontSize: 11, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
|
||||||
|
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
|
||||||
|
{log.cutStyle && <span> · {log.cutStyle}</span>}
|
||||||
|
{log.notes && <span> · {log.notes}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{logs.length > 3 && (
|
||||||
|
<div style={{ fontSize: 11, color: "#6b7280" }}>+{logs.length - 3} more visits</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef, useId } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
||||||
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
||||||
@@ -647,8 +647,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Client modal ── */}
|
{/* ── Client modal ── */}
|
||||||
{showClientForm && (
|
{showClientForm && (
|
||||||
<Modal onClose={() => setShowClientForm(false)}>
|
<Modal title={editingClient ? "Edit Client" : "New Client"} onClose={() => setShowClientForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>{editingClient ? "Edit Client" : "New Client"}</h2>
|
|
||||||
<form onSubmit={submitClient}>
|
<form onSubmit={submitClient}>
|
||||||
<Field label="Full name">
|
<Field label="Full name">
|
||||||
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||||
@@ -678,8 +677,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Pet modal ── */}
|
{/* ── Pet modal ── */}
|
||||||
{showPetForm && (
|
{showPetForm && (
|
||||||
<Modal onClose={() => setShowPetForm(false)}>
|
<Modal title={editingPet ? "Edit Pet" : "Add Pet"} onClose={() => setShowPetForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>{editingPet ? "Edit Pet" : "Add Pet"}</h2>
|
|
||||||
<form onSubmit={submitPet}>
|
<form onSubmit={submitPet}>
|
||||||
<Field label="Pet name">
|
<Field label="Pet name">
|
||||||
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||||
@@ -753,8 +751,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Visit log modal ── */}
|
{/* ── Visit log modal ── */}
|
||||||
{showLogForm && logPetId && (
|
{showLogForm && logPetId && (
|
||||||
<Modal onClose={() => setShowLogForm(false)}>
|
<Modal title="Log Grooming Visit" onClose={() => setShowLogForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>Log Grooming Visit</h2>
|
|
||||||
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history…</p>}
|
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history…</p>}
|
||||||
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
|
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
@@ -817,8 +814,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Delete confirmation modal ── */}
|
{/* ── Delete confirmation modal ── */}
|
||||||
{showDeleteConfirm && selectedClient && (
|
{showDeleteConfirm && selectedClient && (
|
||||||
<Modal onClose={() => setShowDeleteConfirm(false)}>
|
<Modal title="Permanently Delete Client" titleStyle={{ color: "#dc2626" }} onClose={() => setShowDeleteConfirm(false)}>
|
||||||
<h2 style={{ marginTop: 0, color: "#dc2626" }}>Permanently Delete Client</h2>
|
|
||||||
<p style={{ fontSize: 14, color: "#374151" }}>
|
<p style={{ fontSize: 14, color: "#374151" }}>
|
||||||
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
|
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
@@ -856,13 +852,60 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
// ─── Shared UI ───────────────────────────────────────────────────────────────
|
// ─── Shared UI ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
function Modal({ children, onClose, title, titleStyle }: { children: React.ReactNode; onClose: () => void; title: string; titleStyle?: React.CSSProperties }) {
|
||||||
|
const titleId = useId();
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement;
|
||||||
|
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||||
|
const firstFocusable = focusableElements?.[0];
|
||||||
|
firstFocusable?.focus();
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
if (!modalRef.current) return;
|
||||||
|
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||||
|
const first = focusables[0];
|
||||||
|
const last = focusables[focusables.length - 1];
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last?.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
previouslyFocused?.focus();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
|
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(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}>
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}
|
||||||
|
>
|
||||||
|
<h2 id={titleId} style={{ marginTop: 0, ...titleStyle }}>{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+223
-29
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
|
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -173,6 +173,21 @@ function InvoiceDetailModal({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
||||||
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
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}
|
// Tip split state: array of {staffId, staffName, pct}
|
||||||
const linkedAppt = invoice.appointmentId
|
const linkedAppt = invoice.appointmentId
|
||||||
@@ -211,36 +226,41 @@ function InvoiceDetailModal({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const tipCents = Math.round(parseFloat(tipStr) * 100) || 0;
|
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 {
|
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}`, {
|
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ status: "paid", paymentMethod, tipCents }),
|
body: JSON.stringify(patchBody),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = (await res.json()) as { error?: string };
|
const err = (await res.json()) as { error?: string };
|
||||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
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();
|
onUpdated();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to update");
|
setError(e instanceof Error ? e.message : "Failed to update");
|
||||||
@@ -330,6 +350,19 @@ function InvoiceDetailModal({
|
|||||||
/>
|
/>
|
||||||
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
||||||
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* ── Tip Distribution ── */}
|
{/* ── Tip Distribution ── */}
|
||||||
@@ -447,11 +480,92 @@ function InvoiceDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(invoice.status === "paid" || invoice.status === "void") && (
|
{(invoice.status === "paid" || invoice.status === "void") && (
|
||||||
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
|
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
|
||||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
|
||||||
|
{showRefundDialog && (
|
||||||
|
<div style={{ marginTop: "1rem", border: "1px solid #e2e8f0", borderRadius: 8, padding: "1rem", background: "#f9fafb" }}>
|
||||||
|
<p style={{ fontWeight: 600, margin: "0 0 0.75rem" }}>Process Refund</p>
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
|
||||||
|
<input type="radio" checked={refundType === "full"} onChange={() => setRefundType("full")} />
|
||||||
|
Full refund
|
||||||
|
</label>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
|
||||||
|
<input type="radio" checked={refundType === "partial"} onChange={() => setRefundType("partial")} />
|
||||||
|
Partial refund
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{refundType === "partial" && (
|
||||||
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="Amount ($)"
|
||||||
|
value={refundAmount}
|
||||||
|
onChange={(e) => setRefundAmount(e.target.value)}
|
||||||
|
style={{ ...inputStyle, width: 100 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setRefunding(true);
|
||||||
|
setRefundError(null);
|
||||||
|
try {
|
||||||
|
if (refundType === "partial") {
|
||||||
|
const parsed = parseFloat(refundAmount);
|
||||||
|
if (isNaN(parsed) || parsed <= 0) {
|
||||||
|
setRefundError("Please enter a valid amount greater than zero.");
|
||||||
|
setRefunding(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const body = refundType === "partial" ? { amountCents: Math.round(parseFloat(refundAmount) * 100) } : {};
|
||||||
|
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json()) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setShowRefundDialog(false);
|
||||||
|
onUpdated();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setRefundError(e instanceof Error ? e.message : "Refund failed");
|
||||||
|
} finally {
|
||||||
|
setRefunding(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={refunding}
|
||||||
|
style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}
|
||||||
|
>
|
||||||
|
{refunding ? "Processing…" : "Process Refund"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,9 +606,17 @@ export function InvoicesPage() {
|
|||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
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;
|
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) {
|
async function loadInvoices(newOffset: number) {
|
||||||
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
|
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
|
||||||
if (statusFilter) params.set("status", statusFilter);
|
if (statusFilter) params.set("status", statusFilter);
|
||||||
@@ -573,6 +695,34 @@ export function InvoicesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ? (
|
{invoiceList.length === 0 ? (
|
||||||
<p style={{ color: "#6b7280" }}>
|
<p style={{ color: "#6b7280" }}>
|
||||||
No invoices yet. Create one from a completed appointment.
|
No invoices yet. Create one from a completed appointment.
|
||||||
@@ -677,19 +827,63 @@ export function InvoicesPage() {
|
|||||||
// ─── Shared UI helpers ────────────────────────────────────────────────────────
|
// ─── Shared UI helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
|
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
|
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
|
||||||
}}
|
}}
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div
|
||||||
background: "#fff", borderRadius: 8, padding: "1.5rem",
|
ref={modalRef}
|
||||||
maxWidth: 520, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
|
style={{
|
||||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -199,11 +199,11 @@ export function ReportsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
|
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
|
||||||
summRes.json() as Promise<Summary>,
|
summRes.ok ? summRes.json() as Promise<Summary> : summRes.text().then(() => { throw new Error("summary response not ok"); }),
|
||||||
revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }>,
|
revRes.ok ? revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }> : revRes.text().then(() => { throw new Error("revenue response not ok"); }),
|
||||||
apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }>,
|
apptRes.ok ? apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }> : apptRes.text().then(() => { throw new Error("appointments response not ok"); }),
|
||||||
svcRes.json() as Promise<{ rows: ServiceRow[] }>,
|
svcRes.ok ? svcRes.json() as Promise<{ rows: ServiceRow[] }> : svcRes.text().then(() => { throw new Error("services response not ok"); }),
|
||||||
clientRes.json() as Promise<ClientReport>,
|
clientRes.ok ? clientRes.json() as Promise<ClientReport> : clientRes.text().then(() => { throw new Error("clients response not ok"); }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setSummary(summData);
|
setSummary(summData);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface AuthProviderForm {
|
|||||||
|
|
||||||
const REDACTED = "••••••••";
|
const REDACTED = "••••••••";
|
||||||
|
|
||||||
|
const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||||
|
|
||||||
interface CurrentUser {
|
interface CurrentUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -87,24 +89,14 @@ export function SettingsPage() {
|
|||||||
fetch("/api/admin/settings")
|
fetch("/api/admin/settings")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(async (data) => {
|
.then(async (data) => {
|
||||||
let logoUrl: string | null = null;
|
// The logo is now proxied through the API server so the browser
|
||||||
if (data.logoKey) {
|
// never receives an S3 URL — use the proxy path directly as the src.
|
||||||
try {
|
|
||||||
const logoRes = await fetch("/api/admin/settings/logo");
|
|
||||||
if (logoRes.ok) {
|
|
||||||
const logoData = await logoRes.json();
|
|
||||||
logoUrl = logoData.url;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setForm({
|
setForm({
|
||||||
businessName: data.businessName ?? "GroomBook",
|
businessName: data.businessName ?? "GroomBook",
|
||||||
primaryColor: data.primaryColor ?? "#4f8a6f",
|
primaryColor: data.primaryColor ?? "#4f8a6f",
|
||||||
accentColor: data.accentColor ?? "#8b7355",
|
accentColor: data.accentColor ?? "#8b7355",
|
||||||
logoKey: data.logoKey ?? null,
|
logoKey: data.logoKey ?? null,
|
||||||
logoUrl,
|
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
|
||||||
logoBase64: data.logoBase64 ?? null,
|
logoBase64: data.logoBase64 ?? null,
|
||||||
logoMimeType: data.logoMimeType ?? null,
|
logoMimeType: data.logoMimeType ?? null,
|
||||||
});
|
});
|
||||||
@@ -149,54 +141,28 @@ export function SettingsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validTypes = ["image/png", "image/svg+xml", "image/jpeg", "image/webp"];
|
const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||||
if (!validTypes.includes(file.type)) {
|
if (!validTypes.includes(file.type)) {
|
||||||
setMessage({ type: "error", text: "Logo must be PNG, SVG, JPEG, or WebP." });
|
setMessage({ type: "error", text: "Logo must be PNG, JPEG, GIF, or WebP." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get presigned upload URL
|
// Upload directly through the API server to avoid mixed-content issues
|
||||||
const uploadRes = await fetch("/api/admin/settings/logo/upload-url", {
|
// with pre-signed URLs that use the internal HTTP endpoint
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const uploadRes = await fetch("/api/admin/settings/logo/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: formData,
|
||||||
body: JSON.stringify({ contentType: file.type, fileSizeBytes: file.size }),
|
|
||||||
});
|
});
|
||||||
if (!uploadRes.ok) {
|
if (!uploadRes.ok) {
|
||||||
const err = await uploadRes.json().catch(() => null);
|
const err = await uploadRes.json().catch(() => null);
|
||||||
throw new Error(err?.error ?? "Failed to get upload URL");
|
throw new Error(err?.error ?? "Failed to upload logo");
|
||||||
}
|
|
||||||
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." });
|
setMessage({ type: "success", text: "Logo uploaded." });
|
||||||
refresh();
|
refresh();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -326,7 +292,7 @@ issuerUrl: authForm.issuerUrl,
|
|||||||
|
|
||||||
if (!loaded) return <p>Loading settings...</p>;
|
if (!loaded) return <p>Loading settings...</p>;
|
||||||
|
|
||||||
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
|
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType && ALLOWED_LOGO_TYPES.has(form.logoMimeType) ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 600 }}>
|
<div style={{ maxWidth: 600 }}>
|
||||||
@@ -393,7 +359,7 @@ issuerUrl: authForm.issuerUrl,
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/svg+xml,image/jpeg,image/webp"
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
onChange={handleLogoChange}
|
onChange={handleLogoChange}
|
||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
export { SetupWizard } from "./SetupWizard.jsx";
|
export { SetupWizard } from "./SetupWizard.tsx";
|
||||||
|
|||||||
@@ -2,16 +2,39 @@ import { useState, useEffect } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useBranding } from "../BrandingContext.js";
|
import { useBranding } from "../BrandingContext.js";
|
||||||
|
|
||||||
export function SetupWizard({ onSetupComplete }) {
|
interface SetupStatus {
|
||||||
|
showAuthProviderStep?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestResult {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthFormState {
|
||||||
|
providerId: string;
|
||||||
|
displayName: string;
|
||||||
|
issuerUrl: string;
|
||||||
|
internalBaseUrl: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
scopes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { refresh: refreshBranding } = useBranding();
|
const { refresh: refreshBranding } = useBranding();
|
||||||
|
|
||||||
// Fetch setup status to determine if auth provider step is needed
|
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
|
||||||
const [setupStatus, setSetupStatus] = useState(null); // null = loading
|
|
||||||
const [loadingStatus, setLoadingStatus] = useState(true);
|
const [loadingStatus, setLoadingStatus] = useState(true);
|
||||||
|
|
||||||
// Auth provider form state
|
const [authForm, setAuthForm] = useState<AuthFormState>({
|
||||||
const [authForm, setAuthForm] = useState({
|
|
||||||
providerId: "authentik",
|
providerId: "authentik",
|
||||||
displayName: "",
|
displayName: "",
|
||||||
issuerUrl: "",
|
issuerUrl: "",
|
||||||
@@ -21,16 +44,16 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
scopes: "openid profile email",
|
scopes: "openid profile email",
|
||||||
});
|
});
|
||||||
const [testingConnection, setTestingConnection] = useState(false);
|
const [testingConnection, setTestingConnection] = useState(false);
|
||||||
const [testResult, setTestResult] = useState(null); // {ok: boolean, error?: string}
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [businessName, setBusinessName] = useState("");
|
const [businessName, setBusinessName] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/setup/status")
|
fetch("/api/setup/status")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json() as Promise<SetupStatus>)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setSetupStatus(data);
|
setSetupStatus(data);
|
||||||
setLoadingStatus(false);
|
setLoadingStatus(false);
|
||||||
@@ -40,8 +63,7 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Build steps dynamically based on setup status
|
const STEPS: Step[] = setupStatus?.showAuthProviderStep
|
||||||
const STEPS = setupStatus?.showAuthProviderStep
|
|
||||||
? [
|
? [
|
||||||
{ id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
|
{ 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." },
|
{ id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." },
|
||||||
@@ -63,9 +85,8 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
const isFirst = step === 0;
|
const isFirst = step === 0;
|
||||||
const canGoBack = step > 0 && step < STEPS.length - 1;
|
const canGoBack = step > 0 && step < STEPS.length - 1;
|
||||||
|
|
||||||
// Determine if we can proceed - depends on which step we're on
|
|
||||||
const canGoNext = (() => {
|
const canGoNext = (() => {
|
||||||
if (step === STEPS.length - 1) return true; // done step
|
if (step === STEPS.length - 1) return true;
|
||||||
if (current?.id === "business") return businessName.trim().length > 0;
|
if (current?.id === "business") return businessName.trim().length > 0;
|
||||||
if (current?.id === "auth") {
|
if (current?.id === "auth") {
|
||||||
return (
|
return (
|
||||||
@@ -94,9 +115,9 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
scopes: authForm.scopes,
|
scopes: authForm.scopes,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = (await res.json()) as TestResult;
|
||||||
setTestResult(data);
|
setTestResult(data);
|
||||||
} catch (e) {
|
} catch {
|
||||||
setTestResult({ ok: false, error: "Network error. Please try again." });
|
setTestResult({ ok: false, error: "Network error. Please try again." });
|
||||||
} finally {
|
} finally {
|
||||||
setTestingConnection(false);
|
setTestingConnection(false);
|
||||||
@@ -105,12 +126,10 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
if (step === STEPS.length - 1) {
|
if (step === STEPS.length - 1) {
|
||||||
// Done - redirect to admin
|
|
||||||
navigate("/admin");
|
navigate("/admin");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit auth provider config
|
|
||||||
if (current?.id === "auth") {
|
if (current?.id === "auth") {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -129,12 +148,12 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = (await res.json()) as { error?: string };
|
||||||
setError(data.error || "Failed to save auth provider configuration. Please try again.");
|
setError(data.error || "Failed to save auth provider configuration. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
setError("Network error. Please try again.");
|
setError("Network error. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -142,7 +161,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit business name and complete setup
|
|
||||||
if (current?.id === "business" && businessName.trim()) {
|
if (current?.id === "business" && businessName.trim()) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -153,16 +171,14 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
body: JSON.stringify({ businessName: businessName.trim() }),
|
body: JSON.stringify({ businessName: businessName.trim() }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = (await res.json()) as { error?: string };
|
||||||
setError(data.error || "Setup failed. Please try again.");
|
setError(data.error || "Setup failed. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Refresh branding so the nav bar shows the new business name
|
|
||||||
refreshBranding();
|
refreshBranding();
|
||||||
// Clear needsSetup state in App so the redirect to /admin sticks
|
|
||||||
if (onSetupComplete) onSetupComplete();
|
if (onSetupComplete) onSetupComplete();
|
||||||
} catch (e) {
|
} catch {
|
||||||
setError("Network error. Please try again.");
|
setError("Network error. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -192,7 +208,7 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputStyle = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "0.6rem 0.85rem",
|
padding: "0.6rem 0.85rem",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@@ -220,7 +236,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
maxWidth: 480,
|
maxWidth: 480,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}}>
|
}}>
|
||||||
{/* Progress dots */}
|
|
||||||
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
|
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
|
||||||
{STEPS.map((_, i) => (
|
{STEPS.map((_, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -237,38 +252,32 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step indicator */}
|
|
||||||
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
||||||
Step {step + 1} of {STEPS.length}
|
Step {step + 1} of {STEPS.length}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
||||||
{current?.title}
|
{current?.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
||||||
{current?.description}
|
{current?.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Step: Business name input */}
|
|
||||||
{current?.id === "business" && (
|
{current?.id === "business" && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. Happy Paws Grooming"
|
placeholder="e.g. Happy Paws Grooming"
|
||||||
value={businessName}
|
value={businessName}
|
||||||
onChange={(e) => setBusinessName(e.target.value)}
|
onChange={(e) => setBusinessName(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
|
onKeyDown={(e) => e.key === "Enter" && canGoNext && void handleNext()}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step: Auth provider config form */}
|
|
||||||
{current?.id === "auth" && (
|
{current?.id === "auth" && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
|
||||||
{/* Provider ID */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Provider ID
|
Provider ID
|
||||||
@@ -282,7 +291,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Display Name
|
Display Name
|
||||||
@@ -296,7 +304,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Issuer URL */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Issuer URL
|
Issuer URL
|
||||||
@@ -310,7 +317,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Internal Base URL (optional) */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
|
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
|
||||||
@@ -324,7 +330,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Client ID */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Client ID
|
Client ID
|
||||||
@@ -338,7 +343,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Client Secret */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Client Secret
|
Client Secret
|
||||||
@@ -352,7 +356,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scopes */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Scopes
|
Scopes
|
||||||
@@ -366,10 +369,9 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Test Connection button */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleTestConnection}
|
onClick={() => { void handleTestConnection(); }}
|
||||||
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
|
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.45rem 0.85rem",
|
padding: "0.45rem 0.85rem",
|
||||||
@@ -387,7 +389,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
{testingConnection ? "Testing..." : "Test Connection"}
|
{testingConnection ? "Testing..." : "Test Connection"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Test result */}
|
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: "0.5rem 0.75rem",
|
padding: "0.5rem 0.75rem",
|
||||||
@@ -405,7 +406,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step: Super user info */}
|
|
||||||
{current?.id === "superuser" && (
|
{current?.id === "superuser" && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: "#f0fdf4",
|
background: "#f0fdf4",
|
||||||
@@ -420,7 +420,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step: Second admin info */}
|
|
||||||
{current?.id === "admin" && (
|
{current?.id === "admin" && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: "#fffbeb",
|
background: "#fffbeb",
|
||||||
@@ -434,7 +433,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error message */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<p style={{
|
<p style={{
|
||||||
margin: "0.5rem 0 0",
|
margin: "0.5rem 0 0",
|
||||||
@@ -449,7 +447,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Navigation buttons */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
@@ -476,7 +473,7 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
onClick={() => { void handleNext(); }}
|
||||||
disabled={(!canGoNext && !isLast) || loading}
|
disabled={(!canGoNext && !isLast) || loading}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.55rem 1.25rem",
|
padding: "0.55rem 1.25rem",
|
||||||
@@ -16,6 +16,7 @@ import { AuditLogViewer } from "./AuditLogViewer.js";
|
|||||||
import { useBranding } from "../BrandingContext.js";
|
import { useBranding } from "../BrandingContext.js";
|
||||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||||
import type { ImpersonationSession } from "@groombook/types";
|
import type { ImpersonationSession } from "@groombook/types";
|
||||||
|
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
||||||
|
|
||||||
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
|
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export function CustomerPortal() {
|
|||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
const [showReschedule, setShowReschedule] = useState(false);
|
const [showReschedule, setShowReschedule] = useState(false);
|
||||||
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
|
const [rescheduleAppointment, setRescheduleAppointment] = useState<PortalAppointment | null>(null);
|
||||||
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
||||||
const [sessionExtended, setSessionExtended] = useState(false);
|
const [sessionExtended, setSessionExtended] = useState(false);
|
||||||
const [clientName, setClientName] = useState<string>("");
|
const [clientName, setClientName] = useState<string>("");
|
||||||
@@ -149,7 +150,7 @@ export function CustomerPortal() {
|
|||||||
const handleReschedule = useCallback((appointmentId: string) => {
|
const handleReschedule = useCallback((appointmentId: string) => {
|
||||||
// Look up the full appointment from Dashboard's displayed data
|
// 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
|
// The appointment was already fetched by Dashboard, so we use the ID to find it
|
||||||
setRescheduleAppointment({ id: appointmentId } as Record<string, unknown>);
|
setRescheduleAppointment({ id: appointmentId } as PortalAppointment);
|
||||||
setShowReschedule(true);
|
setShowReschedule(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -227,7 +228,7 @@ export function CustomerPortal() {
|
|||||||
|
|
||||||
{showReschedule && rescheduleAppointment && (
|
{showReschedule && rescheduleAppointment && (
|
||||||
<RescheduleFlow
|
<RescheduleFlow
|
||||||
appointment={rescheduleAppointment as any}
|
appointment={rescheduleAppointment}
|
||||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||||
sessionId={session?.id ?? null}
|
sessionId={session?.id ?? null}
|
||||||
/>
|
/>
|
||||||
@@ -325,7 +326,7 @@ export function CustomerPortal() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 min-h-screen">
|
<main className="flex-1 min-h-screen overflow-hidden">
|
||||||
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-stone-800">
|
<h1 className="text-lg font-semibold text-stone-800">
|
||||||
@@ -339,7 +340,7 @@ export function CustomerPortal() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 md:p-8 max-w-6xl">
|
<div className="p-4 md:p-8 max-w-6xl w-full overflow-hidden">
|
||||||
{renderSection()}
|
{renderSection()}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface Appointment {
|
export interface Appointment {
|
||||||
id: string;
|
id: string;
|
||||||
petId: string;
|
petId: string;
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
@@ -379,7 +379,7 @@ export function ConfirmationSection({
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||||
}
|
}
|
||||||
const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, {
|
const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -455,7 +455,7 @@ function CancelAppointmentButton({
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||||
}
|
}
|
||||||
const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, {
|
const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -507,7 +507,7 @@ export function CustomerNotesSection({
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||||
}
|
}
|
||||||
const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, {
|
const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -600,7 +600,7 @@ export function RescheduleFlow({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (sessionId) headers['Authorization'] = `Bearer ${sessionId}`;
|
if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||||
const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, {
|
const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
@@ -784,7 +784,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${sessionId}`,
|
'X-Impersonation-Session-Id': sessionId ?? '',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
petId: selectedPet.id,
|
petId: selectedPet.id,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { loadStripe } from "@stripe/stripe-js";
|
import { loadStripe } from "@stripe/stripe-js";
|
||||||
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
||||||
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
||||||
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap overflow-x-auto">
|
||||||
{([
|
{([
|
||||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||||
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
||||||
@@ -356,6 +356,48 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isComplete, setIsComplete] = useState(false);
|
const [isComplete, setIsComplete] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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) =>
|
const formatCents = (cents: number) =>
|
||||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
||||||
@@ -420,8 +462,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
|
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div role="dialog" aria-modal="true" 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 ref={completeModalRef} className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
|
||||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
@@ -440,8 +482,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div role="dialog" aria-modal="true" 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 ref={paymentModalRef} className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2>
|
<h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2>
|
||||||
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
|
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ interface Appointment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AppointmentsResponse {
|
interface AppointmentsResponse {
|
||||||
upcoming: Appointment[];
|
appointments: Appointment[];
|
||||||
past: Appointment[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -46,7 +45,7 @@ function buildHeaders(sessionId: string | null): Record<string, string> {
|
|||||||
|
|
||||||
export function PetProfiles({ sessionId, readOnly }: Props) {
|
export function PetProfiles({ sessionId, readOnly }: Props) {
|
||||||
const [pets, setPets] = useState<Pet[]>([]);
|
const [pets, setPets] = useState<Pet[]>([]);
|
||||||
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
|
const [appointments, setAppointments] = useState<AppointmentsResponse>({ appointments: [] });
|
||||||
const [selectedPetId, setSelectedPetId] = useState<string>("");
|
const [selectedPetId, setSelectedPetId] = useState<string>("");
|
||||||
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
|
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
|
||||||
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
||||||
@@ -90,7 +89,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
|||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
|
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
|
||||||
const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
|
const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date());
|
||||||
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
|
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
|
||||||
|
|
||||||
function handlePetSave(updatedPet: Pet) {
|
function handlePetSave(updatedPet: Pet) {
|
||||||
|
|||||||
@@ -119,3 +119,10 @@ uri
|
|||||||
database-url
|
database-url
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Auth secret name — always use groombook-auth (sealed secret name)
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.authSecretName" -}}
|
||||||
|
{{- printf "%s" "groombook-auth" }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -50,6 +50,27 @@ spec:
|
|||||||
- name: OIDC_AUDIENCE
|
- name: OIDC_AUDIENCE
|
||||||
value: {{ .Values.api.env.oidcAudience | quote }}
|
value: {{ .Values.api.env.oidcAudience | quote }}
|
||||||
{{- end }}
|
{{- 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
|
- name: DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ api:
|
|||||||
corsOrigin: ""
|
corsOrigin: ""
|
||||||
oidcIssuer: ""
|
oidcIssuer: ""
|
||||||
oidcAudience: groombook
|
oidcAudience: groombook
|
||||||
|
betterAuthUrl: ""
|
||||||
|
internalBaseUrl: ""
|
||||||
port: "3000"
|
port: "3000"
|
||||||
service:
|
service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"permission": "allow",
|
|
||||||
"experimental": {
|
|
||||||
"snapshots": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "refunds" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"invoice_id" uuid NOT NULL REFERENCES "invoices"("id") ON DELETE RESTRICT,
|
||||||
|
"stripe_refund_id" text NOT NULL,
|
||||||
|
"idempotency_key" text UNIQUE,
|
||||||
|
"amount_cents" integer,
|
||||||
|
"created_at" timestamp NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "idx_refunds_invoice_id" ON "refunds"("invoice_id");
|
||||||
|
CREATE INDEX "idx_refunds_idempotency_key" ON "refunds"("idempotency_key");
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- SMS opt-in fields for clients (idempotent)
|
||||||
|
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_in" boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_date" timestamp;
|
||||||
|
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_out_date" timestamp;
|
||||||
|
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_text" text;
|
||||||
|
|
||||||
|
-- Add channel column to reminder_logs with default 'email' (idempotent)
|
||||||
|
ALTER TABLE "reminder_logs" ADD COLUMN IF NOT EXISTS "channel" text NOT NULL DEFAULT 'email';
|
||||||
|
|
||||||
|
-- Drop old unique constraints if they exist (idempotent)
|
||||||
|
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_key";
|
||||||
|
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_unique";
|
||||||
|
|
||||||
|
-- Add new unique constraint with channel
|
||||||
|
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel");
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Migration: 0029_db_indexes_constraints.sql
|
||||||
|
-- Add missing indexes on appointments, pets, clients tables and NOT NULL constraint on clients.email
|
||||||
|
|
||||||
|
-- Backfill NULL emails before setting NOT NULL
|
||||||
|
UPDATE clients SET email = concat('unknown-', id::text, '@placeholder.local') WHERE email IS NULL;
|
||||||
|
|
||||||
|
-- Add indexes on appointments table
|
||||||
|
CREATE INDEX idx_appointments_client_id ON appointments(client_id);
|
||||||
|
CREATE INDEX idx_appointments_staff_id ON appointments(staff_id);
|
||||||
|
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
|
||||||
|
CREATE INDEX idx_appointments_status ON appointments(status);
|
||||||
|
|
||||||
|
-- Add index on pets table
|
||||||
|
CREATE INDEX idx_pets_client_id ON pets(client_id);
|
||||||
|
|
||||||
|
-- Add index on clients table
|
||||||
|
CREATE INDEX idx_clients_email ON clients(email);
|
||||||
|
|
||||||
|
-- Set NOT NULL on clients.email (after backfill)
|
||||||
|
ALTER TABLE clients ALTER COLUMN email SET NOT NULL;
|
||||||
@@ -190,6 +190,20 @@
|
|||||||
"when": 1775568867192,
|
"when": 1775568867192,
|
||||||
"tag": "0026_stripe_payment",
|
"tag": "0026_stripe_payment",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775655267192,
|
||||||
|
"tag": "0027_refunds",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775741667192,
|
||||||
|
"tag": "0028_sms_reminders",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -71,6 +71,10 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
|
|||||||
address: "1 Main St, Springfield, CA 90000",
|
address: "1 Main St, Springfield, CA 90000",
|
||||||
notes: null,
|
notes: null,
|
||||||
emailOptOut: false,
|
emailOptOut: false,
|
||||||
|
smsOptIn: false,
|
||||||
|
smsConsentDate: null,
|
||||||
|
smsOptOutDate: null,
|
||||||
|
smsConsentText: null,
|
||||||
stripeCustomerId: null,
|
stripeCustomerId: null,
|
||||||
status: "active",
|
status: "active",
|
||||||
disabledAt: null,
|
disabledAt: null,
|
||||||
|
|||||||
+125
-82
@@ -102,43 +102,55 @@ export const verification = pgTable("verification", {
|
|||||||
|
|
||||||
// ─── Tables ───────────────────────────────────────────────────────────────────
|
// ─── Tables ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const clients = pgTable("clients", {
|
export const clients = pgTable(
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
"clients",
|
||||||
name: text("name").notNull(),
|
{
|
||||||
email: text("email"),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
phone: text("phone"),
|
name: text("name").notNull(),
|
||||||
address: text("address"),
|
email: text("email").notNull(),
|
||||||
notes: text("notes"),
|
phone: text("phone"),
|
||||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
address: text("address"),
|
||||||
stripeCustomerId: text("stripe_customer_id"),
|
notes: text("notes"),
|
||||||
status: clientStatusEnum("status").notNull().default("active"),
|
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||||
disabledAt: timestamp("disabled_at"),
|
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
smsConsentDate: timestamp("sms_consent_date"),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
smsOptOutDate: timestamp("sms_opt_out_date"),
|
||||||
});
|
smsConsentText: text("sms_consent_text"),
|
||||||
|
stripeCustomerId: text("stripe_customer_id"),
|
||||||
|
status: clientStatusEnum("status").notNull().default("active"),
|
||||||
|
disabledAt: timestamp("disabled_at"),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index("idx_clients_email").on(t.email)]
|
||||||
|
);
|
||||||
|
|
||||||
export const pets = pgTable("pets", {
|
export const pets = pgTable(
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
"pets",
|
||||||
clientId: uuid("client_id")
|
{
|
||||||
.notNull()
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
.references(() => clients.id, { onDelete: "cascade" }),
|
clientId: uuid("client_id")
|
||||||
name: text("name").notNull(),
|
.notNull()
|
||||||
species: text("species").notNull(),
|
.references(() => clients.id, { onDelete: "cascade" }),
|
||||||
breed: text("breed"),
|
name: text("name").notNull(),
|
||||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
species: text("species").notNull(),
|
||||||
dateOfBirth: timestamp("date_of_birth"),
|
breed: text("breed"),
|
||||||
healthAlerts: text("health_alerts"),
|
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||||
groomingNotes: text("grooming_notes"),
|
dateOfBirth: timestamp("date_of_birth"),
|
||||||
cutStyle: text("cut_style"),
|
healthAlerts: text("health_alerts"),
|
||||||
shampooPreference: text("shampoo_preference"),
|
groomingNotes: text("grooming_notes"),
|
||||||
specialCareNotes: text("special_care_notes"),
|
cutStyle: text("cut_style"),
|
||||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
shampooPreference: text("shampoo_preference"),
|
||||||
photoKey: text("photo_key"),
|
specialCareNotes: text("special_care_notes"),
|
||||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
||||||
image: text("image"),
|
photoKey: text("photo_key"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
image: text("image"),
|
||||||
});
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index("idx_pets_client_id").on(t.clientId)]
|
||||||
|
);
|
||||||
|
|
||||||
export const services = pgTable("services", {
|
export const services = pgTable("services", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
@@ -188,51 +200,60 @@ export const appointmentGroups = pgTable("appointment_groups", {
|
|||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const appointments = pgTable("appointments", {
|
export const appointments = pgTable(
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
"appointments",
|
||||||
clientId: uuid("client_id")
|
{
|
||||||
.notNull()
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
.references(() => clients.id, { onDelete: "restrict" }),
|
clientId: uuid("client_id")
|
||||||
petId: uuid("pet_id")
|
.notNull()
|
||||||
.notNull()
|
.references(() => clients.id, { onDelete: "restrict" }),
|
||||||
.references(() => pets.id, { onDelete: "restrict" }),
|
petId: uuid("pet_id")
|
||||||
serviceId: uuid("service_id")
|
.notNull()
|
||||||
.notNull()
|
.references(() => pets.id, { onDelete: "restrict" }),
|
||||||
.references(() => services.id, { onDelete: "restrict" }),
|
serviceId: uuid("service_id")
|
||||||
staffId: uuid("staff_id").references(() => staff.id, {
|
.notNull()
|
||||||
onDelete: "set null",
|
.references(() => services.id, { onDelete: "restrict" }),
|
||||||
}),
|
staffId: uuid("staff_id").references(() => staff.id, {
|
||||||
// Optional secondary staff (bather/assistant) for tip-split tracking
|
onDelete: "set null",
|
||||||
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
|
}),
|
||||||
onDelete: "set null",
|
// Optional secondary staff (bather/assistant) for tip-split tracking
|
||||||
}),
|
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
|
||||||
status: appointmentStatusEnum("status").notNull().default("scheduled"),
|
onDelete: "set null",
|
||||||
startTime: timestamp("start_time").notNull(),
|
}),
|
||||||
endTime: timestamp("end_time").notNull(),
|
status: appointmentStatusEnum("status").notNull().default("scheduled"),
|
||||||
notes: text("notes"),
|
startTime: timestamp("start_time").notNull(),
|
||||||
// Override price at time of booking (null = use service base price)
|
endTime: timestamp("end_time").notNull(),
|
||||||
priceCents: integer("price_cents"),
|
notes: text("notes"),
|
||||||
// Recurring series support
|
// Override price at time of booking (null = use service base price)
|
||||||
seriesId: uuid("series_id").references(() => recurringSeries.id, {
|
priceCents: integer("price_cents"),
|
||||||
onDelete: "set null",
|
// Recurring series support
|
||||||
}),
|
seriesId: uuid("series_id").references(() => recurringSeries.id, {
|
||||||
seriesIndex: integer("series_index"),
|
onDelete: "set null",
|
||||||
// Multi-pet group booking: links this appointment to others in the same visit
|
}),
|
||||||
groupId: uuid("group_id").references(() => appointmentGroups.id, {
|
seriesIndex: integer("series_index"),
|
||||||
onDelete: "set null",
|
// Multi-pet group booking: links this appointment to others in the same visit
|
||||||
}),
|
groupId: uuid("group_id").references(() => appointmentGroups.id, {
|
||||||
// Customer confirmation/cancellation tracking
|
onDelete: "set null",
|
||||||
// Values: "pending" | "confirmed" | "cancelled"
|
}),
|
||||||
confirmationStatus: text("confirmation_status").notNull().default("pending"),
|
// Customer confirmation/cancellation tracking
|
||||||
confirmedAt: timestamp("confirmed_at"),
|
// Values: "pending" | "confirmed" | "cancelled"
|
||||||
cancelledAt: timestamp("cancelled_at"),
|
confirmationStatus: text("confirmation_status").notNull().default("pending"),
|
||||||
// Token for tokenized email confirm/cancel links (no auth required)
|
confirmedAt: timestamp("confirmed_at"),
|
||||||
confirmationToken: text("confirmation_token").unique(),
|
cancelledAt: timestamp("cancelled_at"),
|
||||||
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
|
// Token for tokenized email confirm/cancel links (no auth required)
|
||||||
customerNotes: text("customer_notes"),
|
confirmationToken: text("confirmation_token").unique(),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
customerNotes: text("customer_notes"),
|
||||||
});
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("idx_appointments_client_id").on(t.clientId),
|
||||||
|
index("idx_appointments_staff_id").on(t.staffId),
|
||||||
|
index("idx_appointments_start_time").on(t.startTime),
|
||||||
|
index("idx_appointments_status").on(t.status),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const invoices = pgTable(
|
export const invoices = pgTable(
|
||||||
"invoices",
|
"invoices",
|
||||||
@@ -300,8 +321,28 @@ export const invoiceTipSplits = pgTable(
|
|||||||
(t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)]
|
(t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Refund records with idempotency key support
|
||||||
|
export const refunds = pgTable(
|
||||||
|
"refunds",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
invoiceId: uuid("invoice_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => invoices.id, { onDelete: "restrict" }),
|
||||||
|
stripeRefundId: text("stripe_refund_id").notNull(),
|
||||||
|
idempotencyKey: text("idempotency_key").unique(),
|
||||||
|
amountCents: integer("amount_cents"),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("idx_refunds_invoice_id").on(t.invoiceId),
|
||||||
|
index("idx_refunds_idempotency_key").on(t.idempotencyKey),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
|
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
|
||||||
// reminder_type values: "confirmation", "24h", "2h"
|
// reminder_type values: "confirmation", "24h", "2h"
|
||||||
|
// channel values: "email", "sms"
|
||||||
export const reminderLogs = pgTable(
|
export const reminderLogs = pgTable(
|
||||||
"reminder_logs",
|
"reminder_logs",
|
||||||
{
|
{
|
||||||
@@ -311,9 +352,11 @@ export const reminderLogs = pgTable(
|
|||||||
.references(() => appointments.id, { onDelete: "cascade" }),
|
.references(() => appointments.id, { onDelete: "cascade" }),
|
||||||
// "confirmation" | "24h" | "2h"
|
// "confirmation" | "24h" | "2h"
|
||||||
reminderType: text("reminder_type").notNull(),
|
reminderType: text("reminder_type").notNull(),
|
||||||
|
// "email" | "sms"
|
||||||
|
channel: text("channel").notNull().default("email"),
|
||||||
sentAt: timestamp("sent_at").notNull().defaultNow(),
|
sentAt: timestamp("sent_at").notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => [unique().on(t.appointmentId, t.reminderType)]
|
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Impersonation ──────────────────────────────────────────────────────────
|
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
+69
-2
@@ -398,6 +398,7 @@ async function seedKnownUsers() {
|
|||||||
id: ADMIN_STAFF_ID,
|
id: ADMIN_STAFF_ID,
|
||||||
name: adminName,
|
name: adminName,
|
||||||
email: adminEmail,
|
email: adminEmail,
|
||||||
|
oidcSub: adminEmail,
|
||||||
role: "manager",
|
role: "manager",
|
||||||
isSuperUser: true,
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -458,6 +459,37 @@ async function seedKnownUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
||||||
|
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
||||||
|
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
||||||
|
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
||||||
|
for (let i = 0; i < groomerCount; i++) {
|
||||||
|
const email = groomerEmails[i]!;
|
||||||
|
const name = groomerNames[i]!;
|
||||||
|
// Use deterministic IDs in the 00000000-0000-0000-0000-000000000005+ range
|
||||||
|
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
||||||
|
const [existingGroomer] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.staff)
|
||||||
|
.where(eq(schema.staff.email, email))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingGroomer) {
|
||||||
|
console.log(`✓ Staff groomer '${existingGroomer.name}' already exists — skipping`);
|
||||||
|
} else {
|
||||||
|
await db.insert(schema.staff).values({
|
||||||
|
id: staffId,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
oidcSub: email,
|
||||||
|
role: "groomer",
|
||||||
|
isSuperUser: false,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
console.log(`✓ Created staff groomer '${name}' (${email})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||||
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||||
@@ -567,7 +599,7 @@ async function seed() {
|
|||||||
|
|
||||||
// ── Staff ──
|
// ── Staff ──
|
||||||
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
|
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
|
||||||
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: false })
|
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 })
|
||||||
);
|
);
|
||||||
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) =>
|
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) =>
|
||||||
({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })
|
({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })
|
||||||
@@ -612,6 +644,7 @@ async function seed() {
|
|||||||
id: ADMIN_STAFF_ID,
|
id: ADMIN_STAFF_ID,
|
||||||
name: adminName,
|
name: adminName,
|
||||||
email: adminEmail,
|
email: adminEmail,
|
||||||
|
oidcSub: adminEmail,
|
||||||
role: "manager",
|
role: "manager",
|
||||||
isSuperUser: true,
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -623,6 +656,31 @@ async function seed() {
|
|||||||
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
||||||
|
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
||||||
|
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
||||||
|
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
||||||
|
for (let i = 0; i < groomerCount; i++) {
|
||||||
|
const email = groomerEmails[i]!;
|
||||||
|
const name = groomerNames[i]!;
|
||||||
|
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
||||||
|
await db.insert(schema.staff)
|
||||||
|
.values({
|
||||||
|
id: staffId,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
oidcSub: email,
|
||||||
|
role: "groomer",
|
||||||
|
isSuperUser: false,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: schema.staff.email,
|
||||||
|
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
|
||||||
|
});
|
||||||
|
console.log(`✓ Upserted groomer '${name}' (${email})`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Services ──
|
// ── Services ──
|
||||||
// Upsert services using name as unique key. With deterministic IDs in
|
// Upsert services using name as unique key. With deterministic IDs in
|
||||||
// servicesDef and TRUNCATE clearing downstream tables first, this is
|
// servicesDef and TRUNCATE clearing downstream tables first, this is
|
||||||
@@ -825,6 +883,7 @@ async function seed() {
|
|||||||
let appointmentCount = 0;
|
let appointmentCount = 0;
|
||||||
let invoiceCount = 0;
|
let invoiceCount = 0;
|
||||||
let visitLogCount = 0;
|
let visitLogCount = 0;
|
||||||
|
let paidInvoiceCounter = 0;
|
||||||
|
|
||||||
// Process in batches per client to keep memory manageable
|
// Process in batches per client to keep memory manageable
|
||||||
const apptBatchSize = 100;
|
const apptBatchSize = 100;
|
||||||
@@ -919,6 +978,10 @@ async function seed() {
|
|||||||
|
|
||||||
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
||||||
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
||||||
|
paidInvoiceCounter++;
|
||||||
|
const stripePaymentIntentId = invoiceStatus === "paid"
|
||||||
|
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
|
||||||
|
: null;
|
||||||
|
|
||||||
invoiceBatch.push({
|
invoiceBatch.push({
|
||||||
id: invoiceId,
|
id: invoiceId,
|
||||||
@@ -931,6 +994,7 @@ async function seed() {
|
|||||||
status: invoiceStatus,
|
status: invoiceStatus,
|
||||||
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
||||||
paidAt,
|
paidAt,
|
||||||
|
stripePaymentIntentId,
|
||||||
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1034,13 +1098,16 @@ async function seed() {
|
|||||||
const taxCents = Math.round(effectivePrice * 0.08);
|
const taxCents = Math.round(effectivePrice * 0.08);
|
||||||
const totalCents = effectivePrice + taxCents + tipCents;
|
const totalCents = effectivePrice + taxCents + tipCents;
|
||||||
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
||||||
|
paidInvoiceCounter++;
|
||||||
|
|
||||||
invoiceBatch.push({
|
invoiceBatch.push({
|
||||||
id: invoiceId, appointmentId: apptId, clientId,
|
id: invoiceId, appointmentId: apptId, clientId,
|
||||||
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
||||||
status: "paid" as const,
|
status: "paid" as const,
|
||||||
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
||||||
paidAt, notes: null,
|
paidAt,
|
||||||
|
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
|
||||||
|
notes: null,
|
||||||
});
|
});
|
||||||
lineItemBatch.push({
|
lineItemBatch.push({
|
||||||
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface Pet {
|
|||||||
shampooPreference: string | null;
|
shampooPreference: string | null;
|
||||||
specialCareNotes: string | null;
|
specialCareNotes: string | null;
|
||||||
customFields: Record<string, string>;
|
customFields: Record<string, string>;
|
||||||
|
photoKey?: string;
|
||||||
|
photoUploadedAt?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -150,10 +152,16 @@ export interface Invoice {
|
|||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
paymentMethod: PaymentMethod | null;
|
paymentMethod: PaymentMethod | null;
|
||||||
paidAt: string | null;
|
paidAt: string | null;
|
||||||
|
stripePaymentIntentId: string | null;
|
||||||
|
stripeRefundId: string | null;
|
||||||
|
paymentFailureReason: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lineItems?: InvoiceLineItem[];
|
lineItems?: InvoiceLineItem[];
|
||||||
|
// Transient fields populated from Stripe API (not stored in DB)
|
||||||
|
cardLast4?: string | null;
|
||||||
|
paymentStatus?: string | null;
|
||||||
tipSplits?: InvoiceTipSplit[];
|
tipSplits?: InvoiceTipSplit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+43
-53
@@ -43,6 +43,9 @@ importers:
|
|||||||
stripe:
|
stripe:
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.0.1(@types/node@22.19.15)
|
version: 22.0.1(@types/node@22.19.15)
|
||||||
|
telnyx:
|
||||||
|
specifier: ^1.23.0
|
||||||
|
version: 1.27.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -177,7 +180,7 @@ importers:
|
|||||||
version: 22.19.15
|
version: 22.19.15
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: ^0.30.4
|
specifier: ^0.30.4
|
||||||
version: 0.30.6
|
version: 0.30.4
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.19.0
|
specifier: ^4.19.0
|
||||||
version: 4.21.0
|
version: 4.21.0
|
||||||
@@ -1696,9 +1699,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
|
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
'@petamoriken/float16@3.9.3':
|
|
||||||
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
|
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -2830,8 +2830,8 @@ packages:
|
|||||||
dom-accessibility-api@0.6.3:
|
dom-accessibility-api@0.6.3:
|
||||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||||
|
|
||||||
drizzle-kit@0.30.6:
|
drizzle-kit@0.30.4:
|
||||||
resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
|
resolution: {integrity: sha512-B2oJN5UkvwwNHscPWXDG5KqAixu7AUzZ3qbe++KU9SsQ+cZWR4DXEPYcvWplyFAno0dhRJECNEhNxiDmFaPGyQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
drizzle-orm@0.38.4:
|
drizzle-orm@0.38.4:
|
||||||
@@ -2955,10 +2955,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
env-paths@3.0.0:
|
|
||||||
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
|
||||||
|
|
||||||
es-abstract@1.24.1:
|
es-abstract@1.24.1:
|
||||||
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
|
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3162,11 +3158,6 @@ packages:
|
|||||||
functions-have-names@1.2.3:
|
functions-have-names@1.2.3:
|
||||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||||
|
|
||||||
gel@2.2.0:
|
|
||||||
resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==}
|
|
||||||
engines: {node: '>= 18.0.0'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
generator-function@2.0.1:
|
generator-function@2.0.1:
|
||||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3434,10 +3425,6 @@ packages:
|
|||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
isexe@3.1.5:
|
|
||||||
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
istanbul-lib-coverage@3.2.2:
|
istanbul-lib-coverage@3.2.2:
|
||||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3619,6 +3606,9 @@ packages:
|
|||||||
lodash.debounce@4.0.8:
|
lodash.debounce@4.0.8:
|
||||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6:
|
||||||
|
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||||
|
|
||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
@@ -3851,6 +3841,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
qs@6.15.1:
|
||||||
|
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
||||||
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
randombytes@2.1.0:
|
randombytes@2.1.0:
|
||||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||||
|
|
||||||
@@ -4046,10 +4040,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
shell-quote@1.8.3:
|
|
||||||
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
|
|
||||||
engines: {node: '>= 0.4'}
|
|
||||||
|
|
||||||
side-channel-list@1.0.0:
|
side-channel-list@1.0.0:
|
||||||
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4188,6 +4178,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
telnyx@1.27.0:
|
||||||
|
resolution: {integrity: sha512-cVbP3jEW4TbmNL5U0UbZc3OkLg+6dHRnMYByYfJnrGw5ZRn0XKb17Hx3fLMWmGgRFow7eqVP4hlCogbIB6T3+w==}
|
||||||
|
engines: {node: ^6 || >=8}
|
||||||
|
|
||||||
temp-dir@2.0.0:
|
temp-dir@2.0.0:
|
||||||
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -4262,6 +4256,9 @@ packages:
|
|||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
tweetnacl@1.0.3:
|
||||||
|
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -4351,6 +4348,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
uuid@9.0.1:
|
||||||
|
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
victory-vendor@37.3.6:
|
victory-vendor@37.3.6:
|
||||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||||
|
|
||||||
@@ -4487,11 +4488,6 @@ packages:
|
|||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
which@4.0.0:
|
|
||||||
resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
|
|
||||||
engines: {node: ^16.13.0 || >=18.0.0}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -6223,8 +6219,6 @@ snapshots:
|
|||||||
|
|
||||||
'@opentelemetry/semantic-conventions@1.40.0': {}
|
'@opentelemetry/semantic-conventions@1.40.0': {}
|
||||||
|
|
||||||
'@petamoriken/float16@3.9.3': {}
|
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -7420,13 +7414,12 @@ snapshots:
|
|||||||
|
|
||||||
dom-accessibility-api@0.6.3: {}
|
dom-accessibility-api@0.6.3: {}
|
||||||
|
|
||||||
drizzle-kit@0.30.6:
|
drizzle-kit@0.30.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@drizzle-team/brocli': 0.10.2
|
'@drizzle-team/brocli': 0.10.2
|
||||||
'@esbuild-kit/esm-loader': 2.6.5
|
'@esbuild-kit/esm-loader': 2.6.5
|
||||||
esbuild: 0.19.12
|
esbuild: 0.19.12
|
||||||
esbuild-register: 3.6.0(esbuild@0.19.12)
|
esbuild-register: 3.6.0(esbuild@0.19.12)
|
||||||
gel: 2.2.0
|
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -7463,8 +7456,6 @@ snapshots:
|
|||||||
|
|
||||||
entities@6.0.1: {}
|
entities@6.0.1: {}
|
||||||
|
|
||||||
env-paths@3.0.0: {}
|
|
||||||
|
|
||||||
es-abstract@1.24.1:
|
es-abstract@1.24.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
array-buffer-byte-length: 1.0.2
|
array-buffer-byte-length: 1.0.2
|
||||||
@@ -7826,17 +7817,6 @@ snapshots:
|
|||||||
|
|
||||||
functions-have-names@1.2.3: {}
|
functions-have-names@1.2.3: {}
|
||||||
|
|
||||||
gel@2.2.0:
|
|
||||||
dependencies:
|
|
||||||
'@petamoriken/float16': 3.9.3
|
|
||||||
debug: 4.4.3
|
|
||||||
env-paths: 3.0.0
|
|
||||||
semver: 7.7.4
|
|
||||||
shell-quote: 1.8.3
|
|
||||||
which: 4.0.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
generator-function@2.0.1: {}
|
generator-function@2.0.1: {}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
@@ -8101,8 +8081,6 @@ snapshots:
|
|||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
isexe@3.1.5: {}
|
|
||||||
|
|
||||||
istanbul-lib-coverage@3.2.2: {}
|
istanbul-lib-coverage@3.2.2: {}
|
||||||
|
|
||||||
istanbul-lib-report@3.0.1:
|
istanbul-lib-report@3.0.1:
|
||||||
@@ -8271,6 +8249,8 @@ snapshots:
|
|||||||
|
|
||||||
lodash.debounce@4.0.8: {}
|
lodash.debounce@4.0.8: {}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6: {}
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
lodash.sortby@4.7.0: {}
|
lodash.sortby@4.7.0: {}
|
||||||
@@ -8469,6 +8449,10 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
qs@6.15.1:
|
||||||
|
dependencies:
|
||||||
|
side-channel: 1.1.0
|
||||||
|
|
||||||
randombytes@2.1.0:
|
randombytes@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
@@ -8703,8 +8687,6 @@ snapshots:
|
|||||||
|
|
||||||
shebang-regex@3.0.0: {}
|
shebang-regex@3.0.0: {}
|
||||||
|
|
||||||
shell-quote@1.8.3: {}
|
|
||||||
|
|
||||||
side-channel-list@1.0.0:
|
side-channel-list@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -8858,6 +8840,14 @@ snapshots:
|
|||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|
||||||
|
telnyx@1.27.0:
|
||||||
|
dependencies:
|
||||||
|
lodash.isplainobject: 4.0.6
|
||||||
|
qs: 6.15.1
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
tweetnacl: 1.0.3
|
||||||
|
uuid: 9.0.1
|
||||||
|
|
||||||
temp-dir@2.0.0: {}
|
temp-dir@2.0.0: {}
|
||||||
|
|
||||||
tempy@0.6.0:
|
tempy@0.6.0:
|
||||||
@@ -8928,6 +8918,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
tweetnacl@1.0.3: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -9024,6 +9016,8 @@ snapshots:
|
|||||||
|
|
||||||
uuid@8.3.2: {}
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
|
uuid@9.0.1: {}
|
||||||
|
|
||||||
victory-vendor@37.3.6:
|
victory-vendor@37.3.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-array': 3.2.2
|
'@types/d3-array': 3.2.2
|
||||||
@@ -9201,10 +9195,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
which@4.0.0:
|
|
||||||
dependencies:
|
|
||||||
isexe: 3.1.5
|
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user