Compare commits

..

1 Commits

Author SHA1 Message Date
Flea Flicker 3d45582609 fix(GRO-874): add requireSuperUser() to GET /api/admin/settings/logo
The logo proxy route was missing auth middleware, allowing any
unauthenticated caller to receive the presigned S3 URL and exposing
the internal Ceph RGW hostname. Matches auth pattern used by all
other /api/admin/* routes in this file.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 03:42:29 +00:00
18 changed files with 586 additions and 450 deletions
-54
View File
@@ -1,54 +0,0 @@
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
@@ -86,8 +86,6 @@ jobs:
- name: Run E2E tests
run: pnpm --filter @groombook/e2e test
env:
PLAYWRIGHT_BASE_URL: http://host.docker.internal:8080
- name: Upload Playwright report
if: failure()
@@ -129,12 +127,18 @@ jobs:
needs: [build, e2e]
outputs:
tag: ${{ steps.version.outputs.tag }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Generate image tag
id: version
run: |
# Always include short SHA so each build is immutable and cache-from can never
# cross-contaminate between commits. For PRs the format is pr-N-sha7; for main
# it is YYYY.MM.DD-sha7.
if [ "${{ github.event_name }}" = "pull_request" ]; then
TAG="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::7}"
else
@@ -146,12 +150,12 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: git.farh.net
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push API image
uses: docker/build-push-action@v6
@@ -161,10 +165,10 @@ jobs:
target: runner
push: true
tags: |
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
cache-from: type=registry,ref=git.farh.net/groombook/cache:api
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
ghcr.io/groombook/api:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/api:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push Migrate image
uses: docker/build-push-action@v6
@@ -174,10 +178,10 @@ jobs:
target: migrate
push: true
tags: |
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate
cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max
ghcr.io/groombook/migrate:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/migrate:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push Seed image
uses: docker/build-push-action@v6
@@ -187,10 +191,10 @@ jobs:
target: seed
push: true
tags: |
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
cache-from: type=registry,ref=git.farh.net/groombook/cache:seed
cache-to: type=registry,ref=git.farh.net/groombook/cache:seed,mode=max
ghcr.io/groombook/seed:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/seed:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push Reset image
uses: docker/build-push-action@v6
@@ -200,10 +204,10 @@ jobs:
target: reset
push: true
tags: |
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
ghcr.io/groombook/reset:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/reset:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push Web image
uses: docker/build-push-action@v6
@@ -212,16 +216,19 @@ jobs:
file: apps/web/Dockerfile
push: true
tags: |
git.farh.net/groombook/web:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }}
cache-from: type=registry,ref=git.farh.net/groombook/cache:web
cache-to: type=registry,ref=git.farh.net/groombook/cache:web,mode=max
ghcr.io/groombook/web:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/web:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-dev:
name: Deploy PR to groombook-dev
runs-on: ubuntu-latest
runs-on: runners-groombook
needs: [docker]
if: github.event_name == 'pull_request'
permissions:
contents: read
pull-requests: write
steps:
- name: Install kubectl
run: |
@@ -238,6 +245,7 @@ jobs:
TAG="pr-$PR_NUM-${SHA::7}"
echo "Deploying images tagged $TAG to groombook-dev..."
# Run migration with PR image
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
cat <<EOF | kubectl apply -n groombook-dev -f -
apiVersion: batch/v1
@@ -252,7 +260,7 @@ jobs:
restartPolicy: Never
containers:
- name: migrate
image: git.farh.net/groombook/migrate:$TAG
image: ghcr.io/groombook/migrate:$TAG
env:
- name: DATABASE_URL
valueFrom:
@@ -263,25 +271,35 @@ jobs:
kubectl wait --for=condition=complete "job/migrate-pr-$PR_NUM" \
-n groombook-dev --timeout=120s
kubectl set image deployment/api api=git.farh.net/groombook/api:$TAG -n groombook-dev
kubectl set image deployment/web web=git.farh.net/groombook/web:$TAG -n groombook-dev
# Update deployments
kubectl set image deployment/api api=ghcr.io/groombook/api:$TAG -n groombook-dev
kubectl set image deployment/web web=ghcr.io/groombook/web:$TAG -n groombook-dev
# Wait for rollout
kubectl rollout status deployment/api -n groombook-dev --timeout=300s
kubectl rollout status deployment/web -n groombook-dev --timeout=300s
echo "Deployment complete."
- name: Comment on PR
env:
PR_NUM: ${{ github.event.pull_request.number }}
GITEA_TOKEN: ${{ gitea.token }}
run: |
TAG="pr-${PR_NUM}"
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"https://git.farh.net/api/v1/repos/groombook/app/issues/$PR_NUM/comments" \
-d "{\"body\": \"## Deployed to groombook-dev\n\n**Images:** \`${TAG}\`\n**URL:** https://dev.groombook.farh.net\n\nReady for UAT validation.\"}"
uses: actions/github-script@v7
with:
script: |
const pr = context.issue.number;
const tag = `pr-${pr}`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr,
body: [
'## Deployed to groombook-dev',
'',
`**Images:** \`${tag}\``,
'**URL:** https://dev.groombook.farh.net',
'',
'Ready for UAT validation.'
].join('\n')
});
web-e2e:
name: Web E2E (Dev)
@@ -322,13 +340,21 @@ jobs:
name: Update Infra Image Tags
runs-on: ubuntu-latest
needs: [docker]
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: write
pull-requests: write
steps:
- name: Generate infra repo token
id: infra-token
uses: tibdex/github-app-token@v2
with:
app_id: ${{ vars.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Clone groombook/infra
env:
GITEA_TOKEN: ${{ gitea.token }}
run: |
git clone https://oauth2:$GITEA_TOKEN@git.farh.net/groombook/infra.git /tmp/infra
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
- name: Install yq
run: |
@@ -345,25 +371,30 @@ jobs:
fi
export SHORT_SHA="${SHA::7}"
echo "Updating dev overlay image tags to: $TAG"
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
cd /tmp/infra
DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
if [ -f "$MIGRATE_JOB" ]; then
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
fi
# Update seed Job name to include short SHA (immutable template fix)
SEED_JOB="apps/groombook/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
fi
@@ -372,40 +403,32 @@ jobs:
- name: Create PR on groombook/infra
env:
TAG: ${{ needs.docker.outputs.tag }}
GITEA_TOKEN: ${{ gitea.token }}
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
run: |
if [ -z "$TAG" ]; then
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
fi
cd /tmp/infra
git config user.name "groombook-engineer[bot]"
git config user.email "groombook-engineer[bot]@git.farh.net"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "chore/update-image-tags-${TAG}"
git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
git push -u origin "chore/update-image-tags-${TAG}"
EXISTING_PR=$(curl -s \
-H "Authorization: token $GITEA_TOKEN" \
"https://git.farh.net/api/v1/repos/groombook/infra/pulls?state=open&limit=50" \
| jq -r ".[] | select(.head.label == \"chore/update-image-tags-${TAG}\") | .number" | head -1)
# Check if PR already exists for this branch
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
if [ -n "$EXISTING_PR" ]; then
echo "PR #$EXISTING_PR already exists, merging"
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"https://git.farh.net/api/v1/repos/groombook/infra/pulls/$EXISTING_PR/merge" \
-d '{"Do":"merge"}'
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
gh pr merge "$EXISTING_PR" --repo groombook/infra --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"}'
PR_URL=$(gh pr create \
--repo groombook/infra \
--base main \
--head "chore/update-image-tags-${TAG}" \
--title "chore: deploy ${TAG} to dev" \
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
gh pr merge "$PR_URL" --merge
fi
+54
View File
@@ -0,0 +1,54 @@
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
@@ -12,6 +12,9 @@ jobs:
promote:
name: Promote to Production
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
steps:
- name: Validate tag format
run: |
@@ -22,25 +25,28 @@ jobs:
fi
echo "Tag format valid: $TAG"
- name: Verify image exists in Gitea Container Registry
- name: Verify image exists in GHCR
env:
GITEA_TOKEN: ${{ gitea.token }}
GH_TOKEN: ${{ secrets.GITHUB_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"
# Check that the API image exists — if API was pushed, web/migrate were too
if ! gh api "/orgs/groombook/packages/container/api/versions" --jq ".[].metadata.container.tags[]" 2>/dev/null | grep -qF "$TAG"; then
echo "::error::Image ghcr.io/groombook/api:$TAG not found in GHCR. Verify the tag was built and pushed."
exit 1
fi
echo "Image verified: ghcr.io/groombook/api:$TAG exists"
- name: Generate infra repo token
id: infra-token
uses: tibdex/github-app-token@v2
with:
app_id: ${{ vars.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Clone groombook/infra
env:
GITEA_TOKEN: ${{ gitea.token }}
run: |
git clone https://oauth2:$GITEA_TOKEN@git.farh.net/groombook/infra.git /tmp/infra
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
- name: Install yq
run: |
@@ -58,17 +64,19 @@ jobs:
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"
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"
@@ -80,29 +88,30 @@ jobs:
- name: Create PR on groombook/infra
env:
TAG: ${{ inputs.tag }}
GITEA_TOKEN: ${{ gitea.token }}
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
run: |
cd /tmp/infra
git config user.name "groombook-engineer[bot]"
git config user.email "groombook-engineer[bot]@git.farh.net"
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}"
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\"}"
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()
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."}'
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.'
});
@@ -12,12 +12,20 @@ jobs:
promote-to-uat:
name: Promote to groombook-uat
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Generate infra repo token
id: infra-token
uses: tibdex/github-app-token@v2
with:
app_id: ${{ vars.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Clone groombook/infra
env:
GITEA_TOKEN: ${{ gitea.token }}
run: |
git clone https://oauth2:$GITEA_TOKEN@git.farh.net/groombook/infra.git /tmp/infra
git clone https://x-access-token:${{ steps.infra-token.outputs.token }}@github.com/groombook/infra.git /tmp/infra
- name: Install yq
run: |
@@ -41,17 +49,21 @@ jobs:
export SHORT_SHA
export TAG
yq -i '(.images[] | select(.name == "git.farh.net/groombook/api")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/web")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/migrate")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "git.farh.net/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$UAT_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
# Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
if [ -f "$MIGRATE_JOB" ]; then
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
fi
# Update seed Job name to include short SHA (immutable template fix)
# NOTE: Do NOT update the image tag here — let the Kustomize images transformer
# in the UAT overlay handle it via newTag. This avoids the immutable template issue.
SEED_JOB="apps/groombook/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
@@ -63,36 +75,34 @@ jobs:
- name: Create PR on groombook/infra
env:
TAG: ${{ inputs.image_tag }}
GITEA_TOKEN: ${{ gitea.token }}
GH_TOKEN: ${{ steps.infra-token.outputs.token }}
run: |
cd /tmp/infra
git config user.name "groombook-engineer[bot]"
git config user.email "groombook-engineer[bot]@git.farh.net"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "chore/update-uat-image-tags-${TAG}"
git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git commit -m "chore: promote ${TAG} to UAT"
git push -u origin "chore/update-uat-image-tags-${TAG}"
PR_NUM=$(curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"https://git.farh.net/api/v1/repos/groombook/infra/pulls" \
-d "{\"head\":\"chore/update-uat-image-tags-${TAG}\",\"base\":\"main\",\"title\":\"chore: promote ${TAG} to UAT\",\"body\":\"[GRO-429](/GRO/issues/GRO-429) — UAT promotion triggered by CTO\"}" \
| jq '.number')
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"https://git.farh.net/api/v1/repos/groombook/infra/pulls/$PR_NUM/merge" \
-d '{"Do":"merge"}'
# Create PR and merge immediately (no required checks on groombook/infra)
PR_URL=$(gh pr create \
--repo groombook/infra \
--base main \
--head "chore/update-uat-image-tags-${TAG}" \
--title "chore: promote ${TAG} to UAT" \
--body "[GRO-429](/GRO/issues/GRO-429) — UAT promotion triggered by CTO")
gh pr merge "$PR_URL" --merge
- 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": "## 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"}'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: '## UAT Promotion Failed\n\nThe `promote-to-uat` workflow failed. Check the workflow run logs for details.\n\nCommon issues:\n- UAT overlay not found (ensure GRO-427 is complete)\n- Infra repo access token expired'
});
-11
View File
@@ -1,11 +0,0 @@
{
"mcpServers": {
"gitea": {
"type": "http",
"url": "https://git-mcp.farh.net/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_TOKEN}"
}
}
}
}
+213 -38
View File
@@ -1,43 +1,218 @@
# GroomBook Monorepo — Archived
# GroomBook
> **This repository has been archived and replaced by standalone repositories.**
> **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.
## Successor Repositories
| Repository | Description |
|---|---|
| [groombook/api](https://github.com/groombook/api) | Hono REST API (TypeScript, Node.js) |
| [groombook/web](https://github.com/groombook/web) | React PWA frontend |
| [groombook/charts](https://github.com/groombook/charts) | Helm charts for Kubernetes deployment |
## What Changed
- **Monorepo split complete** — The former `apps/api`, `apps/web`, and `packages/*` are now standalone repos
- **`@groombook/types`** — Inlined directly into `groombook/api` and `groombook/web`
- **E2E testing** — Now via Playwright MCP, no standalone repo needed
- **CI/CD** — Each repo has its own pipeline; see individual repos for status
## Migration Notes
If you were cloning `groombook/groombook` for local development:
```bash
# API
git clone https://github.com/groombook/api.git
cd api && pnpm install && pnpm dev
# Web (in a new terminal)
git clone https://github.com/groombook/web.git
cd web && pnpm install && pnpm dev
```
For full Docker Compose setup, see each repo's README.
## Archive Info
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.
**Built for groomers, not corporations.**
---
*For Kubernetes deployments, see [groombook/infra](https://github.com/groombook/infra) (private).*
## 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) |
| Frontend | React 19 + Vite + [vite-plugin-pwa](https://vite-pwa-org.netlify.app/) |
| Database | PostgreSQL via [CNPG](https://cloudnative-pg.io/) + [Drizzle ORM](https://orm.drizzle.team/) |
| Auth | OIDC via [Authentik](https://goauthentik.io/) |
| Infra | Kubernetes (namespace: `groombook`), Flux GitOps |
| CI | GitHub Actions (self-hosted `groombook-runners`) |
## Repository Structure
```
groombook/
├── apps/
│ ├── api/ # Hono REST API
│ └── web/ # React PWA
├── packages/
│ ├── db/ # Drizzle schema + migrations
│ └── types/ # Shared TypeScript types
├── .github/
│ └── workflows/ # CI/CD pipelines
└── docker-compose.yml
```
## Getting Started
### Prerequisites
- Node.js >= 20
- pnpm >= 9 (`npm install -g pnpm`)
- Docker & Docker Compose (for local Postgres)
### Local Development
```bash
# Clone the repo
git clone https://github.com/groombook/groombook.git
cd groombook
# Install dependencies
pnpm install
# 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
Web will be available at http://localhost:5173
### Environment Variables
#### API (`apps/api/.env`)
```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
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
+37 -64
View File
@@ -101,8 +101,6 @@ invoicesRouter.get(
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
stripePaymentIntentId: invoices.stripePaymentIntentId,
stripeRefundId: invoices.stripeRefundId,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
@@ -130,17 +128,7 @@ invoicesRouter.get("/:id", async (c) => {
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
]);
let cardLast4: string | null = null;
let paymentStatus: string | null = null;
if (invoice.stripePaymentIntentId) {
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
if (details) {
cardLast4 = details.cardLast4;
paymentStatus = details.paymentStatus;
}
}
return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
return c.json({ ...invoice, lineItems, tipSplits });
});
// Save tip splits for an invoice (replaces existing splits)
@@ -460,6 +448,9 @@ invoicesRouter.post(
if (invoice.status !== "paid") {
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
}
if (!invoice.stripePaymentIntentId) {
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
}
return await db.transaction(async (tx) => {
if (body.idempotencyKey) {
@@ -472,75 +463,57 @@ invoicesRouter.post(
}
}
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()}`;
}
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
await tx.insert(refunds).values({
invoiceId: id,
stripeRefundId: refundId,
stripeRefundId: result.refundId,
idempotencyKey: body.idempotencyKey ?? null,
amountCents: body.amountCents ?? null,
});
return c.json({ refundId });
return c.json({ refundId: result.refundId });
});
}
);
// Payment stats for admin dashboard
invoicesRouter.get("/stats/summary", async (c) => {
try {
const db = getDb();
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
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 [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 [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 [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);
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: [],
});
}
return c.json({
revenueThisMonth: revenueResult?.total ?? 0,
outstanding: outstandingResult?.total ?? 0,
refundsThisMonth: refundsResult?.total ?? 0,
methodBreakdown,
});
});
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
+1 -1
View File
@@ -218,7 +218,7 @@ settingsRouter.post(
* 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", requireSuperUser(), async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
-26
View File
@@ -112,17 +112,9 @@ export function AppointmentsPage() {
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
// null key = unassigned; staffId string = that groomer; undefined set = all visible
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
const weekEnd = addDays(weekStart, 6);
useEffect(() => {
fetch("/api/invoices/stats/summary")
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setPaymentStats(data); })
.catch(() => {});
}, []);
const loadAppointments = useCallback(() => {
const from = weekStart.toISOString();
const to = addDays(weekStart, 7).toISOString();
@@ -322,24 +314,6 @@ export function AppointmentsPage() {
</button>
</div>
{/* Payment Stats Summary */}
{paymentStats && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</div>
</div>
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>${(paymentStats.outstanding / 100).toFixed(2)}</div>
</div>
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
</div>
</div>
)}
{/* ── View Mode + Groomer Filters ── */}
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
+96 -81
View File
@@ -173,21 +173,22 @@ function InvoiceDetailModal({
const [error, setError] = useState<string | null>(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
const [showRefundDialog, setShowRefundDialog] = useState(false);
const [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);
const [partialAmount, setPartialAmount] = useState("");
const [stripeDetails, setStripeDetails] = useState<{ cardLast4: string | null; paymentStatus: string | null; stripeRefundId: string | null } | null>(null);
// Fetch current staff role to determine manager access
const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null);
// Fetch Stripe details when modal opens for paid invoices with a payment intent
useEffect(() => {
fetch("/api/staff/me")
.then((r) => r.json())
.then((d) => setStaffMe(d))
.catch(() => setStaffMe(null));
}, []);
const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser);
if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
fetch(`/api/invoices/${invoice.id}/stripe-details`)
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setStripeDetails(data); })
.catch(() => {});
} else {
setStripeDetails(null);
}
}, [invoice.id, invoice.status, invoice.stripePaymentIntentId]);
// Tip split state: array of {staffId, staffName, pct}
const linkedAppt = invoice.appointmentId
@@ -291,6 +292,35 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
}
}
async function issueRefund() {
const amountCents = refundType === "partial"
? Math.round(parseFloat(partialAmount) * 100)
: undefined;
if (refundType === "partial" && (!amountCents || amountCents <= 0)) {
setError("Enter a valid refund amount");
return;
}
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(amountCents ? { amountCents } : {}),
});
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) {
setError(e instanceof Error ? e.message : "Failed to issue refund");
} finally {
setSaving(false);
}
}
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading</p></Modal>;
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
@@ -350,15 +380,15 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
/>
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
{invoice.stripePaymentIntentId && (
{stripeDetails && (
<>
{invoice.cardLast4 && (
<SummaryRow label="Card" value={`•••• ${invoice.cardLast4}`} />
{stripeDetails.cardLast4 && (
<SummaryRow label="Card" value={`•••• ${stripeDetails.cardLast4}`} />
)}
{invoice.paymentStatus && (
<SummaryRow label="Stripe status" value={invoice.paymentStatus} />
{stripeDetails.paymentStatus && (
<SummaryRow label="Stripe status" value={stripeDetails.paymentStatus} />
)}
{invoice.stripeRefundId && (
{stripeDetails.stripeRefundId && (
<SummaryRow label="Refund" value="Refunded" />
)}
</>
@@ -480,92 +510,77 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
</div>
)}
{(invoice.status === "paid" || invoice.status === "void") && (
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
{invoice.stripeRefundId && (
<div style={{ marginBottom: "0.75rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{ background: "#fef3c7", color: "#92400e", padding: "0.2rem 0.6rem", borderRadius: 4, fontSize: 13, fontWeight: 600 }}>Refunded</span>
</div>
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
{invoice.status === "paid" && invoice.stripePaymentIntentId && (
<button
onClick={() => setShowRefundDialog(true)}
style={{ ...btnStyle, color: "#b45309", borderColor: "#b45309" }}
>
Refund
</button>
)}
<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>
<button onClick={onClose} style={btnStyle}>Close</button>
</div>
)}
{/* Refund Dialog */}
{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")} />
<Modal onClose={() => setShowRefundDialog(false)}>
<h2 style={{ marginTop: 0 }}>Issue Refund</h2>
<p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
Invoice total: <strong>{fmtMoney(invoice.totalCents)}</strong>
</p>
<div style={{ marginBottom: "0.75rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600, marginBottom: "0.5rem" }}>
<input
type="radio"
name="refundType"
value="full"
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")} />
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600 }}>
<input
type="radio"
name="refundType"
value="partial"
checked={refundType === "partial"}
onChange={() => setRefundType("partial")}
/>
Partial refund
</label>
</div>
{refundType === "partial" && (
<div style={{ marginBottom: "0.75rem" }}>
<div style={{ marginBottom: "1rem" }}>
<input
type="number"
min="0.01"
step="0.01"
placeholder="Amount ($)"
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
style={{ ...inputStyle, width: 100 }}
placeholder="0.00"
value={partialAmount}
onChange={(e) => setPartialAmount(e.target.value)}
style={{ ...inputStyle, width: 120 }}
/>
</div>
)}
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>}
<div style={{ display: "flex", gap: "0.5rem" }}>
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
<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" }}
onClick={issueRefund}
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#b45309", color: "#fff", borderColor: "#b45309" }}
>
{refunding ? "Processing…" : "Process Refund"}
{saving ? "Processing…" : "Issue Refund"}
</button>
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>
Cancel
</button>
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
</div>
</div>
</Modal>
)}
</Modal>
</Modal>
);
}
+1 -1
View File
@@ -326,7 +326,7 @@ export function CustomerPortal() {
)}
{/* Main Content */}
<main className="flex-1 min-h-screen overflow-hidden">
<main className="flex-1 min-h-screen overflow-x-hidden">
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
<div>
<h1 className="text-lg font-semibold text-stone-800">
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
<div className="flex gap-2 flex-wrap overflow-x-auto">
<div className="flex gap-2 flex-wrap">
{([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
-7
View File
@@ -119,10 +119,3 @@ uri
database-url
{{- end -}}
{{- end }}
{{/*
Auth secret name always use groombook-auth (sealed secret name)
*/}}
{{- define "groombook.authSecretName" -}}
{{- printf "%s" "groombook-auth" }}
{{- end }}
@@ -50,27 +50,6 @@ spec:
- name: OIDC_AUDIENCE
value: {{ .Values.api.env.oidcAudience | quote }}
{{- end }}
{{- if .Values.api.env.internalBaseUrl }}
- name: OIDC_INTERNAL_BASE
value: {{ .Values.api.env.internalBaseUrl | quote }}
{{- end }}
- name: BETTER_AUTH_URL
value: {{ .Values.api.env.betterAuthUrl | quote }}
- name: OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ include "groombook.authSecretName" . }}
key: OIDC_CLIENT_ID
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "groombook.authSecretName" . }}
key: OIDC_CLIENT_SECRET
- name: BETTER_AUTH_SECRET
valueFrom:
secretKeyRef:
name: {{ include "groombook.authSecretName" . }}
key: BETTER_AUTH_SECRET
- name: DATABASE_URL
valueFrom:
secretKeyRef:
-2
View File
@@ -18,8 +18,6 @@ api:
corsOrigin: ""
oidcIssuer: ""
oidcAudience: groombook
betterAuthUrl: ""
internalBaseUrl: ""
port: "3000"
service:
type: ClusterIP
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://opencode.ai/config.json",
"permission": "allow",
"experimental": {
"snapshots": false
}
}
+1 -10
View File
@@ -883,7 +883,6 @@ async function seed() {
let appointmentCount = 0;
let invoiceCount = 0;
let visitLogCount = 0;
let paidInvoiceCounter = 0;
// Process in batches per client to keep memory manageable
const apptBatchSize = 100;
@@ -978,10 +977,6 @@ async function seed() {
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
paidInvoiceCounter++;
const stripePaymentIntentId = invoiceStatus === "paid"
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
: null;
invoiceBatch.push({
id: invoiceId,
@@ -994,7 +989,6 @@ async function seed() {
status: invoiceStatus,
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
paidAt,
stripePaymentIntentId,
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
});
@@ -1098,16 +1092,13 @@ async function seed() {
const taxCents = Math.round(effectivePrice * 0.08);
const totalCents = effectivePrice + taxCents + tipCents;
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
paidInvoiceCounter++;
invoiceBatch.push({
id: invoiceId, appointmentId: apptId, clientId,
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
status: "paid" as const,
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
paidAt,
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
notes: null,
paidAt, notes: null,
});
lineItemBatch.push({
id: uuid(), invoiceId, description: svc.name, quantity: 1,