Compare commits

...

61 Commits

Author SHA1 Message Date
Flea Flicker dc3c23055a fix(api): add timeouts for OIDC discovery fetch and DB connection
CI / Test (pull_request) Successful in 16s
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Build & Push Docker Images (pull_request) Successful in 52s
- OIDC discovery fetch in initAuth() now has a 5s AbortSignal.timeout
  to fail fast instead of hanging indefinitely when the auth server is unreachable.
  This was identified as a root cause of startup ECONNRESET crashes on UAT
  where ztunnel drops TCP connections before headers arrive.

- DB postgres client now sets connect_timeout: 5 so failed connection attempts
  fail fast rather than hanging the startup sequence.

- Graceful shutdown timeout tightened to 8s (from 10s) to avoid
  getting killed by Kubernetes liveness-probe deadline while draining.

Fixes GRO-1678.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Merge PR #45 to dev. QA approved (review #3506), CTO reviewed.
2026-05-22 13:17:09 +00:00
Flea Flicker 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 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 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 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 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
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 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 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
Flea Flicker 4204bea2b3 fix(GRO-1395): add drizzle-orm and postgres to root package.json
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Test (pull_request) Failing after 20s
CI / Build & Push Docker Image (pull_request) Has been skipped
Add drizzle-orm ^0.38.4 and postgres ^3.4.5 to root package.json
dependencies so that pnpm --frozen-lockfile install in CI makes
them available at the root node_modules level.

apps/api is not a pnpm workspace member (workspace declares
packages: ["apps/*"]), so CI's pnpm install does not reach into
apps/api/node_modules/. Adding these deps to the root package.json
fixes the ERR_MODULE_NOT_FOUND error that Vitest encountered when
running tests under pnpm --frozen-lockfile.

