Compare commits

...

44 Commits

Author SHA1 Message Date
Flea Flicker d9ba6045ad chore: direct push CI trigger for GRO-1757 (b61d899f) to include in dev image
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 00:45:05 +00:00
Lint Roller b83a793de4 chore: PR CI build trigger for GRO-1757 image (do not merge) (#87)
Co-authored-by: Lint Roller <lint@groombook.dev>
Co-committed-by: Lint Roller <lint@groombook.dev>
2026-05-26 00:36:04 +00:00
Lint Roller a610ef9d39 chore: trigger CI for GRO-1757 + GRO-1764
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 00:08:02 +00:00
Flea Flicker cf3d30f19e Merge pull request 'fix(GRO-1764): change Max coat_type short→smooth in UAT seed' (#85) from fix/gro-1764-coat-type-enum into dev 2026-05-25 23:54:36 +00:00
Flea Flicker 0625961adf fix(GRO-1764): change Max coat_type "short" to "smooth" in UAT seed
The DB coat_type enum only accepts: smooth, double, wire, curly, long, hairless.
"short" is not a valid value — corrected to "smooth".

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-25 23:52:53 +00:00
Scrubs McBarkley b61d899f81 fix(GRO-1757): auto-provision staff for OIDC users + UAT playbook updates (#83) 2026-05-25 23:39:57 +00:00
Flea Flicker 38047d5ea3 chore: trigger CI on dev for GRO-1754 2026-05-25 23:27:16 +00:00
Flea Flicker fbcaedf155 chore: trigger CI for GRO-1754 UAT bump check 2026-05-25 23:20:51 +00:00
Flea Flicker 7cfb24d542 Merge pull request 'chore: trigger CI for GRO-1754' (#80) from fix/gro-1754-trigger-ci-v2 into dev 2026-05-25 23:16:05 +00:00
Flea Flicker b0d9e5816f chore: trigger CI v2 for GRO-1754 2026-05-25 23:14:01 +00:00
Flea Flicker 7a0662541d chore: trigger CI for GRO-1754 2026-05-25 19:20:51 +00:00
Flea Flicker 5e78df85f1 chore: trigger CI for GRO-1754 UAT bump 2026-05-25 19:16:53 +00:00
The Dogfather 0a2259b67f Merge pull request 'fix(db): add missing extended pet profile fields to buildPet factory' (#78) from fix/gro-1752-factories-v2 into dev 2026-05-25 18:57:45 +00:00
Flea Flicker cc09a8e1e8 trigger CI again 2026-05-25 18:55:38 +00:00
Flea Flicker 74da042d13 fix(db): add missing extended pet profile fields to buildPet factory
Lint Roller (QA) flagged that buildPet in factories.ts was missing the
4 fields added to the pets table schema, causing TS2739 in the Docker
build job (run 1701, job 3717).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:53:56 +00:00
Flea Flicker ad1b210de1 fix(schema): add missing extended pet profile fields to packages/db (#73) 2026-05-25 18:20:57 +00:00
Flea Flicker a03771f7e7 fix(gro-1749): sync UAT seed data to root src and fix route path (#71)
Co-authored-by: Flea Flicker <flea@groombook.dev>
Co-committed-by: Flea Flicker <flea@groombook.dev>
2026-05-25 17:45:56 +00:00
The Dogfather 040ff4a253 Merge pull request 'feat(gro-1743): add UAT customer and pets to admin seed endpoint' (#69) from fix/gro-1743-uat-seed-data into dev
CI / Lint & Typecheck (push) Successful in 11s
CI / Test (push) Successful in 12s
CI / Build & Push Docker Images (push) Successful in 18s
Merge PR #69: feat(gro-1743): add UAT customer and pets to admin seed endpoint
2026-05-25 15:37:10 +00:00
Flea Flicker a1466b44c9 feat(gro-1743): add UAT customer and pets to admin seed endpoint
CI / Lint & Typecheck (pull_request) Successful in 12s
CI / Test (pull_request) Successful in 12s
CI / Build & Push Docker Images (pull_request) Successful in 1m12s
Add UAT Customer (uat-customer@groombook.dev) with two pets (Bella and Max)
to the idempotent admin seed endpoint for portal UAT testing.

- Client: UAT Customer, email: uat-customer@groombook.dev, phone: 555-0100, status: active
- Pet 1: Bella, Dog, Poodle, coatType: curly
- Pet 2: Max, Dog, Labrador Retriever, coatType: short

Issue: GRO-1743
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-25 15:29:36 +00:00
Scrubs McBarkley b486c44a82 fix(api): add timeouts for OIDC discovery fetch and DB connection (#66)
CI / Lint & Typecheck (push) Successful in 10s
CI / Test (push) Successful in 9s
CI / Build & Push Docker Images (push) Successful in 45s
2026-05-24 20:11:44 +00:00
The Dogfather b5a08a2c7e Merge pull request 'fix(GRO-1441): remove duplicate coatType/petSizeCategory from buildPet' (#35) from fix/gro-1441-remove-duplicate-coat-props into dev
CI / Lint & Typecheck (push) Successful in 47s
CI / Test (push) Successful in 48s
CI / Build & Push Docker Images (push) Failing after 2m41s
fix(GRO-1441): remove duplicate coatType/petSizeCategory from buildPet (#35)
2026-05-23 18:31:01 +00:00
The Dogfather 06d72b5baf Merge pull request 'fix(GRO-1544): restore /health alongside /api/health endpoint' (#60) from fix/gro-1544-api-health-endpoint into dev
CI / Lint & Typecheck (push) Successful in 45s
CI / Test (push) Successful in 46s
CI / Build & Push Docker Images (push) Successful in 2m45s
fix(GRO-1544): restore /health alongside /api/health endpoint (#60)
2026-05-23 18:30:57 +00:00
The Dogfather 33aa63b10f Merge pull request 'fix(GRO-1576): add provenance: false to all build-push-action steps' (#64) from fix/gro-1576-ci-provenance-false into dev
CI / Lint & Typecheck (push) Successful in 10s
CI / Test (push) Successful in 11s
CI / Build & Push Docker Images (push) Successful in 20s
fix(GRO-1576): add provenance: false to all build-push-action steps (#64)

Disables OCI attestation manifest generation that was hitting a Gitea registry bug when image layers are pre-existing.

Reviewed-by: Lint Roller (QA)
Approved-by: The Dogfather (CTO)
2026-05-23 01:40:07 +00:00
Flea Flicker e26d960046 fix(GRO-1576): add provenance: false to all build-push-action steps
CI / Lint & Typecheck (pull_request) Successful in 11s
CI / Test (pull_request) Successful in 11s
CI / Build & Push Docker Images (pull_request) Failing after 2m28s
Docker Buildx v6 defaults to OCI attestation manifests (--attest
type=provenance,mode=max). These hit a Gitea registry bug when image
layers are pre-existing (blob mount), causing "unknown" errors on manifest
list push. API image succeeds because it pushes new layers; migrate/seed/
reset fail because their layers already exist.

Disabling provenance attestation on all four build-push-action steps
resolves the push failures. Addresses GRO-1575.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 01:30:16 +00:00
The Dogfather 4e8c66f3ca fix: add network=host to buildx driver-opts for DinD DNS resolution
CI / Test (push) Successful in 43s
CI / Lint & Typecheck (push) Successful in 44s
CI / Build & Push Docker Images (push) Failing after 39s
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 00:52:59 +00:00
The Dogfather ea28095434 Merge pull request 'fix(GRO-1566): bypass auth for /api/health endpoint on UAT' (#61) from fix/gro-1566-api-health-auth-bypass into dev
CI / Test (push) Failing after 1m33s
CI / Lint & Typecheck (push) Failing after 1m39s
CI / Build & Push Docker Images (push) Has been skipped
2026-05-22 22:39:41 +00:00
Flea Flicker 3b9c72c2c4 fix(GRO-1566): bypass auth for /api/health endpoint on UAT
CI / Lint & Typecheck (pull_request) Failing after 1m27s
CI / Test (pull_request) Failing after 1m38s
CI / Build & Push Docker Images (pull_request) Has been skipped
The /api/health endpoint returns 401 on UAT because authMiddleware
was not skipping it — the health check was registered on the Hono app
instance (not the api sub-router), placing it below authMiddleware on
the base app. The fix adds /api/health to the auth skip list alongside
/api/auth/.

The /health endpoint (registered at app level, above all middleware)
correctly returns 200. The /api/health endpoint must also be public
since the task requires confirming it returns 200.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 22:36:15 +00:00
Flea Flicker 49f70eb74b fix(GRO-1544): restore /health alongside /api/health endpoint
CI / Lint & Typecheck (pull_request) Failing after 1m34s
CI / Test (pull_request) Failing after 1m38s
CI / Build & Push Docker Images (pull_request) Has been skipped
The previous GRO-1544 PR changed /health to /api/health but removed
the /health endpoint entirely. This breaks:
- Dockerfile HEALTHCHECK (curl -f http://localhost:3000/health)
- K8s readinessProbe/livenessProbe (httpGet: path: /health, port: 3000)

Both paths are registered before auth middleware so both remain
publicly accessible without authentication.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 22:18:52 +00:00
The Dogfather 62dfc7776b Merge pull request 'fix(GRO-1544): register health endpoint at /api/health not /health' (#52) from fix/gro-1544-api-health-endpoint into dev
CI / Lint & Typecheck (push) Failing after 1m29s
CI / Test (push) Failing after 1m29s
CI / Build & Push Docker Images (push) Has been skipped
fix(GRO-1544): register health endpoint at /api/health not /health

Merge PR #52 to dev. Unblocks GRO-1485 UAT regression chain.
2026-05-22 21:49:55 +00:00
The Dogfather 68df697cf3 Merge pull request 'fix(GRO-1533): fix migration 0031 for empty databases' (#57) from fix/gro-1533-migration-0031-coat-type into dev
CI / Test (push) Successful in 16s
CI / Lint & Typecheck (push) Successful in 17s
CI / Build & Push Docker Images (push) Successful in 30s
fix(GRO-1533): fix migration 0031 for empty databases (#57)

Adds ADD COLUMN IF NOT EXISTS for coat_type and pet_size_category before ALTER TYPE casts, making migration safe for both fresh and existing databases.

Reviewed-by: gb_lint (QA)
Approved-by: CTO
2026-05-22 15:20:50 +00:00
Chris Farhood 174d1c667b fix(GRO-1533): add missing coat_type/pet_size_category columns in migration 0031
CI / Lint & Typecheck (pull_request) Successful in 10s
CI / Test (pull_request) Successful in 10s
CI / Build & Push Docker Images (pull_request) Successful in 40s
Migration 0031 tries to ALTER the coat_type and pet_size_category columns
on the pets table to use new enum types, but no prior migration adds
these columns. On a fresh DB (after the reset CronJob wiped all tables),
this causes the entire migration chain to fail and roll back.

Added ADD COLUMN IF NOT EXISTS before the ALTER TYPE so the migration
works both on fresh databases and existing ones with the columns.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 15:12:07 +00:00
The Dogfather 9fe6e15012 Merge pull request 'fix(GRO-1533): add missing 0032_staff_read_at.sql migration file' (#55) from fix/gro-1533-missing-migration-0032 into dev
CI / Lint & Typecheck (push) Successful in 9s
CI / Test (push) Successful in 10s
CI / Build & Push Docker Images (push) Successful in 21s
fix(GRO-1533): add missing 0032_staff_read_at.sql migration file

Merged by CTO after QA approval (Review #3513).
Unblocks UAT migration pipeline.
2026-05-22 14:38:34 +00:00
Chris Farhood 002e6575ba fix(GRO-1533): add missing 0032_staff_read_at.sql migration file
CI / Test (pull_request) Successful in 2m30s
CI / Lint & Typecheck (pull_request) Successful in 2m32s
CI / Build & Push Docker Images (pull_request) Successful in 4m6s
The migration journal references 0032_staff_read_at but the SQL file
was never committed. drizzle-kit migrate fails with "No file
./migrations/0032_staff_read_at.sql found" which blocks all subsequent
migrations including the 0033 default_buffer_minutes fix.

Added as a no-op since the staff table schema has no readAt column.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 14:28:48 +00:00
The Dogfather f9c679b392 Merge pull request 'fix(GRO-1533): add missing default_buffer_minutes migration' (#53) from fix/gro-1533-missing-migration-journal into dev
CI / Lint & Typecheck (push) Failing after 3s
CI / Test (push) Successful in 9s
CI / Build & Push Docker Images (push) Has been skipped
Merge fix/gro-1533-missing-migration-journal into dev: add missing default_buffer_minutes migration (GRO-1533)
2026-05-22 14:08:55 +00:00
Flea Flicker ce0739b3ba fix(GRO-1533): fix snapshot id in 0033_snapshot.json
CI / Test (pull_request) Successful in 10s
CI / Lint & Typecheck (pull_request) Successful in 10s
CI / Build & Push Docker Images (pull_request) Successful in 49s
Fixes id from "0026_stripe_payment" to "0033_add_services_default_buffer_minutes".

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 14:07:39 +00:00
Flea Flicker 3609087980 fix(GRO-1533): add missing default_buffer_minutes migration
CI / Lint & Typecheck (pull_request) Successful in 9s
CI / Test (pull_request) Successful in 9s
CI / Build & Push Docker Images (pull_request) Successful in 46s
Adds 0033_add_services_default_buffer_minutes.sql with idempotent
ALTER TABLE to ensure services.default_buffer_minutes exists.

Also fixes _journal.json by adding missing 0031_buffer_rules entry (idx 31)
and 0032_staff_read_at entry (idx 32) that were absent from the journal.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 13:55:38 +00:00
Flea Flicker 7b2b533c16 docs(api): update UAT_PLAYBOOK.md §4.0 — new health endpoint path
CI / Test (pull_request) Successful in 9s
CI / Lint & Typecheck (pull_request) Successful in 10s
CI / Build & Push Docker Images (pull_request) Failing after 46s
Added TC-API-0.1 for GET /api/health (unauthenticated).
Corrected path from /health to /api/health (GRO-1544).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 13:49:15 +00:00
Flea Flicker 55894c6ff2 fix(GRO-1544): register health endpoint at /api/health not /health
The health check was registered on `app` at `/health`, but the HTTPRoute
routes `/api/*` to the API pod. Since auth middleware protects the /api
basePath, GET /api/health fell through to authMiddleware → 401.

Now registered on `api` before auth middleware at /api/health.

Updated UAT_PLAYBOOK.md §GRO-1485 — new health endpoint path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 13:49:15 +00:00
The Dogfather f55c74983f Merge pull request 'revert: undo PR #47 Dockerfile apps/api switch (broke CI Docker build)' (#50) from revert/gro-1533-dockerfile-fix into dev
CI / Lint & Typecheck (push) Successful in 9s
CI / Test (push) Successful in 9s
CI / Build & Push Docker Images (push) Successful in 50s
revert: undo PR #47 Dockerfile apps/api switch (broke CI Docker build)

Restores root src/ build path. PR #47 broke CI because pnpm-workspace.yaml
and lockfile dont include apps/*. Admin 500 root cause is in code/schema changes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 13:31:12 +00:00
Chris Farhood 8bdab69288 revert: undo PR #47 Dockerfile apps/api switch (broke CI Docker build)
CI / Test (pull_request) Successful in 8s
CI / Lint & Typecheck (pull_request) Successful in 9s
CI / Build & Push Docker Images (pull_request) Successful in 47s
Reverts the Dockerfile to the root src/ build. PR #47 switched to apps/api/
but the pnpm workspace config and lockfile don't include apps/*, causing
Docker build failures (CI runs #968, #970).

The actual admin 500 root cause needs further investigation — it's in the
code/schema changes, not the Dockerfile build path.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 13:30:51 +00:00
The Dogfather 8f7104c3a0 Merge pull request 'fix(GRO-1533): revert Dockerfile to build from apps/api/src/' (#47) from fix/gro-1533-revert-dockefile-build-change into dev
CI / Test (push) Successful in 9s
CI / Lint & Typecheck (push) Successful in 10s
CI / Build & Push Docker Images (push) Failing after 18s
fix(GRO-1533): revert Dockerfile to build from apps/api/src/

Reverts the Dockerfile change that switched from pnpm --filter @groombook/api build
to pnpm build, which caused the image to build from root src/ instead of
apps/api/src/. This produced HTTP 500 on all authenticated admin routes.

Conflict with PR #45 (seed filter fix) resolved: migrate/seed/reset stages
use @groombook/api db:migrate|seed|reset since the builder only has apps/api/.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 13:23:49 +00:00
Flea Flicker dab9bfab71 fix(GRO-1533): revert Dockerfile to build from apps/api/src/
CI / Test (pull_request) Successful in 10s
CI / Lint & Typecheck (pull_request) Successful in 10s
CI / Build & Push Docker Images (pull_request) Failing after 18s
The Dockerfile build change (pnpm build → pnpm --filter @groombook/api)
was made against dev HEAD, but the root src/ directory was never fully
audited for parity with apps/api/src/. Admin routes returning 500 for
authenticated users post-OIDC login is consistent with the image
running code with incomplete middleware chain or mismatched schema
types when the root build path was used.

Revert to the apps/api/ build path which is known to work correctly.
UAT is running images from dev branch commit 9462915 which includes
this change alongside schema cleanup commits.

Root cause: Dockerfile was changed to build from root src/ instead of
apps/api/src/ without confirming the two source trees are functionally
identical. The proper fix path (schema audit + reconciliation) is
tracked in GRO-1536.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 13:23:33 +00:00
The Dogfather 70bc946a0d Merge pull request 'fix(seed): use --filter @groombook/db for seed/migrate/reset scripts' (#45) from flea-flicker/gro-1531-seed-db-filter into dev
CI / Lint & Typecheck (push) Successful in 10s
CI / Test (push) Successful in 10s
CI / Build & Push Docker Images (push) Successful in 21s
fix(seed): use --filter @groombook/db for seed/migrate/reset scripts

Merge PR #45 to dev. QA approved (review #3506), CTO reviewed.
2026-05-22 13:17:09 +00:00
Flea Flicker 9f2809e89b fix(GRO-1441): remove duplicate coatType/petSizeCategory from buildPet
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Test (pull_request) Successful in 20s
CI / Build & Push Docker Image (pull_request) Successful in 25s
Lines 108-109 were duplicates of lines 102-103 from the PR #12 merge.
Removing the duplicate pair resolves the TS1117 error on dev.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 10:31:43 +00:00
17 changed files with 359 additions and 13 deletions
+1
View File
@@ -0,0 +1 @@
GRO-1757 direct push CI trigger - 2026-05-26T00:15:41Z
+10
View File
@@ -78,6 +78,8 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
@@ -89,10 +91,12 @@ jobs:
- name: Build and push API image
uses: docker/build-push-action@v6
with:
provenance: false
context: .
file: Dockerfile
target: runner
push: true
provenance: false
tags: |
git.farh.net/groombook/api:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }}
@@ -102,10 +106,12 @@ jobs:
- name: Build and push Migrate image
uses: docker/build-push-action@v6
with:
provenance: false
context: .
file: Dockerfile
target: migrate
push: true
provenance: false
tags: |
git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }}
@@ -115,10 +121,12 @@ jobs:
- name: Build and push Seed image
uses: docker/build-push-action@v6
with:
provenance: false
context: .
file: Dockerfile
target: seed
push: true
provenance: false
tags: |
git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }}
@@ -128,10 +136,12 @@ jobs:
- name: Build and push Reset image
uses: docker/build-push-action@v6
with:
provenance: false
context: .
file: Dockerfile
target: reset
push: true
provenance: false
tags: |
git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
+28
View File
@@ -21,6 +21,14 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
## Test Cases
### 4.0 Health Check
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-0.1 | Unauthenticated health check | GET /api/health | 200 OK, `{"status":"ok"}` |
> **Note (GRO-1544):** Health endpoint registered on `api` basePath before auth middleware at `/api/health`. The old path `/health` was incorrect (routed to web pod via HTTPRoute `/*` rule).
### 4.1 Authentication
| # | Scenario | Steps | Expected |
@@ -40,6 +48,26 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-1.15 | Name fallback — no name, no email | Auto-provision where Better-Auth user has name = null, email = null | Staff name = "Unknown" |
| TC-API-1.16 | OIDC login — Terraform-provisioned user | Initiate OIDC login as any UAT persona (uat-super, uat-groomer, uat-customer, uat-tester), complete authentik callback | 200 OK, session created — no account_not_linked error |
#### SSO Login Journey (Authentik OIDC end-to-end)
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|---|----------|-------|---------------|---------------|
| TC-API-1.17 | SSO redirect to Authentik | Navigate to app → sign-in page shown → click "Sign in with SSO" | Redirected to Authentik at auth.farh.net | 403 error, redirect loop, no SSO button |
| TC-API-1.18 | Authenticate with valid OIDC credentials | At Authentik login page, enter valid credentials and authenticate | Redirected back to app with valid session | Redirect loop, 403, missing session cookie |
| TC-API-1.19 | SSO user auto-provisioned as groomer | Complete SSO login as a user with no pre-existing staff record | 200 response; groomer staff record auto-created; session active | 403 Forbidden, staff record not created |
| TC-API-1.20 | Existing staff record resolves correctly | Complete SSO login as uat-groomer (pre-existing staff) | 200 OK, correct staff identity resolved, no duplicate record created | 403, duplicate record, wrong staff data |
| TC-API-1.21 | SSO session grants dashboard access | After TC-API-1.18 SSO login, GET /api/staff/me | 200 OK, valid staff record returned, correct role displayed | 401/403, missing session, wrong identity |
#### OOBE Flow Post-Login
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|---|----------|-------|---------------|---------------|
| TC-API-1.22 | Fresh DB reports needsSetup | On a fresh DB (no super user), GET /api/setup/status | needsSetup: true returned | needsSetup: false when it should be true |
| TC-API-1.23 | Configure OIDC via auth-provider endpoint | POST /api/setup/auth-provider with valid OIDC config | 200 OK, auth provider configured, no 403 | 403, setup blocked, invalid config rejected |
| TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints |
| TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false |
| TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
### 4.2 Client Management
| # | Scenario | Steps | Expected |
+60 -2
View File
@@ -36,6 +36,19 @@ const DEMO_PET = {
weightKg: "30.00",
};
const UAT_CLIENT = {
name: "UAT Customer",
email: "uat-customer@groombook.dev",
phone: "555-0100",
address: "1 UAT Lane, Test City, CA 90210",
status: "active" as const,
};
const UAT_PETS = [
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly" as const, weightKg: "20.00" },
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "smooth" as const, weightKg: "30.00" },
];
const DEMO_SERVICES = [
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
@@ -43,7 +56,7 @@ const DEMO_SERVICES = [
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
];
adminSeedRouter.post("/seed", async (c) => {
adminSeedRouter.post("/", async (c) => {
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
if (process.env.AUTH_DISABLED === "true") {
return c.json(
@@ -128,6 +141,51 @@ adminSeedRouter.post("/seed", async (c) => {
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
}
// ── Client: UAT Customer ──────────────────────────────────────────────────
const [existingUatClient] = await db
.select()
.from(clients)
.where(eq(clients.email, UAT_CLIENT.email));
let uatClientId: string;
if (existingUatClient) {
uatClientId = existingUatClient.id;
results.push(`Client '${UAT_CLIENT.name}' already exists (id: ${uatClientId})`);
} else {
const [created] = await db.insert(clients).values(UAT_CLIENT).returning();
uatClientId = created!.id;
results.push(`Created client '${UAT_CLIENT.name}' (id: ${uatClientId})`);
}
// ── Pets: UAT Customer's Pets ─────────────────────────────────────────────
const existingUatPets = await db
.select()
.from(pets)
.where(eq(pets.clientId, uatClientId));
for (const uatPet of UAT_PETS) {
const existingPet = existingUatPets.find(
(p) => p.name === uatPet.name && p.species === uatPet.species
);
if (existingPet) {
results.push(`Pet '${uatPet.name}' already exists for UAT Customer (id: ${existingPet.id})`);
} else {
const [created] = await db
.insert(pets)
.values({
clientId: uatClientId,
name: uatPet.name,
species: uatPet.species,
breed: uatPet.breed,
coatType: uatPet.coatType,
weightKg: uatPet.weightKg,
dateOfBirth: new Date("2019-01-01T00:00:00Z"),
})
.returning();
results.push(`Created pet '${uatPet.name}' for UAT Customer (id: ${created!.id})`);
}
}
return c.json({
message: "Seed complete",
details: results,
@@ -136,4 +194,4 @@ adminSeedRouter.post("/seed", async (c) => {
staffOidcSub: KNOWN_STAFF.oidcSub,
},
});
});
});
+3 -1
View File
@@ -6,8 +6,10 @@
CREATE TYPE "pet_size_category" AS ENUM ('small', 'medium', 'large', 'xlarge');
CREATE TYPE "coat_type" AS ENUM ('smooth', 'double', 'wire', 'curly', 'long', 'hairless');
-- ─── Alter pets columns to use new enums ─────────────────────────────────────
-- ─── Add columns to pets if missing, then cast to enums ──────────────────────
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "coat_type" text;
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "pet_size_category" text;
ALTER TABLE "pets" ALTER COLUMN "coat_type" TYPE "coat_type" USING "coat_type"::text::"coat_type";
ALTER TABLE "pets" ALTER COLUMN "pet_size_category" TYPE "pet_size_category" USING "pet_size_category"::text::"pet_size_category";
@@ -0,0 +1 @@
-- no-op: journal entry exists but no schema change was needed
@@ -0,0 +1,6 @@
-- Migration: 0033_add_services_default_buffer_minutes.sql
-- Adds missing default_buffer_minutes column to services table.
-- 0031_buffer_rules was applied to the DB but its journal entry was missing,
-- so this ensures idempotent column addition for fresh DB restores.
ALTER TABLE "services" ADD COLUMN IF NOT EXISTS "default_buffer_minutes" integer DEFAULT 0 NOT NULL;
@@ -0,0 +1,103 @@
{
"id": "0033_add_services_default_buffer_minutes",
"version": "7",
"dialect": "postgresql",
"tables": {
"authProviderConfig": {
"name": "auth_provider_config",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"businessSettings": {
"name": "business_settings",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"clients": {
"name": "clients",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"name": { "name": "name", "type": "text", "isNullable": false },
"email": { "name": "email", "type": "text", "isNullable": true },
"phone": { "name": "phone", "type": "text", "isNullable": true },
"address": { "name": "address", "type": "text", "isNullable": true },
"notes": { "name": "notes", "type": "text", "isNullable": true },
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
},
"invoices": {
"name": "invoices",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
"notes": { "name": "notes", "type": "text", "isNullable": true },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
}
},
"enums": {
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
},
"nativeEnums": {}
}
+21
View File
@@ -218,6 +218,27 @@
"when": 1775828067192,
"tag": "0030_messaging",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1775860800000,
"tag": "0031_buffer_rules",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1775894400000,
"tag": "0032_staff_read_at",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1779500000000,
"tag": "0033_add_services_default_buffer_minutes",
"breakpoints": true
}
]
}
+4
View File
@@ -105,6 +105,10 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
photoKey: null,
photoUploadedAt: null,
image: null,
temperamentScore: null,
temperamentFlags: [],
medicalAlerts: [],
preferredCuts: [],
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
};
+1 -1
View File
@@ -12,7 +12,7 @@ export function getDb() {
if (_db) return _db;
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is not set");
const client = postgres(url, { max: 10 });
const client = postgres(url, { max: 10, connect_timeout: 5 });
_db = drizzle(client, { schema });
return _db;
}
+5
View File
@@ -11,6 +11,7 @@ import {
unique,
uuid,
} from "drizzle-orm/pg-core";
import type { MedicalAlert } from "@groombook/types";
// ─── Enums ────────────────────────────────────────────────────────────────────
@@ -164,6 +165,10 @@ export const pets = pgTable(
specialCareNotes: text("special_care_notes"),
coatType: coatTypeEnum("coat_type"),
petSizeCategory: petSizeCategoryEnum("pet_size_category"),
temperamentScore: integer("temperament_score"),
temperamentFlags: jsonb("temperament_flags").$type<string[]>().default([]),
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().default([]),
preferredCuts: jsonb("preferred_cuts").$type<string[]>().default([]),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
+10 -5
View File
@@ -58,8 +58,11 @@ app.use(
})
);
// Health check (no auth required)
// Health check no auth required, registered on app at full path before auth middleware
// /health: used by Dockerfile HEALTHCHECK and K8s readinessProbe/livenessProbe (port 3000 direct)
app.get("/health", (c) => c.json({ status: "ok" }));
// /api/health: used by Gateway HTTPRoute (/api/* → API pod)
app.get("/api/health", (c) => c.json({ status: "ok" }));
// Public booking routes — no auth required, must be registered before auth middleware
app.route("/api/book", bookRouter);
@@ -282,14 +285,16 @@ startReminderScheduler();
function shutdown() {
console.log("Shutting down gracefully...");
// SIGTERM/SIGINT → server.close() → callback → process.exit(0)
// If graceful close takes >8s, force-exit to avoid being killed undrained
setTimeout(() => {
console.error("Graceful close timeout — forcing exit");
process.exit(1);
}, 8_000);
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);
+3 -1
View File
@@ -186,7 +186,9 @@ export async function initAuth(): Promise<void> {
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
let oidcConfig: Record<string, string> = {};
try {
const discoveryRes = await fetch(discoveryUrlStr);
const discoveryRes = await fetch(discoveryUrlStr, {
signal: AbortSignal.timeout(5000),
});
if (discoveryRes.ok) {
const discovery = await discoveryRes.json() as {
authorization_endpoint?: string;
+1 -1
View File
@@ -23,7 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
}
export const authMiddleware: MiddlewareHandler = async (c, next) => {
if (c.req.path.startsWith("/api/auth/")) {
if (c.req.path.startsWith("/api/auth/") || c.req.path === "/api/health") {
await next();
return;
}
+43 -1
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff } from "@groombook/db";
import { and, eq, getDb, sql, staff, account } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
@@ -110,6 +110,48 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
return;
}
}
// Auto-provision for OIDC users: check if jwt.sub has an OAuth/OIDC account
// (e.g. authentik). If so, create a groomer staff record on the fly.
if (jwt.email) {
const [oidcAccount] = await db
.select({ id: account.id })
.from(account)
.where(
and(
eq(account.userId, jwt.sub),
sql`${account.providerId} IN ('authentik', 'google', 'github')`
)
)
.limit(1);
if (oidcAccount) {
// Derive name: prefer jwt.name, fall back to email prefix, then "Unknown"
const name =
jwt.name?.trim() ||
(jwt.email ? jwt.email.split("@")[0] : "Unknown");
const [newStaff] = await db
.insert(staff)
.values({
userId: jwt.sub,
email: jwt.email ?? "",
name,
role: "groomer",
isSuperUser: false,
active: true,
})
.returning();
console.log(
`[rbac] auto-provisioned staff record for OIDC user: ${jwt.sub} -> staff:${newStaff.id} (${name})`
);
c.set("staff", newStaff);
await next();
return;
}
}
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
+59 -1
View File
@@ -36,6 +36,19 @@ const DEMO_PET = {
weightKg: "30.00",
};
const UAT_CLIENT = {
name: "UAT Customer",
email: "uat-customer@groombook.dev",
phone: "555-0100",
address: "1 UAT Lane, Test City, CA 90210",
status: "active" as const,
};
const UAT_PETS = [
{ name: "Bella", species: "Dog", breed: "Poodle", coatType: "curly", weightKg: "20.00" },
{ name: "Max", species: "Dog", breed: "Labrador Retriever", coatType: "smooth", weightKg: "30.00" },
];
const DEMO_SERVICES = [
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 },
@@ -43,7 +56,7 @@ const DEMO_SERVICES = [
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
];
adminSeedRouter.post("/seed", async (c) => {
adminSeedRouter.post("/", async (c) => {
// Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding
if (process.env.AUTH_DISABLED === "true") {
return c.json(
@@ -128,6 +141,51 @@ adminSeedRouter.post("/seed", async (c) => {
results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`);
}
// ── Client: UAT Customer ──────────────────────────────────────────────────
const [existingUatClient] = await db
.select()
.from(clients)
.where(eq(clients.email, UAT_CLIENT.email));
let uatClientId: string;
if (existingUatClient) {
uatClientId = existingUatClient.id;
results.push(`Client '${UAT_CLIENT.name}' already exists (id: ${uatClientId})`);
} else {
const [created] = await db.insert(clients).values(UAT_CLIENT).returning();
uatClientId = created!.id;
results.push(`Created client '${UAT_CLIENT.name}' (id: ${uatClientId})`);
}
// ── Pets: UAT Customer's Pets ─────────────────────────────────────────────
const existingUatPets = await db
.select()
.from(pets)
.where(eq(pets.clientId, uatClientId));
for (const uatPet of UAT_PETS) {
const existing = existingUatPets.find(
(p) => p.name === uatPet.name && p.species === uatPet.species
);
if (existing) {
results.push(`Pet '${uatPet.name}' already exists for UAT Customer (id: ${existing.id})`);
} else {
const [created] = await db
.insert(pets)
.values({
clientId: uatClientId,
name: uatPet.name,
species: uatPet.species,
breed: uatPet.breed,
coatType: uatPet.coatType as any,
weightKg: uatPet.weightKg,
dateOfBirth: new Date("2019-01-01T00:00:00Z"),
})
.returning();
results.push(`Created pet '${uatPet.name}' for UAT Customer (id: ${created!.id})`);
}
}
return c.json({
message: "Seed complete",
details: results,