Compare commits

..

162 Commits

Author SHA1 Message Date
Flea Flicker 73205bdc18 docs(UAT_PLAYBOOK): note GRO-1977 seed idempotency fix for TC-API-1.4-1.7
CI / Test (pull_request) Successful in 13s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 1m17s
Updated UAT_PLAYBOOK.md §4.1 — add note that seed credential provisioning
is idempotent and TC-API-1.4 through TC-API-1.7 now return 200 for all
4 UAT personas (previously returned 401 due to frozen-hash bug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 00:20:08 +00:00
Flea Flicker 24104b3105 fix(seed): update credential password on re-run instead of skipping
- seed.ts: existingAccount branch now re-hashes the current
  SEED_UAT_*_PASSWORD env value and updates the stored credential.
  Re-running the seed now properly rotates credentials.
- seed-uat-credentials.test.ts: AC-5 updated to reflect update
  (not insert) behavior on existing accounts. AC-5b added to verify
  password rotation when re-seeded with a new env password.

Fixes GRO-1977. Unblocks GRO-1969.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 00:20:07 +00:00
The Dogfather b928acf5d6 fix(seed): update credential password on existing accounts — not skip (GRO-1977) (#120)
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 37s
2026-06-01 00:08:19 +00:00
The Dogfather dd220598ca fix: add missing coat_type enum values (GRO-1971) (#118)
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 24s
CI / Build & Push Docker Images (push) Successful in 36s
2026-05-31 23:09:36 +00:00
Scrubs McBarkley bec7b014be fix(seed): remove stale uc.petName closure ref, correct medicalAlerts distribution to 30% (#115)
CI / Test (push) Successful in 11s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Images (push) Successful in 1m4s
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 14s
CI / Build & Push Docker Images (pull_request) Successful in 1m4s
2026-05-31 22:14:30 +00:00
Flea Flicker 01cff9006a GRO-1961: populate extended fields on UAT Pup Alpha/Beta on re-runs (#114)
CI / Test (push) Successful in 10s
CI / Lint & Typecheck (push) Successful in 22s
CI / Build & Push Docker Images (push) Successful in 59s
GRO-1961: populate extended fields on UAT Pup Alpha/Beta on re-runs
2026-05-31 21:52:06 +00:00
The Dogfather c99e2980a1 ci: add blackhole smoke for migrate image (GRO-1939) (#111)
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 16s
CI / Test (pull_request) Successful in 10s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Images (push) Successful in 48s
CI / Build & Push Docker Images (pull_request) Successful in 27s
Adds smoke-test step after the migrate image build that runs the image with registry.npmjs.org pointed at 127.0.0.1; pnpm --version must succeed without npm access. Guards against corepack-offline regression from GRO-1916.

QA: Lint Roller APPROVED (commit 5ec9e9a8) — CI all-green.
CTO: signed off (self-approval blocked by Gitea — I authored).

Closes #GRO-1954
Closes #GRO-1957
Closes #GRO-1958
Relates #GRO-1939
2026-05-30 11:15:56 +00:00
Flea Flicker 5ec9e9a8fd fix(ci): correct typo groombok->groombook and fix Reset image cache-from indentation
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 44s
- Fix API image tag typo: groombok -> groombook (line 103)
- Fix Reset image cache-from/cache-to indentation: moved from under tags: (12 spaces) to under with: (10 spaces)
- This corrects the Reset image build failure in CI runs.
2026-05-30 05:14:51 +00:00
Flea Flicker e9aef5719f GRO-1939: Add CI smoke test for blackholed migrate runtime
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Images (pull_request) Failing after 53s
Cherry-picked from fix/GRO-1909-migrate-corepack-offline (f007eca)
2026-05-30 04:49:59 +00:00
Flea Flicker c588c94dcb GRO-1955: hotfix seed.ts broken uc reference in random pet batch (#112)
CI / Test (push) Failing after 9s
CI / Lint & Typecheck (push) Successful in 20s
CI / Build & Push Docker Images (push) Has been skipped
2026-05-30 04:42:33 +00:00
Lint Roller e00cdc1321 fix(db): add missing 'short' value to coat_type enum (GRO-1953) (#110)
CI / Lint & Typecheck (push) Failing after 14s
CI / Test (push) Successful in 14s
CI / Build & Push Docker Images (push) Has been skipped
2026-05-30 04:20:02 +00:00
Scrubs McBarkley 1891b9c523 GRO-1949: add behavioral and skin medicalAlertPool types, deterministic seeding for TestCooper/TestRocky (#109)
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Failing after 15s
CI / Build & Push Docker Images (push) Has been skipped
2026-05-30 04:12:06 +00:00
Flea Flicker 0ab16b82e0 GRO-1921: Fix UAT reset CronJob to seed full UAT profile with extended pet fields (#106)
CI / Test (push) Successful in 11s
CI / Test (pull_request) Successful in 10s
CI / Lint & Typecheck (push) Successful in 16s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Images (push) Successful in 57s
CI / Build & Push Docker Images (pull_request) Successful in 1m2s
2026-05-30 03:42:43 +00:00
Flea Flicker 981a257d2d Merge pull request 'fix(api): repair root src/routes/pets.ts visit-count query (GRO-1945)' (#107) from flea/GRO-1945-pets-visitcount-hotfix into dev
CI / Test (push) Successful in 10s
CI / Lint & Typecheck (push) Successful in 14s
CI / Build & Push Docker Images (push) Successful in 21s
CI / Test (pull_request) Successful in 10s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 59s
2026-05-30 03:24:03 +00:00
Flea Flicker a14bb5e17d fix(api): repair root src/routes/pets.ts visit-count query (GRO-1945)
CI / Test (pull_request) Successful in 11s
CI / Lint & Typecheck (pull_request) Successful in 14s
CI / Build & Push Docker Images (pull_request) Successful in 1m4s
Use sql\`count(*)::int\` instead of selecting appointments.id, which was
causing TS2339 under noUncheckedIndexedAccess (arr[0] is T | undefined).

Import sql from @groombook/db. Use countRow?.count ?? 0 to stay
noUncheckedIndexedAccess-safe.

Matches the working implementation in apps/api/src/routes/pets.ts:365.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-30 03:21:28 +00:00
Flea Flicker 280c699d0d fix(seed): add uat-customer client record for SSO bridge UAT (GRO-1935) (#104)
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Test (pull_request) Successful in 10s
CI / Build & Push Docker Images (pull_request) Failing after 37s
CI / Lint & Typecheck (push) Successful in 14s
CI / Test (push) Successful in 2m19s
CI / Build & Push Docker Images (push) Failing after 33s
2026-05-30 03:10:48 +00:00
The Dogfather 5d6bc06295 Merge pull request 'fix(docker): bake pnpm into image to avoid runtime corepack downloads (GRO-1909)' (#101) from fix/GRO-1909-migrate-corepack-offline into dev
CI / Test (push) Successful in 10s
CI / Lint & Typecheck (push) Successful in 16s
CI / Test (pull_request) Successful in 9s
CI / Lint & Typecheck (pull_request) Successful in 14s
CI / Build & Push Docker Images (push) Failing after 37s
CI / Build & Push Docker Images (pull_request) Failing after 36s
2026-05-30 03:05:10 +00:00
Flea Flicker 53677b1420 feat(pets): add GET /:id/profile-summary endpoint
CI / Test (pull_request) Successful in 11s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Failing after 36s
Adds profile-summary endpoint for groombook web to display:
- Basic pet fields (name, species, breed, coatType, etc.)
- Recent grooming history (last 10 completed appointments with staff names)
- Visit count (completed appointments)
- Upcoming appointment (next scheduled/confirmed)

Groomer RBAC: groomers can only see pets they've had appointments with.
Non-groomer staff (admin/super) can see all pets.

Fixes GRO-1802 (UAT regression: profile-summary route never deployed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 03:00:39 +00:00
Flea Flicker 0a3eb8a282 fix(docker): bake pnpm into image to avoid runtime corepack downloads (GRO-1909)
Use `corepack install -g` instead of `corepack prepare --activate` to write
pnpm to a stable global path (/usr/local/bin/pnpm) rather than relying on
corepack shims that re-validate against npmjs.org at runtime.

Set COREPACK_ENABLE_DOWNLOAD_PROMPT=0 and COREPACK_ENABLE_STRICT=0 to suppress
the interactive download prompt and strict version checks that also trigger
network access.

Remove the dead `RUN mkdir -p /home/node/.cache/node/corepack` line from the
builder stage (vestigial cache-location configuration).

Fixes: GRO-1916 (prod migrate-schema EAI_AGAIN on registry.npmjs.org)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 03:00:39 +00:00
Flea Flicker b5f964c1ff fix(test): mock db to handle sql count(*) queries (GRO-1917) (#102)
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Images (push) Successful in 37s
CI / Test (pull_request) Successful in 11s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Images (pull_request) Successful in 36s
2026-05-29 16:36:40 +00:00
Flea Flicker 86a6e3245c fix(seed): use typeof on enum.enumValues for db build (#100)
CI / Lint & Typecheck (push) Successful in 21s
CI / Test (push) Failing after 13m48s
CI / Build & Push Docker Images (push) Has been skipped
2026-05-29 15:40:51 +00:00
Flea Flicker aee82efbac feat(seed): populate extended pet profile fields for UAT verification (#99)
CI / Lint & Typecheck (push) Successful in 1m53s
CI / Test (push) Successful in 1m55s
CI / Build & Push Docker Images (push) Failing after 3m24s
2026-05-29 14:39:05 +00:00
Flea Flicker 4cc0676d52 Merge remote-tracking branch 'origin/seed/extended-profile-fields-gro-1898' into dev
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Images (push) Failing after 1m52s
2026-05-29 01:16:06 +00:00
Flea Flicker dff0e17a63 docs(UAT_PLAYBOOK): add TC-API-3.20 through TC-API-3.24 for seed data verification
CI / Lint & Typecheck (pull_request) Successful in 21s
CI / Test (pull_request) Successful in 35s
CI / Build & Push Docker Images (pull_request) Failing after 4m57s
Updated UAT_PLAYBOOK.md §4.3 — new seed data verification tests.

GRO-1898: After populating extended profile fields in the UAT seed, add
test cases to verify the data is actually present and shaped correctly.
Test cases cover:
- /api/clients returns seed data
- /api/pets/{id} returns all 5 extended fields for UAT test pets
- medicalAlerts shape is correct ({type, description, severity})
- Deterministic UAT pets (Charlie = behavioral alert, Delta = skin alert)
  are verifiably populated

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 01:15:55 +00:00
Flea Flicker 612c0467a1 feat(seed): populate extended pet profile fields for UAT regression
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 21s
CI / Build & Push Docker Images (pull_request) Failing after 1m35s
GRO-1898: Ensure UAT seed data includes clients and pets with extended
profile fields (temperamentScore, temperamentFlags, medicalAlerts,
preferredCuts, coatType).

- Add data pools for extended profile fields in pet batch generation
- Populate all 5 extended fields for randomly generated pets
- Update UAT test client pets with fully populated extended profiles
- Fix type mismatches: medicalAlerts uses MedicalAlert[] with
  {type, description, severity} shape per @groombook/types

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 01:14:56 +00:00
Flea Flicker 543d9560ec fix(gro-1889): bake pnpm into reset stage to avoid runtime DNS (#97)
CI / Lint & Typecheck (push) Successful in 20s
CI / Test (push) Successful in 26s
CI / Build & Push Docker Images (push) Successful in 3m2s
2026-05-28 22:31:12 +00:00
Flea Flicker 17b44e3b00 Merge origin/uat into promote/dev-to-uat-gro-1866
CI / Lint & Typecheck (pull_request) Successful in 36s
CI / Test (pull_request) Successful in 33s
CI / Build & Push Docker Images (pull_request) Successful in 3m11s
Conflicts resolved:
- src/middleware/rbac.ts: keep dev version (email null-guard, type assertion, single null-check)
- .gitea/workflows/ci.yml: keep uat version (branches: [main, dev, uat])

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-28 21:39:39 +00:00
Flea Flicker 2e0d63f7f6 fix(gro-1866): address QA review failures — portalSession null-guard,
CI / Test (push) Successful in 32s
CI / Lint & Typecheck (push) Successful in 34s
CI / Build & Push Docker Images (push) Successful in 2m34s
email null-dereference guard, externalize DEMO_STAFF_ID

1. portal.ts:138 — add null guard for portalSession before accessing .id
   (TS18048: 'portalSession' is possibly 'undefined')
2. rbac.ts:130 — guard jwt.email before split() to prevent runtime throw
3. portal.ts:39,105 — externalize DEMO_STAFF_ID as env var
   (process.env.DEMO_STAFF_ID ?? "00000000-...")

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:50:14 +00:00
The Dogfather 7bdb92999a Merge pull request 'fix(gro-1866): add session-from-auth portal endpoint + role scope' (#93) from fix/gro-1866-sso-bridge into dev
CI / Test (push) Successful in 34s
CI / Lint & Typecheck (push) Successful in 38s
CI / Build & Push Docker Images (push) Failing after 1m46s
fix(gro-1866): add session-from-auth portal endpoint + role scope (#93)

Bridges Better Auth SSO sessions to portal sessions for real customers.
Adds role to genericOAuth scopes for Authentik role propagation.

Closes GRO-1866
2026-05-28 18:46:38 +00:00
Flea Flicker b96b6c06fc fix: add missing getAuth import and fix db.insert() mock chain
Fixes two bugs found in QA review:
- ReferenceError: getAuth not defined in beforeEach - add import
- TypeError: wrong mock chain insert().into().values() vs insert().values()

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-28 15:59:41 +00:00
Flea Flicker fa67b75b76 docs: add UAT test cases TC-API-8.8 through TC-API-8.11 for SSO bridge
Adds manual test cases covering:
- TC-API-8.8: valid Better Auth session → portal session (201)
- TC-API-8.9: no session → 401
- TC-API-8.10: no matching client → 404
- TC-API-8.11: returned sessionId works on subsequent portal calls

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-28 15:01:24 +00:00
Flea Flicker 7e329ff72f fix(gro-1866): add session-from-auth portal endpoint and role scope
Adds POST /api/portal/session-from-auth which bridges a valid Better Auth
customer session (from SSO login) to a portal impersonation session, so
real SSO customers can access the client portal.

The endpoint is registered before the validatePortalSession catch-all so it
is not subject to that middleware. It validates the Better Auth session
from request cookies, looks up the client by email, creates an active
impersonation session, and returns { sessionId, clientId, clientName }.

Also adds "role" to the genericOAuth scopes so Authentik propagates the
role claim into Better Auth user objects (GRO-1862 root cause fix).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-28 15:00:15 +00:00
Flea Flicker b050fb9a5f Merge pull request 'feat(db): add migration 0034 for extended pet profile columns (GRO-1850)' (#92) from fix/gro-1850-pet-profile-migration into dev
CI / Lint & Typecheck (push) Successful in 14s
CI / Test (push) Successful in 13s
CI / Build & Push Docker Images (push) Successful in 1m20s
2026-05-28 11:39:51 +00:00
Flea Flicker 63ed91e5f3 feat(db): add migration 0034 for extended pet profile columns
CI / Lint & Typecheck (pull_request) Successful in 11s
CI / Test (pull_request) Successful in 11s
CI / Build & Push Docker Images (pull_request) Successful in 50s
GRO-1850: Adds temperament_score, temperament_flags, medical_alerts,
and preferred_cuts to the pets table.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:39:21 +00:00
The Dogfather 45b3d4343d Merge pull request 'promote: dev → uat (GRO-1790 pet profile summary fixes)' (#91) from promote/dev-to-uat-gro-1790 into uat
CI / Lint & Typecheck (push) Successful in 12s
CI / Test (push) Successful in 15s
CI / Build & Push Docker Images (push) Successful in 35s
promote: dev → uat (GRO-1790 pet profile summary fixes)

Merged by CTO after QA approval.
Refs: GRO-1798, GRO-1790
2026-05-26 12:36:06 +00:00
Flea Flicker 32156e9a45 fix: restore pet profile summary endpoint from dev (GRO-1177)
CI / Lint & Typecheck (pull_request) Successful in 13s
CI / Test (pull_request) Successful in 12s
CI / Build & Push Docker Images (pull_request) Successful in 41s
2026-05-26 12:30:10 +00:00
Flea Flicker ed3d7df1c9 Merge dev into promote/dev-to-uat-gro-1790
CI / Lint & Typecheck (pull_request) Successful in 11s
CI / Test (pull_request) Successful in 11s
CI / Build & Push Docker Images (pull_request) Successful in 1m9s
Resolve .ci-trigger conflict preferring dev version.
2026-05-26 12:11:44 +00:00
The Dogfather 9622b109d0 Merge pull request 'feat(GRO-1177): add pet profile summary endpoint' (#30) from flea-flicker/pet-profile-summary into dev
CI / Lint & Typecheck (push) Successful in 12s
CI / Test (push) Successful in 12s
CI / Build & Push Docker Images (push) Successful in 2m52s
feat(GRO-1177): add pet profile summary endpoint (#30)

Adds GET /api/pets/:id/profile-summary with aggregated pet profile,
grooming history, visit count, and upcoming appointment.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 11:40:16 +00:00
Barcode Betty a25b2fe281 docs: add TC-API-3.18 and TC-API-3.19 to UAT_PLAYBOOK for visitCount regression + date filter
CI / Lint & Typecheck (pull_request) Successful in 12s
CI / Test (pull_request) Successful in 12s
CI / Build & Push Docker Images (pull_request) Successful in 1m4s
Updated UAT_PLAYBOOK.md §3.3 — new visitCount cap and past appointment filter test cases

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:34:25 +00:00
Barcode Betty de33edd7c6 fix: address CTO review — visitCount bug + upcomingAppointment date filter
- Replace .select({ count: appointments.id }).limit(1) + .length with
  sql<number>`count(*)::int` pattern per project standard (references invoices.ts:86)
- Add gte(appointments.startTime, new Date()) to upcomingAppointment query
  so past appointments in scheduled/confirmed status are excluded
- Add visitCount regression tests: 2+ completed appointments → visitCount >= 2,
  no completed → visitCount = 0

Updated UAT_PLAYBOOK.md §profile-summary (visitCount regression + date filter)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:34:11 +00:00
Lint Roller 3b9e82adff fix(rbac): guard noUncheckedIndexedAccess in name derivation and newStaff insert
CI / Lint & Typecheck (push) Successful in 12s
CI / Test (push) Successful in 14s
CI / Build & Push Docker Images (push) Successful in 46s
With noUncheckedIndexedAccess:true, split("@")[0] returns string|undefined,
making `name` typed as string|undefined and failing the notNull staff.name
insert constraint. Fix by using ?? fallback on the array access.

Also add newStaff null guard after .returning() destructure — array
destructuring yields T|undefined with noUncheckedIndexedAccess enabled.
2026-05-26 01:48:41 +00:00
Lint Roller 385ed10211 fix(rbac): guard noUncheckedIndexedAccess in name derivation and newStaff insert
CI / Test (push) Successful in 10s
CI / Lint & Typecheck (push) Successful in 10s
CI / Build & Push Docker Images (push) Successful in 43s
CI / Test (pull_request) Successful in 9s
CI / Lint & Typecheck (pull_request) Successful in 10s
CI / Build & Push Docker Images (pull_request) Failing after 10s
With noUncheckedIndexedAccess:true, split("@")[0] returns string|undefined,
making `name` typed as string|undefined and failing the notNull staff.name
insert constraint. Fix by using ?? fallback on the array access.

Also add newStaff null guard after .returning() destructure — array
destructuring yields T|undefined with noUncheckedIndexedAccess enabled.
2026-05-26 01:48:12 +00:00
Lint Roller 8e8a87767c fix(ci): remove duplicate provenance keys + add uat push trigger (GRO-1762)
CI / Lint & Typecheck (push) Successful in 12s
CI / Test (push) Successful in 13s
CI / Build & Push Docker Images (push) Failing after 41s
2026-05-26 01:34:42 +00:00
The Dogfather b796d36aed fix(ci): remove duplicate provenance keys causing YAML parse error
CI / Lint & Typecheck (push) Failing after 4s
CI / Test (push) Successful in 15s
CI / Build & Push Docker Images (push) Has been skipped
Duplicate 'provenance: false' in each docker/build-push-action step caused
Gitea to reject the workflow file, breaking push CI and workflow_dispatch.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 01:25:57 +00:00
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
The Dogfather 2f17b1ab85 Promo/Gro 1764 Uat (#86) 2026-05-26 00:36:15 +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 2a0b3cf3d3 Merge remote-tracking branch 'origin/dev' into dev-to-uat 2026-05-25 23:54:49 +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
Lint Roller 78762b5278 Merge pull request 'promote: dev → uat (GRO-1757 SSO auto-provision fix)' (#84) from dev into uat
promote: dev → uat (GRO-1757 SSO auto-provision fix)
2026-05-25 23:48:09 +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 aa9670d4dc Merge pull request 'promote(dev→uat): add missing extended pet profile fields (GRO-1752)' (#79) from dev into uat
promote(dev→uat): add missing extended pet profile fields (GRO-1752)
2026-05-25 19:08:13 +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 e5f16a5fe5 Merge pull request 'chore: promote dev → uat (GRO-1749 seed data sync)' (#72) from promo/gro-1749-uat into uat 2026-05-25 18:02:30 +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 baeff6c4f5 Merge pull request 'chore: promote dev → uat (GRO-1743 seed data)' (#70) from dev into uat
Merge PR #70: chore: promote dev → uat (GRO-1743 seed data)
2026-05-25 15:37:38 +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
The Dogfather 8d9a9d8dba Merge pull request 'chore: promote dev → uat (GRO-1678 TCP resilience + backlog fixes)' (#67) from dev into uat
chore: promote dev → uat (GRO-1678 TCP resilience + backlog fixes)
2026-05-24 23:49:11 +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
Flea Flicker 8c62ce2368 feat(GRO-1177): add GET /api/pets/:id/profile-summary endpoint
CI / Lint & Typecheck (pull_request) Successful in 9s
CI / Test (pull_request) Successful in 9s
CI / Build & Push Docker Images (pull_request) Successful in 37s
Returns aggregated pet profile with:
- All pet fields (basic + extended)
- recentGroomingHistory: last 10 entries from groomingVisitLogs with staff name join
- lastVisitDate: most recent groomedAt timestamp
- visitCount: count of completed appointments
- upcomingAppointment: next scheduled/confirmed appointment with service/staff name

Enforces same groomer RBAC as GET /:id. Returns 404 for non-existent pets.
Adds PetProfileSummary, GroomingHistoryEntry, and UpcomingAppointment types.
Adds unit tests covering: 404, 403, aggregated profile, empty history, no upcoming appt.
Updates UAT_PLAYBOOK.md §3 with TC-API-3.8 and TC-API-3.9.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:17:24 +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 2380698128 Merge pull request 'Promote dev → uat: provenance: false CI fix' (#65) from dev into uat
Promote dev → uat: provenance: false CI fix (#65)

Includes fix(GRO-1576): add provenance: false to all build-push-action steps.

Approved-by: The Dogfather (CTO)
2026-05-23 01:40:59 +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 00c6a36021 Merge pull request 'Promote dev to UAT: GRO-1566 auth bypass fix' (#62) from dev into uat 2026-05-22 22:39:58 +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 f4561b539f Merge pull request 'chore: promote dev → uat (GRO-1544 health endpoint fix)' (#59) from dev into uat
chore: promote dev → uat (GRO-1544 health endpoint fix)

Merge dev → uat. CI auto-deploys to UAT environment.
2026-05-22 21:50:13 +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 d847343090 Merge pull request 'promote: dev → uat (migration 0031 fix, GRO-1533)' (#58) from dev into uat
promote: dev → uat — migration 0031 fix (GRO-1533) (#58)
2026-05-22 15:22:24 +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 190c39f905 Merge pull request 'chore: promote dev → uat (GRO-1533 migration fix)' (#56) from dev into uat
chore: promote dev → uat (GRO-1533 migration fix)

Promotes 0032_staff_read_at.sql migration file to uat branch.
Unblocks UAT migration pipeline.
2026-05-22 14:39:42 +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 122d32d635 Merge pull request 'chore: promote dev → uat (GRO-1533 migration fix)' (#54) from dev into uat
Promote dev → uat: GRO-1533 migration fix (PR #53)
2026-05-22 14:09:56 +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 d458f93600 Merge pull request 'promote: dev → uat (revert Dockerfile + GRO-1533 CI fix)' (#51) from dev into uat
CI / Lint & Typecheck (pull_request) Successful in 9s
CI / Test (pull_request) Successful in 10s
CI / Build & Push Docker Images (pull_request) Successful in 46s
promote: dev → uat (revert Dockerfile + GRO-1533 CI fix)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 13:32:48 +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 634e9d03e1 Merge pull request 'promote: dev → uat (GRO-1533 Dockerfile fix)' (#49) from dev into uat
CI / Lint & Typecheck (pull_request) Successful in 9s
CI / Test (pull_request) Successful in 11s
CI / Build & Push Docker Images (pull_request) Failing after 19s
promote: dev → uat (GRO-1533 Dockerfile fix)

Promotes PR #47 fix to uat. Reverts Dockerfile to build from apps/api/src/.
Fixes HTTP 500 on authenticated admin routes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 13:24:33 +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 974dade8f7 Merge pull request 'promote: dev → uat (pnpm-lock.yaml fix + CI/enum fixes + seed Docker fix)' (#48) from dev into uat
CI / Lint & Typecheck (pull_request) Successful in 12s
CI / Test (pull_request) Successful in 11s
CI / Build & Push Docker Images (pull_request) Successful in 53s
promote: dev → uat (pnpm-lock.yaml fix + CI/enum fixes + seed Docker fix)

Includes PR #45 seed fix, lockfile, CI, and enum alignment.
2026-05-22 13:18:12 +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 3eaefb4911 fix: add better-auth to pnpm-lock.yaml packages/db specifiers
CI / Lint & Typecheck (pull_request) Failing after 13s
CI / Test (pull_request) Failing after 23s
CI / Build & Push Docker Image (pull_request) Has been skipped
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 13:11:39 +00:00
Flea Flicker 40422a14f0 fix(seed): use --filter @groombook/db to invoke seed/migrate/reset scripts
CI / Lint & Typecheck (pull_request) Successful in 11s
CI / Test (pull_request) Successful in 11s
CI / Build & Push Docker Images (pull_request) Successful in 51s
The seed/migrate/reset Dockerfile targets specified `pnpm db:migrate`
etc., but those scripts are defined in packages/db/package.json, not
at the workspace root. pnpm workspace filtering is required to route
the command to the correct package.

- migrate: pnpm --filter @groombook/db migrate
- seed:    pnpm --filter @groombook/db seed
- reset:   pnpm --filter @groombook/db reset

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 13:08:49 +00:00
Chris Farhood 9462915a66 fix: align root src/ enum values with packages/db schema
CI / Lint & Typecheck (push) Successful in 9s
CI / Test (push) Successful in 10s
CI / Build & Push Docker Images (push) Failing after 36s
Root src/routes/ used stale enum values (xlarge→extra_large,
smooth→silky, missing short/medium/silky from coatType) and
sizeCategory→petSizeCategory field name mismatch with the
pets table column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 03:17:13 +00:00
Chris Farhood 46f134a294 fix: use root pnpm build instead of --filter for api in Dockerfile
CI / Test (push) Successful in 11s
CI / Lint & Typecheck (push) Successful in 12s
CI / Build & Push Docker Images (push) Failing after 2m11s
pnpm --filter @groombook/api doesn't match the workspace root package
because pnpm-workspace.yaml only includes packages/*. The root
package.json already has a build script that compiles src/ → dist/,
which is what the Dockerfile copies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 03:11:03 +00:00
Chris Farhood 4086b6f5c0 fix: remove duplicate bufferRules table and duplicate properties blocking Docker build
CI / Test (push) Successful in 10s
CI / Lint & Typecheck (push) Successful in 10s
CI / Build & Push Docker Images (push) Failing after 24s
CI Run 942 Docker build fails on TS2451 (duplicate bufferRules at lines
190 & 653 of schema.ts), TS1117 (duplicate defaultBufferMinutes in
services table, duplicate coatType/petSizeCategory in factories.ts),
and TS2322 (null vs number for defaultBufferMinutes in factories.ts).

Keep the newer, more complete bufferRules declaration (with comments and
index) and the .notNull().default(0) variant of defaultBufferMinutes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 03:09:00 +00:00
Chris Farhood d7d98791c7 fix(ci): use monorepo-filtered commands in Gitea CI (GRO-1522)
CI / Lint & Typecheck (push) Successful in 10s
CI / Test (push) Successful in 9s
CI / Build & Push Docker Images (push) Failing after 2m1s
The root pnpm scripts typecheck/lint/test the stale src/ directory.
Use pnpm --filter @groombook/api to target the correct apps/api/ path,
matching the GitHub Actions CI configuration.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 03:04:12 +00:00
Chris Farhood 3bec5d095a fix(db): remove duplicate petSizeCategoryEnum/coatTypeEnum declarations (GRO-1522)
CI / Lint & Typecheck (push) Failing after 13s
CI / Test (push) Failing after 20s
CI / Build & Push Docker Images (push) Has been skipped
The schema file had two sets of these enum declarations with different values.
The first (stale) set broke all tests importing @groombook/db via the vitest alias,
causing CI to fail and blocking the docker build job.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 03:02:20 +00:00
The Dogfather 3fddf80fac Merge pull request 'fix(ci): build all service images + upgrade Node 22 + pin packageManager (GRO-1522)' (#44) from fix/gro-1522-ci-images-node22 into dev
CI / Lint & Typecheck (push) Failing after 12s
CI / Test (push) Failing after 21s
CI / Build & Push Docker Images (push) Has been skipped
fix(ci): build all service images + upgrade Node 22 + pin packageManager (GRO-1522)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 02:58:30 +00:00
Chris Farhood 1ea319e122 merge: resolve conflicts with dev (keep Node 22 + add Gitea CI images)
CI / Lint & Typecheck (pull_request) Failing after 14s
CI / Test (pull_request) Failing after 20s
CI / Build & Push Docker Images (pull_request) Has been skipped
- Dockerfile: keep node:22-alpine for both base and runner stages
- package.json: keep dev's full content + add packageManager field
- .gitea/workflows/ci.yml: keep fixed version with all 4 image targets
- petsExtendedFields.test.ts: keep dev UUIDs + PR's vi.fn() mocks

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 02:58:11 +00:00
The Dogfather da913d600f fix(ci): add Gitea CI workflow with all 4 image targets + Node 22 (GRO-1522)
CI / Lint & Typecheck (pull_request) Failing after 9s
CI / Test (pull_request) Failing after 10s
CI / Build & Push Docker Images (pull_request) Has been skipped
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 02:56:20 +00:00
Flea Flicker ce9fcfb362 fix: resolve pre-existing test and TypeScript errors for CI compliance
CI / Lint & Typecheck (pull_request) Failing after 6s
CI / Test (pull_request) Successful in 21s
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
- Fix CLIENT_ID/PET_ID in petsExtendedFields.test.ts to valid UUIDs so
  createPetSchema validation (z.string().uuid()) passes in tests
- Replace top-level imports of and/eq/exists/or with vi.fn() stubs in
  petsExtendedFields.test.ts mock to avoid vi.mock hoisting ReferenceError
- Add impersonationAuditLogs proxy + insert() chain to portal.test.ts mock
  to fix audit-log write failures
- Add 5 missing extended fields to buildPet factory defaults
- Add non-null assertion on petRows[0] in makeDeleteChainable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 02:36:18 +00:00
Flea Flicker 59893908e2 fix: resolve pre-existing TypeScript errors for CI compliance
CI / Lint & Typecheck (pull_request) Failing after 17s
CI / Test (pull_request) Failing after 24s
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
- petsExtendedFields.test.ts: import and/eq/exists/or from db/index.js
  (avoids mock scope collision with TypeScript closures)
- petsExtendedFields.test.ts: add non-null assertion on petRows[0]
  in makeDeleteChainable (petRows always has at least one element)
- factories.ts buildPet: add missing extended pet fields to defaults
  (coatType, temperamentScore, temperamentFlags, medicalAlerts,
  preferredCuts) so the inferred PetRow type is satisfied

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 02:23:05 +00:00
Flea Flicker 2b78fcf731 fix(ci): build all service images + upgrade Node 22 + pin packageManager (GRO-1522)
CI / Lint & Typecheck (pull_request) Failing after 18s
CI / Test (pull_request) Failing after 26s
CI / Build (pull_request) Has been skipped
CI / Build & Push Docker Images (pull_request) Has been skipped
CI / Update Infra Image Tags (pull_request) Has been skipped
- Upgrade CI jobs (lint-typecheck, test, build) to Node 22
- Dockerfile uses node:22-alpine for base and runner stages
- Root package.json gets packageManager field for corepack pin
- Docker build already targets all 4 stages (api/migrate/seed/reset)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-22 02:16:49 +00:00
Flea Flicker c9e176f08c Merge branch 'dev' of https://git.farh.net/groombook/api into dev
CI / Lint & Typecheck (push) Failing after 14s
CI / Test (push) Failing after 20s
CI / Build & Push Docker Image (push) Has been skipped
2026-05-21 22:54:47 +00:00
Flea Flicker 0cab1522cf fix(deps): update pnpm-lock.yaml for better-auth ^1.5.6
ERR_PNPM_OUTDATED_LOCKFILE — specifiers in lockfile did not match
packages/db/package.json after better-auth upgrade.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:54:01 +00:00
The Dogfather ff6f8471d5 Merge pull request 'promote: dev → uat (GRO-1509 OIDC account_not_linked fix)' (#43) from dev into uat
CI / Lint & Typecheck (pull_request) Failing after 5s
CI / Test (pull_request) Failing after 6s
CI / Build & Push Docker Image (pull_request) Has been skipped
promote: dev → uat (GRO-1509 OIDC account_not_linked fix)

Merged-by: The Dogfather (CTO)
Gitea-approved-by: Lint Roller (GRO-1512)
2026-05-21 22:53:49 +00:00
The Dogfather 2a27e8bee2 Merge pull request 'fix(auth): add accountLinking trustedProviders for authentik (GRO-1509)' (#42) from flea-flicker/gro-1509-better-auth-account-not-linked into dev
CI / Lint & Typecheck (push) Failing after 5s
CI / Test (push) Failing after 6s
CI / Build & Push Docker Image (push) Has been skipped
fix(auth): add accountLinking trustedProviders for authentik (GRO-1509)

Merged-by: The Dogfather (CTO)
QA-approved-by: Lint Roller (GRO-1510)
2026-05-21 22:47:25 +00:00
Flea Flicker d6f7ade7bd docs(UAT): add TC-API-1.16 for OIDC login Terraform-provisioned users
CI / Lint & Typecheck (pull_request) Failing after 6s
CI / Test (pull_request) Failing after 6s
CI / Build & Push Docker Image (pull_request) Has been skipped
Updated UAT_PLAYBOOK.md §4.1 — new TC-API-1.16 covering OIDC login
for Terraform-provisioned users (GRO-1509 fix, GRO-1511).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 22:44:04 +00:00
Flea Flicker 00dadac0a1 fix(auth): add accountLinking trustedProviders for authentik (GRO-1509)
CI / Test (pull_request) Failing after 44s
CI / Lint & Typecheck (pull_request) Failing after 52s
CI / Build & Push Docker Image (pull_request) Has been skipped
Betters Auth v1.5.6 link-account.mjs:22 rejects OAuth callbacks when the
genericOAuth provider is not in trustedProviders AND email_verified is
falsy. Adding authentik to trustedProviders bypasses this guard so OIDC
login works for TF-created users whose emails were never verified through
an authentik flow.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 22:24:48 +00:00
The Dogfather 9692476202 fix(GRO-1470): add portal PATCH /pets/:id + expand GET /pets response
CI / Lint & Typecheck (push) Failing after 7s
CI / Test (push) Failing after 7s
CI / Build & Push Docker Image (push) Has been skipped
2026-05-21 20:16:53 +00:00
Flea Flicker 44da26820b feat(GRO-1171): add Admin API — Buffer Rules CRUD + service/pet updates
CI / Lint & Typecheck (pull_request) Failing after 7s
CI / Test (pull_request) Failing after 9s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Add buffer_rules table with serviceId/sizeCategory/coatType/bufferMinutes
- Add petSizeCategoryEnum (small/medium/large/extra_large) and coatTypeEnum
  to schema; update pets table columns to use the typed enums
- Add defaultBufferMinutes to services table
- Add apps/api/src/routes/buffer-rules.ts with GET/POST/PATCH/DELETE,
  all manager-only via requireRole("manager")
- Register /api/buffer-rules router in index.ts
- PATCH /api/services/:id accepts optional defaultBufferMinutes
- POST/PATCH /api/pets accepts optional sizeCategory and coatType

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 20:10:20 +00:00
Flea Flicker 21981fbdc4 fix(GRO-1365): add missing imports for and/eq/exists/or in test
The vi.mock factory uses db.and/eq/exists/or from the imported module,
but TypeScript's module-level import binding (const declarations)
can't be referenced inside the async factory before initialization.
Adding top-level imports from "../db/index.js" and using them
directly in the mock return fixes the TDZ error.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 20:10:20 +00:00
Chris Farhood 85fc803548 fix(GRO-1365): address QA review findings on api/#21
1. Fix vi.mock factory: importOriginal -> db.and/eq/exists/or stubs
   (removes ReferenceError from undeclared imports in test)
2. Remove MedicalAlert.id — not in schema/migration/DB, only in types
3. Replace z.string().max(100) coatType with z.enum for CoatType union
4. Fix test expecting coatType "smooth" (invalid) -> "double" (valid)
5. Add TC-API-3.8 through TC-API-3.15 to UAT_PLAYBOOK.md §4.3

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:10:20 +00:00
The Dogfather 6045024150 Merge pull request 'Promote dev → uat: GRO-1178 enhanced pet profile editor' (#39) from dev into uat
Promote dev → uat: GRO-1178 enhanced pet profile editor
2026-05-21 19:19:10 +00:00
The Dogfather 6a3c1aa65e Merge pull request 'GRO-1178: client-facing enhanced pet profile editor' (#21) from flea-flicker/pet-profile-editor into dev
CI / Lint & Typecheck (push) Failing after 6s
CI / Test (push) Failing after 7s
CI / Build & Push Docker Image (push) Has been skipped
Merge PR #21: GRO-1178 — client-facing enhanced pet profile editor
2026-05-21 19:18:53 +00:00
The Dogfather df5e413930 Merge pull request 'chore: promote dev → uat (GRO-1463 UAT playbook expansion)' (#38) from dev into uat
chore: promote dev → uat (GRO-1463 UAT playbook expansion)
2026-05-21 16:49:18 +00:00
The Dogfather 490ab06e8c Merge pull request 'fix(GRO-1461): expand UAT playbook with GRO-1272 auto-provision test cases' (#37) from fix/gro-1461-uat-playbook-auto-provision into dev
CI / Lint & Typecheck (push) Failing after 7s
CI / Test (push) Failing after 6s
CI / Build & Push Docker Image (push) Has been skipped
fix(GRO-1461): expand UAT playbook with GRO-1272 auto-provision test cases
2026-05-21 16:48:58 +00:00
Flea Flicker 609f86b927 fix(GRO-1461): expand UAT playbook with GRO-1272 auto-provision test cases
CI / Lint & Typecheck (pull_request) Failing after 6s
CI / Test (pull_request) Failing after 6s
CI / Build & Push Docker Image (pull_request) Has been skipped
Add TC-API-1.11 through TC-API-1.15 covering existing staff unaffected by
OIDC login, auto-provisioned role/superUser flags, and name fallback
variants (name present, no name+email present, no name+no email).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 16:37:43 +00:00
The Dogfather a74423c8b4 fix: align types/index.ts with dev to resolve merge conflict
CI / Lint & Typecheck (pull_request) Failing after 6s
CI / Test (pull_request) Failing after 7s
CI / Build & Push Docker Image (pull_request) Has been skipped
2026-05-21 15:07:57 +00:00
Chris Farhood 05cb91a13e fix(GRO-1365): address QA review findings on api/#21
1. Fix vi.mock factory: importOriginal -> db.and/eq/exists/or stubs
   (removes ReferenceError from undeclared imports in test)
2. Remove MedicalAlert.id — not in schema/migration/DB, only in types
3. Replace z.string().max(100) coatType with z.enum for CoatType union
4. Fix test expecting coatType "smooth" (invalid) -> "double" (valid)
5. Add TC-API-3.8 through TC-API-3.15 to UAT_PLAYBOOK.md §4.3

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:07:57 +00:00
Chris Farhood 1b264d715d fix: add missing extended pet profile fields to buildPet factory
- Add coatType, temperamentScore, temperamentFlags, medicalAlerts, preferredCuts
- Fixes TypeScript error on factories.ts:89 where extended profile fields were missing
- GRO-1346

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 15:07:30 +00:00
The Dogfather 7cb5fda3e3 Merge pull request 'promote: dev → uat (GRO-1272 auto-provision staff on OIDC login)' (#36) from dev into uat
promote: dev → uat (GRO-1272 auto-provision staff on OIDC login) (#36)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 14:17:40 +00:00
The Dogfather 73461f2200 Merge pull request 'fix(GRO-1272): auto-provision staff record on first OIDC login' (#19) from fleaflicker/gro-1272-auto-provision-staff-dev into dev
CI / Lint & Typecheck (push) Failing after 7s
CI / Test (push) Failing after 7s
CI / Build & Push Docker Image (push) Has been skipped
fix(GRO-1272): auto-provision staff record on first OIDC login (#19)

Fixes HTTP 403 on all authenticated routes for new OIDC users by auto-creating
a minimal groomer staff record on first login when a Better-Auth user exists
but no staff record is found.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 14:16:42 +00:00
Flea Flicker 9b24e299db feat(GRO-1445): provision Better-Auth credential accounts in seed.ts
CI / Lint & Typecheck (push) Failing after 6s
CI / Test (push) Failing after 6s
CI / Build & Push Docker Image (push) Has been skipped
GRO-1325 was marked done but never implemented. This adds the missing
Better-Auth user + account seeding for UAT email+password logins.

For each SEED_UAT_*_PASSWORD env var present, the seed now:
1. Creates (or links to existing) a Better-Auth user record with
   emailVerified: true
2. Creates a credential account with providerId: "credential"
   and a bcrypt-hashed password (using better-auth/crypto)
3. Links the staff record to the Better-Auth user via userId

Idempotent: skips user/account creation if already seeded.

Updated UAT_PLAYBOOK.md §4.1 — TC-API-1.4 through 1.9 now reference
the new seed provisioning (GRO-1325 was the missing piece).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 11:28:30 +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
The Dogfather 76540cea0d Merge pull request 'chore(promote): dev → uat (Buffer Rules CRUD — GRO-1171)' (#34) from dev into uat
chore(promote): dev → uat (Buffer Rules CRUD — GRO-1171)

Promote PR #12 merge to UAT for regression testing.
2026-05-21 10:18:10 +00:00
The Dogfather e6803c7061 Merge pull request '[gro-1171] Admin API — Buffer Rules CRUD' (#12) from flea-flicker/gro-1162-pet-buffer-time into dev
CI / Lint & Typecheck (push) Failing after 14s
CI / Test (push) Successful in 20s
CI / Build & Push Docker Image (push) Has been skipped
[gro-1171] Admin API — Buffer Rules CRUD

Merge PR #12: flea-flicker/gro-1162-pet-buffer-time → dev

QA approved (review 3417), CTO approved (review 3423).
CI lint+typecheck+tests all pass.
2026-05-21 10:17:34 +00:00
Flea Flicker 24c1a603ec fix: add missing COPY tsconfig.json to builder stage
CI / Lint & Typecheck (pull_request) Failing after 13s
CI / Test (pull_request) Successful in 20s
CI / Build & Push Docker Image (pull_request) Has been skipped
tsc --project . fails without tsconfig.json in the builder stage.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 10:01:42 +00:00
Flea Flicker 07eb611549 feat(GRO-1427): add buffer rules CRUD — enums, table, routes
Re-implement lost commit from worktree cleanup. PR #12 already has
UAT_PLAYBOOK + factories fix; add all missing core implementation:
- Add petSizeCategoryEnum/coatTypeEnum to schema
- Add bufferRules table with service FK + unique constraint
- Add defaultBufferMinutes column to services table
- Change pets.coatType/petSizeCategory text columns to use enums
- Add routes/buffer-rules.ts: GET/POST/PATCH/DELETE, manager role guard
- Register /api/buffer-rules in index.ts
- Update services.ts PATCH to accept defaultBufferMinutes
- Update pets.ts POST/PATCH to accept sizeCategory/coatType
- Cast coatType/petSizeCategory in book.ts insert to match new enums
- Add 0031_buffer_rules.sql migration
- Fix factories.ts buildService to include defaultBufferMinutes: null

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 10:01:40 +00:00
Flea Flicker 1345db3620 fix(GRO-1171): restore UAT_PLAYBOOK and add coatType/petSizeCategory to buildPet
Address QA review findings on PR #12:
- Add coatType and petSizeCategory to buildPet defaults in packages/db/src/factories.ts
  to fix TypeScript typecheck failure
- Restore UAT_PLAYBOOK.md (was deleted during monorepo extraction) and add
  §4.15 Buffer Rules test cases

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 10:01:39 +00:00
Chris Farhood b067ba8b85 Save petSizeCategory to pet record on booking creation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 10:01:38 +00:00
Chris Farhood 3d41820f02 feat(GRO-1174): add MedicalAlert/CoatType/AlertSeverity types to @groombook/types
Sync api packages/types with web workspace — add MedicalAlert, AlertSeverity,
CoatType, preferredCuts, medicalAlerts, temperamentScore, temperamentFlags.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 10:01:38 +00:00
Chris Farhood 73f39951b3 feat(GRO-1174): persist petSizeCategory and petCoatType from booking
- Add petSizeCategory and petCoatType to bookingSchema zod validator (optional)
- Save coatType to pets row on booking creation
- Add coatType and petSizeCategory columns to pets DB schema
- Add coatType and petSizeCategory to Pet interface in @groombook/types

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 10:01:37 +00:00
Lint Roller d83210e7e2 Merge pull request 'chore(promote): dev → uat (petsExtendedFields test fix GRO-1390)' (#33) from dev into uat 2026-05-21 07:03:24 +00:00
Lint Roller fdd9d62ee9 Merge pull request 'fix(test): resolve petsExtendedFields vi.mock hoisting + invalid UUIDs (GRO-1390)' (#32) from fix/gro-1390-pets-test-mock-hoisting into dev
CI / Lint & Typecheck (push) Successful in 17s
CI / Test (push) Successful in 21s
CI / Build & Push Docker Image (push) Successful in 11s
2026-05-21 07:02:58 +00:00
Chris Farhood c1d28635ba fix(test): fix petsExtendedFields vi.mock hoisting and invalid UUIDs
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Test (pull_request) Successful in 21s
CI / Build & Push Docker Image (pull_request) Successful in 27s
Two pre-existing bugs prevented petsExtendedFields.test.ts from running:

1. vi.mock factory referenced bare `and`, `eq`, `exists`, `or` variables
   that are undefined at hoist time — replaced with inline mock functions
2. CLIENT_ID/PET_ID used non-UUID strings but Zod schema requires uuid()

All 36 test files (521 tests) now pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 05:28:39 +00:00
The Dogfather 5c9cac7a28 Merge pull request 'promote: dev → uat (GRO-1395 drizzle-orm root dep fix)' (#31) from dev into uat
promote: dev → uat (GRO-1395 drizzle-orm root dep fix) (#31)
2026-05-21 04:11:29 +00:00
The Dogfather 51b45b529d Merge pull request 'fix(GRO-1395): add drizzle-orm and postgres to root package.json' (#29) from fix/gro-1395-drizzle-orm-root-dep into dev
CI / Lint & Typecheck (push) Successful in 15s
CI / Test (push) Failing after 20s
CI / Build & Push Docker Image (push) Has been skipped
fix(GRO-1395): add drizzle-orm and postgres to root package.json (#29)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 04:05:32 +00:00
Chris Farhood ea825dfdda fix(GRO-1272): address QA review items for rbac.test.ts
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Test (pull_request) Failing after 21s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Rename insertedStaff to _insertedStaff (ESLint unused var, line 49)
- Rename table param to _table in insert mock (ESLint unused param, line 91)
- Fix buildApp jwtPayload to prefer userLookupResult.id over staffLookupResult.userId
  (corrects auto-provision test failures where sub was 'unknown-sub' instead of 'ba-user-new')

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 02:03:13 +00:00
Chris Farhood f9b68eb932 fix(GRO-1272): fix TS2769 and test mock iterable issues
- Add null guard for newStaff after .returning() in auto-provision block
- Make buildQuery() iterable without .limit() call (for WHERE-only queries)
- Use fallback in .limit() for manager-fallback dev-mode tests

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 02:01:00 +00:00
Chris Farhood 4a80440513 fix(GRO-1272): update rbac tests and UAT playbook for auto-provision
- Add user table mock and db.insert returning chain to rbac.test.ts
- Add three new tests: happy-path auto-provision, email-prefix fallback,
  and miss-path (no user → 403)
- Add TC-API-1.4 to UAT_PLAYBOOK.md §4.1 for first-login auto-provision

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 02:01:00 +00:00
Chris Farhood ce83b1847d fix(GRO-1272): auto-provision staff record on first OIDC login
When a user authenticates via OIDC but has no staff record (userId NULL,
oidcSub mismatch, email mismatch), resolveStaffMiddleware now checks for
a Better-Auth user record by jwt.sub and auto-creates a minimal groomer
staff record on first login.

This fixes the UAT regression where all API routes returned 403 for all
authenticated users after GRO-1207, because seedKnownUsers() sets
oidcSub to Authentik integer PKs or emails rather than the actual Authentik
OIDC sub (a UUID). The auto-provision path bridges the gap for all UAT
personas without requiring seed/Terraform changes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 02:00:46 +00:00
The Dogfather fad99dc032 Merge pull request 'promote: dev → uat (Renovate config, GRO-1081)' (#26) from dev into uat
promote: dev → uat (Renovate config, GRO-1081) (#26)
2026-05-20 12:37:23 +00:00
The Dogfather 247570abc8 Merge pull request 'Promote dev → uat: GRO-1326 UAT email+password credentials' (#25) from dev into uat
Promote dev → uat: GRO-1326 UAT email+password credentials (#25)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 04:25:29 +00:00
the-dogfather-cto[bot] 4f5ec60961 chore: promote dev to uat — Dockerfile pnpm-workspace fix (GRO-1231)
chore: promote dev to uat (GRO-1231 pnpm-workspace fix)
2026-05-14 17:15:52 +00:00
the-dogfather-cto[bot] 39ffdccac7 promote: dev → uat (rate limit override) (#13)
promote: dev → uat (rate limit override)
2026-05-14 10:55:45 +00:00
the-dogfather-cto[bot] 1ff0d4230c promote: dev → uat (UAT Tester seed fix + TypeScript CI compliance)
promote: dev → uat (UAT Tester seed fix + TypeScript CI compliance)
2026-05-14 08:07:54 +00:00
the-dogfather-cto[bot] be5e9d8fc7 chore: promote dev to uat (PR #5 mock path fix)
chore: promote dev to uat (PR #5 mock path fix)
2026-05-12 21:34:03 +00:00
48 changed files with 2845 additions and 193 deletions
+1
View File
@@ -0,0 +1 @@
GRO-1757 direct push CI trigger - 2026-05-26T00:15:41Z
+68 -9
View File
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [main, dev]
branches: [main, dev, uat]
pull_request:
branches: [main, dev]
branches: [main, dev, uat]
workflow_dispatch:
inputs:
ref:
@@ -25,17 +25,19 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
run: |
pnpm --filter @groombook/api typecheck
pnpm --filter @groombook/db typecheck
- name: Lint
run: pnpm lint
run: pnpm --filter @groombook/api lint
test:
name: Test
@@ -49,17 +51,17 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm test
run: pnpm --filter @groombook/api test
docker:
name: Build & Push Docker Image
name: Build & Push Docker Images
runs-on: ubuntu-latest
needs: [lint-typecheck, test]
steps:
@@ -78,6 +80,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,11 +93,66 @@ jobs:
- name: Build and push API image
uses: docker/build-push-action@v6
with:
provenance: false
context: .
file: Dockerfile
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
cache-to: type=registry,ref=git.farh.net/groombook/cache:api,mode=max
- name: Build and push Migrate image
uses: docker/build-push-action@v6
with:
provenance: false
context: .
file: Dockerfile
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
- name: Smoke test migrate image (blackhole npmjs.org)
run: |
set -euo pipefail
IMAGE="git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}"
docker pull "$IMAGE"
docker run --rm \
--add-host registry.npmjs.org:127.0.0.1 \
--entrypoint="" \
"$IMAGE" \
pnpm --version
- name: Build and push Seed image
uses: docker/build-push-action@v6
with:
provenance: false
context: .
file: Dockerfile
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
- name: Build and push Reset image
uses: docker/build-push-action@v6
with:
provenance: false
context: .
file: Dockerfile
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
+3 -3
View File
@@ -25,7 +25,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
- name: Install dependencies
@@ -49,7 +49,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
- name: Install dependencies
@@ -71,7 +71,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
- name: Install dependencies
+16 -9
View File
@@ -1,5 +1,7 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
FROM node:22-alpine AS base
RUN corepack enable && corepack install -g pnpm@9.15.4
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ENV COREPACK_ENABLE_STRICT=0
WORKDIR /app
# Install deps
@@ -11,16 +13,18 @@ RUN pnpm install --frozen-lockfile
# Build
FROM deps AS builder
RUN mkdir -p /home/node/.cache/node/corepack
COPY packages/ packages/
COPY src/ src/
COPY tsconfig.json ./
RUN pnpm --filter @groombook/types build && \
pnpm --filter @groombook/db build && \
pnpm --filter @groombook/api build
pnpm build
# Runtime
FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
FROM node:22-alpine AS runner
RUN corepack enable && corepack install -g pnpm@9.15.4
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ENV COREPACK_ENABLE_STRICT=0
WORKDIR /app
ENV NODE_ENV=production
@@ -41,12 +45,15 @@ CMD ["node", "dist/index.js"]
# Migrate stage — runs drizzle-kit migrate against the database
FROM builder AS migrate
CMD ["pnpm", "db:migrate"]
CMD ["pnpm", "--filter", "@groombook/db", "migrate"]
# Seed stage — populates the database with test data
FROM builder AS seed
CMD ["pnpm", "db:seed"]
CMD ["pnpm", "--filter", "@groombook/db", "seed"]
# Reset stage — drops all tables, re-runs migrations, and re-seeds
FROM builder AS reset
CMD ["pnpm", "db:reset"]
RUN corepack enable && corepack install -g pnpm@9.15.4
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ENV COREPACK_ENABLE_STRICT=0
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
+80 -2
View File
@@ -21,19 +21,54 @@ 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 |
|---|----------|-------|----------|
| TC-API-1.1 | Login via OIDC | POST to OIDC provider callback, verify JWT token issued | 200 OK, JWT returned with valid claims |
| TC-API-1.2 | Session persistence | Make authenticated request, verify session token valid | 200 OK, request succeeds |
| TC-API-1.3 | Logout | Call logout endpoint, verify token invalidated | 200 OK, subsequent requests return 401 |
| TC-API-1.4 | Email+password login (UAT) | POST /api/auth/sign-in/email with uat-super@groombook.dev + SEED_UAT_SUPER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.5 | Email+password login — groomer | POST /api/auth/sign-in/email with uat-groomer@groombook.dev + SEED_UAT_GROOMER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.6 | Email+password login — customer | POST /api/auth/sign-in/email with uat-customer@groombook.dev + SEED_UAT_CUSTOMER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.7 | Email+password login — tester | POST /api/auth/sign-in/email with uat-tester@groombook.dev + SEED_UAT_TESTER_PASSWORD | 200 OK, session cookie returned |
| TC-API-1.8 | Email+password — invalid password | POST /api/auth/sign-in/email with wrong password | 400 Bad Request, error returned |
| TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned |
| TC-API-1.10 | Auto-provision on first OIDC login | First login as a Better-Auth user with no existing staff record | 200 OK, access granted; groomer staff record auto-created with name/email from user table |
> **Note (GRO-1977):** Seed credential provisioning is idempotent — re-running the seed with updated `SEED_UAT_*_PASSWORD` env vars rotates stored credential hashes. TC-API-1.4 through TC-API-1.7 now return 200 for all 4 UAT personas (previously returned 401 due to frozen-hash bug).
| TC-API-1.11 | Existing staff unaffected by OIDC login | Login as uat-groomer@groombook.dev (email+password), then GET /api/staff to find that record | 200 OK, staff record unchanged — no duplicate created, original role and isSuperUser preserved |
| TC-API-1.12 | Auto-provisioned role and superUser flags | After TC-API-1.10, GET /api/staff and inspect the auto-created record | role = "groomer", isSuperUser = false, active = true |
| TC-API-1.13 | Name fallback — user.name present | Auto-provision where Better-Auth user has name set | Staff name = user.name value from user table |
| TC-API-1.14 | Name fallback — no name, email present | Auto-provision where Better-Auth user has name = null, email = "test@example.com" | Staff name = "test" (email prefix before @) |
| 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
@@ -57,6 +92,33 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-3.5 | Delete pet | DELETE /api/pets/{id} | 200 OK, pet deleted |
| TC-API-3.6 | Upload pet photo | POST /api/pets/{id}/photo/upload-url, then confirm | 200 OK, photo uploaded and key stored |
| TC-API-3.7 | View pet photo | GET /api/pets/{id}/photo | 200 OK, presigned URL returned |
| TC-API-3.8 | Create pet with extended fields | POST /api/pets with coatType, temperamentScore, temperamentFlags, medicalAlerts, preferredCuts | 201 Created, all extended fields stored and returned |
| TC-API-3.9 | Update pet extended fields | PATCH /api/pets/{id} with coatType, temperamentScore, medicalAlerts | 200 OK, extended fields updated |
| TC-API-3.10 | Reject invalid coatType | POST /api/pets with coatType: "smooth" | 400 Bad Request, invalid coatType rejected |
| TC-API-3.11 | Reject out-of-range temperamentScore | POST /api/pets with temperamentScore: 0 or 6 | 400 Bad Request, score out of range rejected |
| TC-API-3.12 | Reject invalid medicalAlert severity | POST /api/pets with medicalAlerts severity: "critical" | 400 Bad Request, invalid severity rejected |
| TC-API-3.13 | Reject too many temperamentFlags | POST /api/pets with 21 temperamentFlags | 400 Bad Request, max 20 flags enforced |
| TC-API-3.14 | Reject too many preferredCuts | POST /api/pets with 21 preferredCuts | 400 Bad Request, max 20 cuts enforced |
| TC-API-3.15 | Reject too many medicalAlerts | POST /api/pets with 51 medicalAlerts | 400 Bad Request, max 50 alerts enforced |
| TC-API-3.16 | Get pet profile summary | GET /api/pets/{id}/profile-summary | 200 OK, aggregated profile with grooming history, visit count, upcoming appointment |
| TC-API-3.17 | Get pet profile summary — groomer restricted | GET /api/pets/{id}/profile-summary as groomer with no pet linkage | 403 Forbidden |
| TC-API-3.18 | Get pet profile summary — visitCount returns full count | GET /api/pets/{id}/profile-summary with 2+ completed appointments | visitCount >= 2 (not capped at 1) |
| TC-API-3.19 | Get pet profile summary — upcomingAppointment excludes past | GET /api/pets/{id}/profile-summary with a past confirmed/scheduled appointment | upcomingAppointment is null (past appointments filtered by startTime >= now) |
#### Seed Data Verification (GRO-1898)
> As of PR #98, UAT seed data populates all 5 extended profile fields for every pet, including the 5 deterministic UAT test client pets (Alpha, Bravo, Charlie, Delta, Echo). This enables manual verification of extended profile rendering without requiring a DB reset.
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-3.20 | GET /api/clients returns seed data | GET /api/clients | 200 OK, array with 1+ clients (UAT seed creates 500 + 5 deterministic UAT clients) |
| TC-API-3.21 | GET /api/pets/{id} returns extended fields for seed pet | Pick any pet ID from UAT test clients (uat-alpha through uat-echo pet names: TestBuddy, TestMax, TestCooper, TestRocky, TestDuke) and GET /api/pets/{id} | 200 OK; coatType, temperamentScore, temperamentFlags, medicalAlerts, preferredCuts all non-null |
| TC-API-3.22 | Verify medicalAlerts shape | GET /api/pets/{id} for any pet with non-empty medicalAlerts | medicalAlerts is an array; each entry has type, description, severity |
| TC-API-3.23 | Verify UAT test pet Charlie has behavioral alert | GET /api/pets/{id} where name = "TestCooper" (pet for uat-charlie@groombook.dev) | medicalAlerts includes an entry with type: "behavioral", severity: "low" or "high" |
| TC-API-3.24 | Verify UAT test pet Delta has skin alert | GET /api/pets/{id} where name = "TestRocky" (pet for uat-delta@groombook.dev) | medicalAlerts includes an entry with type: "skin" |
| TC-API-3.25 | Verify 30+ total pets in UAT DB | GET /api/pets then count total | 30+ pets returned (UAT seed creates 500 random-pool + 5 UAT test clients + 2 UAT customer = 507 total) |
| TC-API-3.26 | Verify 25-35% medicalAlerts distribution | GET /api/pets (first 30 pets), count how many have non-empty medicalAlerts | Ratio is 25-35% (seed uses rand() < 0.3 for ~30% distribution) |
| TC-API-3.27 | Verify coat_type enum has all seed values | After UAT seed completes, inspect the coat_type enum on the UAT DB — it must contain: short, medium, long, double, wire, silky, curly, hairless | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; coat_type includes all 8 values used by seed.ts `coatTypePool` |
### 4.4 Appointment Scheduling
@@ -118,6 +180,10 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-8.5 | Add waitlist entry | POST /api/portal/waitlist with pet and service | 201 Created, waitlist entry created |
| TC-API-8.6 | View portal invoices | GET /api/portal/invoices | 200 OK, list of client's invoices returned |
| TC-API-8.7 | Pay multiple invoices | POST /api/portal/invoices/pay-multiple with invoice IDs | 200 OK, payment intent created |
| TC-API-8.8 | SSO bridge — valid Better Auth session | POST /api/portal/session-from-auth with valid Better Auth session cookie (authenticated SSO user with matching client email) | 201 Created, `{sessionId, clientId, clientName}` returned |
| TC-API-8.9 | SSO bridge — no Better Auth session | POST /api/portal/session-from-auth without Better Auth session cookie | 401 Unauthorized |
| TC-API-8.10 | SSO bridge — no matching client | POST /api/portal/session-from-auth with valid Better Auth session for a user with no client record | 404 Not Found, error "No client record found for this user" |
| TC-API-8.11 | SSO bridge — returned session works on portal routes | After TC-API-8.8, use returned sessionId as `X-Impersonation-Session-Id` header on GET /api/portal/me | 200 OK, client profile returned |
### 4.9 Waitlist
@@ -183,6 +249,18 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-14.4 | Update group notes | PATCH /api/appointment-groups/{id} with notes | 200 OK, notes updated |
| TC-API-14.5 | Cancel group | DELETE /api/appointment-groups/{id} | 200 OK, all appointments cancelled |
### 4.15 Buffer Rules
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-15.1 | List buffer rules | GET /api/admin/buffer-rules | 200 OK, list of active buffer rules returned |
| TC-API-15.2 | Create buffer rule | POST /api/admin/buffer-rules with service, species, sizeCategory, bufferMinutes | 201 Created, buffer rule created |
| TC-API-15.3 | Update buffer rule | PATCH /api/admin/buffer-rules/{id} with updated bufferMinutes | 200 OK, buffer rule updated |
| TC-API-15.4 | Delete buffer rule | DELETE /api/admin/buffer-rules/{id} | 200 OK, buffer rule removed |
| TC-API-15.5 | Reject invalid bufferMinutes | POST /api/admin/buffer-rules with bufferMinutes: -5 | 400 Bad Request, invalid bufferMinutes rejected |
| TC-API-15.6 | Reject missing required fields | POST /api/admin/buffer-rules with service only | 400 Bad Request, species and sizeCategory required |
| TC-API-15.7 | Booking uses buffer | Book appointment for pet with sizeCategory; verify duration reflects buffer | 201 Created, appointment duration includes buffer time |
## Pass/Fail Criteria
**Pass:**
@@ -0,0 +1,402 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
import { petsRouter } from "../routes/pets.js";
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
userId: null,
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const GROOMER: StaffRow = {
id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub",
userId: null,
role: "groomer",
isSuperUser: false,
name: "Groomer McGroome",
email: "groomer@example.com",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
// ─── Mutable mock state ───────────────────────────────────────────────────────
const CLIENT_ID = "client-uuid-summary";
const PET_ID = "pet-uuid-summary";
interface MockState {
pets: Record<string, unknown>[];
appointments: Record<string, unknown>[];
groomingLogs: Record<string, unknown>[];
staffMembers: Record<string, unknown>[];
services: Record<string, unknown>[];
}
let mock: MockState;
function resetMock() {
mock = {
pets: [{
id: PET_ID,
clientId: CLIENT_ID,
name: "Biscuit",
species: "dog",
breed: "Golden Retriever",
weightKg: "30.00",
dateOfBirth: null,
healthAlerts: null,
groomingNotes: null,
cutStyle: null,
shampooPreference: null,
specialCareNotes: null,
customFields: {},
photoKey: null,
photoUploadedAt: null,
image: null,
coatType: "double",
temperamentScore: 3,
temperamentFlags: ["gentle"],
medicalAlerts: [],
preferredCuts: ["puppy cut"],
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
}],
appointments: [
{
id: "appt-completed-1",
clientId: CLIENT_ID,
petId: PET_ID,
serviceId: "service-1",
staffId: "staff-groomer-id",
batherStaffId: null,
status: "completed",
startTime: new Date("2024-06-01T09:00:00Z"),
endTime: new Date("2024-06-01T11:00:00Z"),
notes: null,
priceCents: 6000,
seriesId: null,
seriesIndex: null,
groupId: null,
confirmationStatus: "confirmed",
confirmedAt: null,
cancelledAt: null,
confirmationToken: null,
customerNotes: null,
createdAt: new Date("2024-05-15"),
updatedAt: new Date("2024-05-15"),
},
{
id: "appt-upcoming-1",
clientId: CLIENT_ID,
petId: PET_ID,
serviceId: "service-2",
staffId: "staff-groomer-id",
batherStaffId: null,
status: "confirmed",
startTime: new Date("2024-12-01T09:00:00Z"),
endTime: new Date("2024-12-01T11:00:00Z"),
notes: null,
priceCents: 6500,
seriesId: null,
seriesIndex: null,
groupId: null,
confirmationStatus: "confirmed",
confirmedAt: null,
cancelledAt: null,
confirmationToken: null,
customerNotes: null,
createdAt: new Date("2024-11-01"),
updatedAt: new Date("2024-11-01"),
},
],
groomingLogs: [
{
id: "log-1",
petId: PET_ID,
appointmentId: "appt-completed-1",
staffId: "staff-groomer-id",
cutStyle: "puppy cut",
productsUsed: "oatmeal shampoo",
notes: "Trimmed nails",
groomedAt: new Date("2024-06-01T10:00:00Z"),
createdAt: new Date("2024-06-01T10:00:00Z"),
},
],
staffMembers: [
{
id: "staff-groomer-id",
name: "Groomer McGroome",
email: "groomer@example.com",
role: "groomer",
isSuperUser: false,
active: true,
oidcSub: "oidc-groomer-sub",
userId: null,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "staff-manager-id",
name: "Manager McManager",
email: "manager@example.com",
role: "manager",
isSuperUser: true,
active: true,
oidcSub: "oidc-manager-sub",
userId: null,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
},
],
services: [
{ id: "service-1", name: "Full Groom", description: null, basePriceCents: 6000, durationMinutes: 120, active: true, createdAt: new Date(), updatedAt: new Date() },
{ id: "service-2", name: "Bath & Brush", description: null, basePriceCents: 4000, durationMinutes: 60, active: true, createdAt: new Date(), updatedAt: new Date() },
],
};
}
vi.mock("../db/index.js", () => {
const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} });
const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} });
const groomingVisitLogs = new Proxy({ _name: "groomingVisitLogs" }, { get: (t, p) => p === "_name" ? "groomingVisitLogs" : {} });
const staff = new Proxy({ _name: "staff" }, { get: (t, p) => p === "_name" ? "staff" : {} });
const services = new Proxy({ _name: "services" }, { get: (t, p) => p === "_name" ? "services" : {} });
// Tracks { [tableName]: { [alias]: SQLExpression } } for the current select() call
let selectedColumns: Record<string, Record<string, unknown>> = {};
function makeChainable(rows: unknown[]) {
const arr = rows as unknown[];
return new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "leftJoin" || prop === "from") {
return () => makeChainable(target);
}
if (prop === Symbol.iterator) {
return function* () { for (const v of target) yield v; };
}
if (prop === Symbol.asyncIterator) {
return async function* () { for (const v of target) yield v; };
}
// @ts-expect-error proxy
return target[prop];
},
});
}
// sql mock: returns an object with .as() so drizzle's select() can alias it
function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) {
const queryString = _strings[0];
const asFn = (alias: string) => ({
sql: { queryChunks: [_strings[0]] },
fieldAlias: alias,
getSQL() { return this.sql; },
});
return { queryChunks: [queryString], as: asFn };
}
return {
getDb: () => ({
select: (cols?: Record<string, unknown>) => {
selectedColumns = {};
if (cols) {
// Inspect cols to find sql-aliased expressions and their aliases
for (const [alias, expr] of Object.entries(cols)) {
if (expr && typeof expr === "object" && "as" in expr && typeof (expr as Record<string, unknown>).as === "function") {
const aliased = (expr as { as: (a: string) => { fieldAlias: string; sql: unknown } }).as(alias);
// Detect count(*) queries
if (typeof aliased.sql === "object" && aliased.sql !== null && "queryChunks" in (aliased.sql as Record<string, unknown>) && String((aliased.sql as { queryChunks?: unknown[] }).queryChunks).includes("count")) {
// Store count query intent — we'll resolve it in from()
if (!selectedColumns["appointments"]) selectedColumns["appointments"] = {};
selectedColumns["appointments"][alias] = { _isCountQuery: true };
}
}
}
}
return {
from: (table: unknown) => {
const name = (table as { _name?: string })._name;
const tableCols = selectedColumns[name] || {};
// If this table has a count query, return computed count result
const countQueryEntry = Object.entries(tableCols).find(([, v]) =>
typeof v === "object" && v !== null && "_isCountQuery" in v
);
if (countQueryEntry) {
const [countAlias] = countQueryEntry;
const count = (name === "appointments" ? mock.appointments : [])
.filter((row: Record<string, unknown>) => row.status === "completed").length;
return makeChainable([{ [countAlias]: count }]);
}
if (name === "pets") return makeChainable(mock.pets);
if (name === "appointments") return makeChainable(mock.appointments);
if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs);
if (name === "staff") return makeChainable(mock.staffMembers);
if (name === "services") return makeChainable(mock.services);
return makeChainable([]);
},
};
},
insert: () => ({ values: () => ({ returning: () => [{}] }) }),
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
}),
pets,
appointments,
groomingVisitLogs,
staff,
services,
and: vi.fn((a: unknown, b: unknown) => [a, b]),
desc: vi.fn((c: unknown) => c),
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
exists: vi.fn(() => true),
gte: vi.fn((a: unknown, b: unknown) => ({ col: a, val: b })),
or: vi.fn((a: unknown, b: unknown) => [a, b]),
sql: sqlMock,
};
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeApp(staff: StaffRow = MANAGER) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("staff", staff);
await next();
});
return app.route("/pets", petsRouter);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("GET /:id/profile-summary", () => {
beforeEach(resetMock);
it("returns 404 for non-existent pet", async () => {
const app = makeApp();
mock.pets = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(404);
});
it("returns 403 for groomer with no pet linkage", async () => {
const app = makeApp(GROOMER);
// Groomer has no linkage to this pet's client — clear appointments
mock.appointments = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(403);
});
it("returns complete aggregated profile for manager", async () => {
const app = makeApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe(PET_ID);
expect(body.name).toBe("Biscuit");
expect(body.species).toBe("dog");
expect(body.recentGroomingHistory).toBeInstanceOf(Array);
expect(body.lastVisitDate).toBeTruthy();
expect(body.visitCount).toBeGreaterThanOrEqual(0);
});
it("groomer with pet linkage returns 200", async () => {
const app = makeApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
});
it("recentGroomingHistory is limited to 10 entries", async () => {
const app = makeApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.recentGroomingHistory.length).toBeLessThanOrEqual(10);
});
it("returns null upcomingAppointment when none scheduled", async () => {
const app = makeApp(MANAGER);
mock.appointments = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.upcomingAppointment).toBeNull();
});
});
describe("GET /:id/profile-summary — visitCount", () => {
beforeEach(resetMock);
it("returns visitCount >= 2 when pet has 2+ completed appointments", async () => {
const app = makeApp(MANAGER);
// Add a second completed appointment
mock.appointments = [
...mock.appointments,
{
id: "appt-completed-2",
clientId: CLIENT_ID,
petId: PET_ID,
serviceId: "service-1",
staffId: "staff-groomer-id",
batherStaffId: null,
status: "completed",
startTime: new Date("2024-07-01T09:00:00Z"),
endTime: new Date("2024-07-01T11:00:00Z"),
notes: null,
priceCents: 6000,
seriesId: null,
seriesIndex: null,
groupId: null,
confirmationStatus: "confirmed",
confirmedAt: null,
cancelledAt: null,
confirmationToken: null,
customerNotes: null,
createdAt: new Date("2024-06-15"),
updatedAt: new Date("2024-06-15"),
},
];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.visitCount).toBeGreaterThanOrEqual(2);
});
it("returns visitCount = 0 when no completed appointments", async () => {
const app = makeApp(MANAGER);
mock.appointments = mock.appointments.map((a) => ({ ...a, status: "cancelled" }));
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.visitCount).toBe(0);
});
});
describe("GET /:id/profile-summary — empty history", () => {
beforeEach(resetMock);
it("returns empty history array when no grooming logs", async () => {
const app = makeApp(MANAGER);
mock.groomingLogs = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.recentGroomingHistory).toEqual([]);
expect(body.lastVisitDate).toBeNull();
});
});
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
import { petsRouter } from "../routes/pets.js";
import { and, eq, exists, or } from "../db/index.js";
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
@@ -21,8 +22,8 @@ const MANAGER: StaffRow = {
// ─── Mutable mock state ───────────────────────────────────────────────────────
const CLIENT_ID = "client-uuid-extended";
const PET_ID = "pet-uuid-extended";
const CLIENT_ID = "a0000000-0000-4000-8000-000000000001";
const PET_ID = "b0000000-0000-4000-8000-000000000002";
let petRows: Record<string, unknown>[] = [];
let appointmentRows: Record<string, unknown>[] = [];
@@ -134,7 +135,7 @@ function makeDeleteChainable(): unknown {
}
if (prop === "returning") {
return () => {
const row = petRows[0];
const row = petRows[0]!;
deletedId = row.id as string;
return [row];
};
@@ -145,7 +146,8 @@ function makeDeleteChainable(): unknown {
return chain;
}
vi.mock("../db", () => {
vi.mock("../db", async (importOriginal) => {
const db = await importOriginal<typeof import("../db/index.js")>();
const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} });
const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} });
return {
@@ -163,10 +165,10 @@ vi.mock("../db", () => {
}),
pets,
appointments,
and,
eq,
exists,
or,
and: vi.fn(),
eq: vi.fn(),
exists: vi.fn(),
or: vi.fn(),
};
});
@@ -322,11 +324,11 @@ describe("Extended pet profile fields — update", () => {
const res = await app.request(`/pets/${PET_ID}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ coatType: "smooth" }),
body: JSON.stringify({ coatType: "double" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.coatType).toBe("smooth");
expect(body.coatType).toBe("double");
});
it("updates temperamentScore", async () => {
+9
View File
@@ -67,6 +67,11 @@ vi.mock("../db", () => {
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
);
const impersonationAuditLogs = new Proxy(
{ _name: "impersonationAuditLogs" },
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
);
const appointments = new Proxy(
{ _name: "appointments" },
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
@@ -99,8 +104,12 @@ vi.mock("../db", () => {
}),
}),
}),
insert: () => ({
values: () => ({ returning: () => [{}] }),
}),
}),
impersonationSessions,
impersonationAuditLogs,
appointments,
eq: vi.fn(),
and: vi.fn(),
+108 -23
View File
@@ -45,40 +45,76 @@ const GROOMER: StaffRow = {
let staffLookupResult: StaffRow | null = null;
let managerFallbackResult: StaffRow | null = MANAGER;
let userLookupResult: { id: string; name: string | null; email: string | null } | null = null;
let _insertedStaff: StaffRow | null = null;
vi.mock("../db", () => {
const staff = new Proxy(
{ _name: "staff" },
{
get(target, prop) {
if (prop === "_name") return "staff";
if (prop === "$inferSelect") return {};
return { table: "staff", column: prop };
},
}
);
const makeTableProxy = (name: string) =>
new Proxy(
{ _name: name },
{
get(target, prop) {
if (prop === "_name") return name;
if (prop === "$inferSelect") return {};
return { table: name, column: prop };
},
}
);
const staff = makeTableProxy("staff");
const user = makeTableProxy("user");
const buildQuery = (result: unknown, fallback: unknown) => ({
[Symbol.iterator]: function* () {
if (result) yield result;
},
limit: (_n: number) => {
const item = result ?? fallback;
return {
[Symbol.iterator]: function* () { if (item) yield item; },
0: item,
length: item ? 1 : 0,
};
},
});
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => {
// dev mode fallback to first manager
return managerFallbackResult ? [managerFallbackResult] : [];
},
[Symbol.iterator]: function* () {
if (staffLookupResult) yield staffLookupResult;
},
0: staffLookupResult,
length: staffLookupResult ? 1 : 0,
}),
from: (table: unknown) => ({
where: () => buildQuery(
table === staff ? staffLookupResult : userLookupResult,
table === staff ? managerFallbackResult : null
),
}),
}),
insert: (_table: unknown) => ({
values: (vals: Record<string, unknown>) => ({
returning: () => {
const newStaff: StaffRow = {
id: "new-staff-id",
oidcSub: null,
userId: vals.userId as string,
role: vals.role as StaffRow["role"],
isSuperUser: false,
name: vals.name as string,
email: vals.email as string,
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
_insertedStaff = newStaff;
return [newStaff];
},
}),
}),
}),
staff,
user,
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
and: vi.fn((..._clauses: unknown[]) => ({})),
sql: vi.fn((..._args: unknown[]) => ({})),
};
});
@@ -87,6 +123,8 @@ vi.mock("../db", () => {
function resetMocks() {
staffLookupResult = null;
managerFallbackResult = MANAGER;
userLookupResult = null;
_insertedStaff = null;
}
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
@@ -96,7 +134,10 @@ function buildApp(
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
c.set("jwtPayload", {
sub: userLookupResult?.id ?? staffLookupResult?.userId ?? "unknown-sub",
email: userLookupResult?.email,
});
await next();
});
app.use("*", middleware);
@@ -202,6 +243,50 @@ describe("resolveStaffMiddleware", () => {
const body = await res.json();
expect(body.error).toMatch(/no staff records found/i);
});
it("auto-provision: creates groomer staff record on first login when Better-Auth user exists", async () => {
staffLookupResult = null;
userLookupResult = { id: "ba-user-new", name: "New User", email: "newuser@example.com" };
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff).not.toBeNull();
expect(capturedStaff!.role).toBe("groomer");
expect(capturedStaff!.userId).toBe("ba-user-new");
expect(capturedStaff!.name).toBe("New User");
expect(capturedStaff!.email).toBe("newuser@example.com");
expect(capturedStaff!.isSuperUser).toBe(false);
});
it("auto-provision: falls back to email prefix when user has no name", async () => {
staffLookupResult = null;
userLookupResult = { id: "ba-user-noname", name: null, email: "firstlogin@example.com" };
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff!.name).toBe("firstlogin");
});
it("auto-provision: returns 403 when no staff record and no Better-Auth user exists", async () => {
staffLookupResult = null;
userLookupResult = null;
const app = buildApp(resolveStaffMiddleware);
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/no staff record found for authenticated user/i);
});
});
// ─── requireRole tests ────────────────────────────────────────────────────────
@@ -67,6 +67,7 @@ let dbAccounts: AccountRow[] = [];
let dbStaff: StaffRow[] = [];
let insertedUsers: UserRow[] = [];
let insertedAccounts: AccountRow[] = [];
let updatedAccounts: Array<{ id: string; password: string }> = [];
let updatedStaff: Array<{ id: string; userId: string }> = [];
const originalEnv = { ...process.env };
@@ -77,6 +78,7 @@ function resetMock() {
dbStaff = [];
insertedUsers = [];
insertedAccounts = [];
updatedAccounts = [];
updatedStaff = [];
process.env = { ...originalEnv };
}
@@ -173,7 +175,11 @@ async function seedUatCredentials(
);
if (existingAccount) {
// skip — already has credential account
// Idempotent update: re-hash the current env password and update the stored hash.
const { hashPassword } = await import("better-auth/crypto");
const passwordHash = await hashPassword(password);
existingAccount.password = passwordHash;
updatedAccounts.push({ id: existingAccount.id, password: passwordHash });
} else {
// Use Better-Auth's hashPassword so test helper matches production seed.ts
const { hashPassword } = await import("better-auth/crypto");
@@ -312,9 +318,9 @@ describe("seedUatCredentials — credential provisioning logic", () => {
expect(updatedStaff).toHaveLength(0);
});
// ── AC-5: idempotent — skips when user already exists ───────────────────────
// ── AC-5: idempotent — does not insert duplicate records ───────────────────
it("AC-5: re-running does not duplicate user or account records (idempotent)", async () => {
it("AC-5: re-running does not insert duplicate user or account records", async () => {
process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD;
const preExistingUsers: UserRow[] = [
@@ -330,25 +336,96 @@ describe("seedUatCredentials — credential provisioning logic", () => {
},
];
// First call — nothing inserted (user + account pre-exist)
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
// No inserts — user and account already exist
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
});
// ── AC-5b: password rotation on re-seed ─────────────────────────────────────
it("AC-5b: re-running with a new password updates the stored credential hash", async () => {
const OLD_PASSWORD = "old-password-abc";
const NEW_PASSWORD = "new-password-xyz";
process.env.SEED_UAT_CUSTOMER_PASSWORD = NEW_PASSWORD;
const preExistingUsers: UserRow[] = [
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
];
const preExistingAccounts: AccountRow[] = [
{
id: "pre-existing-acct",
accountId: "pre-existing-user",
providerId: "credential",
userId: "pre-existing-user",
password: await hashPassword(OLD_PASSWORD),
},
];
// Second call — still nothing inserted
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
// No new records inserted
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
// Password WAS updated to the new env value
expect(updatedAccounts).toHaveLength(1);
expect(updatedAccounts[0]!.id).toBe("pre-existing-acct");
// New hash is valid Better-Auth format (salt:key, each hex)
const newHashParts = updatedAccounts[0]!.password.split(":");
expect(Buffer.from(newHashParts[0]!, "hex")).toHaveLength(16);
expect(Buffer.from(newHashParts[1]!, "hex")).toHaveLength(64);
});
// ── AC-8: existing account password IS updated (not frozen at first-seed) ──
it("AC-8: re-seeding with a changed password env var updates the stored hash", async () => {
const ORIGINAL_PASSWORD = "original-password";
const ROTATED_PASSWORD = "rotated-password-456";
process.env.SEED_UAT_CUSTOMER_PASSWORD = ROTATED_PASSWORD;
const preExistingUsers: UserRow[] = [
{ id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true },
];
// Account was created with the original password on first seed
const originalHash = await hashPassword(ORIGINAL_PASSWORD);
const preExistingAccounts: AccountRow[] = [
{
id: "pre-existing-acct",
accountId: "pre-existing-user",
providerId: "credential",
userId: "pre-existing-user",
password: originalHash,
},
];
// Re-seed with the rotated password env var
await seedUatCredentials([UAT_ACCOUNTS[2]!], {
users: preExistingUsers,
accounts: preExistingAccounts,
staff: [],
});
// No new user or account created
expect(insertedUsers).toHaveLength(0);
expect(insertedAccounts).toHaveLength(0);
// The pre-existing account's password WAS updated (not frozen at first-seed).
// hashPassword uses a random salt so we verify by format + that it is a new,
// different valid hash from the original.
const updatedAcct = preExistingAccounts[0]!;
expect(updatedAcct.password).toBeDefined();
expect(updatedAcct.password).toMatch(/^[a-f0-9]{32}:[a-f0-9]{128}$/);
expect(updatedAcct.password).not.toBe(originalHash); // it actually changed
});
// ── AC-6: missing env var skips with warning ────────────────────────────────
+5
View File
@@ -103,6 +103,11 @@ export function buildPet(overrides: Partial<PetRow> & { clientId: string }): Pet
photoKey: null,
photoUploadedAt: null,
image: null,
coatType: null,
temperamentScore: null,
temperamentFlags: [],
medicalAlerts: [],
preferredCuts: [],
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
};
+82 -8
View File
@@ -20,6 +20,7 @@ import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { eq, and, sql } from "drizzle-orm";
import * as schema from "./schema.js";
import type { MedicalAlert, MedicalAlertSeverity } from "./schema.js";
// ── Seed profile configuration ─────────────────────────────────────────────
@@ -252,6 +253,38 @@ const appointmentNotes = [
"Client running late, pushed start by 15min",
];
const temperamentScores = [3, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9];
const temperamentFlags = [
[], ["anxious"], ["friendly"], ["nippy"], ["anxious", "sensitive"],
["friendly", "calm"], ["nippy", "territorial"], ["calm"], ["sensitive"],
["friendly", "nippy"], ["anxious", "territorial"],
];
const medicalAlertsList = [
[] as MedicalAlert[],
[] as MedicalAlert[],
[{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }],
[{ type: "ear", description: "Ear infection prone — dry ears thoroughly", severity: "medium" as MedicalAlertSeverity }],
[{ type: "mobility", description: "Hip dysplasia — handle with care", severity: "high" as MedicalAlertSeverity }],
[{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }],
[{ type: "medical", description: "Seizure history — avoid stress triggers", severity: "high" as MedicalAlertSeverity }],
[{ type: "skin", description: "Skin allergies — use hypoallergenic products only", severity: "medium" as MedicalAlertSeverity }],
[{ type: "behavioral", description: "Aggressive when nails trimmed — muzzle required", severity: "high" as MedicalAlertSeverity }],
[{ type: "cardiac", description: "Heart murmur — monitor during grooming", severity: "high" as MedicalAlertSeverity }],
[{ type: "dietary", description: "Diabetic — owner brings treats", severity: "medium" as MedicalAlertSeverity }],
];
const preferredCutsList = [
[], ["Puppy Cut"], ["Teddy Bear Cut"], ["Breed Standard"],
["Puppy Cut", "Sanitary Trim"], ["Full Groom"], ["Lion Cut"],
["Kennel Cut", "Face & Feet Trim"], ["Teddy Bear Cut", "Sanitary Trim"],
["Breed Standard", "Sanitary Trim"], ["Summer Shave"],
["Puppy Cut", "Face & Feet Trim", "Sanitary Trim"],
];
const coatTypes: string[] = ["short", "medium", "long", "curly", "wire", "double", "silky"];
const visitLogNotes = [
null, null,
"Coat in great condition",
@@ -561,7 +594,15 @@ async function seedKnownUsers() {
.limit(1);
if (existingAccount) {
console.log(`✓ Credential account for '${acct.email}' already exists — skipping`);
// Re-hash and update the password so that re-seeding rotates credentials
// when the env var changes (e.g. after a password rotation). Previously
// this branch skipped entirely, freezing the hash at first-seed.
const { hashPassword } = await import("better-auth/crypto");
const passwordHash = await hashPassword(password);
await db.update(schema.account)
.set({ password: passwordHash })
.where(eq(schema.account.id, existingAccount.id));
console.log(`✓ Updated credential account password for '${acct.email}'`);
} else {
// Use Better-Auth's own hashPassword to guarantee parameter/encoding match.
// better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64, salt as 16-byte random
@@ -872,6 +913,11 @@ async function seed() {
cutStyle: pick(cutStyles),
shampooPreference: pick(shampoos),
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
coatType: pick(coatTypes),
temperamentScore: pick(temperamentScores),
temperamentFlags: pick(temperamentFlags),
medicalAlerts: pick(medicalAlertsList),
preferredCuts: pick(preferredCutsList),
customFields: {},
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
});
@@ -907,6 +953,11 @@ async function seed() {
cutStyle: pet.cutStyle,
shampooPreference: pet.shampooPreference,
specialCareNotes: pet.specialCareNotes,
coatType: pet.coatType,
temperamentScore: pet.temperamentScore,
temperamentFlags: pet.temperamentFlags,
medicalAlerts: pet.medicalAlerts,
preferredCuts: pet.preferredCuts,
customFields: pet.customFields,
image: pet.image,
},
@@ -929,13 +980,18 @@ async function seed() {
petId: string;
petName: string;
petBreed: string;
petCoatType: string;
petTemperamentScore: number;
petTemperamentFlags: string[];
petMedicalAlerts: MedicalAlert[];
petPreferredCuts: string[];
}
const uatClients: UatClient[] = [
{ id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever" },
{ id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever" },
{ id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle" },
{ id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog" },
{ id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle" },
{ id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever", petCoatType: "double", petTemperamentScore: 7, petTemperamentFlags: ["calm", "friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Breed Standard"] },
{ id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever", petCoatType: "short", petTemperamentScore: 8, petTemperamentFlags: ["friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Bath & Brush", "Sanitary Trim"] },
{ id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle", petCoatType: "curly", petTemperamentScore: 9, petTemperamentFlags: ["calm"], petMedicalAlerts: [{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }], petPreferredCuts: ["Teddy Bear Cut"] },
{ id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog", petCoatType: "short", petTemperamentScore: 6, petTemperamentFlags: ["nippy"], petMedicalAlerts: [{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }], petPreferredCuts: ["Puppy Cut"] },
{ id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle", petCoatType: "short", petTemperamentScore: 7, petTemperamentFlags: ["friendly", "energetic"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Full Groom", "Nail Trim"] },
];
for (const uc of uatClients) {
@@ -943,8 +999,26 @@ async function seed() {
.values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address })
.onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
await db.insert(schema.pets)
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) })
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } });
.values({
id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed,
weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"),
coatType: uc.petCoatType,
temperamentScore: uc.petTemperamentScore,
temperamentFlags: uc.petTemperamentFlags,
medicalAlerts: uc.petMedicalAlerts,
preferredCuts: uc.petPreferredCuts,
image: pick(demoPetImages),
})
.onConflictDoUpdate({ target: schema.pets.id, set: {
clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed,
weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"),
coatType: uc.petCoatType,
temperamentScore: uc.petTemperamentScore,
temperamentFlags: uc.petTemperamentFlags,
medicalAlerts: uc.petMedicalAlerts,
preferredCuts: uc.petPreferredCuts,
image: pick(demoPetImages),
} });
// Create one completed appointment for this client
const apptId = uuid();
const svcIdx = 0;
+3
View File
@@ -26,6 +26,7 @@ import { getDb, businessSettings, eq, staff } from "./db/index.js";
import { authMiddleware } from "./middleware/auth.js";
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
import { devRouter } from "./routes/dev.js";
import { bufferRulesRouter } from "./routes/buffer-rules.js";
import { adminSeedRouter } from "./routes/admin/seed.js";
import { startReminderScheduler } from "./services/reminders.js";
import { webhooksRouter } from "./routes/stripe-webhooks.js";
@@ -211,6 +212,7 @@ api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
api.use("/admin/*", requireRoleOrSuperUser("manager"));
api.use("/buffer-rules/*", requireRole("manager"));
api.use("/admin/settings/*", requireSuperUser());
api.use("/reports/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager", "groomer"));
@@ -268,6 +270,7 @@ api.route("/impersonation", impersonationRouter);
api.route("/admin/settings", settingsRouter);
api.route("/admin/auth-provider", authProviderRouter);
api.route("/admin/seed", adminSeedRouter);
api.route("/buffer-rules", bufferRulesRouter);
api.route("/search", searchRouter);
const port = Number(process.env.PORT ?? 3000);
+28 -1
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff } from "../db/index.js";
import { and, eq, getDb, sql, staff, user } from "../db/index.js";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
@@ -110,6 +110,33 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
return;
}
}
// Auto-provision: no staff record exists for this user at all, but a valid
// Better-Auth user session exists (jwt.sub = user.id from user table).
// Create a minimal groomer staff record on first login.
const [userRow] = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, jwt.sub))
.limit(1);
if (userRow) {
const [newStaff] = await db
.insert(staff)
.values({
name: userRow.name ?? jwt.email?.split("@")[0] ?? "Unknown",
email: userRow.email ?? jwt.email ?? "",
userId: jwt.sub,
role: "groomer",
isSuperUser: false,
active: true,
})
.returning();
if (!newStaff) {
return c.json({ error: "Internal error: staff record creation failed" }, 500);
}
c.set("staff", newStaff);
await next();
return;
}
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
+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,
},
});
});
});
+124
View File
@@ -0,0 +1,124 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, getDb, isNull } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
import { bufferRules, services } from "../db/index.js";
export const bufferRulesRouter = new Hono<AppEnv>();
const createBufferRuleSchema = z.object({
serviceId: z.string().uuid(),
sizeCategory: z
.enum(["small", "medium", "large", "extra_large"])
.optional(),
coatType: z
.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"])
.optional(),
bufferMinutes: z.number().int().positive(),
});
const updateBufferRuleSchema = z.object({
bufferMinutes: z.number().int().positive(),
});
// GET / — list all buffer rules, optionally filtered by serviceId
bufferRulesRouter.get("/", async (c) => {
const db = getDb();
const serviceId = c.req.query("serviceId");
const conditions = [];
if (serviceId) conditions.push(eq(bufferRules.serviceId, serviceId));
const rows = await db
.select({
id: bufferRules.id,
serviceId: bufferRules.serviceId,
sizeCategory: bufferRules.sizeCategory,
coatType: bufferRules.coatType,
bufferMinutes: bufferRules.bufferMinutes,
createdAt: bufferRules.createdAt,
updatedAt: bufferRules.updatedAt,
serviceName: services.name,
})
.from(bufferRules)
.innerJoin(services, eq(bufferRules.serviceId, services.id))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(bufferRules.createdAt);
return c.json(rows);
});
// POST / — create a buffer rule
bufferRulesRouter.post(
"/",
zValidator("json", createBufferRuleSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
// Validate serviceId exists
const [svc] = await db
.select({ id: services.id })
.from(services)
.where(eq(services.id, body.serviceId));
if (!svc) return c.json({ error: "Service not found" }, 404);
// Check for duplicate (service + size + coat)
const [existing] = await db
.select({ id: bufferRules.id })
.from(bufferRules)
.where(
and(
eq(bufferRules.serviceId, body.serviceId),
body.sizeCategory !== undefined
? eq(bufferRules.sizeCategory, body.sizeCategory)
: isNull(bufferRules.sizeCategory),
body.coatType !== undefined
? eq(bufferRules.coatType, body.coatType)
: isNull(bufferRules.coatType)
)
);
if (existing) return c.json({ error: "Duplicate rule for this service+size+coat combination" }, 409);
const [row] = await db
.insert(bufferRules)
.values({
serviceId: body.serviceId,
sizeCategory: body.sizeCategory ?? null,
coatType: body.coatType ?? null,
bufferMinutes: body.bufferMinutes,
})
.returning();
return c.json(row, 201);
}
);
// PATCH /:id — update bufferMinutes only
bufferRulesRouter.patch(
"/:id",
zValidator("json", updateBufferRuleSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const [row] = await db
.update(bufferRules)
.set({ bufferMinutes: body.bufferMinutes, updatedAt: new Date() })
.where(eq(bufferRules.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json(row);
}
);
// DELETE /:id — delete a buffer rule
bufferRulesRouter.delete("/:id", async (c) => {
const db = getDb();
const [row] = await db
.delete(bufferRules)
.where(eq(bufferRules.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
});
+133 -2
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, exists, getDb, or, pets, appointments } from "../db/index.js";
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
import {
getPresignedUploadUrl,
@@ -24,7 +24,8 @@ const createPetSchema = z.object({
shampooPreference: z.string().max(500).optional(),
specialCareNotes: z.string().max(2000).optional(),
customFields: z.record(z.string(), z.string()).optional(),
coatType: z.string().max(100).optional(),
sizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
temperamentScore: z.number().int().min(1).max(5).optional(),
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
medicalAlerts: z.array(z.object({
@@ -282,3 +283,133 @@ petsRouter.get("/:petId/photo", async (c) => {
const url = await getPresignedGetUrl(pet.photoKey);
return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt });
});
// ─── Profile Summary ───────────────────────────────────────────────────────────
async function groomerLinkageCheck(
db: ReturnType<typeof getDb>,
clientId: string,
staffRow: NonNullable<AppEnv["Variables"]["staff"]>
): Promise<boolean> {
const [linkage] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.clientId, clientId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
return !!linkage;
}
/**
* GET /:id/profile-summary
* Returns aggregated profile: basic pet fields + grooming history + visit stats + upcoming appointment.
* Groomer RBAC: same visibility rules as GET /:id.
*/
petsRouter.get("/:id/profile-summary", async (c) => {
const db = getDb();
const petId = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [row] = await db.select().from(pets).where(eq(pets.id, petId));
if (!row) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const hasLinkage = await groomerLinkageCheck(db, row.clientId, staffRow);
if (!hasLinkage) return c.json({ error: "Forbidden" }, 403);
}
// Recent grooming history: last 10, with staff name join
const historyRows = await db
.select({
id: groomingVisitLogs.id,
petId: groomingVisitLogs.petId,
appointmentId: groomingVisitLogs.appointmentId,
staffId: groomingVisitLogs.staffId,
staffName: staff.name,
cutStyle: groomingVisitLogs.cutStyle,
productsUsed: groomingVisitLogs.productsUsed,
notes: groomingVisitLogs.notes,
groomedAt: groomingVisitLogs.groomedAt,
createdAt: groomingVisitLogs.createdAt,
})
.from(groomingVisitLogs)
.leftJoin(staff, eq(staff.id, groomingVisitLogs.staffId))
.where(eq(groomingVisitLogs.petId, petId))
.orderBy(desc(groomingVisitLogs.groomedAt))
.limit(10);
const recentGroomingHistory = historyRows.map((r) => ({
id: r.id,
petId: r.petId,
appointmentId: r.appointmentId,
staffId: r.staffId,
staffName: r.staffName,
cutStyle: r.cutStyle,
productsUsed: r.productsUsed,
notes: r.notes,
groomedAt: r.groomedAt?.toISOString() ?? null,
createdAt: r.createdAt?.toISOString() ?? null,
}));
const lastVisitDate = historyRows[0]?.groomedAt?.toISOString() ?? null;
// Completed appointment count for this pet
const [{ count: visitCount }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(appointments)
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")));
// Upcoming appointment: next scheduled or confirmed
const [nextAppt] = await db
.select({
id: appointments.id,
serviceId: appointments.serviceId,
staffId: appointments.staffId,
startTime: appointments.startTime,
endTime: appointments.endTime,
status: appointments.status,
serviceName: services.name,
staffName: staff.name,
})
.from(appointments)
.leftJoin(services, eq(services.id, appointments.serviceId))
.leftJoin(staff, eq(staff.id, appointments.staffId))
.where(
and(
eq(appointments.petId, petId),
or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed")),
gte(appointments.startTime, new Date())
)
)
.orderBy(appointments.startTime)
.limit(1);
const upcomingAppointment = nextAppt
? {
id: nextAppt.id,
serviceId: nextAppt.serviceId,
serviceName: nextAppt.serviceName,
staffId: nextAppt.staffId,
staffName: nextAppt.staffName,
startTime: nextAppt.startTime?.toISOString() ?? null,
endTime: nextAppt.endTime?.toISOString() ?? null,
status: nextAppt.status,
}
: null;
return c.json({
...row,
recentGroomingHistory,
lastVisitDate,
visitCount,
upcomingAppointment,
});
});
+3 -1
View File
@@ -13,7 +13,9 @@ const createServiceSchema = z.object({
active: z.boolean().default(true),
});
const updateServiceSchema = createServiceSchema.partial();
const updateServiceSchema = createServiceSchema.partial().extend({
defaultBufferMinutes: z.number().int().min(0).optional(),
});
servicesRouter.get("/", async (c) => {
const db = getDb();
+13
View File
@@ -26,6 +26,19 @@ export interface Client {
updatedAt: string;
}
// ─── Medical Alerts ────────────────────────────────────────────────────────────
export type AlertSeverity = "low" | "medium" | "high";
export interface MedicalAlert {
type: string;
description: string;
severity: AlertSeverity;
}
// ─── Pet Profile Summary ────────────────────────────────────────────────────
export type CoatType = "short" | "medium" | "long" | "double" | "wire" | "silky" | "curly" | "hairless";
export interface Pet {
id: string;
clientId: string;
+1
View File
@@ -3,6 +3,7 @@
"version": "0.0.1",
"private": true,
"type": "module",
"packageManager": "pnpm@9.15.4",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --project .",
@@ -0,0 +1,33 @@
-- Migration: 0031_buffer_rules.sql
-- Buffer rules CRUD: pet size/coat enums, bufferRules table, services.defaultBufferMinutes
-- ─── Enums ───────────────────────────────────────────────────────────────────
CREATE TYPE "pet_size_category" AS ENUM ('small', 'medium', 'large', 'xlarge');
CREATE TYPE "coat_type" AS ENUM ('smooth', 'double', 'wire', 'curly', 'long', 'hairless');
-- ─── 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";
-- ─── Services: add defaultBufferMinutes ───────────────────────────────────────
ALTER TABLE "services" ADD COLUMN "default_buffer_minutes" integer;
-- ─── Buffer Rules table ───────────────────────────────────────────────────────
CREATE TABLE "buffer_rules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"service_id" uuid NOT NULL REFERENCES "services"("id") ON DELETE CASCADE,
"size_category" "pet_size_category",
"coat_type" "coat_type",
"buffer_minutes" integer NOT NULL,
"created_at" timestamp NOT NULL DEFAULT now(),
"updated_at" timestamp NOT NULL DEFAULT now(),
CONSTRAINT "uq_buffer_rules_service_size_coat" UNIQUE ("service_id", "size_category", "coat_type")
);
CREATE INDEX "idx_buffer_rules_service_id" ON "buffer_rules"("service_id");
@@ -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,8 @@
-- Migration: 0034_extend_pet_profile_columns.sql
-- GRO-1850: Adds temperament_score, temperament_flags, medical_alerts,
-- and preferred_cuts columns to the pets table.
ALTER TABLE "pets" ADD COLUMN "temperament_score" integer;
ALTER TABLE "pets" ADD COLUMN "temperament_flags" jsonb DEFAULT '[]';
ALTER TABLE "pets" ADD COLUMN "medical_alerts" jsonb DEFAULT '[]';
ALTER TABLE "pets" ADD COLUMN "preferred_cuts" jsonb DEFAULT '[]';
@@ -0,0 +1,9 @@
-- Migration: 0035_add_missing_coat_type_values.sql
-- Adds missing values to coat_type enum that seed.ts requires but which were
-- omitted from the 0031_buffer_rules.sql CREATE TYPE statement (migration drift).
-- 0031 created: 'smooth', 'double', 'wire', 'curly', 'long', 'hairless'
-- Missing (from schema.ts coatTypeEnum): 'short', 'medium', 'silky'
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short';
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'medium';
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'silky';
@@ -0,0 +1,14 @@
-- Migration: 0035_add_short_to_coat_type_enum.sql
-- GRO-1953: Adds missing "short" value to the coat_type enum so that seed data
-- (which uses coatTypePool including "short") can be inserted without error.
--
-- The seed file defines coatTypePool as:
-- ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]
-- but migration 0031 created the enum without "short", causing:
-- PostgresError: invalid input value for enum coat_type: "short"
BEGIN;
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short';
COMMIT;
@@ -0,0 +1,9 @@
-- Migration: 0036_add_missing_coat_type_values.sql
-- Adds missing values to coat_type enum that seed.ts requires but which were
-- omitted from the 0031_buffer_rules.sql CREATE TYPE statement (migration drift).
-- 0031 created: 'smooth', 'double', 'wire', 'curly', 'long', 'hairless'
-- Missing (from schema.ts coatTypeEnum): 'short', 'medium', 'silky'
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short';
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'medium';
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'silky';
@@ -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": {}
}
@@ -0,0 +1,210 @@
{
"id": "0034_extend_pet_profile_columns",
"prevId": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.pets": {
"name": "pets",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"client_id": {
"name": "client_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"species": {
"name": "species",
"type": "text",
"primaryKey": false,
"notNull": true
},
"breed": {
"name": "breed",
"type": "text",
"primaryKey": false,
"notNull": false
},
"weight_kg": {
"name": "weight_kg",
"type": "numeric(5, 2)",
"primaryKey": false,
"notNull": false
},
"date_of_birth": {
"name": "date_of_birth",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"health_alerts": {
"name": "health_alerts",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grooming_notes": {
"name": "grooming_notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cut_style": {
"name": "cut_style",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shampoo_preference": {
"name": "shampoo_preference",
"type": "text",
"primaryKey": false,
"notNull": false
},
"special_care_notes": {
"name": "special_care_notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"coat_type": {
"name": "coat_type",
"type": "coat_type",
"primaryKey": false,
"notNull": false
},
"pet_size_category": {
"name": "pet_size_category",
"type": "pet_size_category",
"primaryKey": false,
"notNull": false
},
"temperament_score": {
"name": "temperament_score",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"temperament_flags": {
"name": "temperament_flags",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[]'::jsonb"
},
"medical_alerts": {
"name": "medical_alerts",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[]'::jsonb"
},
"preferred_cuts": {
"name": "preferred_cuts",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[]'::jsonb"
},
"custom_fields": {
"name": "custom_fields",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"photo_key": {
"name": "photo_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"photo_uploaded_at": {
"name": "photo_uploaded_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"pets_client_id_clients_id_fk": {
"name": "pets_client_id_clients_id_fk",
"tableFrom": "pets",
"tableTo": "clients",
"columnsFrom": [
"client_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"coat_type": {
"name": "coat_type",
"values": [
"short",
"medium",
"long",
"wire",
"double",
"hairless",
"curly"
]
},
"pet_size_category": {
"name": "pet_size_category",
"values": [
"small",
"medium",
"large",
"extra_large"
]
}
},
"nativeEnums": {}
}
+35
View File
@@ -218,6 +218,41 @@
"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
},
{
"idx": 34,
"version": "7",
"when": 1751140800000,
"tag": "0034_extend_pet_profile_columns",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1751480000000,
"tag": "0036_add_missing_coat_type_values",
"breakpoints": true
}
]
}
+1
View File
@@ -25,6 +25,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"better-auth": "^1.5.6",
"drizzle-orm": "^0.38.4",
"postgres": "^3.4.5"
},
+5
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"),
};
@@ -119,6 +123,7 @@ export function buildService(overrides: Partial<ServiceRow> = {}): ServiceRow {
description: "A grooming service",
basePriceCents: 6500,
durationMinutes: 60,
defaultBufferMinutes: 0,
active: true,
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;
}
+59 -2
View File
@@ -11,6 +11,7 @@ import {
unique,
uuid,
} from "drizzle-orm/pg-core";
import type { MedicalAlert } from "@groombook/types";
// ─── Enums ────────────────────────────────────────────────────────────────────
@@ -100,6 +101,26 @@ export const verification = pgTable("verification", {
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
// ─── Pet enums ─────────────────────────────────────────────────────────────────
export const petSizeCategoryEnum = pgEnum("pet_size_category", [
"small",
"medium",
"large",
"extra_large",
]);
export const coatTypeEnum = pgEnum("coat_type", [
"short",
"medium",
"long",
"double",
"wire",
"silky",
"curly",
"hairless",
]);
// ─── Tables ───────────────────────────────────────────────────────────────────
export const clients = pgTable(
@@ -142,8 +163,12 @@ export const pets = pgTable(
cutStyle: text("cut_style"),
shampooPreference: text("shampoo_preference"),
specialCareNotes: text("special_care_notes"),
coatType: text("coat_type"),
petSizeCategory: text("pet_size_category"),
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"),
@@ -161,6 +186,7 @@ export const services = pgTable("services", {
basePriceCents: integer("base_price_cents").notNull(),
durationMinutes: integer("duration_minutes").notNull(),
active: boolean("active").notNull().default(true),
defaultBufferMinutes: integer("default_buffer_minutes").notNull().default(0),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
@@ -601,3 +627,34 @@ export const authProviderConfig = pgTable("auth_provider_config", {
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
// ─── Buffer Rules ─────────────────────────────────────────────────────────────
// Buffer time rules per service + pet size/coat combination.
// Covers service-level defaults and pet-specific overrides.
export const bufferRules = pgTable(
"buffer_rules",
{
id: uuid("id").primaryKey().defaultRandom(),
serviceId: uuid("service_id")
.notNull()
.references(() => services.id, { onDelete: "cascade" }),
// null sizeCategory means "any size" (wildcard)
sizeCategory: petSizeCategoryEnum("size_category"),
// null coatType means "any coat type" (wildcard)
coatType: coatTypeEnum("coat_type"),
// minutes to add to the service duration for this size/coat combo
bufferMinutes: integer("buffer_minutes").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
// One rule per unique (service, size, coat) combination
unique("uq_buffer_rules_service_size_coat").on(
t.serviceId,
t.sizeCategory,
t.coatType
),
index("idx_buffer_rules_service_id").on(t.serviceId),
]
);
+410 -96
View File
@@ -18,8 +18,9 @@
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { eq, sql } from "drizzle-orm";
import { eq, and, sql } from "drizzle-orm";
import * as schema from "./schema.js";
import type { MedicalAlert } from "@groombook/types";
// ── Seed profile configuration ─────────────────────────────────────────────
@@ -243,6 +244,59 @@ const groomingNotes = [
"Previous clipper burn — be gentle on belly",
];
// ── Extended pet profile pools ─────────────────────────────────────────────────
const temperamentFlagPool: string[] = [
"friendly",
"anxious-with-strangers",
"good-with-kids",
"leash-reactive",
"vocal",
"high-energy",
"calm-on-table",
"treat-motivated",
];
const medicalAlertPool: MedicalAlert[] = [
{ id: "", type: "allergies", description: "Seasonal allergies — monitor skin", severity: "low" },
{ id: "", type: "allergies", description: "Chicken allergy — avoid poultry-based treats", severity: "high" },
{ id: "", type: "joint", description: "Hip dysplasia — handle with care", severity: "medium" },
{ id: "", type: "joint", description: "Arthritis — anti-inflammatory medication on file", severity: "medium" },
{ id: "", type: "dental", description: "Dental disease — extractions in history", severity: "medium" },
{ id: "", type: "dental", description: "Baby teeth retained — vet monitor", severity: "low" },
{ id: "", type: "heart", description: "Heart murmur grade II — avoid stress", severity: "high" },
{ id: "", type: "heart", description: "Murmur cleared by vet last year", severity: "low" },
{ id: "", type: "other", description: "Eye ulcer history — be careful around face", severity: "medium" },
{ id: "", type: "other", description: "Seizure history — avoid flashing lights", severity: "high" },
{ id: "", type: "other", description: "Luxating patella — short walks only", severity: "medium" },
{ id: "", type: "other", description: "Ear infections — dry thoroughly after bath", severity: "low" },
{ id: "", type: "behavioral", description: "Anxiety — calm environment preferred", severity: "low" },
{ id: "", type: "behavioral", description: "Fear-based aggression — approach with caution", severity: "high" },
{ id: "", type: "skin", description: "Contact dermatitis — avoid harsh chemicals", severity: "medium" },
{ id: "", type: "skin", description: "Hot spots — monitor and report any worsening", severity: "high" },
];
const preferredCutPool: string[] = [
"Puppy Cut",
"Teddy Bear Cut",
"Lion Cut",
"Breed Standard",
"Summer Shave",
"Kennel Cut",
"Lamb Cut",
"Continental Clip",
"Sporting Clip",
"Sanitary Trim",
"Face & Feet Trim",
"Full Groom",
];
type CoatType = (typeof schema.coatTypeEnum.enumValues)[number];
type PetSizeCategory = (typeof schema.petSizeCategoryEnum.enumValues)[number];
const coatTypePool: CoatType[] = ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"];
const petSizeCategoryPool: PetSizeCategory[] = ["small", "medium", "large", "extra_large"];
const appointmentNotes = [
null, null, null, null,
"Client requested extra brushing",
@@ -335,78 +389,19 @@ const servicesDef = [
{ id: "b0000001-0000-0000-0000-00000000000a", name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 },
];
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
// ── UAT staff account seeding (shared between seed paths) ─────────────────────
/**
* Seeds only the minimal known users for prod/demo environments.
* Creates: Demo Manager staff + Demo Client + Demo Dog + basic services.
* Idempotent: skips creation if records already exist.
* Seeds or upserts the deterministic UAT staff accounts with numeric OIDC subs
* from SEED_UAT_*_OIDC_SUB / SEED_UAT_GROOMER_OIDC_SUBS env vars.
*
* In the full seed path this must run AFTER random staff are created so the
* deterministic upserts land on the correct rows (groomers referenced by the
* UAT test-client appointment logic use groomers[0] etc.).
*
* In seedKnownUsers() this replaces the inline UAT-staff block.
*/
async function seedKnownUsers() {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL is not set");
process.exit(1);
}
const client = postgres(url, { max: 5 });
const db = drizzle(client, { schema });
console.log("Seeding known users (prod/demo mode)...\n");
const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001";
const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002";
const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003";
// ── Staff: Demo Manager ──
const [existingStaff] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, "demo-manager@groombook.dev"))
.limit(1);
if (existingStaff) {
console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: KNOWN_STAFF_ID,
name: "Demo Manager",
email: "demo-manager@groombook.dev",
oidcSub: "demo-manager-001",
role: "manager",
isSuperUser: true,
active: true,
});
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
}
// ── Staff: SEED_ADMIN_EMAIL admin ──
const adminEmail = process.env.SEED_ADMIN_EMAIL;
if (adminEmail) {
const adminName = process.env.SEED_ADMIN_NAME ?? "Admin";
const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002";
const [existingAdmin] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, adminEmail))
.limit(1);
if (existingAdmin) {
console.log(`✓ Staff admin '${existingAdmin.name}' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: ADMIN_STAFF_ID,
name: adminName,
email: adminEmail,
oidcSub: adminEmail,
role: "manager",
isSuperUser: true,
active: true,
});
console.log(`✓ Created staff admin '${adminName}' (${adminEmail})`);
}
}
async function seedUatStaffAccounts(db: ReturnType<typeof drizzle>) {
// ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ──
const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB;
if (uatSuperOidcSub) {
@@ -490,6 +485,268 @@ async function seedKnownUsers() {
}
}
// ── Better-Auth email+password credentials for UAT accounts ──────────────────
// Provisions Better-Auth user + account records so UAT testers can log in
// via email+password (POST /api/auth/sign-in/email) instead of Authentik SSO.
const uatPasswordAccounts = [
{ email: "uat-super@groombook.dev", name: "UAT Super User", passwordEnv: "SEED_UAT_SUPER_PASSWORD", staffEmail: "uat-super@groombook.dev" },
{ email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" },
{ email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null },
{ email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" },
];
for (const acct of uatPasswordAccounts) {
const password = process.env[acct.passwordEnv];
if (!password) {
console.warn(`⚠ Skipping ${acct.email}${acct.passwordEnv} not set`);
continue;
}
// 1. Find or create the Better-Auth user
const [existingUser] = await db
.select()
.from(schema.user)
.where(eq(schema.user.email, acct.email))
.limit(1);
let userId: string;
if (existingUser) {
userId = existingUser.id;
console.log(`✓ Better-Auth user '${acct.name}' already exists — skipping user creation`);
} else {
userId = uuid();
await db.insert(schema.user).values({
id: userId,
name: acct.name,
email: acct.email,
emailVerified: true,
});
console.log(`✓ Created Better-Auth user '${acct.name}' (${acct.email})`);
}
// 2. Check if credential account already exists
const [existingAccount] = await db
.select()
.from(schema.account)
.where(and(
eq(schema.account.userId, userId),
eq(schema.account.providerId, "credential")
))
.limit(1);
if (existingAccount) {
console.log(`✓ Credential account for '${acct.email}' already exists — skipping`);
} else {
// Use Better-Auth's own hashPassword so the hash format matches what
// better-auth validates at sign-in time.
// better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64.
const { hashPassword } = await import("better-auth/crypto");
const passwordHash = await hashPassword(password);
await db.insert(schema.account).values({
id: uuid(),
accountId: userId,
providerId: "credential",
userId,
password: passwordHash,
});
console.log(`✓ Created credential account for '${acct.email}'`);
}
// 3. Link staff record to Better-Auth user (for accounts that have staff records)
if (acct.staffEmail) {
const [existingStaff] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, acct.staffEmail))
.limit(1);
if (existingStaff && !existingStaff.userId) {
await db.update(schema.staff)
.set({ userId })
.where(eq(schema.staff.id, existingStaff.id));
console.log(`✓ Linked staff '${acct.staffEmail}' → Better-Auth user`);
}
}
}
// ── Client: UAT Customer ─────────────────────────────────────────────────────
// Only uat-customer is a real end-user who needs a clients row.
// uat-groomer and uat-super are staff — they have staff records, not client records.
const UAT_CUSTOMER_ID = "c0000001-0000-0000-0000-000000000001";
const [uatCustomerRow] = await db
.select()
.from(schema.clients)
.where(eq(schema.clients.email, "uat-customer@groombook.dev"))
.limit(1);
let uatCustomerClientId: string;
if (uatCustomerRow) {
uatCustomerClientId = uatCustomerRow.id;
console.log(`✓ UAT Customer client record already exists — skipping`);
} else {
const [created] = await db
.insert(schema.clients)
.values({
id: UAT_CUSTOMER_ID,
email: "uat-customer@groombook.dev",
name: "UAT Customer",
phone: "555-0102",
address: "1 UAT Lane, Test City, CA 90210",
})
.returning();
uatCustomerClientId = created!.id;
console.log(`✓ Created client 'UAT Customer' for SSO bridge`);
}
// ── Pets: UAT Customer's dogs ────────────────────────────────────────────────
const uatCustomerPets = [
{ id: "c0000001-0000-0000-0000-000000000002", name: "UAT Pup Alpha", species: "Dog", breed: "Beagle", weight: "12.00", dob: "2022-03-10", image: "/demo-pets/dog-beagle.png" },
{ id: "c0000001-0000-0000-0000-000000000003", name: "UAT Pup Beta", species: "Dog", breed: "Labrador", weight: "28.00", dob: "2021-07-22", image: "/demo-pets/dog-labrador.png" },
];
for (const pet of uatCustomerPets) {
const [existing] = await db
.select()
.from(schema.pets)
.where(eq(schema.pets.id, pet.id))
.limit(1);
if (existing) {
// Upsert so extended fields are always populated on re-runs
await db.insert(schema.pets)
.values({
id: pet.id,
clientId: uatCustomerClientId,
name: pet.name,
species: pet.species,
breed: pet.breed,
weightKg: pet.weight,
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
image: pet.image,
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: [],
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
coatType: pick(coatTypePool),
petSizeCategory: pick(petSizeCategoryPool),
})
.onConflictDoUpdate({
target: schema.pets.id,
set: {
clientId: uatCustomerClientId,
name: pet.name,
species: pet.species,
breed: pet.breed,
weightKg: pet.weight,
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
image: pet.image,
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: [],
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
coatType: pick(coatTypePool),
petSizeCategory: pick(petSizeCategoryPool),
},
});
console.log(`✓ Upserted UAT pet '${pet.name}' with extended fields`);
} else {
await db.insert(schema.pets).values({
id: pet.id,
clientId: uatCustomerClientId,
name: pet.name,
species: pet.species,
breed: pet.breed,
weightKg: pet.weight,
dateOfBirth: new Date(`${pet.dob}T00:00:00Z`),
image: pet.image,
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: [],
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
coatType: pick(coatTypePool),
petSizeCategory: pick(petSizeCategoryPool),
});
console.log(`✓ Created UAT pet '${pet.name}' with extended fields`);
}
}
}
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
/**
* Seeds only the minimal known users for prod/demo environments.
* Creates: Demo Manager staff + Demo Client + Demo Dog + basic services.
* Idempotent: skips creation if records already exist.
*/
async function seedKnownUsers() {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL is not set");
process.exit(1);
}
const client = postgres(url, { max: 5 });
const db = drizzle(client, { schema });
console.log("Seeding known users (prod/demo mode)...\n");
const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001";
const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002";
const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003";
// ── Staff: Demo Manager ──
const [existingStaff] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, "demo-manager@groombook.dev"))
.limit(1);
if (existingStaff) {
console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: KNOWN_STAFF_ID,
name: "Demo Manager",
email: "demo-manager@groombook.dev",
oidcSub: "demo-manager-001",
role: "manager",
isSuperUser: true,
active: true,
});
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
}
// ── Staff: SEED_ADMIN_EMAIL admin ──
const adminEmail = process.env.SEED_ADMIN_EMAIL;
if (adminEmail) {
const adminName = process.env.SEED_ADMIN_NAME ?? "Admin";
const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002";
const [existingAdmin] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, adminEmail))
.limit(1);
if (existingAdmin) {
console.log(`✓ Staff admin '${existingAdmin.name}' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: ADMIN_STAFF_ID,
name: adminName,
email: adminEmail,
oidcSub: adminEmail,
role: "manager",
isSuperUser: true,
active: true,
});
console.log(`✓ Created staff admin '${adminName}' (${adminEmail})`);
}
}
// ── UAT staff accounts + Better Auth credentials (shared impl) ──────────────
// Extracted into seedUatStaffAccounts() so it runs in both seedKnownUsers()
// and the full seed() UAT branch.
await seedUatStaffAccounts(db);
// ── Services: idempotent upsert using name as unique key ─────────────────────
// UNIQUE constraint on services.name (migration 0020) must exist first.
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
@@ -656,30 +913,10 @@ async function seed() {
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
}
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
for (let i = 0; i < groomerCount; i++) {
const email = groomerEmails[i]!;
const name = groomerNames[i]!;
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
await db.insert(schema.staff)
.values({
id: staffId,
name,
email,
oidcSub: email,
role: "groomer",
isSuperUser: false,
active: true,
})
.onConflictDoUpdate({
target: schema.staff.email,
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
});
console.log(`✓ Upserted groomer '${name}' (${email})`);
}
// ── UAT staff accounts + Better Auth credentials (shared impl) ──────────────
// Seeds deterministic UAT staff with numeric OIDC subs and Better Auth credentials.
// Must run AFTER random staff are created so upserts land correctly.
await seedUatStaffAccounts(db);
// ── Services ──
// Upsert services using name as unique key. With deterministic IDs in
@@ -769,6 +1006,19 @@ async function seed() {
specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null,
customFields: {},
image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages),
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: (() => {
// ~30% of random-pool pets have alerts — lands squarely in the 2535% AC band
if (rand() < 0.3) {
const count = rand() < 0.7 ? 1 : 2;
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
}
return [];
})(),
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
coatType: pick(coatTypePool),
petSizeCategory: pick(petSizeCategoryPool),
});
petRecords.push({ id: petId, clientId });
@@ -804,6 +1054,12 @@ async function seed() {
specialCareNotes: pet.specialCareNotes,
customFields: pet.customFields,
image: pet.image,
temperamentScore: pet.temperamentScore,
temperamentFlags: pet.temperamentFlags,
medicalAlerts: pet.medicalAlerts,
preferredCuts: pet.preferredCuts,
coatType: pet.coatType,
petSizeCategory: pet.petSizeCategory,
},
});
}
@@ -838,8 +1094,66 @@ async function seed() {
.values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address })
.onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } });
await db.insert(schema.pets)
.values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) })
.onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } });
.values({
id: uc.petId,
clientId: uc.id,
name: uc.petName,
species: "Dog",
breed: uc.petBreed,
weightKg: "25.00",
dateOfBirth: new Date("2021-03-15T00:00:00Z"),
image: pick(demoPetImages),
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: (() => {
// ~30% of pets get alerts; TestCooper/TestRocky get deterministic types
if (rand() < 0.3) {
if (uc.petName === "TestCooper") {
return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() }));
}
if (uc.petName === "TestRocky") {
return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() }));
}
const count = rand() < 0.7 ? 1 : 2;
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
}
return [];
})(),
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
coatType: pick(coatTypePool),
petSizeCategory: pick(petSizeCategoryPool),
})
.onConflictDoUpdate({
target: schema.pets.id,
set: {
clientId: uc.id,
name: uc.petName,
species: "Dog",
breed: uc.petBreed,
weightKg: "25.00",
dateOfBirth: new Date("2021-03-15T00:00:00Z"),
image: pick(demoPetImages),
temperamentScore: randInt(1, 5),
temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)),
medicalAlerts: (() => {
// ~30% of pets get alerts; TestCooper/TestRocky get deterministic types
if (rand() < 0.3) {
if (uc.petName === "TestCooper") {
return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() }));
}
if (uc.petName === "TestRocky") {
return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() }));
}
const count = rand() < 0.7 ? 1 : 2;
return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() }));
}
return [];
})(),
preferredCuts: pickN(preferredCutPool, randInt(1, 2)),
coatType: pick(coatTypePool),
petSizeCategory: pick(petSizeCategoryPool),
},
});
// Create one completed appointment for this client
const apptId = uuid();
const svcIdx = 0;
+31
View File
@@ -225,3 +225,34 @@ export interface MedicalAlert {
}
export type CoatType = "smooth" | "double" | "curly" | "wire" | "long" | "hairless";
export interface GroomingHistoryEntry {
id: string;
petId: string;
appointmentId: string | null;
staffId: string | null;
staffName: string | null;
cutStyle: string | null;
productsUsed: string | null;
notes: string | null;
groomedAt: string;
createdAt: string;
}
export interface UpcomingAppointment {
id: string;
serviceId: string;
serviceName: string;
staffId: string | null;
staffName: string | null;
startTime: string;
endTime: string;
status: AppointmentStatus;
}
export interface PetProfileSummary extends Pet {
recentGroomingHistory: GroomingHistoryEntry[];
lastVisitDate: string | null;
visitCount: number;
upcomingAppointment: UpcomingAppointment | null;
}
+19 -2
View File
@@ -28,7 +28,7 @@ importers:
version: 0.7.6(hono@4.12.18)(zod@4.4.3)
better-auth:
specifier: ^1.5.6
version: 1.6.10(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0))
version: 1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0))
drizzle-orm:
specifier: ^0.38.4
version: 0.38.4(kysely@0.28.17)(postgres@3.4.9)
@@ -84,6 +84,9 @@ importers:
packages/db:
dependencies:
better-auth:
specifier: ^1.5.6
version: 1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0))
drizzle-orm:
specifier: ^0.38.4
version: 0.38.4(kysely@0.28.17)(postgres@3.4.9)
@@ -967,66 +970,79 @@ packages:
resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.3':
resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.3':
resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.3':
resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.3':
resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.3':
resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.3':
resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.3':
resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.3':
resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.3':
resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.3':
resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.3':
resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.3':
resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.3':
resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==}
@@ -3927,7 +3943,7 @@ snapshots:
balanced-match@4.0.4: {}
better-auth@1.6.10(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)):
better-auth@1.6.10(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.18)(tsx@4.21.0)):
dependencies:
'@better-auth/core': 1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.10(@better-auth/core@1.6.10(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(kysely@0.28.17)(postgres@3.4.9))
@@ -3947,6 +3963,7 @@ snapshots:
nanostores: 1.3.0
zod: 4.4.3
optionalDependencies:
drizzle-kit: 0.30.6
drizzle-orm: 0.38.4(kysely@0.28.17)(postgres@3.4.9)
vitest: 3.2.4(@types/node@22.19.18)(tsx@4.21.0)
transitivePeerDependencies:
+206
View File
@@ -0,0 +1,206 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import { getAuth } from "../lib/auth.js";
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
const CLIENT_EMAIL = "alice@example.com";
const CLIENT_NAME = "Alice Smith";
const UAT_CUSTOMER_ID = "c0000001-0000-0000-0000-000000000001";
const UAT_CUSTOMER_EMAIL = "uat-customer@groombook.dev";
const UAT_CUSTOMER_NAME = "UAT Customer";
const BETTER_AUTH_SESSION = {
user: {
id: "auth-user-001",
email: CLIENT_EMAIL,
name: CLIENT_NAME,
},
session: {
id: "ba-session-001",
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
};
const MOCK_CLIENT = {
id: CLIENT_ID,
email: CLIENT_EMAIL,
name: CLIENT_NAME,
};
let mockGetAuth: ReturnType<typeof vi.fn>;
let mockGetSession: ReturnType<typeof vi.fn>;
let insertedSession: Record<string, unknown> | null = null;
let mockClientRow: Record<string, unknown> | null = null;
let mockStaffRow: Record<string, unknown> | null = null;
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
return new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit") {
return () => makeChainable(target);
}
// @ts-expect-error proxy
return target[prop];
},
});
}
vi.mock("@groombook/db", () => {
const impersonationSessions = new Proxy(
{ _name: "impersonationSessions" },
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
);
const clients = new Proxy(
{ _name: "clients" },
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
);
const staff = new Proxy(
{ _name: "staff" },
{ get: (t, p) => (p === "_name" ? "staff" : { table: "staff", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: (table: { _name: string }) => {
if (table._name === "clients") {
return makeChainable(mockClientRow ? [mockClientRow] : []);
}
if (table._name === "staff") {
return makeChainable(mockStaffRow ? [mockStaffRow] : []);
}
return makeChainable([]);
},
}),
insert: (table: { _name: string }) => ({
values: (vals: Record<string, unknown>) => ({
returning: () => {
if (table._name === "impersonationSessions") {
insertedSession = { id: "new-session-001", ...vals };
return [insertedSession];
}
return [];
},
}),
}),
}),
impersonationSessions,
clients,
staff,
eq: vi.fn(),
and: vi.fn(),
inArray: vi.fn(),
};
});
vi.mock("../lib/auth.js", () => ({
getAuth: vi.fn(),
}));
const { portalRouter } = await import("../routes/portal.js");
const app = new Hono();
app.route("/portal", portalRouter);
describe("POST /portal/session-from-auth", () => {
beforeEach(() => {
insertedSession = null;
mockClientRow = null;
mockStaffRow = null;
mockGetSession = vi.fn();
mockGetAuth = vi.fn(() => ({
api: {
getSession: mockGetSession,
},
}));
vi.mocked(getAuth).mockImplementation(mockGetAuth);
});
it("returns 401 when no Better Auth session", async () => {
mockGetSession.mockResolvedValue(null);
const res = await app.request("/portal/session-from-auth", {
method: "POST",
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 404 when authenticated user has no client record", async () => {
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
mockClientRow = null;
const res = await app.request("/portal/session-from-auth", {
method: "POST",
});
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toBe("No client record found for this user");
});
it("returns a portal session with sessionId, clientId, clientName when client is found", async () => {
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
mockClientRow = MOCK_CLIENT;
mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" };
const res = await app.request("/portal/session-from-auth", {
method: "POST",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toHaveProperty("sessionId");
expect(body).toHaveProperty("clientId", CLIENT_ID);
expect(body).toHaveProperty("clientName", CLIENT_NAME);
});
it("creates a portal session with reason sso-bridge", async () => {
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
mockClientRow = MOCK_CLIENT;
mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" };
const res = await app.request("/portal/session-from-auth", {
method: "POST",
});
expect(res.status).toBe(201);
expect(insertedSession).not.toBeNull();
expect((insertedSession as Record<string, unknown>).reason).toBe("sso-bridge");
});
it("returns 201 for uat-customer SSO bridge with correct clientId and clientName", async () => {
const uatAuthSession = {
user: {
id: "auth-user-uat-customer",
email: UAT_CUSTOMER_EMAIL,
name: UAT_CUSTOMER_NAME,
},
session: {
id: "ba-session-uat-customer",
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
};
mockGetSession.mockResolvedValue(uatAuthSession);
mockClientRow = { id: UAT_CUSTOMER_ID, email: UAT_CUSTOMER_EMAIL, name: UAT_CUSTOMER_NAME };
mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" };
const res = await app.request("/portal/session-from-auth", {
method: "POST",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toHaveProperty("sessionId");
expect(body.clientId).toBe(UAT_CUSTOMER_ID);
expect(body.clientName).toBe(UAT_CUSTOMER_NAME);
expect(insertedSession).not.toBeNull();
expect((insertedSession as Record<string, unknown>).reason).toBe("sso-bridge");
});
it("returns 503 when auth is not configured", async () => {
mockGetAuth.mockImplementation(() => {
throw new Error("Auth not initialized");
});
const res = await app.request("/portal/session-from-auth", {
method: "POST",
});
expect(res.status).toBe(503);
});
});
+12 -5
View File
@@ -19,6 +19,7 @@ import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js";
import { authProviderRouter } from "./routes/authProvider.js";
import { searchRouter } from "./routes/search.js";
import { bufferRulesRouter } from "./routes/buffer-rules.js";
import { getObject } from "./lib/s3.js";
import { calendarRouter } from "./routes/calendar.js";
import { setupRouter } from "./routes/setup.js";
@@ -57,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);
@@ -269,6 +273,7 @@ api.route("/admin/settings", settingsRouter);
api.route("/admin/auth-provider", authProviderRouter);
api.route("/admin/seed", adminSeedRouter);
api.route("/search", searchRouter);
api.route("/buffer-rules", bufferRulesRouter);
const port = Number(process.env.PORT ?? 3000);
await initAuth();
@@ -280,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);
+8 -2
View File
@@ -172,7 +172,7 @@ export async function initAuth(): Promise<void> {
clientSecret: oidcClientSecret,
issuerUrl: oidcIssuer,
internalBaseUrl: process.env.OIDC_INTERNAL_BASE,
scopes: "openid profile email",
scopes: "openid profile email role",
};
console.log("[auth] Using env var config (no DB config found)");
}
@@ -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;
@@ -251,6 +253,10 @@ export async function initAuth(): Promise<void> {
},
},
account: {
accountLinking: {
enabled: true,
trustedProviders: ["authentik"],
},
storeStateStrategy: "cookie" as const,
},
emailAndPassword: {
+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;
}
+49 -4
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;
@@ -22,7 +22,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
c,
next
) => {
// Better-Auth's own routes handle their own auth — skip staff resolution
// Better-Auth\'s own routes handle their own auth — skip staff resolution
// OOBE setup routes also handle their own auth — staff record is created during setup
if (c.req.path.startsWith("/api/auth/") || c.req.path.startsWith("/api/setup")) {
await next();
@@ -110,6 +110,51 @@ 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 emailPrefix = jwt.email ? jwt.email.split("@")[0] : "Unknown";
const name = jwt.name?.trim() || emailPrefix;
const [newStaff] = await db
.insert(staff)
.values({
userId: jwt.sub,
email: (jwt.email ?? "") as string,
name,
role: "groomer",
isSuperUser: false,
active: true,
} as Parameters<typeof db.insert>[0] extends { values: infer V } ? V : never)
.returning()!;
if (!newStaff) {
return c.json({ error: "Forbidden: auto-provision failed" }, 500);
}
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
@@ -135,7 +180,7 @@ export function requireRole(
if (!(allowedRoles as string[]).includes(staffRow.role)) {
return c.json(
{
error: `Forbidden: role '${staffRow.role}' is not permitted to access this resource`,
error: `Forbidden: role \'${staffRow.role}\' is not permitted to access this resource`,
},
403
);
@@ -168,7 +213,7 @@ export function requireRoleOrSuperUser(
{
error: hasAllowedRole
? "Forbidden: super user privileges required"
: `Forbidden: role '${staffRow.role}' is not permitted`,
: `Forbidden: role \'${staffRow.role}\' is not permitted`,
},
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,
+2 -2
View File
@@ -193,8 +193,8 @@ bookRouter.post(
name: body.petName,
species: body.petSpecies,
breed: body.petBreed ?? null,
coatType: body.petCoatType ?? null,
petSizeCategory: body.petSizeCategory ?? null,
coatType: (body.petCoatType ?? null) as "short" | "medium" | "long" | "double" | "wire" | "silky" | "curly" | "hairless" | null,
petSizeCategory: (body.petSizeCategory ?? null) as "small" | "medium" | "large" | "extra_large" | null,
})
.returning();
const pet = petInserted[0];
+128
View File
@@ -0,0 +1,128 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import {
and,
eq,
isNull,
getDb,
bufferRules,
services,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import { requireRole } from "../middleware/rbac.js";
export const bufferRulesRouter = new Hono<AppEnv>();
// Apply manager role guard to all routes
bufferRulesRouter.use("*", requireRole("manager"));
const createBufferRuleSchema = z.object({
serviceId: z.string().uuid(),
sizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
bufferMinutes: z.number().int().positive(),
});
const updateBufferRuleSchema = z.object({
bufferMinutes: z.number().int().positive(),
});
bufferRulesRouter.get("/", async (c) => {
const db = getDb();
const serviceId = c.req.query("serviceId");
const conditions = [];
if (serviceId) conditions.push(eq(bufferRules.serviceId, serviceId));
const rows = await db
.select({
id: bufferRules.id,
serviceId: bufferRules.serviceId,
serviceName: services.name,
sizeCategory: bufferRules.sizeCategory,
coatType: bufferRules.coatType,
bufferMinutes: bufferRules.bufferMinutes,
createdAt: bufferRules.createdAt,
updatedAt: bufferRules.updatedAt,
})
.from(bufferRules)
.leftJoin(services, eq(bufferRules.serviceId, services.id))
.where(conditions.length > 0 ? and(...conditions) : undefined);
return c.json(rows);
});
bufferRulesRouter.post(
"/",
zValidator("json", createBufferRuleSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
// Validate serviceId exists
const [svc] = await db
.select({ id: services.id })
.from(services)
.where(eq(services.id, body.serviceId))
.limit(1);
if (!svc) return c.json({ error: "Service not found" }, 404);
// Check for duplicate — sizeCategory/coatType are nullable, use isNull for null check
const conditions = [eq(bufferRules.serviceId, body.serviceId)];
if (body.sizeCategory) {
conditions.push(eq(bufferRules.sizeCategory, body.sizeCategory));
} else {
conditions.push(isNull(bufferRules.sizeCategory));
}
if (body.coatType) {
conditions.push(eq(bufferRules.coatType, body.coatType));
} else {
conditions.push(isNull(bufferRules.coatType));
}
const [existing] = await db
.select({ id: bufferRules.id })
.from(bufferRules)
.where(and(...conditions))
.limit(1);
if (existing) return c.json({ error: "Duplicate rule for this service and attributes" }, 409);
const [row] = await db
.insert(bufferRules)
.values({
serviceId: body.serviceId,
sizeCategory: body.sizeCategory,
coatType: body.coatType,
bufferMinutes: body.bufferMinutes,
})
.returning();
return c.json(row, 201);
}
);
bufferRulesRouter.patch(
"/:id",
zValidator("json", updateBufferRuleSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const [row] = await db
.update(bufferRules)
.set({ bufferMinutes: body.bufferMinutes, updatedAt: new Date() })
.where(eq(bufferRules.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json(row);
}
);
bufferRulesRouter.delete("/:id", async (c) => {
const db = getDb();
const [row] = await db
.delete(bufferRules)
.where(eq(bufferRules.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
});
+115 -1
View File
@@ -1,7 +1,19 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db";
import {
and,
desc,
eq,
exists,
getDb,
or,
pets,
appointments,
staff,
services,
sql,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import {
getPresignedUploadUrl,
@@ -24,6 +36,8 @@ const createPetSchema = z.object({
shampooPreference: z.string().max(500).optional(),
specialCareNotes: z.string().max(2000).optional(),
customFields: z.record(z.string(), z.string()).optional(),
petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
});
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
@@ -95,6 +109,106 @@ petsRouter.get("/:id", async (c) => {
return c.json(row);
});
petsRouter.get("/:id/profile-summary", async (c) => {
const db = getDb();
const petId = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
// Fetch the pet
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
if (!pet) return c.json({ error: "Not found" }, 404);
// Groomer RBAC: check appointment linkage to this pet's client
if (isGroomer) {
const [linkage] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.clientId, pet.clientId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!linkage) return c.json({ error: "Forbidden" }, 403);
}
// Recent grooming history — last 10 completed appointments
const recentHistory = await db
.select({
id: appointments.id,
startTime: appointments.startTime,
notes: appointments.notes,
serviceName: services.name,
staffName: staff.name,
})
.from(appointments)
.innerJoin(services, eq(appointments.serviceId, services.id))
.leftJoin(staff, eq(appointments.staffId, staff.id))
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")))
.orderBy(desc(appointments.startTime))
.limit(10);
// Visit count (completed appointments)
const [countRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(appointments)
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")));
const visitCount = countRow?.count ?? 0;
// Upcoming appointment (next scheduled or confirmed)
const [upcoming] = await db
.select({
id: appointments.id,
startTime: appointments.startTime,
notes: appointments.notes,
confirmationStatus: appointments.confirmationStatus,
serviceName: services.name,
})
.from(appointments)
.innerJoin(services, eq(appointments.serviceId, services.id))
.where(
and(
eq(appointments.petId, petId),
or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed"))
)
)
.orderBy(appointments.startTime)
.limit(1);
return c.json({
id: pet.id,
name: pet.name,
species: pet.species,
breed: pet.breed,
coatType: pet.coatType,
petSizeCategory: pet.petSizeCategory,
weightKg: pet.weightKg,
dateOfBirth: pet.dateOfBirth,
recentGroomingHistory: recentHistory.map((h) => ({
id: h.id,
startTime: h.startTime,
notes: h.notes,
serviceName: h.serviceName,
staffName: h.staffName,
})),
visitCount,
upcomingAppointment: upcoming
? {
id: upcoming.id,
startTime: upcoming.startTime,
notes: upcoming.notes,
confirmationStatus: upcoming.confirmationStatus,
serviceName: upcoming.serviceName,
}
: null,
});
});
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
const db = getDb();
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
+77 -1
View File
@@ -36,7 +36,7 @@ portalRouter.post(
return c.json({ error: "Client not found" }, 404);
}
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
const DEMO_STAFF_ID = process.env.DEMO_STAFF_ID ?? "00000000-0000-0000-0000-000000000001";
let staffId = DEMO_STAFF_ID;
const [demoStaff] = await db
@@ -71,6 +71,82 @@ portalRouter.post(
}
);
// Bridge Better Auth session → portal session for real SSO customers (GRO-1866).
// Registered BEFORE the /* middleware so it is NOT subject to validatePortalSession.
import { getAuth } from "../lib/auth.js";
portalRouter.post("/session-from-auth", async (c) => {
let auth;
try {
auth = getAuth();
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
const db = getDb();
const [client] = await db
.select()
.from(clients)
.where(eq(clients.email, session.user.email))
.limit(1);
if (!client) {
return c.json({ error: "No client record found for this user" }, 404);
}
const DEMO_STAFF_ID = process.env.DEMO_STAFF_ID ?? "00000000-0000-0000-0000-000000000001";
let staffId = DEMO_STAFF_ID;
const [demoStaff] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.id, DEMO_STAFF_ID))
.limit(1);
if (!demoStaff) {
const [firstStaff] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.active, true))
.limit(1);
if (!firstStaff) {
return c.json({ error: "No staff records found" }, 500);
}
staffId = firstStaff.id;
}
const [portalSession] = await db
.insert(impersonationSessions)
.values({
staffId,
clientId: client.id,
reason: "sso-bridge",
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
})
.returning();
if (!portalSession) {
return c.json({ error: "Failed to create session" }, 500);
}
return c.json(
{
sessionId: portalSession.id,
clientId: client.id,
clientName: client.name,
},
201
);
});
// Apply middleware to all portal routes
portalRouter.use("/*", validatePortalSession, portalAudit);
+1
View File
@@ -10,6 +10,7 @@ const createServiceSchema = z.object({
description: z.string().max(2000).optional(),
basePriceCents: z.number().int().positive(),
durationMinutes: z.number().int().positive().max(480),
defaultBufferMinutes: z.number().int().min(0).optional(),
active: z.boolean().default(true),
});