Also moves drizzle-orm and postgres from apps/api/devDependencies
to dependencies per the issue spec.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 03:19:25 +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 f36a3626a8 Merge pull request 'fix(ci): use REGISTRY_TOKEN for Docker push auth' (#24) from gitea/migrate-workflows into dev
CI / Lint & Typecheck (push) Successful in 16s
CI / Test (push) Failing after 21s
CI / Build & Push Docker Image (push) Has been skipped
Merge gitea/migrate-workflows into dev: fix(ci) REGISTRY_TOKEN, Dockerfile, schema updates
2026-05-21 01:26:29 +00:00
32 changed files with 875 additions and 71 deletions
+47 -7
View File
@@ -25,17 +25,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: Typecheck
run: pnpm typecheck
run: pnpm --filter @groombook/api typecheck
- name: Lint
run: pnpm lint
run: pnpm --filter @groombook/api lint
test:
name: Test
@@ -49,17 +49,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:
@@ -91,9 +91,49 @@ jobs:
with:
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:
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: Build and push Seed image
uses: docker/build-push-action@v6
with:
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:
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
+7 -6
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS base
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
@@ -14,12 +14,13 @@ 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
FROM node:22-alpine AS runner
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
ENV NODE_ENV=production
@@ -41,12 +42,12 @@ 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"]
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
+35 -2
View File
@@ -21,19 +21,32 @@ 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 |
| 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 |
### 4.2 Client Management
@@ -57,6 +70,14 @@ 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 |
### 4.4 Appointment Scheduling
@@ -183,6 +204,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:**
@@ -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 ────────────────────────────────────────────────────────
+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"),
};
+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
+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 });
});
+2 -1
View File
@@ -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({
+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;
+3
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 .",
@@ -19,9 +20,11 @@
"@hono/node-server": "^1.13.7",
"@hono/zod-validator": "^0.7.6",
"better-auth": "^1.5.6",
"drizzle-orm": "^0.38.4",
"hono": "^4.6.17",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.16",
"postgres": "^3.4.5",
"stripe": "^22.0.0",
"telnyx": "^1.23.0",
"zod": "^4.3.6"
@@ -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,103 @@
{
"id": "0033_add_services_default_buffer_minutes",
"version": "7",
"dialect": "postgresql",
"tables": {
"authProviderConfig": {
"name": "auth_provider_config",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"businessSettings": {
"name": "business_settings",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"clients": {
"name": "clients",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"name": { "name": "name", "type": "text", "isNullable": false },
"email": { "name": "email", "type": "text", "isNullable": true },
"phone": { "name": "phone", "type": "text", "isNullable": true },
"address": { "name": "address", "type": "text", "isNullable": true },
"notes": { "name": "notes", "type": "text", "isNullable": true },
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
},
"invoices": {
"name": "invoices",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
"notes": { "name": "notes", "type": "text", "isNullable": true },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
}
},
"enums": {
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
},
"nativeEnums": {}
}
+21
View File
@@ -218,6 +218,27 @@
"when": 1775828067192,
"tag": "0030_messaging",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1775860800000,
"tag": "0031_buffer_rules",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1775894400000,
"tag": "0032_staff_read_at",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1779500000000,
"tag": "0033_add_services_default_buffer_minutes",
"breakpoints": true
}
]
}
+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"
},
+1
View File
@@ -119,6 +119,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;
}
+54 -2
View File
@@ -100,6 +100,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 +162,8 @@ 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"),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
@@ -161,6 +181,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 +622,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),
]
);
+85 -1
View File
@@ -18,7 +18,7 @@
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";
// ── Seed profile configuration ─────────────────────────────────────────────
@@ -490,6 +490,90 @@ 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`);
}
}
}
// ── 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.
+17 -4
View File
@@ -28,7 +28,10 @@ importers:
version: 0.7.6(hono@4.12.18)(zod@4.4.3)
better-auth:
specifier: ^1.5.6
version: 1.6.10(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)
hono:
specifier: ^4.6.17
version: 4.12.18
@@ -38,6 +41,9 @@ importers:
nodemailer:
specifier: ^6.9.16
version: 6.10.1
postgres:
specifier: ^3.4.5
version: 3.4.9
stripe:
specifier: ^22.0.0
version: 22.1.1(@types/node@22.19.18)
@@ -78,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)
@@ -2924,10 +2933,12 @@ snapshots:
nanostores: 1.3.0
zod: 4.4.3
'@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)':
'@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))':
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/utils': 0.4.0
optionalDependencies:
drizzle-orm: 0.38.4(kysely@0.28.17)(postgres@3.4.9)
'@better-auth/kysely-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)(kysely@0.28.17)':
dependencies:
@@ -3919,10 +3930,10 @@ snapshots:
balanced-match@4.0.4: {}
better-auth@1.6.10(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)
'@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))
'@better-auth/kysely-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)(kysely@0.28.17)
'@better-auth/memory-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)
'@better-auth/mongo-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)
@@ -3939,6 +3950,8 @@ 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:
- '@cloudflare/workers-types'
+10 -6
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,8 @@ app.use(
})
);
// Health check (no auth required)
app.get("/health", (c) => c.json({ status: "ok" }));
// Health check no auth required, registered on app at full path before auth middleware
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 +270,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 +282,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);
+7 -1
View File
@@ -186,7 +186,9 @@ export async function initAuth(): Promise<void> {
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
let oidcConfig: Record<string, string> = {};
try {
const discoveryRes = await fetch(discoveryUrlStr);
const discoveryRes = await fetch(discoveryUrlStr, {
signal: AbortSignal.timeout(5000),
});
if (discoveryRes.ok) {
const discovery = await discoveryRes.json() as {
authorization_endpoint?: string;
@@ -251,6 +253,10 @@ export async function initAuth(): Promise<void> {
},
},
account: {
accountLinking: {
enabled: true,
trustedProviders: ["authentik"],
},
storeStateStrategy: "cookie" as const,
},
emailAndPassword: {
+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 });
});
+2
View File
@@ -24,6 +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(),
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 });
+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),
});